diff --git a/App.tsx b/App.tsx
deleted file mode 100644
index bf24c331..00000000
--- a/App.tsx
+++ /dev/null
@@ -1,118 +0,0 @@
-/**
- * Sample React Native App
- * https://github.com/facebook/react-native
- *
- * @format
- */
-
-import React from 'react';
-import type {PropsWithChildren} from 'react';
-import {
- SafeAreaView,
- ScrollView,
- StatusBar,
- StyleSheet,
- Text,
- useColorScheme,
- View,
-} from 'react-native';
-
-import {
- Colors,
- DebugInstructions,
- Header,
- LearnMoreLinks,
- ReloadInstructions,
-} from 'react-native/Libraries/NewAppScreen';
-
-type SectionProps = PropsWithChildren<{
- title: string;
-}>;
-
-function Section({children, title}: SectionProps): JSX.Element {
- const isDarkMode = useColorScheme() === 'dark';
- return (
-
-
- {title}
-
-
- {children}
-
-
- );
-}
-
-function App(): JSX.Element {
- const isDarkMode = useColorScheme() === 'dark';
-
- const backgroundStyle = {
- backgroundColor: isDarkMode ? Colors.darker : Colors.lighter,
- };
-
- return (
-
-
-
-
-
-
- Edit App.tsx to change this
- screen and then come back to see your edits.
-
-
-
-
- Read the docs to discover what to do next:
-
-
-
-
-
- );
-}
-
-const styles = StyleSheet.create({
- sectionContainer: {
- marginTop: 32,
- paddingHorizontal: 24,
- },
- sectionTitle: {
- fontSize: 24,
- fontWeight: '600',
- },
- sectionDescription: {
- marginTop: 8,
- fontSize: 18,
- fontWeight: '400',
- },
- highlight: {
- fontWeight: '700',
- },
-});
-
-export default App;
diff --git a/index.js b/index.js
index a850d031..a701d0b1 100644
--- a/index.js
+++ b/index.js
@@ -1,9 +1,14 @@
-/**
- * @format
- */
+import 'react-native-reanimated' // needed for qrcode reader
+import Crypto from 'react-native-quick-crypto' // needed for secp256k1, conf in babel.config
+import 'react-native-url-polyfill/auto' // URL.host etc
+import 'text-encoding-polyfill' // needed in cashu-ts
+import {AppRegistry} from 'react-native'
+import App from './src/App'
+import {name as appName} from './app.json'
-import {AppRegistry} from 'react-native';
-import App from './App';
-import {name as appName} from './app.json';
-AppRegistry.registerComponent(appName, () => App);
+function BootstrapApp() {
+ return
+}
+
+AppRegistry.registerComponent(appName, () => BootstrapApp)
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 00000000..2f5dc2ed
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,51 @@
+import React from 'react'
+import * as Sentry from '@sentry/react-native'
+import {SENTRY_DSN} from '@env'
+import {
+ initialWindowMetrics,
+ SafeAreaProvider,
+} from 'react-native-safe-area-context'
+
+import {AppNavigator} from './navigation'
+import {useInitialRootStore} from './models'
+import {Database} from './services'
+import {ErrorBoundary} from './screens/ErrorScreen/ErrorBoundary'
+import Config from './config'
+import {log} from './utils/logger'
+
+Sentry.init({
+ dsn: SENTRY_DSN,
+})
+
+interface AppProps {
+ appName: string
+}
+
+function App(props: AppProps) {
+ // const isDarkMode = useColorScheme() === 'dark'
+ log.info(`${props.appName} app started...`)
+
+ const {rehydrated} = useInitialRootStore(() => {
+ // This runs after the root store has been initialized and rehydrated from storage.
+ log.trace('Root store rehydrated', [], 'useInitialRootStore')
+
+ // This creates and opens a sqlite database that stores transactions history.
+ // It triggers db migrations if database version has changed.
+ // As it runs inside a callback, it should not block UI.
+ const dbVersion = Database.getDatabaseVersion()
+ })
+
+ if (!rehydrated) {
+ return null
+ }
+
+ return (
+
+
+
+
+
+ )
+}
+
+export default App
diff --git a/src/components/AutoImage.tsx b/src/components/AutoImage.tsx
new file mode 100644
index 00000000..10eb0da9
--- /dev/null
+++ b/src/components/AutoImage.tsx
@@ -0,0 +1,72 @@
+import React, { useLayoutEffect, useState } from "react"
+import { Image, ImageProps, ImageURISource, Platform } from "react-native"
+
+// TODO: document new props
+export interface AutoImageProps extends ImageProps {
+ /**
+ * How wide should the image be?
+ */
+ maxWidth?: number
+ /**
+ * How tall should the image be?
+ */
+ maxHeight?: number
+}
+
+/**
+ * A hook that will return the scaled dimensions of an image based on the
+ * provided dimensions' aspect ratio. If no desired dimensions are provided,
+ * it will return the original dimensions of the remote image.
+ *
+ * How is this different from `resizeMode: 'contain'`? Firstly, you can
+ * specify only one side's size (not both). Secondly, the image will scale to fit
+ * the desired dimensions instead of just being contained within its image-container.
+ *
+ */
+export function useAutoImage(
+ remoteUri: string,
+ dimensions?: [maxWidth: number, maxHeight: number],
+): [width: number, height: number] {
+ const [[remoteWidth, remoteHeight], setRemoteImageDimensions] = useState([0, 0])
+ const remoteAspectRatio = remoteWidth / remoteHeight
+ const [maxWidth, maxHeight] = dimensions ?? []
+
+ useLayoutEffect(() => {
+ if (!remoteUri) return
+
+ Image.getSize(remoteUri, (w, h) => setRemoteImageDimensions([w, h]))
+ }, [remoteUri])
+
+ if (Number.isNaN(remoteAspectRatio)) return [0, 0]
+
+ if (maxWidth && maxHeight) {
+ const aspectRatio = Math.min(maxWidth / remoteWidth, maxHeight / remoteHeight)
+ return [remoteWidth * aspectRatio, remoteHeight * aspectRatio]
+ } else if (maxWidth) {
+ return [maxWidth, maxWidth / remoteAspectRatio]
+ } else if (maxHeight) {
+ return [maxHeight * remoteAspectRatio, maxHeight]
+ } else {
+ return [remoteWidth, remoteHeight]
+ }
+}
+
+/**
+ * An Image component that automatically sizes a remote or data-uri image.
+ *
+ * - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-AutoImage.md)
+ */
+export function AutoImage(props: AutoImageProps) {
+ const { maxWidth, maxHeight, ...ImageProps } = props
+ const source = props.source as ImageURISource
+
+ const [width, height] = useAutoImage(
+ Platform.select({
+ web: (source?.uri as string) ?? (source as string),
+ default: source?.uri as string,
+ }),
+ [maxWidth, maxHeight],
+ )
+
+ return
+}
diff --git a/src/components/BottomModal.tsx b/src/components/BottomModal.tsx
new file mode 100644
index 00000000..943f36da
--- /dev/null
+++ b/src/components/BottomModal.tsx
@@ -0,0 +1,235 @@
+import React, { ComponentType, Fragment, ReactElement } from "react"
+import Modal from 'react-native-modal'
+import {
+ StyleProp,
+ TextStyle,
+ ViewProps,
+ View,
+ ViewStyle,
+ ColorValue,
+} from "react-native"
+import { colors, useThemeColor, spacing } from "../theme"
+import { Text, TextProps } from "./Text"
+
+
+interface ModalProps extends ViewProps {
+ isVisible?: boolean
+ top?: number
+ onBackdropPress?: any
+ onBackButtonPress?: any
+ backdropOpacity?: number
+ /**
+ * The heading text to display if not using `headingTx`.
+ */
+ heading?: TextProps["text"]
+ /**
+ * Heading text which is looked up via i18n.
+ */
+ headingTx?: TextProps["tx"]
+ /**
+ * Optional heading options to pass to i18n. Useful for interpolation
+ * as well as explicitly setting locale or translation fallbacks.
+ */
+ headingTxOptions?: TextProps["txOptions"]
+ /**
+ * Style overrides for heading text.
+ */
+ headingStyle?: StyleProp
+ /**
+ * Pass any additional props directly to the heading Text component.
+ */
+ HeadingTextProps?: TextProps
+ /**
+ * Custom heading component.
+ * Overrides all other `heading*` props.
+ */
+ HeadingComponent?: ReactElement
+ /**
+ * The content text to display if not using `contentTx`.
+ */
+ content?: TextProps["text"]
+ /**
+ * Content text which is looked up via i18n.
+ */
+ contentTx?: TextProps["tx"]
+ /**
+ * Optional content options to pass to i18n. Useful for interpolation
+ * as well as explicitly setting locale or translation fallbacks.
+ */
+ contentTxOptions?: TextProps["txOptions"]
+ /**
+ * Style overrides for content text.
+ */
+ contentStyle?: StyleProp
+ /**
+ * Pass any additional props directly to the content Text component.
+ */
+ ContentTextProps?: TextProps
+ /**
+ * Custom content component.
+ * Overrides all other `content*` props.
+ */
+ ContentComponent?: ReactElement
+ /**
+ * The footer text to display if not using `footerTx`.
+ */
+ footer?: TextProps["text"]
+ /**
+ * Footer text which is looked up via i18n.
+ */
+ footerTx?: TextProps["tx"]
+ /**
+ * Optional footer options to pass to i18n. Useful for interpolation
+ * as well as explicitly setting locale or translation fallbacks.
+ */
+ footerTxOptions?: TextProps["txOptions"]
+ /**
+ * Style overrides for footer text.
+ */
+ footerStyle?: StyleProp
+ /**
+ * Pass any additional props directly to the footer Text component.
+ */
+ FooterTextProps?: TextProps
+ /**
+ * Custom footer component.
+ * Overrides all other `footer*` props.
+ */
+ FooterComponent?: ReactElement
+}
+
+/**
+ * Modal
+ */
+export function BottomModal(props: ModalProps) {
+
+ // TODO based on number of childs
+ const defaultTop = spacing.screenHeight * 0.8
+
+ const {
+ isVisible = true,
+ top = defaultTop,
+ onBackdropPress,
+ onBackButtonPress,
+ backdropOpacity = 0.05,
+ content,
+ contentTx,
+ contentTxOptions,
+ footer,
+ footerTx,
+ footerTxOptions,
+ heading,
+ headingTx,
+ headingTxOptions,
+ ContentComponent,
+ HeadingComponent,
+ FooterComponent,
+ style: $containerStyleOverride,
+ contentStyle: $contentStyleOverride,
+ headingStyle: $headingStyleOverride,
+ footerStyle: $footerStyleOverride,
+ ContentTextProps,
+ HeadingTextProps,
+ FooterTextProps,
+ ...otherProps
+ } = props
+
+ const $innerContainerStyle = [
+ $innerContainerBase, { backgroundColor: useThemeColor('card') }, $containerStyleOverride
+ ]
+
+ const isHeadingPresent = !!(HeadingComponent || heading || headingTx)
+ const isContentPresent = !!(ContentComponent || content || contentTx)
+ const isFooterPresent = !!(FooterComponent || footer || footerTx)
+
+ const $headingStyle = [
+ (isFooterPresent || isContentPresent) && { marginBottom: spacing.micro },
+ $headingStyleOverride,
+ HeadingTextProps?.style,
+ ]
+ const $contentStyle = [
+ isHeadingPresent && { marginTop: spacing.micro },
+ isFooterPresent && { marginBottom: spacing.micro },
+ $contentStyleOverride,
+ ContentTextProps?.style,
+ ]
+ const $footerStyle = [
+ (isHeadingPresent || isContentPresent) && { marginTop: spacing.micro },
+ $footerStyleOverride,
+ FooterTextProps?.style,
+ ]
+
+
+ return (
+
+
+
+ {HeadingComponent ||
+ (isHeadingPresent && (
+
+ ))}
+
+ {ContentComponent ||
+ (isContentPresent && (
+
+ ))}
+
+
+ {FooterComponent ||
+ (isFooterPresent && (
+
+ ))}
+
+
+ )
+}
+
+const $outerContainerBase: ViewStyle = {
+ margin: 0,
+ bottom: 0,
+ borderTopLeftRadius: spacing.large,
+ borderTopRightRadius: spacing.large,
+ shadowColor: colors.palette.neutral900,
+ //shadowOffset: { width: 10, height: -50 },
+ shadowOpacity: 1,
+ shadowRadius: 10,
+ elevation: 10,
+}
+
+const $innerContainerBase: ViewStyle = {
+ flex: 1,
+ borderTopLeftRadius: spacing.large,
+ borderTopRightRadius: spacing.large,
+}
+
+
diff --git a/src/components/Button.tsx b/src/components/Button.tsx
new file mode 100644
index 00000000..63ab6a51
--- /dev/null
+++ b/src/components/Button.tsx
@@ -0,0 +1,190 @@
+import React, { ComponentType } from "react"
+import {
+ Pressable,
+ PressableProps,
+ PressableStateCallbackType,
+ StyleProp,
+ TextStyle,
+ ViewStyle,
+} from "react-native"
+import { colors, spacing, typography, useThemeColor } from "../theme"
+import { Text, TextProps } from "./Text"
+
+type Presets = keyof typeof $viewPresets
+
+export interface ButtonAccessoryProps {
+ style: StyleProp
+ pressableState: PressableStateCallbackType
+}
+
+export interface ButtonProps extends PressableProps {
+ /**
+ * Text which is looked up via i18n.
+ */
+ tx?: TextProps["tx"]
+ /**
+ * The text to display if not using `tx` or nested components.
+ */
+ text?: TextProps["text"]
+ /**
+ * Optional options to pass to i18n. Useful for interpolation
+ * as well as explicitly setting locale or translation fallbacks.
+ */
+ txOptions?: TextProps["txOptions"]
+ /**
+ * An optional style override useful for padding & margin.
+ */
+ style?: StyleProp
+ /**
+ * An optional style override for the "pressed" state.
+ */
+ pressedStyle?: StyleProp
+ /**
+ * An optional style override for the button text.
+ */
+ textStyle?: StyleProp
+ /**
+ * An optional style override for the button text when in the "pressed" state.
+ */
+ pressedTextStyle?: StyleProp
+ /**
+ * One of the different types of button presets.
+ */
+ preset?: Presets
+ /**
+ * An optional component to render on the right side of the text.
+ * Example: `RightAccessory={(props) => }`
+ */
+ RightAccessory?: ComponentType
+ /**
+ * An optional component to render on the left side of the text.
+ * Example: `LeftAccessory={(props) => }`
+ */
+ LeftAccessory?: ComponentType
+ /**
+ * Children components.
+ */
+ children?: React.ReactNode
+}
+
+/**
+ * A component that allows users to take actions and make choices.
+ * Wraps the Text component with a Pressable component.
+ *
+ * - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-Button.md)
+ */
+export function Button(props: ButtonProps) {
+ const {
+ tx,
+ text,
+ txOptions,
+ preset = "default",
+ style: $viewStyleOverride,
+ pressedStyle: $pressedViewStyleOverride,
+ textStyle: $textStyleOverride,
+ pressedTextStyle: $pressedTextStyleOverride,
+ children,
+ RightAccessory,
+ LeftAccessory,
+ ...rest
+ } = props
+
+ const textColor = useThemeColor("text")
+ const defaultBg = useThemeColor("button")
+ const secondaryBg = useThemeColor("background")
+ const tertiaryBg = "transparent"
+
+ function $viewStyle({ pressed }) {
+ return [
+ $viewPresets[preset],
+ { backgroundColor: defaultBg },
+ preset === "secondary" && { backgroundColor: secondaryBg },
+ preset === "tertiary" && { backgroundColor: tertiaryBg },
+ $viewStyleOverride,
+ !!pressed && [$pressedViewPresets[preset], $pressedViewStyleOverride],
+ ]
+ }
+ function $textStyle({ pressed }) {
+ return [
+ $textPresets[preset],
+ { color: textColor },
+ preset === "default" && { color: 'white' },
+ // $textStyleOverride,
+ !!pressed && [$pressedTextPresets[preset], $pressedTextStyleOverride],
+ ]
+ }
+
+ return (
+
+ {(state) => (
+ <>
+ {!!LeftAccessory && }
+
+
+ {children}
+
+
+ {!!RightAccessory && (
+
+ )}
+ >
+ )}
+
+ )
+}
+
+const $baseViewStyle: ViewStyle = {
+ minHeight: 52,
+ borderRadius: 8,
+ justifyContent: "center",
+ alignItems: "center",
+ flexDirection: "row",
+ paddingVertical: spacing.small,
+ paddingHorizontal: spacing.small,
+ overflow: "hidden",
+}
+
+const $baseTextStyle: TextStyle = {
+ fontSize: 16,
+ lineHeight: 20,
+ fontFamily: typography.primary?.light,
+ textAlign: "center",
+ flexShrink: 1,
+ flexGrow: 0,
+ zIndex: 2,
+}
+
+const $rightAccessoryStyle: ViewStyle = { marginStart: spacing.extraSmall, zIndex: 1 }
+const $leftAccessoryStyle: ViewStyle = { marginEnd: spacing.extraSmall, zIndex: 1 }
+
+const $viewPresets = {
+ default: [
+ $baseViewStyle,
+ ] as StyleProp,
+
+ secondary: [
+ $baseViewStyle,
+ ] as StyleProp,
+
+ tertiary: [
+ $baseViewStyle,
+ ] as StyleProp,
+}
+
+const $textPresets: Record> = {
+ default: $baseTextStyle,
+ secondary: $baseTextStyle,
+ tertiary: $baseTextStyle,
+}
+
+const $pressedViewPresets: Record> = {
+ default: { backgroundColor: colors.palette.success100 },
+ secondary: { backgroundColor: colors.palette.neutral500 },
+ tertiary: { backgroundColor: colors.palette.neutral500 },
+}
+
+const $pressedTextPresets: Record> = {
+ default: { opacity: 0.9 },
+ secondary: { opacity: 0.9 },
+ tertiary: { opacity: 0.9 },
+}
diff --git a/src/components/Card.tsx b/src/components/Card.tsx
new file mode 100644
index 00000000..918c5e29
--- /dev/null
+++ b/src/components/Card.tsx
@@ -0,0 +1,299 @@
+import React, { ComponentType, Fragment, ReactElement } from "react"
+import {
+ StyleProp,
+ TextStyle,
+ TouchableOpacity,
+ TouchableOpacityProps,
+ View,
+ ViewStyle,
+ useColorScheme
+} from "react-native"
+import { colors, useThemeColor, spacing } from "../theme"
+import { Text, TextProps } from "./Text"
+
+type Presets = keyof typeof $containerPresets
+
+interface CardProps extends TouchableOpacityProps {
+ /**
+ * One of the different types of text presets.
+ */
+ preset?: Presets
+ /**
+ * How the content should be aligned vertically. This is especially (but not exclusively) useful
+ * when the card is a fixed height but the content is dynamic.
+ *
+ * `top` (default) - aligns all content to the top.
+ * `center` - aligns all content to the center.
+ * `space-between` - spreads out the content evenly.
+ * `force-footer-bottom` - aligns all content to the top, but forces the footer to the bottom.
+ */
+ verticalAlignment?: "top" | "center" | "space-between" | "force-footer-bottom"
+ /**
+ * Custom component added to the left of the card body.
+ */
+ LeftComponent?: ReactElement
+ /**
+ * Custom component added to the right of the card body.
+ */
+ RightComponent?: ReactElement
+ /**
+ * The heading text to display if not using `headingTx`.
+ */
+ heading?: TextProps["text"]
+ /**
+ * Heading text which is looked up via i18n.
+ */
+ headingTx?: TextProps["tx"]
+ /**
+ * Optional heading options to pass to i18n. Useful for interpolation
+ * as well as explicitly setting locale or translation fallbacks.
+ */
+ headingTxOptions?: TextProps["txOptions"]
+ /**
+ * Style overrides for heading text.
+ */
+ headingStyle?: StyleProp
+ /**
+ * Pass any additional props directly to the heading Text component.
+ */
+ HeadingTextProps?: TextProps
+ /**
+ * Custom heading component.
+ * Overrides all other `heading*` props.
+ */
+ HeadingComponent?: ReactElement
+ /**
+ * The content text to display if not using `contentTx`.
+ */
+ content?: TextProps["text"]
+ /**
+ * Content text which is looked up via i18n.
+ */
+ contentTx?: TextProps["tx"]
+ /**
+ * Optional content options to pass to i18n. Useful for interpolation
+ * as well as explicitly setting locale or translation fallbacks.
+ */
+ contentTxOptions?: TextProps["txOptions"]
+ /**
+ * Style overrides for content text.
+ */
+ contentStyle?: StyleProp
+ /**
+ * Pass any additional props directly to the content Text component.
+ */
+ ContentTextProps?: TextProps
+ /**
+ * Custom content component.
+ * Overrides all other `content*` props.
+ */
+ ContentComponent?: ReactElement
+ /**
+ * The footer text to display if not using `footerTx`.
+ */
+ footer?: TextProps["text"]
+ /**
+ * Footer text which is looked up via i18n.
+ */
+ footerTx?: TextProps["tx"]
+ /**
+ * Optional footer options to pass to i18n. Useful for interpolation
+ * as well as explicitly setting locale or translation fallbacks.
+ */
+ footerTxOptions?: TextProps["txOptions"]
+ /**
+ * Style overrides for footer text.
+ */
+ footerStyle?: StyleProp
+ /**
+ * Pass any additional props directly to the footer Text component.
+ */
+ FooterTextProps?: TextProps
+ /**
+ * Custom footer component.
+ * Overrides all other `footer*` props.
+ */
+ FooterComponent?: ReactElement
+}
+
+/**
+ * Cards are useful for displaying related information in a contained way.
+ * If a ListItem displays content horizontally, a Card can be used to display content vertically.
+ *
+ * - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-Card.md)
+ */
+export function Card(props: CardProps) {
+
+ const backgroundColor = useThemeColor('card')
+
+ const {
+ preset = "default",
+ content,
+ contentTx,
+ contentTxOptions,
+ footer,
+ footerTx,
+ footerTxOptions,
+ heading,
+ headingTx,
+ headingTxOptions,
+ ContentComponent,
+ HeadingComponent,
+ FooterComponent,
+ LeftComponent,
+ RightComponent,
+ verticalAlignment = "top",
+ style: $containerStyleOverride,
+ contentStyle: $contentStyleOverride,
+ headingStyle: $headingStyleOverride,
+ footerStyle: $footerStyleOverride,
+ ContentTextProps,
+ HeadingTextProps,
+ FooterTextProps,
+ ...WrapperProps
+ } = props
+
+ const isPressable = !!WrapperProps.onPress
+ const isHeadingPresent = !!(HeadingComponent || heading || headingTx)
+ const isContentPresent = !!(ContentComponent || content || contentTx)
+ const isFooterPresent = !!(FooterComponent || footer || footerTx)
+
+ const Wrapper: ComponentType = isPressable ? TouchableOpacity : View
+ const HeaderContentWrapper = verticalAlignment === "force-footer-bottom" ? View : Fragment
+
+ const $containerStyle = [$containerPresets[preset], $containerStyleOverride]
+ const $headingStyle = [
+ $headingPresets[preset],
+ (isFooterPresent || isContentPresent) && { marginBottom: spacing.micro },
+ $headingStyleOverride,
+ HeadingTextProps?.style,
+ ]
+ const $contentStyle = [
+ $contentPresets[preset],
+ isHeadingPresent && { marginTop: spacing.small },
+ isFooterPresent && { marginBottom: spacing.small },
+ $contentStyleOverride,
+ ContentTextProps?.style,
+ ]
+ const $footerStyle = [
+ $footerPresets[preset],
+ (isHeadingPresent || isContentPresent) && { marginTop: spacing.micro },
+ $footerStyleOverride,
+ FooterTextProps?.style,
+ ]
+ const $alignmentWrapperStyle = [
+ $alignmentWrapper,
+ { justifyContent: $alignmentWrapperFlexOptions[verticalAlignment] },
+ LeftComponent && { marginStart: spacing.medium },
+ RightComponent && { marginEnd: spacing.medium },
+ ]
+
+ return (
+
+ {LeftComponent}
+
+
+
+ {HeadingComponent ||
+ (isHeadingPresent && (
+
+ ))}
+
+ {ContentComponent ||
+ (isContentPresent && (
+
+ ))}
+
+
+ {FooterComponent ||
+ (isFooterPresent && (
+
+ ))}
+
+
+ {RightComponent}
+
+ )
+}
+
+const $containerBase: ViewStyle = {
+ borderRadius: spacing.large,
+ padding: spacing.small,
+ // borderWidth: 1,
+ shadowColor: colors.palette.neutral600,
+ shadowOffset: { width: 0, height: 10 },
+ shadowOpacity: 0.2,
+ shadowRadius: 8,
+ elevation: 5,
+ minHeight: 86,
+ flexDirection: "row",
+}
+
+const $alignmentWrapper: ViewStyle = {
+ flex: 1,
+ alignSelf: "stretch",
+}
+
+const $alignmentWrapperFlexOptions = {
+ top: "flex-start",
+ center: "center",
+ "space-between": "space-between",
+ "force-footer-bottom": "space-between",
+} as const
+
+const $containerPresets = {
+ default: [
+ $containerBase,
+ {
+ backgroundColor: colors.palette.neutral100,
+ borderColor: colors.palette.neutral300,
+ },
+ ] as StyleProp,
+
+ reversed: [
+ $containerBase,
+ { backgroundColor: colors.palette.neutral800, borderColor: colors.palette.neutral500 },
+ ] as StyleProp,
+}
+
+const $headingPresets: Record = {
+ default: {},
+ reversed: { color: colors.palette.neutral100 },
+}
+
+const $contentPresets: Record = {
+ default: {},
+ reversed: { color: colors.palette.neutral100 },
+}
+
+const $footerPresets: Record = {
+ default: {},
+ reversed: { color: colors.palette.neutral100 },
+}
diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx
new file mode 100644
index 00000000..da9456c3
--- /dev/null
+++ b/src/components/EmptyState.tsx
@@ -0,0 +1,219 @@
+import React from "react"
+import { Image, ImageProps, ImageStyle, StyleProp, TextStyle, View, ViewStyle } from "react-native"
+import { translate } from "../i18n"
+import { spacing } from "../theme"
+import { Button, ButtonProps } from "./Button"
+import { Text, TextProps } from "./Text"
+
+// const sadFace = require("../../assets/images/sad-face.png")
+
+interface EmptyStateProps {
+ /**
+ * An optional prop that specifies the text/image set to use for the empty state.
+ */
+ preset?: keyof typeof EmptyStatePresets
+ /**
+ * Style override for the container.
+ */
+ style?: StyleProp
+ /**
+ * An Image source to be displayed above the heading.
+ */
+ imageSource?: ImageProps["source"]
+ /**
+ * Style overrides for image.
+ */
+ imageStyle?: StyleProp
+ /**
+ * Pass any additional props directly to the Image component.
+ */
+ ImageProps?: Omit
+ /**
+ * The heading text to display if not using `headingTx`.
+ */
+ heading?: TextProps["text"]
+ /**
+ * Heading text which is looked up via i18n.
+ */
+ headingTx?: TextProps["tx"]
+ /**
+ * Optional heading options to pass to i18n. Useful for interpolation
+ * as well as explicitly setting locale or translation fallbacks.
+ */
+ headingTxOptions?: TextProps["txOptions"]
+ /**
+ * Style overrides for heading text.
+ */
+ headingStyle?: StyleProp
+ /**
+ * Pass any additional props directly to the heading Text component.
+ */
+ HeadingTextProps?: TextProps
+ /**
+ * The content text to display if not using `contentTx`.
+ */
+ content?: TextProps["text"]
+ /**
+ * Content text which is looked up via i18n.
+ */
+ contentTx?: TextProps["tx"]
+ /**
+ * Optional content options to pass to i18n. Useful for interpolation
+ * as well as explicitly setting locale or translation fallbacks.
+ */
+ contentTxOptions?: TextProps["txOptions"]
+ /**
+ * Style overrides for content text.
+ */
+ contentStyle?: StyleProp
+ /**
+ * Pass any additional props directly to the content Text component.
+ */
+ ContentTextProps?: TextProps
+ /**
+ * The button text to display if not using `buttonTx`.
+ */
+ button?: TextProps["text"]
+ /**
+ * Button text which is looked up via i18n.
+ */
+ buttonTx?: TextProps["tx"]
+ /**
+ * Optional button options to pass to i18n. Useful for interpolation
+ * as well as explicitly setting locale or translation fallbacks.
+ */
+ buttonTxOptions?: TextProps["txOptions"]
+ /**
+ * Style overrides for button.
+ */
+ buttonStyle?: ButtonProps["style"]
+ /**
+ * Style overrides for button text.
+ */
+ buttonTextStyle?: ButtonProps["textStyle"]
+ /**
+ * Called when the button is pressed.
+ */
+ buttonOnPress?: ButtonProps["onPress"]
+ /**
+ * Pass any additional props directly to the Button component.
+ */
+ ButtonProps?: ButtonProps
+}
+
+const EmptyStatePresets = {
+ generic: {
+ imageSource: null,
+ heading: translate("emptyStateComponent.generic.heading"),
+ content: translate("emptyStateComponent.generic.content"),
+ button: translate("emptyStateComponent.generic.button"),
+ },
+} as const
+
+/**
+ * A component to use when there is no data to display. It can be utilized to direct the user what to do next.
+ *
+ * - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-EmptyState.md)
+ */
+export function EmptyState(props: EmptyStateProps) {
+ const preset = EmptyStatePresets[props.preset] ? EmptyStatePresets[props.preset] : undefined
+
+ const {
+ button = preset?.button,
+ buttonTx,
+ buttonOnPress,
+ buttonTxOptions,
+ content = preset?.content,
+ contentTx,
+ contentTxOptions,
+ heading = preset?.heading,
+ headingTx,
+ headingTxOptions,
+ imageSource = preset?.imageSource,
+ style: $containerStyleOverride,
+ buttonStyle: $buttonStyleOverride,
+ buttonTextStyle: $buttonTextStyleOverride,
+ contentStyle: $contentStyleOverride,
+ headingStyle: $headingStyleOverride,
+ imageStyle: $imageStyleOverride,
+ ButtonProps,
+ ContentTextProps,
+ HeadingTextProps,
+ ImageProps,
+ } = props
+
+ const isImagePresent = !!imageSource
+ const isHeadingPresent = !!(heading || headingTx)
+ const isContentPresent = !!(content || contentTx)
+ const isButtonPresent = !!(button || buttonTx)
+
+ const $containerStyles = [$containerStyleOverride]
+ const $imageStyles = [
+ $image,
+ (isHeadingPresent || isContentPresent || isButtonPresent) && { marginBottom: spacing.micro },
+ $imageStyleOverride,
+ ImageProps?.style,
+ ]
+ const $headingStyles = [
+ $heading,
+ isImagePresent && { marginTop: spacing.micro },
+ (isContentPresent || isButtonPresent) && { marginBottom: spacing.micro },
+ $headingStyleOverride,
+ HeadingTextProps?.style,
+ ]
+ const $contentStyles = [
+ $content,
+ (isImagePresent || isHeadingPresent) && { marginTop: spacing.micro },
+ isButtonPresent && { marginBottom: spacing.micro },
+ $contentStyleOverride,
+ ContentTextProps?.style,
+ ]
+ const $buttonStyles = [
+ (isImagePresent || isHeadingPresent || isContentPresent) && { marginTop: spacing.extraLarge },
+ $buttonStyleOverride,
+ ButtonProps?.style,
+ ]
+
+ return (
+
+ {isImagePresent && }
+
+ {isHeadingPresent && (
+
+ )}
+
+ {isContentPresent && (
+
+ )}
+
+ {isButtonPresent && (
+
+ )}
+
+ )
+}
+
+const $image: ImageStyle = { alignSelf: "center" }
+const $heading: TextStyle = { textAlign: "center", paddingHorizontal: spacing.large }
+const $content: TextStyle = { textAlign: "center", paddingHorizontal: spacing.large }
diff --git a/src/components/ErrorModal.tsx b/src/components/ErrorModal.tsx
new file mode 100644
index 00000000..8ad159f4
--- /dev/null
+++ b/src/components/ErrorModal.tsx
@@ -0,0 +1,51 @@
+import React, { FC, useState, useEffect } from 'react'
+import { View, ScrollView } from 'react-native'
+import { BottomModal, Icon, Text } from '../components'
+import AppError from '../utils/AppError'
+import { spacing, useThemeColor, colors } from '../theme'
+
+
+type ErrorModalProps = {
+ error: AppError
+}
+
+export const ErrorModal: FC = function ({ error }) {
+
+
+ const [isErrorVisible, setIsErrorVisible] = useState(true)
+
+ // needed for error to re-appear
+ useEffect(() => {
+ setIsErrorVisible(true)
+ }, [error])
+
+
+ const onClose = () => {
+ setIsErrorVisible(false)
+ }
+
+ const backgroundColor = useThemeColor('error')
+
+ return (
+
+
+
+ {error.name}
+
+
+ {error.message}
+ {JSON.stringify(error.params)}
+
+ >
+ }
+ />
+
+ )
+}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
new file mode 100644
index 00000000..b814e050
--- /dev/null
+++ b/src/components/Header.tsx
@@ -0,0 +1,341 @@
+import React, { ReactElement } from "react"
+import {
+ StyleProp,
+ TextStyle,
+ TouchableOpacity,
+ TouchableOpacityProps,
+ View,
+ ViewStyle,
+ useColorScheme,
+ StatusBar,
+ StatusBarProps,
+ ColorValue
+} from "react-native"
+import { useIsFocused } from '@react-navigation/native'
+import { translate } from "../i18n"
+import { useThemeColor, spacing, colors } from "../theme"
+import { displayName as appName } from '../../app.json'
+import { ExtendedEdge, useSafeAreaInsetsStyle } from "../utils/useSafeAreaInsetsStyle"
+import { Icon, IconTypes } from "./Icon"
+import { Text, TextProps } from "./Text"
+
+export interface HeaderProps {
+ /**
+ * The layout of the title relative to the action components.
+ * - `center` will force the title to always be centered relative to the header. If the title or the action buttons are too long, the title will be cut off.
+ * - `flex` will attempt to center the title relative to the action buttons. If the action buttons are different widths, the title will be off-center relative to the header.
+ */
+ titleMode?: "center" | "flex"
+ /**
+ * Optional title style override.
+ */
+ titleStyle?: StyleProp
+ /**
+ * Optional outer title container style override.
+ */
+ titleContainerStyle?: StyleProp
+ /**
+ * Optional inner header wrapper style override.
+ */
+ style?: StyleProp
+ /**
+ * Optional outer header container style override.
+ */
+ containerStyle?: StyleProp
+ /**
+ * Background color
+ */
+ backgroundColor?: ColorValue
+ /**
+ * Title text to display if not using `tx` or nested components.
+ */
+ title?: TextProps["text"]
+ /**
+ * Title text which is looked up via i18n.
+ */
+ titleTx?: TextProps["tx"]
+ /**
+ * Optional options to pass to i18n. Useful for interpolation
+ * as well as explicitly setting locale or translation fallbacks.
+ */
+ titleTxOptions?: TextProps["txOptions"]
+ /**
+ * Icon that should appear on the left.
+ * Can be used with `onLeftPress`.
+ */
+ leftIcon?: IconTypes
+ /**
+ * An optional tint color for the left icon
+ */
+ leftIconColor?: string
+ /**
+ * Left action text to display if not using `leftTx`.
+ * Can be used with `onLeftPress`. Overrides `leftIcon`.
+ */
+ leftText?: TextProps["text"]
+ /**
+ * Left action text text which is looked up via i18n.
+ * Can be used with `onLeftPress`. Overrides `leftIcon`.
+ */
+ leftTx?: TextProps["tx"]
+ /**
+ * Left action custom ReactElement if the built in action props don't suffice.
+ * Overrides `leftIcon`, `leftTx` and `leftText`.
+ */
+ LeftActionComponent?: ReactElement
+ /**
+ * Optional options to pass to i18n. Useful for interpolation
+ * as well as explicitly setting locale or translation fallbacks.
+ */
+ leftTxOptions?: TextProps["txOptions"]
+ /**
+ * What happens when you press the left icon or text action.
+ */
+ onLeftPress?: TouchableOpacityProps["onPress"]
+ /**
+ * Icon that should appear on the right.
+ * Can be used with `onRightPress`.
+ */
+ rightIcon?: IconTypes
+ /**
+ * An optional tint color for the right icon
+ */
+ rightIconColor?: ColorValue
+ /**
+ * Right action text to display if not using `rightTx`.
+ * Can be used with `onRightPress`. Overrides `rightIcon`.
+ */
+ rightText?: TextProps["text"]
+ /**
+ * Right action text text which is looked up via i18n.
+ * Can be used with `onRightPress`. Overrides `rightIcon`.
+ */
+ rightTx?: TextProps["tx"]
+ /**
+ * Right action custom ReactElement if the built in action props don't suffice.
+ * Overrides `rightIcon`, `rightTx` and `rightText`.
+ */
+ RightActionComponent?: ReactElement
+ /**
+ * Optional options to pass to i18n. Useful for interpolation
+ * as well as explicitly setting locale or translation fallbacks.
+ */
+ rightTxOptions?: TextProps["txOptions"]
+ /**
+ * What happens when you press the right icon or text action.
+ */
+ onRightPress?: TouchableOpacityProps["onPress"]
+ /**
+ * Override the default edges for the safe area.
+ */
+ safeAreaEdges?: ExtendedEdge[]
+ /**
+ * Pass any additional props directly to the StatusBar component.
+ */
+ StatusBarProps?: StatusBarProps
+}
+
+interface HeaderActionProps {
+ backgroundColor?: ColorValue
+ icon?: IconTypes
+ iconColor?: string
+ text?: TextProps["text"]
+ tx?: TextProps["tx"]
+ txOptions?: TextProps["txOptions"]
+ onPress?: TouchableOpacityProps["onPress"]
+ ActionComponent?: ReactElement
+}
+
+
+/* Needed to keep status bar styles on navigation tabs */
+function FocusAwareStatusBar(props: StatusBarProps) {
+ const isFocused = useIsFocused();
+
+ return isFocused ? : null;
+}
+
+/**
+ * Header that appears on many screens. Will hold navigation buttons and screen title.
+ * The Header is meant to be used with the `screenOptions.header` option on navigators, routes, or screen components via `navigation.setOptions({ header })`.
+ *
+ * - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-Header.md)
+ */
+export function Header(props: HeaderProps) {
+ const colorScheme = useColorScheme()
+
+ const {
+ backgroundColor = useThemeColor('header'),
+ LeftActionComponent,
+ leftIcon,
+ leftIconColor = "white",
+ leftText,
+ leftTx,
+ leftTxOptions,
+ onLeftPress,
+ onRightPress,
+ RightActionComponent,
+ rightIcon,
+ rightIconColor = "white",
+ rightText,
+ rightTx,
+ rightTxOptions,
+ safeAreaEdges = ["top"],
+ title= appName,
+ titleMode = "center",
+ titleTx,
+ titleTxOptions,
+ titleContainerStyle: $titleContainerStyleOverride,
+ style: $styleOverride,
+ titleStyle: $titleStyleOverride,
+ containerStyle: $containerStyleOverride,
+ StatusBarProps,
+ } = props
+
+ const $containerInsets = useSafeAreaInsetsStyle(safeAreaEdges)
+ const titleContent = titleTx ? translate(titleTx, titleTxOptions) : title
+
+ return (
+
+
+
+
+
+ {!!titleContent && (
+
+
+
+ )}
+
+
+
+
+ )
+}
+
+function HeaderAction(props: HeaderActionProps) {
+ const { backgroundColor, icon, text, tx, txOptions, onPress, ActionComponent, iconColor } = props
+
+ const content = tx ? translate(tx, txOptions) : text
+
+ if (ActionComponent) return ActionComponent
+
+ if (content) {
+ return (
+
+
+
+ )
+ }
+
+ if (icon) {
+ return (
+
+ )
+ }
+
+ return
+}
+
+const $wrapper: ViewStyle = {
+ height: 56,
+ flexDirection: "row",
+ alignItems: "center",
+ justifyContent: "space-between",
+}
+
+const $container: ViewStyle = {
+ width: "100%",
+}
+
+const $title: TextStyle = {
+ textAlign: "center",
+ fontFamily: 'Gluten-Regular',
+ color: 'white'
+}
+
+const $actionTextContainer: ViewStyle = {
+ flexGrow: 0,
+ alignItems: "center",
+ justifyContent: "center",
+ height: "100%",
+ paddingHorizontal: spacing.medium,
+ zIndex: 2,
+}
+
+const $actionText: TextStyle = {
+ color: colors.light.tint,
+}
+
+const $actionIconContainer: ViewStyle = {
+ flexGrow: 0,
+ alignItems: "center",
+ justifyContent: "center",
+ height: "100%",
+ paddingHorizontal: spacing.medium,
+ zIndex: 2,
+}
+
+const $actionFillerContainer: ViewStyle = {
+ width: 16,
+}
+
+const $titleWrapperCenter: ViewStyle = {
+ alignItems: "center",
+ justifyContent: "center",
+ height: "100%",
+ width: "100%",
+ position: "absolute",
+ paddingHorizontal: spacing.huge,
+ zIndex: 1,
+}
+
+const $titleWrapperFlex: ViewStyle = {
+ justifyContent: "center",
+ flexGrow: 1,
+}
diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx
new file mode 100644
index 00000000..b7dfd6d1
--- /dev/null
+++ b/src/components/Icon.tsx
@@ -0,0 +1,205 @@
+import * as React from "react"
+import { ComponentType } from "react"
+import {
+ ColorValue,
+ Image,
+ ImageStyle,
+ StyleProp,
+ TouchableOpacity,
+ TouchableOpacityProps,
+ View,
+ ViewStyle,
+} from "react-native"
+import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'
+import { colors, spacing, useThemeColor } from "../theme"
+import { IconDefinition, Transform } from "@fortawesome/fontawesome-svg-core"
+
+
+import { faWallet } from '@fortawesome/free-solid-svg-icons/faWallet'
+import { faAddressCard } from '@fortawesome/free-solid-svg-icons/faAddressCard'
+import { faAddressBook } from '@fortawesome/free-solid-svg-icons/faAddressBook'
+import { faQrcode } from '@fortawesome/free-solid-svg-icons/faQrcode'
+import { faClipboard } from '@fortawesome/free-solid-svg-icons/faClipboard'
+import { faSliders } from '@fortawesome/free-solid-svg-icons/faSliders'
+import { faCoins } from '@fortawesome/free-solid-svg-icons/faCoins'
+import { faEllipsisVertical } from '@fortawesome/free-solid-svg-icons/faEllipsisVertical'
+import { faEllipsis } from '@fortawesome/free-solid-svg-icons/faEllipsis'
+import { faArrowUp } from '@fortawesome/free-solid-svg-icons/faArrowUp'
+import { faArrowDown } from '@fortawesome/free-solid-svg-icons/faArrowDown'
+import { faArrowLeft } from '@fortawesome/free-solid-svg-icons/faArrowLeft'
+import { faXmark } from '@fortawesome/free-solid-svg-icons/faXmark'
+import { faInfoCircle } from '@fortawesome/free-solid-svg-icons/faInfoCircle'
+import { faBug } from '@fortawesome/free-solid-svg-icons/faBug'
+import { faCheckCircle } from '@fortawesome/free-solid-svg-icons/faCheckCircle'
+import { faArrowTurnUp } from '@fortawesome/free-solid-svg-icons/faArrowTurnUp'
+import { faArrowTurnDown } from '@fortawesome/free-solid-svg-icons/faArrowTurnDown'
+import { faPencil } from '@fortawesome/free-solid-svg-icons/faPencil'
+import { faTags } from '@fortawesome/free-solid-svg-icons/faTags'
+import { faShareFromSquare } from '@fortawesome/free-solid-svg-icons/faShareFromSquare'
+import { faRotate } from '@fortawesome/free-solid-svg-icons/faRotate'
+import { faCode } from '@fortawesome/free-solid-svg-icons/faCode'
+import { faBan } from '@fortawesome/free-solid-svg-icons/faBan'
+import { faCircle } from '@fortawesome/free-solid-svg-icons/faCircle'
+import { faPaperPlane } from '@fortawesome/free-solid-svg-icons/faPaperPlane'
+import { faBolt } from '@fortawesome/free-solid-svg-icons/faBolt'
+import { faArrowUpFromBracket } from '@fortawesome/free-solid-svg-icons/faArrowUpFromBracket'
+import { faArrowRightToBracket } from '@fortawesome/free-solid-svg-icons/faArrowRightToBracket'
+import { faPlus } from '@fortawesome/free-solid-svg-icons/faPlus'
+import { faShieldHalved } from '@fortawesome/free-solid-svg-icons/faShieldHalved'
+import { faCloudArrowUp } from '@fortawesome/free-solid-svg-icons/faCloudArrowUp'
+import { faPaintbrush } from '@fortawesome/free-solid-svg-icons/faPaintbrush'
+import { faCopy } from '@fortawesome/free-solid-svg-icons/faCopy'
+import { faBurst } from '@fortawesome/free-solid-svg-icons/faBurst'
+import { faUserShield } from '@fortawesome/free-solid-svg-icons/faUserShield'
+import { faLock } from '@fortawesome/free-solid-svg-icons/faLock'
+import { faLockOpen } from '@fortawesome/free-solid-svg-icons/faLockOpen'
+import { faTriangleExclamation } from '@fortawesome/free-solid-svg-icons/faTriangleExclamation'
+import { faDownload } from '@fortawesome/free-solid-svg-icons/faDownload'
+import { faUpload } from '@fortawesome/free-solid-svg-icons/faUpload'
+import { faRecycle } from '@fortawesome/free-solid-svg-icons/faRecycle'
+import { faListUl } from '@fortawesome/free-solid-svg-icons/faListUl'
+import { faExpand } from '@fortawesome/free-solid-svg-icons/faExpand'
+
+export type IconTypes = keyof typeof iconRegistry
+
+export const iconRegistry = {
+ faAddressCard: faAddressCard,
+ faAddressBook: faAddressBook,
+ faWallet: faWallet,
+ faQrcode: faQrcode,
+ faClipboard: faClipboard,
+ faSliders: faSliders,
+ faCoins: faCoins,
+ faEllipsisVertical: faEllipsisVertical,
+ faEllipsis: faEllipsis,
+ faArrowUp: faArrowUp,
+ faArrowDown: faArrowDown,
+ faArrowLeft: faArrowLeft,
+ faXmark: faXmark,
+ faInfoCircle: faInfoCircle,
+ faBug: faBug,
+ faCheckCircle: faCheckCircle,
+ faArrowTurnUp: faArrowTurnUp,
+ faArrowTurnDown: faArrowTurnDown,
+ faPencil: faPencil,
+ faTags: faTags,
+ faShareFromSquare: faShareFromSquare,
+ faRotate: faRotate,
+ faCode: faCode,
+ faBan: faBan,
+ faCircle: faCircle,
+ faPaperPlane: faPaperPlane,
+ faBolt: faBolt,
+ faArrowUpFromBracket: faArrowUpFromBracket,
+ faArrowRightToBracket: faArrowRightToBracket,
+ faPlus: faPlus,
+ faShieldHalved: faShieldHalved,
+ faCloudArrowUp: faCloudArrowUp,
+ faPaintbrush: faPaintbrush,
+ faCopy: faCopy,
+ faBurst: faBurst,
+ faUserShield: faUserShield,
+ faLock: faLock,
+ faLockOpen: faLockOpen,
+ faTriangleExclamation: faTriangleExclamation,
+ faDownload: faDownload,
+ faUpload: faUpload,
+ faRecycle: faRecycle,
+ faListUl: faListUl,
+ faExpand: faExpand,
+}
+
+
+interface IconProps extends TouchableOpacityProps {
+ /**
+ * The name of the icon
+ */
+ icon: IconTypes
+
+ /**
+ * An optional tint color for the icon
+ */
+ color?: ColorValue
+
+ /**
+ * An optional size for the icon. If not provided, the icon will be sized to the icon's resolution.
+ */
+ size?: number
+
+ /**
+ * An inverse style with white icon on colored rounded background.
+ */
+ inverse?: boolean
+
+ /**
+ * Style overrides for the icon image
+ */
+ style?: StyleProp
+
+ /**
+ * Style overrides for the icon container
+ */
+ containerStyle?: StyleProp
+
+ /**
+ * Transform style
+ */
+ transform?: string | Transform | undefined
+
+ /**
+ * An optional function to be called when the icon is pressed
+ */
+ onPress?: TouchableOpacityProps["onPress"]
+}
+
+/**
+ * A component to render a registered icon.
+ * It is wrapped in a if `onPress` is provided, otherwise a .
+ *
+ * - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-Icon.md)
+ */
+export function Icon(props: IconProps) {
+ const {
+ icon,
+ color = useThemeColor('text'),
+ size,
+ inverse = false,
+ transform,
+ style: $imageStyleOverride,
+ containerStyle: $containerStyleOverride,
+ ...WrapperProps
+ } = props
+
+ const isPressable = !!WrapperProps.onPress
+ const Wrapper: ComponentType = WrapperProps?.onPress
+ ? TouchableOpacity
+ : View
+
+ return (
+
+
+
+ )
+}
+
+
+
+const $imageStyle: ImageStyle = {
+ resizeMode: "contain",
+}
+
+const $inverseContainer: ImageStyle = {
+ borderRadius: spacing.small,
+ padding: spacing.extraSmall,
+ alignSelf: "center",
+ marginRight: spacing.medium,
+}
diff --git a/src/components/InfoModal.tsx b/src/components/InfoModal.tsx
new file mode 100644
index 00000000..b77f127e
--- /dev/null
+++ b/src/components/InfoModal.tsx
@@ -0,0 +1,44 @@
+import React, { FC, useState, useEffect } from 'react'
+import { View } from 'react-native'
+import { BottomModal, Icon, Text } from '../components'
+import { spacing, useThemeColor } from '../theme'
+
+
+type InfoModalProps = {
+ message: string
+}
+
+export const InfoModal: FC = function ({ message }) {
+
+ const [isInfoVisible, setIsInfoVisible] = useState(true)
+
+ // needed for info to re-appear
+ useEffect(() => {
+ setIsInfoVisible(true)
+ // setTimeout(() => setIsInfoVisible(false), 3000)
+ }, [message])
+
+
+ const onClose = () => {
+ setIsInfoVisible(false)
+ }
+
+ const backgroundColor = useThemeColor('info')
+
+ return (
+ onClose()}
+ onBackButtonPress={() => onClose()}
+ style={{ padding: spacing.medium, backgroundColor }}
+ top={spacing.screenHeight - 120}
+ ContentComponent={
+
+
+ {message}
+
+ }
+ >
+
+ )
+}
diff --git a/src/components/ListItem.tsx b/src/components/ListItem.tsx
new file mode 100644
index 00000000..718df324
--- /dev/null
+++ b/src/components/ListItem.tsx
@@ -0,0 +1,268 @@
+import React, { ReactElement } from "react"
+import { ColorValue, useColorScheme } from "react-native"
+import {
+ StyleProp,
+ TextStyle,
+ TouchableOpacity,
+ TouchableOpacityProps,
+ View,
+ ViewStyle,
+} from "react-native"
+import { useThemeColor, spacing } from "../theme"
+import { Icon, IconTypes } from "./Icon"
+import { Text, TextProps } from "./Text"
+
+export interface ListItemProps extends TouchableOpacityProps {
+ /**
+ * How tall the list item should be.
+ * Default: 56
+ */
+ height?: number
+ /**
+ * Whether to show the top separator.
+ * Default: false
+ */
+ topSeparator?: boolean
+ /**
+ * Whether to show the bottom separator.
+ * Default: false
+ */
+ bottomSeparator?: boolean
+ /**
+ * Text to display if not using `tx` or nested components.
+ */
+ text?: TextProps["text"]
+ /**
+ * Text which is looked up via i18n.
+ */
+ tx?: TextProps["tx"]
+ /**
+ * Sub text to display if not using `tx` or nested components.
+ */
+ subText?: TextProps["text"]
+ /**
+ * Sub text which is looked up via i18n.
+ */
+ subTx?: TextProps["tx"]
+ /**
+ * Children components.
+ */
+ children?: TextProps["children"]
+ /**
+ * Optional options to pass to i18n. Useful for interpolation
+ * as well as explicitly setting locale or translation fallbacks.
+ */
+ txOptions?: TextProps["txOptions"]
+ /**
+ * Optional text style override.
+ */
+ textStyle?: StyleProp
+ /**
+ * Pass any additional props directly to the Text component.
+ */
+ TextProps?: TextProps
+ /**
+ * Optional View container style override.
+ */
+ containerStyle?: StyleProp
+ /**
+ * Optional TouchableOpacity style override.
+ */
+ style?: StyleProp
+ /**
+ * Icon that should appear on the left.
+ */
+ leftIcon?: IconTypes
+ /**
+ * An optional tint color for the left icon
+ */
+ leftIconColor?: string
+ leftIconTransform?: string
+ /**
+ * Icon that should appear on the right.
+ */
+ rightIcon?: IconTypes
+ /**
+ * An optional tint color for the right icon
+ */
+ rightIconColor?: string
+ rightIconTransform?: string
+ /**
+ * Right action custom ReactElement.
+ * Overrides `rightIcon`.
+ */
+ RightComponent?: ReactElement
+ /**
+ * Left action custom ReactElement.
+ * Overrides `leftIcon`.
+ */
+ LeftComponent?: ReactElement
+}
+
+interface ListItemActionProps {
+ icon: IconTypes | undefined
+ iconColor?: ColorValue
+ iconTransform?: string
+ Component?: ReactElement
+ size: number
+ side: "left" | "right"
+}
+
+/**
+ * A styled row component that can be used in FlatList, SectionList, or by itself.
+ *
+ * - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-ListItem.md)
+ */
+export function ListItem(props: ListItemProps) {
+ const colorScheme = useColorScheme()
+
+ const {
+ bottomSeparator,
+ children,
+ height = 56,
+ LeftComponent,
+ leftIcon,
+ leftIconColor = useThemeColor('textDim'),
+ leftIconTransform,
+ RightComponent,
+ rightIcon,
+ rightIconColor = useThemeColor('textDim'),
+ rightIconTransform,
+ style,
+ text,
+ subText,
+ TextProps,
+ topSeparator,
+ tx,
+ subTx,
+ txOptions,
+ textStyle: $textStyleOverride,
+ containerStyle: $containerStyleOverride,
+ ...TouchableOpacityProps
+ } = props
+
+ const $textStyles = [$textStyle, $textStyleOverride, TextProps?.style]
+
+ const separatorColor = useThemeColor('separator')
+ const subTextColor = useThemeColor('textDim')
+
+ const $subTextStyles = [$subTextStyle, TextProps?.style]
+
+ const $containerStyles = [
+ topSeparator && $separatorTop, { borderTopColor: separatorColor },
+ bottomSeparator && $separatorBottom, { borderBottomColor: separatorColor },
+ $containerStyleOverride,
+ ]
+
+ const $touchableStyles = [$touchableStyle, { minHeight: height }, style]
+
+ return (
+
+
+
+ {(subText || subTx) ? (
+
+
+ {children}
+
+
+
+
+ ) : (
+
+ {children}
+
+ )}
+
+
+
+
+ )
+}
+
+function ListItemAction(props: ListItemActionProps) {
+ const { icon, Component, iconColor, iconTransform, size, side } = props
+
+ const $iconContainerStyles = [$iconContainer]
+
+ if (Component) return Component
+
+ if (icon) {
+ return (
+
+ )
+ }
+
+ return null
+}
+
+const $separatorTop: ViewStyle = {
+ borderTopWidth: 1,
+}
+
+const $separatorBottom: ViewStyle = {
+ borderBottomWidth: 1,
+}
+
+const $textStyle: TextStyle = {
+ paddingVertical: spacing.extraSmall,
+ alignSelf: "center",
+ flexGrow: 1,
+ flexShrink: 1,
+}
+
+const $subTextContainer: TextStyle = {
+ flex: 1,
+ flexDirection: 'column',
+ // borderColor: 'red',
+ // borderWidth: 1,
+}
+
+const $subTextStyle: TextStyle = {
+ flexGrow: 1,
+ flexShrink: 1,
+ paddingBottom: spacing.extraSmall,
+}
+
+const $touchableStyle: ViewStyle = {
+ flexDirection: "row",
+ alignItems: "flex-start",
+}
+
+const $iconContainer: ViewStyle = {
+ justifyContent: "center",
+ alignItems: "center",
+ flexGrow: 0,
+}
+const $iconContainerLeft: ViewStyle = {
+ marginEnd: spacing.medium,
+}
+
+const $iconContainerRight: ViewStyle = {
+ marginStart: spacing.medium,
+}
diff --git a/src/components/Loading.tsx b/src/components/Loading.tsx
new file mode 100644
index 00000000..d7ee9274
--- /dev/null
+++ b/src/components/Loading.tsx
@@ -0,0 +1,18 @@
+import * as React from 'react'
+import { StyleSheet, View, ActivityIndicator, ViewProps } from 'react-native'
+import { useThemeColor } from '../theme'
+
+export function Loading(props: ViewProps) {
+ return (
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/Screen.tsx b/src/components/Screen.tsx
new file mode 100644
index 00000000..a4204a71
--- /dev/null
+++ b/src/components/Screen.tsx
@@ -0,0 +1,236 @@
+import { useScrollToTop } from "@react-navigation/native"
+import { useIsFocused } from '@react-navigation/native'
+import React, { useRef, useState } from "react"
+import {
+ KeyboardAvoidingView,
+ KeyboardAvoidingViewProps,
+ LayoutChangeEvent,
+ Platform,
+ ScrollView,
+ ScrollViewProps,
+ StyleProp,
+ View,
+ ViewStyle,
+ useColorScheme,
+ StatusBar,
+ StatusBarProps
+} from "react-native"
+
+import { useThemeColor, spacing } from "../theme"
+import { ExtendedEdge, useSafeAreaInsetsStyle } from "../utils/useSafeAreaInsetsStyle"
+
+interface BaseScreenProps {
+ /**
+ * Children components.
+ */
+ children?: React.ReactNode
+ /**
+ * Style for the outer content container useful for padding & margin.
+ */
+ style?: StyleProp
+ /**
+ * Style for the inner content container useful for padding & margin.
+ */
+ contentContainerStyle?: StyleProp
+ /**
+ * Override the default edges for the safe area.
+ */
+ safeAreaEdges?: ExtendedEdge[]
+ /**
+ * Background color
+ */
+ backgroundColor?: string
+ /**
+ * By how much should we offset the keyboard? Defaults to 0.
+ */
+ keyboardOffset?: number
+ /**
+ * Pass any additional props directly to the KeyboardAvoidingView component.
+ */
+ KeyboardAvoidingViewProps?: KeyboardAvoidingViewProps
+}
+
+interface FixedScreenProps extends BaseScreenProps {
+ preset?: "fixed"
+}
+interface ScrollScreenProps extends BaseScreenProps {
+ preset?: "scroll"
+ /**
+ * Should keyboard persist on screen tap. Defaults to handled.
+ * Only applies to scroll preset.
+ */
+ keyboardShouldPersistTaps?: "handled" | "always" | "never"
+ /**
+ * Pass any additional props directly to the ScrollView component.
+ */
+ ScrollViewProps?: ScrollViewProps
+}
+
+interface AutoScreenProps extends Omit {
+ preset?: "auto"
+ /**
+ * Threshold to trigger the automatic disabling/enabling of scroll ability.
+ * Defaults to `{ percent: 0.92 }`.
+ */
+ scrollEnabledToggleThreshold?: { percent?: number; point?: number }
+}
+
+export type ScreenProps = ScrollScreenProps | FixedScreenProps | AutoScreenProps
+
+const isIos = Platform.OS === "ios"
+
+function isNonScrolling(preset?: ScreenProps["preset"]) {
+ return !preset || preset === "fixed"
+}
+
+function useAutoPreset(props: AutoScreenProps) {
+ const { preset, scrollEnabledToggleThreshold } = props
+ const { percent = 0.92, point = 0 } = scrollEnabledToggleThreshold || {}
+
+ const scrollViewHeight = useRef(null)
+ const scrollViewContentHeight = useRef(null)
+ const [scrollEnabled, setScrollEnabled] = useState(true)
+
+ function updateScrollState() {
+ if (scrollViewHeight.current === null || scrollViewContentHeight.current === null) return
+
+ // check whether content fits the screen then toggle scroll state according to it
+ const contentFitsScreen = (function () {
+ if (point) {
+ return scrollViewContentHeight.current < scrollViewHeight.current - point
+ } else {
+ return scrollViewContentHeight.current < scrollViewHeight.current * percent
+ }
+ })()
+
+ // content is less than the size of the screen, so we can disable scrolling
+ if (scrollEnabled && contentFitsScreen) setScrollEnabled(false)
+
+ // content is greater than the size of the screen, so let's enable scrolling
+ if (!scrollEnabled && !contentFitsScreen) setScrollEnabled(true)
+ }
+
+ function onContentSizeChange(w: number, h: number) {
+ // update scroll-view content height
+ scrollViewContentHeight.current = h
+ updateScrollState()
+ }
+
+ function onLayout(e: LayoutChangeEvent) {
+ const { height } = e.nativeEvent.layout
+ // update scroll-view height
+ scrollViewHeight.current = height
+ updateScrollState()
+ }
+
+ // update scroll state on every render
+ if (preset === "auto") updateScrollState()
+
+ return {
+ scrollEnabled: preset === "auto" ? scrollEnabled : true,
+ onContentSizeChange,
+ onLayout,
+ }
+}
+
+function ScreenWithoutScrolling(props: ScreenProps) {
+ const { style, contentContainerStyle, children } = props
+ return (
+
+ {children}
+
+ )
+}
+
+function ScreenWithScrolling(props: ScreenProps) {
+ const {
+ children,
+ keyboardShouldPersistTaps = "handled",
+ contentContainerStyle,
+ ScrollViewProps,
+ style,
+ } = props as ScrollScreenProps
+
+ const ref = useRef()
+
+ const { scrollEnabled, onContentSizeChange, onLayout } = useAutoPreset(props as AutoScreenProps)
+
+ // Add native behavior of pressing the active tab to scroll to the top of the content
+ // More info at: https://reactnavigation.org/docs/use-scroll-to-top/
+ useScrollToTop(ref)
+
+ return (
+ {
+ onLayout(e)
+ ScrollViewProps?.onLayout?.(e)
+ }}
+ onContentSizeChange={(w: number, h: number) => {
+ onContentSizeChange(w, h)
+ ScrollViewProps?.onContentSizeChange?.(w, h)
+ }}
+ style={[$outerStyle, ScrollViewProps?.style, style]}
+ contentContainerStyle={[
+ $innerStyle,
+ ScrollViewProps?.contentContainerStyle,
+ contentContainerStyle,
+ ]}
+ >
+ {children}
+
+ )
+}
+
+
+
+
+export function Screen(props: ScreenProps) {
+ const {
+ backgroundColor = useThemeColor('background'),
+ KeyboardAvoidingViewProps,
+ keyboardOffset = 0,
+ safeAreaEdges,
+ } = props
+
+ const $containerInsets = useSafeAreaInsetsStyle(safeAreaEdges)
+
+ return (
+
+
+ {isNonScrolling(props.preset) ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
+
+const $containerStyle: ViewStyle = {
+ flex: 1,
+ height: "100%",
+ width: "100%",
+}
+
+const $keyboardAvoidingViewStyle: ViewStyle = {
+ flex: 1,
+}
+
+const $outerStyle: ViewStyle = {
+ flex: 1,
+ height: "100%",
+ width: "100%",
+}
+
+const $innerStyle: ViewStyle = {
+ justifyContent: "flex-start",
+ alignItems: "stretch",
+}
diff --git a/src/components/Text.tsx b/src/components/Text.tsx
new file mode 100644
index 00000000..9dace55e
--- /dev/null
+++ b/src/components/Text.tsx
@@ -0,0 +1,112 @@
+import { I18nOptions } from "i18n-js"
+import { i18n } from "../i18n/i18n"
+import React from "react"
+import { StyleProp, Text as RNText, TextProps as RNTextProps, TextStyle, useColorScheme } from "react-native"
+import { translate, TxKeyPath } from "../i18n"
+import { useThemeColor, typography } from "../theme"
+
+type Sizes = keyof typeof $sizeStyles
+type Weights = keyof typeof typography.primary
+type Presets = keyof typeof $presets
+
+export interface TextProps extends RNTextProps {
+ /**
+ * Text which is looked up via i18n.
+ */
+ tx?: TxKeyPath
+ /**
+ * The text to display if not using `tx` or nested components.
+ */
+ text?: string
+ /**
+ * Optional options to pass to i18n. Useful for interpolation
+ * as well as explicitly setting locale or translation fallbacks.
+ */
+ txOptions?: I18nOptions
+ /**
+ * An optional style override useful for padding & margin.
+ */
+ style?: StyleProp
+ /**
+ * One of the different types of text presets.
+ */
+ preset?: Presets
+ /**
+ * Text weight modifier.
+ */
+ weight?: Weights
+ /**
+ * Text size modifier.
+ */
+ size?: Sizes
+ /**
+ * Children components.
+ */
+ children?: React.ReactNode
+}
+
+/**
+ * For your text displaying needs.
+ * This component is a HOC over the built-in React Native one.
+ *
+ * - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-Text.md)
+ */
+export function Text(props: TextProps) {
+ const { weight, size, tx, txOptions, text, children, style: $styleOverride, ...rest } = props
+
+ const i18nText = tx && translate(tx, txOptions)
+ const content = i18nText || text || children
+
+ const preset: Presets = $presets[props.preset] ? props.preset : "default"
+ const $styles = [
+ $presets[preset],
+ weight ? {fontFamily: weight} : null,
+ size ? $sizeStyles[size] : null,
+ { color: useThemeColor('text') },
+ $styleOverride,
+ ]
+
+ const $baseFontFamily = { fontFamily: typography.primary } as TextStyle
+
+ return (
+
+ {content}
+
+ )
+}
+
+const $sizeStyles = {
+ xxl: { fontSize: 36, lineHeight: 44 } as TextStyle,
+ xl: { fontSize: 24, lineHeight: 34 } as TextStyle,
+ lg: { fontSize: 20, lineHeight: 32 } as TextStyle,
+ md: { fontSize: 18, lineHeight: 26 } as TextStyle,
+ sm: { fontSize: 16, lineHeight: 24 } as TextStyle,
+ xs: { fontSize: 14, lineHeight: 21 } as TextStyle,
+ xxs: { fontSize: 12, lineHeight: 18 } as TextStyle,
+}
+
+
+// does not work
+/* const $fontWeightStyles = Object.entries(typography.primary).reduce((acc, [weight, fontFamily]) => {
+ return { ...acc, [weight]: { fontFamily } }
+}, {}) as Record */
+
+const $baseStyle: StyleProp = [
+ $sizeStyles.sm,
+ {fontFamily: typography.primary?.normal}
+]
+
+const $presets = {
+ default: $baseStyle,
+
+ bold: [$baseStyle, {fontFamily: typography.primary?.medium}] as StyleProp,
+
+ heading: [$baseStyle, $sizeStyles.xxl, {fontFamily: typography.primary?.medium}] as StyleProp,
+
+ subheading: [$baseStyle, $sizeStyles.lg] as StyleProp,
+
+ formLabel: [$baseStyle] as StyleProp,
+
+ formHelper: [$baseStyle, $sizeStyles.sm, {fontFamily: typography.primary?.light}] as StyleProp,
+}
+
diff --git a/src/components/TextField.tsx b/src/components/TextField.tsx
new file mode 100644
index 00000000..5580f0fc
--- /dev/null
+++ b/src/components/TextField.tsx
@@ -0,0 +1,275 @@
+import React, { ComponentType, forwardRef, Ref, useImperativeHandle, useRef } from "react"
+import {
+ StyleProp,
+ TextInput,
+ TextInputProps,
+ TextStyle,
+ TouchableOpacity,
+ View,
+ ViewStyle,
+} from "react-native"
+import { isRTL, translate } from "../i18n"
+import { colors, spacing, typography } from "../theme"
+import { Text, TextProps } from "./Text"
+
+export interface TextFieldAccessoryProps {
+ style: StyleProp
+ status: TextFieldProps["status"]
+ multiline: boolean
+ editable: boolean
+}
+
+export interface TextFieldProps extends Omit {
+ /**
+ * A style modifier for different input states.
+ */
+ status?: "error" | "disabled"
+ /**
+ * The label text to display if not using `labelTx`.
+ */
+ label?: TextProps["text"]
+ /**
+ * Label text which is looked up via i18n.
+ */
+ labelTx?: TextProps["tx"]
+ /**
+ * Optional label options to pass to i18n. Useful for interpolation
+ * as well as explicitly setting locale or translation fallbacks.
+ */
+ labelTxOptions?: TextProps["txOptions"]
+ /**
+ * Pass any additional props directly to the label Text component.
+ */
+ LabelTextProps?: TextProps
+ /**
+ * The helper text to display if not using `helperTx`.
+ */
+ helper?: TextProps["text"]
+ /**
+ * Helper text which is looked up via i18n.
+ */
+ helperTx?: TextProps["tx"]
+ /**
+ * Optional helper options to pass to i18n. Useful for interpolation
+ * as well as explicitly setting locale or translation fallbacks.
+ */
+ helperTxOptions?: TextProps["txOptions"]
+ /**
+ * Pass any additional props directly to the helper Text component.
+ */
+ HelperTextProps?: TextProps
+ /**
+ * The placeholder text to display if not using `placeholderTx`.
+ */
+ placeholder?: TextProps["text"]
+ /**
+ * Placeholder text which is looked up via i18n.
+ */
+ placeholderTx?: TextProps["tx"]
+ /**
+ * Optional placeholder options to pass to i18n. Useful for interpolation
+ * as well as explicitly setting locale or translation fallbacks.
+ */
+ placeholderTxOptions?: TextProps["txOptions"]
+ /**
+ * Optional input style override.
+ */
+ style?: StyleProp
+ /**
+ * Style overrides for the container
+ */
+ containerStyle?: StyleProp
+ /**
+ * Style overrides for the input wrapper
+ */
+ inputWrapperStyle?: StyleProp
+ /**
+ * An optional component to render on the right side of the input.
+ * Example: `RightAccessory={(props) => }`
+ * Note: It is a good idea to memoize this.
+ */
+ RightAccessory?: ComponentType
+ /**
+ * An optional component to render on the left side of the input.
+ * Example: `LeftAccessory={(props) => }`
+ * Note: It is a good idea to memoize this.
+ */
+ LeftAccessory?: ComponentType
+}
+
+/**
+ * A component that allows for the entering and editing of text.
+ *
+ * - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-TextField.md)
+ */
+export const TextField = forwardRef(function TextField(props: TextFieldProps, ref: Ref) {
+ const {
+ labelTx,
+ label,
+ labelTxOptions,
+ placeholderTx,
+ placeholder,
+ placeholderTxOptions,
+ helper,
+ helperTx,
+ helperTxOptions,
+ status,
+ RightAccessory,
+ LeftAccessory,
+ HelperTextProps,
+ LabelTextProps,
+ style: $inputStyleOverride,
+ containerStyle: $containerStyleOverride,
+ inputWrapperStyle: $inputWrapperStyleOverride,
+ ...TextInputProps
+ } = props
+ const input = useRef()
+
+ const disabled = TextInputProps.editable === false || status === "disabled"
+
+ const placeholderContent = placeholderTx
+ ? translate(placeholderTx, placeholderTxOptions)
+ : placeholder
+
+ const $containerStyles = [$containerStyleOverride]
+
+ const $labelStyles = [$labelStyle, LabelTextProps?.style]
+
+ const $inputWrapperStyles = [
+ $inputWrapperStyle,
+ status === "error" && { borderColor: colors.light.error },
+ TextInputProps.multiline && { minHeight: 112 },
+ LeftAccessory && { paddingStart: 0 },
+ RightAccessory && { paddingEnd: 0 },
+ $inputWrapperStyleOverride,
+ ]
+
+ const $inputStyles = [
+ $inputStyle,
+ disabled && { color: colors.light.textDim },
+ isRTL && { textAlign: "right" as TextStyle["textAlign"] },
+ TextInputProps.multiline && { height: "auto" },
+ $inputStyleOverride,
+ ]
+
+ const $helperStyles = [
+ $helperStyle,
+ status === "error" && { color: colors.light.error },
+ HelperTextProps?.style,
+ ]
+
+ function focusInput() {
+ if (disabled) return
+
+ input.current?.focus()
+ }
+
+ useImperativeHandle(ref, () => input.current)
+
+ return (
+
+ {!!(label || labelTx) && (
+
+ )}
+
+
+ {!!LeftAccessory && (
+
+ )}
+
+
+
+ {!!RightAccessory && (
+
+ )}
+
+
+ {!!(helper || helperTx) && (
+
+ )}
+
+ )
+})
+
+const $labelStyle: TextStyle = {
+ marginBottom: spacing.extraSmall,
+}
+
+const $inputWrapperStyle: ViewStyle = {
+ flexDirection: "row",
+ alignItems: "flex-start",
+ borderWidth: 1,
+ borderRadius: 4,
+ backgroundColor: colors.palette.neutral200,
+ borderColor: colors.palette.neutral400,
+ overflow: "hidden",
+}
+
+const $inputStyle: TextStyle = {
+ flex: 1,
+ alignSelf: "stretch",
+ fontFamily: typography.primary?.normal,
+ color: colors.light.text,
+ fontSize: 16,
+ height: 24,
+ // https://github.com/facebook/react-native/issues/21720#issuecomment-532642093
+ paddingVertical: 0,
+ paddingHorizontal: 0,
+ marginVertical: spacing.extraSmall,
+ marginHorizontal: spacing.small,
+}
+
+const $helperStyle: TextStyle = {
+ marginTop: spacing.extraSmall,
+}
+
+const $rightAccessoryStyle: ViewStyle = {
+ marginEnd: spacing.extraSmall,
+ height: 40,
+ justifyContent: "center",
+ alignItems: "center",
+}
+const $leftAccessoryStyle: ViewStyle = {
+ marginStart: spacing.extraSmall,
+ height: 40,
+ justifyContent: "center",
+ alignItems: "center",
+}
diff --git a/src/components/Toggle.tsx b/src/components/Toggle.tsx
new file mode 100644
index 00000000..35689058
--- /dev/null
+++ b/src/components/Toggle.tsx
@@ -0,0 +1,655 @@
+import React, { ComponentType, FC, useMemo } from "react"
+import {
+ GestureResponderEvent,
+ Image,
+ ImageStyle,
+ StyleProp,
+ SwitchProps,
+ TextInputProps,
+ TextStyle,
+ TouchableOpacity,
+ TouchableOpacityProps,
+ View,
+ ViewStyle,
+} from "react-native"
+import Animated, { useAnimatedStyle, withTiming } from "react-native-reanimated"
+import { colors, spacing } from "../theme"
+import { iconRegistry, IconTypes } from "./Icon"
+import { Text, TextProps } from "./Text"
+
+type Variants = "checkbox" | "switch" | "radio"
+
+interface BaseToggleProps extends Omit {
+ /**
+ * The variant of the toggle.
+ * Options: "checkbox", "switch", "radio"
+ * Default: "checkbox"
+ */
+ variant?: unknown
+ /**
+ * A style modifier for different input states.
+ */
+ status?: "error" | "disabled"
+ /**
+ * If false, input is not editable. The default value is true.
+ */
+ editable?: TextInputProps["editable"]
+ /**
+ * The value of the field. If true the component will be turned on.
+ */
+ value?: boolean
+ /**
+ * Invoked with the new value when the value changes.
+ */
+ onValueChange?: SwitchProps["onValueChange"]
+ /**
+ * Style overrides for the container
+ */
+ containerStyle?: StyleProp
+ /**
+ * Style overrides for the input wrapper
+ */
+ inputWrapperStyle?: StyleProp
+ /**
+ * Optional input wrapper style override.
+ * This gives the inputs their size, shape, "off" background-color, and outer border.
+ */
+ inputOuterStyle?: ViewStyle
+ /**
+ * Optional input style override.
+ * This gives the inputs their inner characteristics and "on" background-color.
+ */
+ inputInnerStyle?: ViewStyle
+ /**
+ * The position of the label relative to the action component.
+ * Default: right
+ */
+ labelPosition?: "left" | "right"
+ /**
+ * The label text to display if not using `labelTx`.
+ */
+ label?: TextProps["text"]
+ /**
+ * Label text which is looked up via i18n.
+ */
+ labelTx?: TextProps["tx"]
+ /**
+ * Optional label options to pass to i18n. Useful for interpolation
+ * as well as explicitly setting locale or translation fallbacks.
+ */
+ labelTxOptions?: TextProps["txOptions"]
+ /**
+ * Style overrides for label text.
+ */
+ labelStyle?: StyleProp
+ /**
+ * Pass any additional props directly to the label Text component.
+ */
+ LabelTextProps?: TextProps
+ /**
+ * The helper text to display if not using `helperTx`.
+ */
+ helper?: TextProps["text"]
+ /**
+ * Helper text which is looked up via i18n.
+ */
+ helperTx?: TextProps["tx"]
+ /**
+ * Optional helper options to pass to i18n. Useful for interpolation
+ * as well as explicitly setting locale or translation fallbacks.
+ */
+ helperTxOptions?: TextProps["txOptions"]
+ /**
+ * Pass any additional props directly to the helper Text component.
+ */
+ HelperTextProps?: TextProps
+}
+
+interface CheckboxToggleProps extends BaseToggleProps {
+ variant?: "checkbox"
+ /**
+ * Optional style prop that affects the Image component.
+ */
+ inputDetailStyle?: ImageStyle
+ /**
+ * Checkbox-only prop that changes the icon used for the "on" state.
+ */
+ checkboxIcon?: IconTypes
+}
+
+interface RadioToggleProps extends BaseToggleProps {
+ variant?: "radio"
+ /**
+ * Optional style prop that affects the dot View.
+ */
+ inputDetailStyle?: ViewStyle
+}
+
+interface SwitchToggleProps extends BaseToggleProps {
+ variant?: "switch"
+ /**
+ * Switch-only prop that adds a text/icon label for on/off states.
+ */
+ switchAccessibilityMode?: "text" | "icon"
+ /**
+ * Optional style prop that affects the knob View.
+ * Note: `width` and `height` rules should be points (numbers), not percentages.
+ */
+ inputDetailStyle?: Omit & { width?: number; height?: number }
+}
+
+export type ToggleProps = CheckboxToggleProps | RadioToggleProps | SwitchToggleProps
+
+interface ToggleInputProps {
+ on: boolean
+ status: BaseToggleProps["status"]
+ disabled: boolean
+ outerStyle: ViewStyle
+ innerStyle: ViewStyle
+ detailStyle: Omit
+ switchAccessibilityMode?: SwitchToggleProps["switchAccessibilityMode"]
+ checkboxIcon?: CheckboxToggleProps["checkboxIcon"]
+}
+
+/**
+ * Renders a boolean input.
+ * This is a controlled component that requires an onValueChange callback that updates the value prop in order for the component to reflect user actions. If the value prop is not updated, the component will continue to render the supplied value prop instead of the expected result of any user actions.
+ *
+ * - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Components-Toggle.md)
+ */
+export function Toggle(props: ToggleProps) {
+ const {
+ variant = "checkbox",
+ editable = true,
+ status,
+ value,
+ onPress,
+ onValueChange,
+ labelPosition = "right",
+ helper,
+ helperTx,
+ helperTxOptions,
+ HelperTextProps,
+ containerStyle: $containerStyleOverride,
+ inputWrapperStyle: $inputWrapperStyleOverride,
+ ...WrapperProps
+ } = props
+
+ const { switchAccessibilityMode } = props as SwitchToggleProps
+ const { checkboxIcon } = props as CheckboxToggleProps
+
+ const disabled = editable === false || status === "disabled" || props.disabled
+
+ const Wrapper = useMemo>(
+ () => (disabled ? View : TouchableOpacity),
+ [disabled],
+ )
+ const ToggleInput = useMemo(() => ToggleInputs[variant] || (() => null), [variant])
+
+ const $containerStyles = [$containerStyleOverride]
+ const $inputWrapperStyles = [$inputWrapper, $inputWrapperStyleOverride]
+ const $helperStyles = [
+ $helper,
+ status === "error" && { color: colors.error },
+ HelperTextProps?.style,
+ ]
+
+ function handlePress(e: GestureResponderEvent) {
+ if (disabled) return
+ onValueChange?.(!value)
+ onPress?.(e)
+ }
+
+ return (
+
+
+ {labelPosition === "left" && }
+
+
+
+ {labelPosition === "right" && }
+
+
+ {!!(helper || helperTx) && (
+
+ )}
+
+ )
+}
+
+const ToggleInputs: Record> = {
+ checkbox: Checkbox,
+ switch: Switch,
+ radio: Radio,
+}
+
+function Checkbox(props: ToggleInputProps) {
+ const {
+ on,
+ status,
+ disabled,
+ checkboxIcon,
+ outerStyle: $outerStyleOverride,
+ innerStyle: $innerStyleOverride,
+ detailStyle: $detailStyleOverride,
+ } = props
+
+ const offBackgroundColor = [
+ disabled && colors.palette.neutral400,
+ status === "error" && colors.errorBackground,
+ colors.palette.neutral200,
+ ].filter(Boolean)[0]
+
+ const outerBorderColor = [
+ disabled && colors.palette.neutral400,
+ status === "error" && colors.error,
+ !on && colors.palette.neutral800,
+ colors.palette.secondary500,
+ ].filter(Boolean)[0]
+
+ const onBackgroundColor = [
+ disabled && colors.transparent,
+ status === "error" && colors.errorBackground,
+ colors.palette.secondary500,
+ ].filter(Boolean)[0]
+
+ const iconTintColor = [
+ disabled && colors.palette.neutral600,
+ status === "error" && colors.error,
+ colors.palette.accent100,
+ ].filter(Boolean)[0]
+
+ return (
+
+ ({ opacity: withTiming(on ? 1 : 0) }), [on]),
+ ]}
+ >
+
+
+
+ )
+}
+
+function Radio(props: ToggleInputProps) {
+ const {
+ on,
+ status,
+ disabled,
+ outerStyle: $outerStyleOverride,
+ innerStyle: $innerStyleOverride,
+ detailStyle: $detailStyleOverride,
+ } = props
+
+ const offBackgroundColor = [
+ disabled && colors.palette.neutral400,
+ status === "error" && colors.errorBackground,
+ colors.palette.neutral200,
+ ].filter(Boolean)[0]
+
+ const outerBorderColor = [
+ disabled && colors.palette.neutral400,
+ status === "error" && colors.error,
+ !on && colors.palette.neutral800,
+ colors.palette.secondary500,
+ ].filter(Boolean)[0]
+
+ const onBackgroundColor = [
+ disabled && colors.transparent,
+ status === "error" && colors.errorBackground,
+ colors.palette.neutral100,
+ ].filter(Boolean)[0]
+
+ const dotBackgroundColor = [
+ disabled && colors.palette.neutral600,
+ status === "error" && colors.error,
+ colors.palette.secondary500,
+ ].filter(Boolean)[0]
+
+ return (
+
+ ({ opacity: withTiming(on ? 1 : 0) }), [on]),
+ ]}
+ >
+
+
+
+ )
+}
+
+function Switch(props: ToggleInputProps) {
+ const {
+ on,
+ status,
+ disabled,
+ outerStyle: $outerStyleOverride,
+ innerStyle: $innerStyleOverride,
+ detailStyle: $detailStyleOverride,
+ } = props
+
+ const knobSizeFallback = 2
+
+ const knobWidth = [$detailStyleOverride?.width, $switchDetail?.width, knobSizeFallback].find(
+ (v) => typeof v === "number",
+ )
+
+ const knobHeight = [$detailStyleOverride?.height, $switchDetail?.height, knobSizeFallback].find(
+ (v) => typeof v === "number",
+ )
+
+ const offBackgroundColor = [
+ disabled && colors.palette.neutral400,
+ status === "error" && colors.errorBackground,
+ colors.palette.neutral300,
+ ].filter(Boolean)[0]
+
+ const onBackgroundColor = [
+ disabled && colors.transparent,
+ status === "error" && colors.errorBackground,
+ colors.palette.secondary500,
+ ].filter(Boolean)[0]
+
+ const knobBackgroundColor = (function () {
+ if (on) {
+ return [
+ $detailStyleOverride?.backgroundColor,
+ status === "error" && colors.error,
+ disabled && colors.palette.neutral600,
+ colors.palette.neutral100,
+ ].filter(Boolean)[0]
+ } else {
+ return [
+ $innerStyleOverride?.backgroundColor,
+ disabled && colors.palette.neutral600,
+ status === "error" && colors.error,
+ colors.palette.neutral200,
+ ].filter(Boolean)[0]
+ }
+ })()
+
+ const $animatedSwitchKnob = useAnimatedStyle(() => {
+ const offsetLeft = ($innerStyleOverride?.paddingStart ||
+ $innerStyleOverride?.paddingLeft ||
+ $switchInner?.paddingStart ||
+ $switchInner?.paddingLeft ||
+ 0) as number
+
+ const offsetRight = ($innerStyleOverride?.paddingEnd ||
+ $innerStyleOverride?.paddingRight ||
+ $switchInner?.paddingEnd ||
+ $switchInner?.paddingRight ||
+ 0) as number
+
+ const start = withTiming(on ? "100%" : "0%")
+ const marginStart = withTiming(on ? -(knobWidth || 0) - offsetRight : 0 + offsetLeft)
+
+ return { start, marginStart }
+ }, [on, knobWidth])
+
+ return (
+
+ ({ opacity: withTiming(on ? 1 : 0) }), [on]),
+ ]}
+ />
+
+
+
+
+
+
+ )
+}
+
+function SwitchAccessibilityLabel(props: ToggleInputProps & { role: "on" | "off" }) {
+ const { on, disabled, status, switchAccessibilityMode, role, innerStyle, detailStyle } = props
+
+ if (!switchAccessibilityMode) return null
+
+ const shouldLabelBeVisible = (on && role === "on") || (!on && role === "off")
+
+ const $switchAccessibilityStyle = [
+ $switchAccessibility,
+ role === "off" && { end: "5%" },
+ role === "on" && { left: "5%" },
+ ]
+
+ const color = (function () {
+ if (disabled) return colors.palette.neutral600
+ if (status === "error") return colors.error
+ if (!on) return innerStyle?.backgroundColor || colors.palette.secondary500
+ return detailStyle?.backgroundColor || colors.palette.neutral100
+ })()
+
+ return (
+
+ {switchAccessibilityMode === "text" && shouldLabelBeVisible && (
+
+ )}
+
+ {switchAccessibilityMode === "icon" && shouldLabelBeVisible && (
+
+ )}
+
+ )
+}
+
+function FieldLabel(props: BaseToggleProps) {
+ const {
+ status,
+ label,
+ labelTx,
+ labelTxOptions,
+ LabelTextProps,
+ labelPosition,
+ labelStyle: $labelStyleOverride,
+ } = props
+
+ if (!label && !labelTx && !LabelTextProps?.children) return null
+
+ const $labelStyle = [
+ $label,
+ status === "error" && { color: colors.error },
+ labelPosition === "right" && $labelRight,
+ labelPosition === "left" && $labelLeft,
+ $labelStyleOverride,
+ LabelTextProps?.style,
+ ]
+
+ return (
+
+ )
+}
+
+const $inputWrapper: ViewStyle = {
+ flexDirection: "row",
+ alignItems: "center",
+}
+
+const $inputOuterBase: ViewStyle = {
+ height: 24,
+ width: 24,
+ borderWidth: 2,
+ alignItems: "center",
+ overflow: "hidden",
+ flexGrow: 0,
+ flexShrink: 0,
+ justifyContent: "space-between",
+ flexDirection: "row",
+}
+
+const $inputOuterVariants: Record> = {
+ checkbox: [$inputOuterBase, { borderRadius: 4 }],
+ radio: [$inputOuterBase, { borderRadius: 12 }],
+ switch: [$inputOuterBase, { height: 32, width: 56, borderRadius: 16, borderWidth: 0 }],
+}
+
+const $checkboxInner: ViewStyle = {
+ width: "100%",
+ height: "100%",
+ alignItems: "center",
+ justifyContent: "center",
+ overflow: "hidden",
+}
+
+const $checkboxDetail: ImageStyle = {
+ width: 20,
+ height: 20,
+ resizeMode: "contain",
+}
+
+const $radioInner: ViewStyle = {
+ width: "100%",
+ height: "100%",
+ alignItems: "center",
+ justifyContent: "center",
+ overflow: "hidden",
+}
+
+const $radioDetail: ViewStyle = {
+ width: 12,
+ height: 12,
+ borderRadius: 6,
+}
+
+const $switchInner: ViewStyle = {
+ width: "100%",
+ height: "100%",
+ alignItems: "center",
+ borderColor: colors.transparent,
+ overflow: "hidden",
+ position: "absolute",
+ paddingStart: 4,
+ paddingEnd: 4,
+}
+
+const $switchDetail: SwitchToggleProps["inputDetailStyle"] = {
+ borderRadius: 12,
+ position: "absolute",
+ width: 24,
+ height: 24,
+}
+
+const $helper: TextStyle = {
+ marginTop: spacing.extraSmall,
+}
+
+const $label: TextStyle = {
+ flex: 1,
+}
+
+const $labelRight: TextStyle = {
+ marginStart: spacing.medium,
+}
+
+const $labelLeft: TextStyle = {
+ marginEnd: spacing.medium,
+}
+
+const $switchAccessibility: TextStyle = {
+ width: "40%",
+ justifyContent: "center",
+ alignItems: "center",
+}
+
+const $switchAccessibilityIcon: ImageStyle = {
+ width: 14,
+ height: 14,
+ resizeMode: "contain",
+}
+
+const $switchAccessibilityLine: ViewStyle = {
+ width: 2,
+ height: 12,
+}
+
+const $switchAccessibilityCircle: ViewStyle = {
+ borderWidth: 2,
+ width: 12,
+ height: 12,
+ borderRadius: 6,
+}
diff --git a/src/components/index.ts b/src/components/index.ts
new file mode 100644
index 00000000..be791acc
--- /dev/null
+++ b/src/components/index.ts
@@ -0,0 +1,15 @@
+export * from "./AutoImage"
+export * from "./Button"
+export * from "./Card"
+export * from "./Header"
+export * from "./Icon"
+export * from "./ListItem"
+export * from "./Screen"
+export * from "./Text"
+export * from "./TextField"
+export * from "./Toggle"
+export * from "./EmptyState"
+export * from "./ErrorModal"
+export * from "./InfoModal"
+export * from "./BottomModal"
+export * from "./Loading"
diff --git a/src/config/config.base.ts b/src/config/config.base.ts
new file mode 100644
index 00000000..15076f85
--- /dev/null
+++ b/src/config/config.base.ts
@@ -0,0 +1,26 @@
+export interface ConfigBaseProps {
+ persistNavigation: "always" | "dev" | "prod" | "never"
+ catchErrors: "always" | "dev" | "prod" | "never"
+ exitRoutes: string[]
+}
+
+export type PersistNavigationConfig = ConfigBaseProps["persistNavigation"]
+
+const BaseConfig: ConfigBaseProps = {
+ // This feature is particularly useful in development mode, but
+ // can be used in production as well if you prefer.
+ persistNavigation: "dev",
+
+ /**
+ * Only enable if we're catching errors in the right environment
+ */
+ catchErrors: "always",
+
+ /**
+ * This is a list of all the route names that will exit the app if the back button
+ * is pressed while in that screen. Only affects Android.
+ */
+ exitRoutes: ["Welcome"],
+}
+
+export default BaseConfig
diff --git a/src/config/config.dev.ts b/src/config/config.dev.ts
new file mode 100644
index 00000000..258c51fc
--- /dev/null
+++ b/src/config/config.dev.ts
@@ -0,0 +1,10 @@
+/**
+ * These are configuration settings for the dev environment.
+ *
+ * Do not include API secrets in this file or anywhere in your JS.
+ *
+ * https://reactnative.dev/docs/security#storing-sensitive-info
+ */
+export default {
+ API_URL: "https://api.rss2json.com/v1/",
+}
diff --git a/src/config/config.prod.ts b/src/config/config.prod.ts
new file mode 100644
index 00000000..3acfe0a9
--- /dev/null
+++ b/src/config/config.prod.ts
@@ -0,0 +1,10 @@
+/**
+ * These are configuration settings for the production environment.
+ *
+ * Do not include API secrets in this file or anywhere in your JS.
+ *
+ * https://reactnative.dev/docs/security#storing-sensitive-info
+ */
+export default {
+ API_URL: "CHANGEME",
+}
diff --git a/src/config/index.ts b/src/config/index.ts
new file mode 100644
index 00000000..a0cbc2eb
--- /dev/null
+++ b/src/config/index.ts
@@ -0,0 +1,28 @@
+/**
+ * This file imports configuration objects from either the config.dev.js file
+ * or the config.prod.js file depending on whether we are in __DEV__ or not.
+ *
+ * Note that we do not gitignore these files. Unlike on web servers, just because
+ * these are not checked into your repo doesn't mean that they are secure.
+ * In fact, you're shipping a JavaScript bundle with every
+ * config variable in plain text. Anyone who downloads your app can easily
+ * extract them.
+ *
+ * If you doubt this, just bundle your app, and then go look at the bundle and
+ * search it for one of your config variable values. You'll find it there.
+ *
+ * Read more here: https://reactnative.dev/docs/security#storing-sensitive-info
+ */
+import BaseConfig from "./config.base"
+import ProdConfig from "./config.prod"
+import DevConfig from "./config.dev"
+
+let ExtraConfig = ProdConfig
+
+if (__DEV__) {
+ ExtraConfig = DevConfig
+}
+
+const Config = { ...BaseConfig, ...ExtraConfig }
+
+export default Config
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
new file mode 100644
index 00000000..4380c7eb
--- /dev/null
+++ b/src/i18n/en.ts
@@ -0,0 +1,152 @@
+const en = {
+ common: {
+ ok: "OK",
+ cancel: "Cancel",
+ close: "Close",
+ back: "Back",
+ paste: "Paste",
+ scan: "Scan",
+ copy: "Copy",
+ },
+ welcomeScreen: {
+ heading: "Welcome to Minibits",
+ intro: "Minibits is an e-cash wallet with a focus on performance and usability for non-techies. Cash is backed by Bitcoin via the Cashu protocol and Lightning Network.",
+ warning1: "This wallet should be used for research purposes only.",
+ warning2: "The wallet is an alpha version with incomplete functionality and both known and unknown bugs.",
+ warning3: "Do not use it with large amounts of coins.",
+ warning4: "Start by connecting to the mint you trust or by receiving coins from another e-cash Cashu wallet.",
+ warning5: "Visit github.com/minibits-cash to give your feedback.",
+ go: "Let's go!"
+ },
+ walletScreen: {
+ fund: "Fund wallet",
+ withdraw: "Withdraw",
+ send: "Send",
+ receive: "Receive",
+ topUpWallet: "Top up wallet",
+ topUpWalletSubText: "Top up your balance by paying Bitcoin lightning invoice from another wallet",
+ transferFromWallet: "Transfer from wallet",
+ transferFromWalletSubText: "Debit your balance by paying Bitcoin lightning invoice from this wallet",
+ },
+ receiveScreen: {
+ title: "Receive",
+ paste: "Paste coin",
+ receive: "Receive",
+ newMintsAdded: {
+ one: "{{count}} mint has been added to your wallet",
+ other: "{{count}} mints have been added to your wallet",
+ },
+ toReceive: "Amount to receive",
+ received: "Amount received:",
+ sharePaymentRequest: "Share payment request",
+ sharePaymentRequestDescription: "Share the amount and contact information as a link or QR code, so that the payer can pay you the requested amount.",
+ scanQRCodeToReceive: "Scan QR code to receive",
+ scanQRCodeToReceiveDescription: "If you see the coins in QR code format on another device, scan it to receive.",
+ pasteFromClipboard: "Paste from clipboard",
+ pasteFromClipboardDescription: "If you've received your coins through any other app, paste them here to receive."
+ },
+ transferScreen: {
+ pasteLightningInvoice: "Paste lightning invoice",
+ pasteLightningInvoiceDescription: "Paste the Bitcoin lightning invoice you want to pay. The mint will pay this invoice on your behalf in exchange for the coins from your wallet.",
+ scanLightningInvoice: "Scan lightning invoice",
+ scanLightningInvoiceDescription: "Scan the Bitcoin lightning invoice in QR code format. You can use your coins to pay any service supporting lightning payments."
+ },
+ topupScreen: {
+ sendInvoiceToContact: "Send invoice to contact",
+ sendInvoiceToContactDescription: "Send a lightning invoice to one of your contacts so they can top up your balance by paying it.",
+ showInvoiceQRCode: "Show invoice QR code",
+ showInvoiceQRCodeDescription: "Present the lightning invoice as a QR code so that you can pay it from the wallet on another device.",
+ shareInvoiceAsText: "Share invoice as text",
+ shareInvoiceAsTextDescription: "Copy and share the invoice with anybody through a secure app of your choice."
+ },
+ sendScreen: {
+ sendToContact: "Send to contact",
+ sendToContactDescription: "Send your coins to one of your contacts stored in your contact list.",
+ showAsQRCode: "Show as QR code",
+ showAsQRCodeDescription: "Present the coins as a QR code so that the recipient can scan it to receive them.",
+ shareAsText: "Share as text",
+ shareAsTextDescription: "Copy and send the coins to anybody through a secure app of your choice."
+ },
+ tranDetailScreen: {
+ amount: "Amount (in satoshis)",
+ memoFromSender: "Memo from sender",
+ sentFrom: "Sent from",
+ type: "Type",
+ status: "Status",
+ balanceAfter: "Balance after this transaction",
+ createdAt: "Created at",
+ id: "ID",
+ memoToReceiver: "Memo to receiver",
+ revert: "Revert",
+ claim: "Claim",
+ memoFromInvoice: "Memo from invoice",
+ paidFrom: "Paid from",
+ receivedTo: "Received to",
+ },
+ settingsScreen: {
+ title: "Settings",
+ manageMints: "Manage mints",
+ mintsCount: {
+ one: "{{count}} mint",
+ other: "{{count}} mints",
+ },
+ go: "Let's go!",
+ backupRecovery: "Backup and recovery",
+ security: "Security",
+ devOptions: "Developer options",
+ },
+ mintsScreen: {
+ addMintUrl: "Add mint URL",
+ mintUrl: "Mint URL",
+ mintUrlHint: "Provide the URL of a trusted mint",
+ invalidUrl: "This is not a valid URL",
+ mintExists: "This mint already exists",
+ addMint: "Add mint",
+ mintAdded: "The mint has been added",
+ mintInfo: "Mint information page",
+ rename: "Rename mint",
+ setColor: "Change the mint's icon color",
+ copy: "Copy mint URL",
+ removeMint: "Remove mint",
+ blockMint: "Block receiving from this mint",
+ unblockMint: "Unblock receiving from this mint",
+ mintRemoved: "The mint has been removed",
+ mintBlocked: "The mint has been blocked. The app will not receive coins from this mint.",
+ mintUnblocked: "The mint has been unblocked. You can now receive coins from this mint."
+ },
+ backupScreen: {
+ localBackup: "Local backup",
+ localBackupDescription: "Local backup stores a copy of all your coins in the local database. Coins are never deleted unless you switch off the backup. Please note that this backup is not encrypted.",
+ recoveryTool: "Recovery tool",
+ recoveryToolDescription: "Show coins backed up in the local database and attempt to recover unspent ones into the wallet in case the wallet storage gets corrupted or a transaction fails due to an unexpected error.",
+ removeSpentCoins: "Remove spent coins",
+ removeSpentCoinsDescription: "In case of a SEND or TRANSFER transaction error, spent coins may remain in your wallet. This blocks further transactions with the mint, making the wallet unusable. This tool removes spent coins from the wallet."
+ },
+ securityScreen: {
+ encryptStorage: "Encrypt storage",
+ encryptStorageDescription: "Encrypt the storage that stores your coins with the secret key generated on your device and stored in secure keys storage. On some Android devices, this may cause a slightly longer startup of the application.",
+ },
+ developerScreen: {
+ title: "Developer options",
+ transactions: "Sync recent transactions",
+ transactionsDescription: "There are {{count}} recent transactions cached in the app's fast storage. Resetting will reload them from the app's database.",
+ reset: "Factory reset",
+ resetDescription: "This will wipe out all local data from application state and from the local database including any stored coins. As of now, there is no way to recover your coins. Do only during development or testing.",
+ go: "Let's go!",
+ devOptions: "Developer options",
+ },
+ errorScreen: {
+ title: "Something went wrong!",
+ friendlySubtitle:
+ "Unfortunately, the Minibits app encountered an unexpected error.",
+ reset: "RESET APP",
+ },
+ tabNavigator: {
+ walletLabel: "Wallet",
+ contactsLabel: "Contacts",
+ settingsLabel: "Settings",
+ },
+};
+
+export default en;
+export type Translations = typeof en;
diff --git a/src/i18n/i18n.ts b/src/i18n/i18n.ts
new file mode 100644
index 00000000..c4480763
--- /dev/null
+++ b/src/i18n/i18n.ts
@@ -0,0 +1,44 @@
+// import * as Localization from "expo-localization"
+import { I18n } from "i18n-js"
+import { I18nManager } from "react-native"
+
+// if English isn't your default language, move Translations to the appropriate language file.
+import en, { Translations } from "./en"
+
+
+
+export const i18n = new I18n({'en-US': en})
+
+// i18n.fallbacks = true
+/**
+ * we need always include "*-US" for some valid language codes because when you change the system language,
+ * the language code is the suffixed with "-US". i.e. if a device is set to English ("en"),
+ * if you change to another language and then return to English language code is now "en-US".
+ */
+i18n.translations = { en, "en-US": en }
+
+// TODO get current system locale
+i18n.locale = "en-US"
+
+/**
+ * Builds up valid keypaths for translations.
+ */
+export type TxKeyPath = RecursiveKeyOf
+
+// via: https://stackoverflow.com/a/65333050
+type RecursiveKeyOf = {
+ [TKey in keyof TObj & (string | number)]: RecursiveKeyOfHandleValue
+}[keyof TObj & (string | number)]
+
+type RecursiveKeyOfInner = {
+ [TKey in keyof TObj & (string | number)]: RecursiveKeyOfHandleValue<
+ TObj[TKey],
+ `['${TKey}']` | `.${TKey}`
+ >
+}[keyof TObj & (string | number)]
+
+type RecursiveKeyOfHandleValue = TValue extends any[]
+ ? Text
+ : TValue extends object
+ ? Text | `${Text}${RecursiveKeyOfInner}`
+ : Text
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
new file mode 100644
index 00000000..fbfba4ef
--- /dev/null
+++ b/src/i18n/index.ts
@@ -0,0 +1,3 @@
+import "./i18n"
+export * from "./i18n"
+export * from "./translate"
diff --git a/src/i18n/translate.ts b/src/i18n/translate.ts
new file mode 100644
index 00000000..ae6aebab
--- /dev/null
+++ b/src/i18n/translate.ts
@@ -0,0 +1,32 @@
+
+import { I18nOptions } from "i18n-js"
+import { i18n } from "./i18n"
+import { TxKeyPath } from "./i18n"
+
+/**
+ * Translates text.
+ *
+ * @param key The i18n key.
+ * @param options The i18n options.
+ * @returns The translated text.
+ *
+ * @example
+ * Translations:
+ *
+ * ```en.ts
+ * {
+ * "hello": "Hello, {{name}}!"
+ * }
+ * ```
+ *
+ * Usage:
+ * ```ts
+ * import { translate } from "i18n-js"
+ *
+ * translate("common.ok", { name: "world" })
+ * // => "Hello world!"
+ * ```
+ */
+export function translate(key: TxKeyPath, options?: I18nOptions) {
+ return i18n.t(key, options)
+}
diff --git a/src/models/Invoice.ts b/src/models/Invoice.ts
new file mode 100644
index 00000000..dcc35c2d
--- /dev/null
+++ b/src/models/Invoice.ts
@@ -0,0 +1,53 @@
+import {Instance, SnapshotIn, SnapshotOut, types} from 'mobx-state-tree'
+import {decodeInvoice} from '../services/cashuHelpers'
+import {withSetPropAction} from './helpers/withSetPropAction'
+import {log} from '../utils/logger'
+import addSeconds from 'date-fns/addSeconds'
+
+/**
+ * This represents lightning invoice got for request to mint new tokens
+ */
+
+export const InvoiceModel = types
+ .model('Invoice', {
+ mint: types.string,
+ encodedInvoice: types.string,
+ amount: types.number,
+ description: types.optional(types.string, ''),
+ paymentHash: types.string,
+ expiry: types.optional(types.number, 600),
+ memo: types.optional(types.string, 'Topup of minibits wallet'),
+ transactionId: types.number,
+ expiresAt: types.maybe(types.Date),
+ createdAt: types.optional(types.Date, new Date()),
+ })
+ .actions(withSetPropAction)
+ .actions(self => ({
+ setExpiresAt() {
+ const decoded = decodeInvoice(self.encodedInvoice)
+ let {expiry} = decoded // in seconds
+ if (!expiry) {
+ expiry = 600
+ }
+ const expiresAt = addSeconds(new Date(), expiry)
+
+ log.trace(
+ `Invoice expiry is ${expiry}, setting expiresAt to ${expiresAt}`,
+ )
+ self.expiresAt = new Date(expiresAt)
+ },
+ }))
+
+
+export type Invoice = {
+ mint: string
+ encodedInvoice: string
+ amount: number
+ description?: string
+ paymentHash: string
+ expiry?: number
+ memo: string
+ transactionId: number
+} & Partial>
+export interface InvoiceSnapshotOut extends SnapshotOut {}
+export interface InvoiceSnapshotIn extends SnapshotIn {}
diff --git a/src/models/InvoicesStore.ts b/src/models/InvoicesStore.ts
new file mode 100644
index 00000000..b0d28f96
--- /dev/null
+++ b/src/models/InvoicesStore.ts
@@ -0,0 +1,76 @@
+import {
+ Instance,
+ SnapshotOut,
+ types,
+ destroy,
+ isStateTreeNode,
+} from 'mobx-state-tree'
+import {withSetPropAction} from './helpers/withSetPropAction'
+import {InvoiceModel, Invoice} from './Invoice'
+import {log} from '../utils/logger'
+
+export const InvoicesStoreModel = types
+ .model('Invoices', {
+ invoices: types.array(InvoiceModel),
+ })
+ .views(self => ({
+ findByPaymentHash(paymentHash: string) {
+ const invoice = self.invoices.find(i => i.paymentHash === paymentHash)
+ return invoice ? invoice : undefined
+ },
+ }))
+ .actions(withSetPropAction)
+ .actions(self => ({
+ addInvoice(newInvoice: Invoice) {
+ const invoiceInstance = InvoiceModel.create(newInvoice)
+ // expiry in Date format
+ invoiceInstance.setExpiresAt()
+ self.invoices.push(invoiceInstance)
+
+ log.info('New invoice added to InvoicesStore')
+
+ return invoiceInstance
+ },
+ removeInvoice(invoiceToRemove: Invoice) {
+ // self.invoices.remove(invoice)
+
+ let invoiceInstance: Invoice | undefined
+
+ if (isStateTreeNode(invoiceToRemove)) {
+ invoiceInstance = invoiceToRemove
+ } else {
+ invoiceInstance = self.findByPaymentHash(
+ (invoiceToRemove as Invoice).paymentHash,
+ )
+ }
+
+ if (invoiceInstance) {
+ destroy(invoiceInstance)
+ log.info('Invoice removed from InvoicesStore')
+ }
+ },
+ }))
+ .views(self => ({
+ get invoicesCount() {
+ return self.invoices.length
+ },
+ get allInvoices() {
+ return self.invoices
+ },
+ filterByMint(mintUrl: string) {
+ let filtered: Invoice[] = []
+
+ filtered = self.invoices.filter(invoice => {
+ if (invoice.mint === mintUrl) {
+ return true
+ }
+ return false
+ })
+
+ return filtered
+ },
+ }))
+
+export interface Invoices extends Instance {}
+export interface InvoicesStoreSnapshot
+ extends SnapshotOut {}
diff --git a/src/models/Mint.ts b/src/models/Mint.ts
new file mode 100644
index 00000000..12778b77
--- /dev/null
+++ b/src/models/Mint.ts
@@ -0,0 +1,63 @@
+import {Instance, SnapshotIn, SnapshotOut, types} from 'mobx-state-tree'
+import {withSetPropAction} from './helpers/withSetPropAction'
+import type {MintKeys} from '@cashu/cashu-ts/dist/lib/es5/model/types'
+import {colors, getRandomIconColor} from '../theme'
+
+// used as a helper type across app
+export type MintBalance = {
+ mint: string
+ balance: number
+}
+
+/**
+ * This represents a Cashu mint
+ */
+export const MintModel = types
+ .model('Mint', {
+ mintUrl: types.identifier,
+ hostname: types.maybe(types.string),
+ shortname: types.maybe(types.string),
+ keys: types.frozen(),
+ keysets: types.frozen(),
+ color: types.optional(types.string, colors.palette.iconBlue200),
+ createdAt: types.optional(types.Date, new Date()),
+ })
+ .actions(withSetPropAction)
+ .actions(self => ({
+ validateURL(url: string) {
+ try {
+ new URL(url)
+ return true
+ } catch (e) {
+ return false
+ }
+ },
+ setHostname() {
+ try {
+ self.hostname = new URL(self.mintUrl).hostname
+ } catch (e) {
+ return false
+ }
+ },
+ setShortname(shortname: string) {
+ try {
+ self.shortname = shortname.slice(0, 25)
+ } catch (e) {
+ return false
+ }
+ },
+ setRandomColor() {
+ self.color = getRandomIconColor()
+ },
+ setColor(color: string) {
+ self.color = color
+ },
+ }))
+
+export type Mint = {
+ mintUrl: string
+ keys: MintKeys
+ keysets: string[]
+} & Partial>
+export interface MintSnapshotOut extends SnapshotOut {}
+export interface MintSnapshotIn extends SnapshotIn {}
diff --git a/src/models/MintsStore.ts b/src/models/MintsStore.ts
new file mode 100644
index 00000000..83ee6dd2
--- /dev/null
+++ b/src/models/MintsStore.ts
@@ -0,0 +1,119 @@
+import {
+ Instance,
+ SnapshotOut,
+ types,
+ destroy,
+ isStateTreeNode,
+} from 'mobx-state-tree'
+import {withSetPropAction} from './helpers/withSetPropAction'
+import {MintModel, Mint} from './Mint'
+import {log} from '../utils/logger'
+
+export type MintsByHostname = {
+ hostname: string
+ mints: Mint[]
+}
+
+export const MintsStoreModel = types
+ .model('MintsStore', {
+ mints: types.array(MintModel),
+ blockedMintUrls: types.array(types.string),
+ })
+ .views(self => ({
+ findByUrl: (mintUrl: string | URL) => {
+ const mint = self.mints.find(m => m.mintUrl === mintUrl)
+ return mint ? mint : undefined
+ },
+ }))
+ .actions(withSetPropAction)
+ .actions(self => ({
+ addMint(newMint: Mint) {
+ const mintInstance = MintModel.create(newMint)
+ // set derived properties
+ mintInstance.setHostname()
+ const lastSlashIndex = newMint.mintUrl.lastIndexOf('/')
+ mintInstance.setShortname(newMint.mintUrl.substring(lastSlashIndex + 1))
+ mintInstance.setRandomColor()
+
+ self.mints.push(mintInstance)
+
+ log.info('New mint added to the MintsStore')
+ },
+ removeMint(mintToBeRemoved: Mint) {
+ if (self.blockedMintUrls.some(m => m === mintToBeRemoved.mintUrl)) {
+ self.blockedMintUrls.remove(mintToBeRemoved.mintUrl)
+ log.info('Mint removed from blockedMintUrls')
+ }
+
+ let mintInstance: Mint | undefined
+
+ if (isStateTreeNode(mintToBeRemoved)) {
+ mintInstance = mintToBeRemoved
+ } else {
+ mintInstance = self.findByUrl((mintToBeRemoved as Mint).mintUrl)
+ }
+
+ if (mintInstance) {
+ destroy(mintInstance)
+ log.info('Mint removed from MintsStore')
+ }
+ },
+ blockMint(mintToBeBlocked: Mint) {
+ self.blockedMintUrls.push(mintToBeBlocked.mintUrl)
+ log.info('Mint blocked in MintsStore')
+ },
+ unblockMint(blockedMint: Mint) {
+ self.blockedMintUrls.remove(blockedMint.mintUrl)
+ log.info('Mint unblocked in MintsStore')
+ },
+ }))
+ .views(self => ({
+ get mintCount() {
+ return self.mints.length
+ },
+ get allMints() {
+ return self.mints
+ },
+ get groupedByHostname() {
+ const grouped: Record = {}
+
+ self.mints.forEach((mint: Mint) => {
+ const {hostname} = mint
+
+ if (!grouped[hostname]) {
+ grouped[hostname] = {
+ hostname,
+ mints: [],
+ }
+ }
+
+ grouped[hostname].mints.push(mint)
+ })
+
+ return Object.values(grouped) as MintsByHostname[]
+ },
+ alreadyExists(mintUrl: string) {
+ return self.mints.some(m => m.mintUrl === mintUrl) ? true : false
+ },
+ isBlocked(mintUrl: string) {
+ return self.blockedMintUrls.some(m => m === mintUrl) ? true : false
+ },
+ getBlockedFromList(mintUrls: string[]) {
+ return mintUrls.filter(mintUrl =>
+ self.blockedMintUrls.some(blockedUrl => blockedUrl === mintUrl),
+ )
+ },
+ getMissingMints: (mintUrls: string[]) => {
+ const missingMints: string[] = []
+ for (const url of mintUrls) {
+ if (!self.mints.find(mint => mint.mintUrl === url)) {
+ missingMints.push(url)
+ }
+ }
+ return missingMints
+ },
+ }))
+
+export interface MintsStore extends Instance {}
+export interface MintsStoreSnapshot
+ extends SnapshotOut {}
diff --git a/src/models/Proof.ts b/src/models/Proof.ts
new file mode 100644
index 00000000..e38d272b
--- /dev/null
+++ b/src/models/Proof.ts
@@ -0,0 +1,40 @@
+import {Instance, SnapshotIn, SnapshotOut, types} from 'mobx-state-tree'
+import {withSetPropAction} from './helpers/withSetPropAction'
+
+/**
+ * Proof type saved in database backup
+ */
+
+export type BackupProof = Proof & {
+ isPending: boolean
+ isSpent: boolean
+ updatedAt: Date
+}
+
+/**
+ * Proof model
+ */
+
+export const ProofModel = types
+ .model('Proof', {
+ id: types.string,
+ amount: types.number,
+ secret: types.identifier,
+ C: types.string,
+ tId: types.maybe(types.number),
+ mintUrl: types.maybe(types.string),
+ })
+ .actions(withSetPropAction)
+ .actions(self => ({
+ setTransactionId(id: number) {
+ self.tId = id
+ },
+ setMintUrl(url: string) {
+ self.mintUrl = url
+ },
+ }))
+
+
+export interface Proof extends Instance {}
+export interface ProofSnapshotOut extends SnapshotOut {}
+export interface ProofSnapshotIn extends SnapshotIn {}
diff --git a/src/models/ProofsStore.ts b/src/models/ProofsStore.ts
new file mode 100644
index 00000000..19280a76
--- /dev/null
+++ b/src/models/ProofsStore.ts
@@ -0,0 +1,198 @@
+import {
+ Instance,
+ SnapshotOut,
+ types,
+ isStateTreeNode,
+ detach,
+} from 'mobx-state-tree'
+import {withSetPropAction} from './helpers/withSetPropAction'
+import {ProofModel, Proof} from './Proof'
+import {log} from '../utils/logger'
+import {getRootStore} from './helpers/getRootStore'
+import AppError, {Err} from '../utils/AppError'
+import {Mint, MintBalance} from './Mint'
+import {Database} from '../services'
+
+export const ProofsStoreModel = types
+ .model('Proofs', {
+ proofs: types.array(ProofModel),
+ pendingProofs: types.array(ProofModel),
+ })
+ .actions(withSetPropAction)
+ .views(self => ({
+ getBySecret(secret: string, isPending: boolean = false): Proof | undefined {
+ const proofs = isPending ? self.pendingProofs : self.proofs
+ return proofs.find(proof => proof.secret === secret) || undefined
+ },
+ }))
+ .views(self => ({
+ getMintFromProof(proof: Proof): Mint | undefined {
+ const rootStore = getRootStore(self)
+ const {mintsStore} = rootStore
+
+ for (const m of mintsStore.allMints) {
+ if (m.keysets?.includes(proof.id)) {
+ return m
+ }
+ }
+
+ return undefined
+ },
+ getByMint(
+ mintUrl: string,
+ isPending: boolean = false,
+ ): Proof[] | undefined {
+ const proofs = isPending ? self.pendingProofs : self.proofs
+ return proofs.filter(proof => proof.mintUrl === mintUrl)
+ },
+ getProofInstance(proof: Proof, isPending: boolean = false) {
+ let proofInstance: Proof | undefined
+ if (isStateTreeNode(proof)) {
+ proofInstance = proof
+ } else {
+ proofInstance = self.getBySecret((proof as Proof).secret, isPending)
+ }
+
+ return proofInstance
+ },
+
+ }))
+ .actions(self => ({
+ addProofs(newProofs: Proof[], isPending: boolean = false) {
+ try {
+ const proofs = isPending ? self.pendingProofs : self.proofs
+
+ for (const proof of newProofs) {
+ if (isStateTreeNode(proof)) {
+ proofs.push(proof)
+ } else {
+ const proofInstance = ProofModel.create(proof)
+ proofs.push(proofInstance)
+ }
+ }
+
+ log.info(`Added new ${newProofs.length}${isPending ? ' pending' : ''} proofs to ProofsStore`,
+ )
+
+ const rootStore = getRootStore(self)
+ const {userSettingsStore} = rootStore
+
+ if (userSettingsStore.isLocalBackupOn === true) {
+ Database.addOrUpdateProofs(newProofs, isPending)
+ }
+ } catch (e: any) {
+ throw new AppError(Err.STORAGE_ERROR, e.message)
+ }
+ },
+ removeProofs(proofsToRemove: Proof[], isPending: boolean = false) {
+ try {
+ const proofs = isPending ? self.pendingProofs : self.proofs
+
+ const rootStore = getRootStore(self)
+ const count = proofsToRemove.length
+ const {userSettingsStore} = rootStore
+
+ if (userSettingsStore.isLocalBackupOn === true) {
+ // isPending = false, isSpent = true
+ Database.addOrUpdateProofs(proofsToRemove, false, true)
+ }
+
+ proofsToRemove.map(proof => detach(proof)) // vital
+
+ proofs.replace(proofs.filter(proof => !proofsToRemove.includes(proof)))
+
+ log.info(`${count} ${(isPending) ? 'pending' : ''} proofs removed from ProofsStore`)
+
+ } catch (e: any) {
+ throw new AppError(Err.STORAGE_ERROR, e.message)
+ }
+ },
+ }))
+ .views(self => ({
+ get proofsCount() {
+ return self.proofs.length
+ },
+ get allProofs() {
+ return self.proofs
+ },
+ get allPendingProofs() {
+ return self.pendingProofs
+ },
+ }))
+ .views(self => ({
+ getBalances() {
+ let totalBalance = 0
+ let totalPendingBalance = 0
+
+ const mints = getRootStore(self).mintsStore.allMints
+ const mintBalances: MintBalance[] = mints.map(mint => {
+ return {mint: mint.mintUrl, balance: 0}
+ })
+ const mintPendingBalances: MintBalance[] = mints.map(mint => {
+ return {mint: mint.mintUrl, balance: 0}
+ })
+
+ self.proofs.forEach(proof => {
+ const amount = proof.amount
+ totalBalance += amount
+
+ for (const mintBalance of mintBalances) {
+ if (mintBalance.mint === proof.mintUrl) {
+ mintBalance.balance += amount
+ }
+ }
+ })
+
+ self.pendingProofs.forEach(proof => {
+ const amount = proof.amount
+ totalPendingBalance += amount
+
+ for (const pendingBalance of mintPendingBalances) {
+ if (pendingBalance.mint === proof.mintUrl) {
+ pendingBalance.balance += amount
+ }
+ }
+ })
+
+ const balances = {
+ totalBalance,
+ totalPendingBalance,
+ mintBalances,
+ mintPendingBalances,
+ }
+
+ log.trace(balances)
+
+ return balances
+ },
+ }))
+ .views(self => ({
+ getMintBalancesWithEnoughBalance: (amount: number) => {
+ const balances = self.getBalances().mintBalances
+
+ const filteredMintBalances = balances
+ .slice()
+ .filter((balance: MintBalance) => balance.balance >= amount)
+ .sort((a, b) => b.balance - a.balance)
+
+ return filteredMintBalances
+ },
+ getProofsToSend: (amount: number, proofs: Proof[]) => {
+ let proofsAmount = 0
+ const proofSubset = proofs.filter(proof => {
+ if (proofsAmount < amount) {
+ proofsAmount += proof.amount
+ return true
+ }
+ })
+ return proofSubset
+ },
+ getProofsSubset: (proofs: Proof[], proofsToRemove: Proof[]) => {
+ return proofs.filter(proof => !proofsToRemove.includes(proof))
+ },
+ }))
+
+
+export interface Proofs extends Instance {}
+export interface ProofsStoreSnapshot
+ extends SnapshotOut {}
diff --git a/src/models/RootStore.ts b/src/models/RootStore.ts
new file mode 100644
index 00000000..a61c08ca
--- /dev/null
+++ b/src/models/RootStore.ts
@@ -0,0 +1,36 @@
+import {Instance, SnapshotOut, types} from 'mobx-state-tree'
+import {MintsStoreModel} from './MintsStore'
+import {TransactionsStoreModel} from './TransactionsStore'
+import {UserSettingsStoreModel} from './UserSettingsStore'
+import {InvoicesStoreModel} from './InvoicesStore'
+import {ProofsStoreModel} from './ProofsStore'
+
+export const rootStoreModelVersion = 1 // Update this if model changes require migrations defined in setupRootStore.ts
+
+/**
+ * A RootStore model.
+ */
+export const RootStoreModel = types
+ .model('RootStore')
+ .props({
+ userSettingsStore: types.optional(UserSettingsStoreModel, {}),
+ mintsStore: types.optional(MintsStoreModel, {}),
+ proofsStore: types.optional(ProofsStoreModel, {}),
+ transactionsStore: types.optional(TransactionsStoreModel, {}),
+ invoicesStore: types.optional(InvoicesStoreModel, {}),
+ version: types.optional(types.number, rootStoreModelVersion),
+ })
+ .actions(self => ({
+ setVersion(version: number) {
+ self.version = version
+ },
+ }))
+
+/**
+ * The RootStore instance.
+ */
+export interface RootStore extends Instance {}
+/**
+ * The data of a RootStore.
+ */
+export interface RootStoreSnapshot extends SnapshotOut {}
diff --git a/src/models/Token.ts b/src/models/Token.ts
new file mode 100644
index 00000000..724613ed
--- /dev/null
+++ b/src/models/Token.ts
@@ -0,0 +1,13 @@
+import {Instance, types} from 'mobx-state-tree'
+import {TokenEntryModel} from './TokenEntry'
+
+/**
+ * This represents sendable Cashu token V3
+ */
+
+export const TokenModel = types.model('Token', {
+ token: types.array(TokenEntryModel),
+ memo: types.maybe(types.string),
+})
+
+export interface Token extends Instance {}
diff --git a/src/models/TokenEntry.ts b/src/models/TokenEntry.ts
new file mode 100644
index 00000000..1720361f
--- /dev/null
+++ b/src/models/TokenEntry.ts
@@ -0,0 +1,24 @@
+import {Instance, SnapshotIn, SnapshotOut, types} from 'mobx-state-tree'
+import {withSetPropAction} from './helpers/withSetPropAction'
+import {ProofModel} from './Proof'
+// import type { Proof } from '@cashu/cashu-ts'
+
+/**
+ * This represents a Cashu token V3
+ */
+
+export const TokenEntryModel = types
+ .model('TokenEntry', {
+ mint: types.string,
+ proofs: types.array(
+ types.safeReference(ProofModel, {acceptsUndefined: false}),
+ ),
+ transactionId: types.maybe(types.number),
+ })
+
+
+export interface TokenEntry extends Instance {}
+export interface TokenEntrySnapshotOut
+ extends SnapshotOut {}
+export interface TokenEntrySnapshotIn
+ extends SnapshotIn {}
diff --git a/src/models/Transaction.ts b/src/models/Transaction.ts
new file mode 100644
index 00000000..be072eb4
--- /dev/null
+++ b/src/models/Transaction.ts
@@ -0,0 +1,66 @@
+import {Instance, SnapshotIn, SnapshotOut, types} from 'mobx-state-tree'
+
+export interface TransactionRecord {
+ id: number
+ type: TransactionType
+ amount: number
+ data: string
+ sentFrom?: string
+ memo?: string
+ balanceAfter?: number
+ noteToSelf?: string
+ tags?: Array
+ status: TransactionStatus
+ createdAt: string
+}
+
+export type TransactionData = {
+ status: TransactionStatus
+ [index: string]: any
+}
+
+export enum TransactionType {
+ SEND = 'SEND',
+ RECEIVE = 'RECEIVE',
+ RECEIVE_NOSTR = 'RECEIVE_NOSTR',
+ TOPUP = 'TOPUP',
+ TRANSFER = 'TRANSFER',
+}
+
+export enum TransactionStatus {
+ DRAFT = 'DRAFT',
+ PREPARED = 'PREPARED',
+ PENDING = 'PENDING',
+ REVERTED = 'REVERTED',
+ COMPLETED = 'COMPLETED',
+ ERROR = 'ERROR',
+ BLOCKED = 'BLOCKED',
+ EXPIRED = 'EXPIRED',
+}
+
+export const TransactionModel = types
+ .model('Transaction', {
+ id: types.maybe(types.number),
+ type: types.frozen(),
+ amount: types.integer,
+ fee: types.maybe(types.maybeNull(types.integer)),
+ data: types.string,
+ sentFrom: types.maybe(types.maybeNull(types.string)),
+ memo: types.maybe(types.maybeNull(types.string)),
+ balanceAfter: types.maybe(types.maybeNull(types.integer)),
+ noteToSelf: types.maybe(types.maybeNull(types.string)),
+ tags: types.maybe(types.maybeNull(types.array(types.string))),
+ status: types.frozen(),
+ createdAt: types.optional(types.Date, new Date()),
+ })
+
+export type Transaction = {
+ amount: number
+ type: TransactionType
+ data: string
+ status: TransactionStatus
+} & Partial>
+export interface TransactionSnapshotOut
+ extends SnapshotOut {}
+export interface TransactionSnapshotIn
+ extends SnapshotIn {}
diff --git a/src/models/TransactionsStore.ts b/src/models/TransactionsStore.ts
new file mode 100644
index 00000000..82829835
--- /dev/null
+++ b/src/models/TransactionsStore.ts
@@ -0,0 +1,248 @@
+import {
+ Instance,
+ SnapshotOut,
+ types,
+ flow,
+ destroy,
+ isStateTreeNode,
+} from 'mobx-state-tree'
+import {withSetPropAction} from './helpers/withSetPropAction'
+import {
+ TransactionModel,
+ Transaction,
+ TransactionStatus,
+ TransactionRecord,
+} from './Transaction'
+import {Database} from '../services'
+import {log} from '../utils/logger'
+
+export const maxTransactionsInModel = 10
+
+export const TransactionsStoreModel = types
+ .model('TransactionsStore', {
+ transactions: types.array(TransactionModel),
+ })
+ .actions(withSetPropAction)
+ .actions(self => ({
+ findById: (id: number) => {
+ const tx = self.transactions.find(tx => tx.id === id)
+ return tx ? tx : undefined
+ },
+ removeTransaction: (removedTransaction: Transaction) => {
+ let transactionInstance: Transaction | undefined
+
+ if (isStateTreeNode(removedTransaction)) {
+ transactionInstance = removedTransaction
+ } else {
+ transactionInstance = self.transactions.find(
+ (t: Transaction) => t.id === (removedTransaction as Transaction).id,
+ )
+ }
+
+ if (transactionInstance) {
+ destroy(transactionInstance)
+ log.info('Transaction removed from TransactionsStore')
+ }
+ },
+ removeOldTransactions: () => {
+ const numTransactions = self.transactions.length
+
+ // If there are more than 10 transactions, delete the older ones
+ if (numTransactions > maxTransactionsInModel) {
+ self.transactions
+ .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()) // Sort transactions by createdAt in descending order
+ .splice(maxTransactionsInModel) // Remove transactions beyond the desired number to keep
+
+ log.info(
+ `${
+ numTransactions - maxTransactionsInModel
+ } transaction(s) removed from TransactionsStore`,
+ )
+ }
+ },
+ }))
+ .actions(self => ({
+ addTransaction: flow(function* addTransaction(newTransaction: Transaction) {
+ // First let's store the transaction into the database
+ const dbTransaction = yield Database.addTransactionAsync(newTransaction)
+
+ // Add the new transaction to the transactions store
+ const createdAt = new Date(dbTransaction.createdAt)
+ const inStoreTransaction = {...dbTransaction, createdAt}
+
+ const transactionInstance = TransactionModel.create(inStoreTransaction)
+ self.transactions.push(transactionInstance)
+
+ log.info(
+ 'New transaction added to the TransactionsStore',
+ [],
+ 'addTransaction',
+ )
+
+ // Purge the oldest transaction from the model if the maximum number of transactions is reached
+ if (self.transactions.length > maxTransactionsInModel) {
+ self.removeOldTransactions()
+ }
+
+ return transactionInstance
+ }),
+ addTransactionsToModel: (dbTransactions: TransactionRecord[]) => {
+ // This adds to model only. Used to have observable UI in tx history loaded from database.
+ const inStoreTransactions: Transaction[] = []
+
+ for (const dbTransaction of dbTransactions) {
+ const createdAt = new Date(dbTransaction.createdAt)
+ const inStoreTransaction = {...dbTransaction, createdAt}
+
+ const transactionInstance = TransactionModel.create(inStoreTransaction)
+ inStoreTransactions.push(transactionInstance)
+ }
+
+ self.transactions.push(...inStoreTransactions)
+
+ log.trace(
+ `${inStoreTransactions.length} new transactions added to TransactionsStore`,
+ )
+ },
+ updateStatus: flow(function* updateStatus(
+ id: number,
+ status: TransactionStatus,
+ data: string,
+ ) {
+ // Update status and set related tx data in database
+ yield Database.updateStatusAsync(id, status, data)
+ const transactionInstance = self.findById(id)
+
+ // Update in the model
+ if (transactionInstance) {
+ transactionInstance.status = status
+ transactionInstance.data = data
+ log.info(
+ 'Transaction status and data updated in TransactionsStore',
+ 'updateStatus',
+ )
+ }
+
+ return transactionInstance
+ }),
+ updateStatuses: flow(function* updateStatuses(
+ ids: number[],
+ status: TransactionStatus,
+ data: string,
+ ) {
+ // Update status and amend to existing data in database
+ yield Database.updateStatusesAsync(ids, status, data)
+
+ // Update the model status and amend related tx data
+ for (const id of ids) {
+ const transactionInstance = self.findById(id)
+
+ if (transactionInstance) {
+ transactionInstance.status = status
+
+ // Awkward but I want to keep function signature aligned with single status update
+ const updatedData = JSON.parse(transactionInstance.data)
+ updatedData.push(JSON.parse(data))
+ transactionInstance.data = JSON.stringify(updatedData)
+
+
+ log.info('Transaction status and data updated in TransactionsStore',[],'updateStatuses',)
+ }
+ }
+ }),
+ updateBalanceAfter: flow(function* updateBalanceAfter(
+ id: number,
+ balanceAfter: number,
+ ) {
+ // Update status and related metadata in database
+ yield Database.updateBalanceAfterAsync(id, balanceAfter)
+
+ const transactionInstance = self.findById(id)
+
+ // Update in the model
+ if (transactionInstance) {
+ transactionInstance.balanceAfter = balanceAfter
+ log.info('Transaction balanceAfter updated in TransactionsStore',[],'updateBalanceAfter',)
+ }
+
+ return transactionInstance
+ }),
+ updateFee: flow(function* updateFee(id: number, fee: number) {
+ // Update status and related metadata in database
+ yield Database.updateBalanceAfterAsync(id, fee)
+
+ const transactionInstance = self.findById(id)
+
+ // Update in the model
+ if (transactionInstance) {
+ transactionInstance.fee = fee
+ log.info('Transaction fee updated in TransactionsStore')
+ }
+
+ return transactionInstance
+ }),
+ updateReceivedAmount: flow(function* updateReceivedAmount(
+ id: number,
+ amount: number,
+ ) {
+ // Update status and related metadata in database
+ yield Database.updateReceivedAmountAsync(id, amount)
+
+ const transactionInstance = self.findById(id)
+
+ // Update in the model
+ if (transactionInstance) {
+ transactionInstance.amount = amount
+ log.info('Transaction amount updated in TransactionsStore')
+ }
+
+ return transactionInstance
+ }),
+ saveNote: flow(function* saveNote(id: number, note: string) {
+ // update note stored in database
+ yield Database.updateNoteAsync(id, note)
+
+ // check if the tx is in recent transactions stored in this store
+ const transactionInstance = self.findById(id)
+ // if we found it, we update the model so that the UI updates
+ // TODO how to handle missing UI updates for older notes that are stored in db only
+ if (transactionInstance) {
+ transactionInstance.noteToSelf = note
+ log.info('Transaction note updated in TransactionsStore')
+ }
+ }),
+ removeAllTransactions() {
+ for (const t of self.transactions){
+ destroy(t)
+ }
+
+ log.info('Removed all transactions from TransactionsStore')
+ },
+ }))
+ .views(self => ({
+ get count() {
+ return self.transactions.length
+ },
+ get recent(): Transaction[] {
+ return this.all.slice(0, 3) // Return the first 5 transactions
+ },
+ get all() {
+ return self.transactions
+ .slice()
+ .sort((a, b) => {
+ // Sort by createdAt timestamp
+ if (a.createdAt && b.createdAt) {
+ return b.createdAt.getTime() - a.createdAt.getTime()
+ }
+ })
+ },
+ get pending() {
+ return this.all.filter(t => t.status === TransactionStatus.PENDING)
+ },
+ }))
+
+
+export interface TransactionsStore
+ extends Instance {}
+export interface TransactionsStoreSnapshot
+ extends SnapshotOut {}
diff --git a/src/models/UserSettingsStore.ts b/src/models/UserSettingsStore.ts
new file mode 100644
index 00000000..94f5e42b
--- /dev/null
+++ b/src/models/UserSettingsStore.ts
@@ -0,0 +1,79 @@
+import {Instance, SnapshotOut, types, flow} from 'mobx-state-tree'
+import {Database} from '../services'
+import {MMKVStorage} from '../services'
+import {log} from '../utils/logger'
+
+export type UserSettings = {
+ id?: number
+ isOnboarded: boolean | 0 | 1
+ isStorageEncrypted: boolean | 0 | 1
+ isLocalBackupOn: boolean | 0 | 1
+}
+
+export const UserSettingsStoreModel = types
+ .model('UserSettingsStore')
+ .props({
+ isOnboarded: types.optional(types.boolean, false),
+ isStorageEncrypted: types.optional(types.boolean, false),
+ isLocalBackupOn: types.optional(types.boolean, true),
+ })
+ .actions(self => ({
+ loadUserSettings: () => {
+ const {isOnboarded, isStorageEncrypted, isLocalBackupOn} =
+ Database.getUserSettings()
+ // TODO move to some of mobx preprocessing method
+ self.isOnboarded = isOnboarded as boolean
+ self.isStorageEncrypted = isStorageEncrypted as boolean
+ self.isLocalBackupOn = isLocalBackupOn as boolean
+ },
+ setIsOnboarded: (value: boolean) => {
+ Database.updateUserSettings({...self, isOnboarded: value})
+ self.isOnboarded = value
+
+ log.info('Onboarded new device', value)
+ },
+ setIsLocalBackupOn: (value: boolean) => {
+ Database.updateUserSettings({...self, isLocalBackupOn: value})
+ self.isLocalBackupOn = value
+
+ log.info('Local backup is turned on', value)
+ return value
+ },
+ setIsStorageEncrypted: flow(function* setIsStorageEncryptedvalue(
+ value: boolean,
+ ) {
+ const isEncrypted = yield MMKVStorage.recryptStorage()
+ Database.updateUserSettings({...self, isStorageEncrypted: isEncrypted})
+ self.isStorageEncrypted = isEncrypted
+
+ log.info('Storage is encrypted', value)
+ return isEncrypted
+ }),
+ }))
+ .views(self => ({
+ get isUserOnboarded() {
+ return self.isOnboarded
+ },
+ get isAppStorageEncrypted() {
+ return self.isStorageEncrypted
+ },
+ get userSettings() {
+ return self
+ },
+ }))
+/*.preProcessSnapshot((snapshot) => {
+ // remove sensitive data from snapshot to avoid secrets
+ // being stored in AsyncStorage in plain text if backing up store
+ // const { authToken, authPassword, ...rest } = snapshot // eslint-disable-line @typescript-eslint/no-unused-vars
+
+ // see the following for strategies to consider storing secrets on device
+ // https://reactnative.dev/docs/security#storing-sensitive-info
+
+ // return rest
+ return snapshot
+ })*/
+
+export interface UserSettingsStore
+ extends Instance {}
+export interface UserSettingsStoreSnapshot
+ extends SnapshotOut {}
diff --git a/src/models/helpers/getRootStore.ts b/src/models/helpers/getRootStore.ts
new file mode 100644
index 00000000..5ff50568
--- /dev/null
+++ b/src/models/helpers/getRootStore.ts
@@ -0,0 +1,10 @@
+import { getRoot, IStateTreeNode } from "mobx-state-tree"
+import { RootStore, RootStoreModel } from "../RootStore"
+
+/**
+ * Returns a RootStore object in strongly typed way
+ * for stores to access other stores.
+ */
+export const getRootStore = (self: IStateTreeNode): RootStore => {
+ return getRoot(self)
+}
diff --git a/src/models/helpers/setupRootStore.ts b/src/models/helpers/setupRootStore.ts
new file mode 100644
index 00000000..8b805e7a
--- /dev/null
+++ b/src/models/helpers/setupRootStore.ts
@@ -0,0 +1,173 @@
+/**
+ * This file is where we do "rehydration" of your RootStore from AsyncStorage.
+ * This lets you persist your state between app launches.
+ *
+ * Navigation state persistence is handled in navigationUtilities.tsx.
+ *
+ * Note that Fast Refresh doesn't play well with this file, so if you edit this,
+ * do a full refresh of your app instead.
+ *
+ * @refresh reset
+ */
+import {
+ applySnapshot,
+ IDisposer,
+ IModelType,
+ ModelInstanceType,
+ onSnapshot,
+} from 'mobx-state-tree'
+import type {RootStore} from '../RootStore'
+import {MMKVStorage} from '../../services'
+import {Database} from '../../services'
+import { log } from '../../utils/logger'
+import { rootStoreModelVersion } from '../RootStore'
+import AppError, { Err } from '../../utils/AppError'
+// import { AsyncStorage } from "../../services"
+
+/**
+ * The key we'll be saving our state as within storage.
+ */
+
+const ROOT_STORAGE_KEY = 'minibits-root-storage'
+
+/**
+ * Setup the root state.
+ */
+let _disposer: IDisposer
+export async function setupRootStore(rootStore: RootStore) {
+ let restoredState: any
+ // let latestSnapshot: any
+
+ try {
+ // Give an option to encrypt storage as it might slow down app start on some Android devices
+ // User settings are mastered in sqlite so we can get the encryption setting before loading root store
+ const userSettings = Database.getUserSettings()
+
+ if (userSettings.isStorageEncrypted) {
+ await MMKVStorage.initEncryption() // key retrieval on Android is sometimes very slow
+ }
+
+ // load the last known state from storage
+ restoredState = MMKVStorage.load(ROOT_STORAGE_KEY) || {}
+ applySnapshot(rootStore, restoredState)
+
+ } catch (e: any) {
+ log.error(Err.DATABASE_ERROR, e.message)
+ }
+
+ // stop tracking state changes if we've already setup
+ if (_disposer) {
+ _disposer()
+ }
+
+ // track changes & save to storage // TODO defering and batching of writes to storage
+ _disposer = onSnapshot(rootStore, snapshot =>
+ MMKVStorage.save(ROOT_STORAGE_KEY, snapshot),
+ )
+
+ // run migrations if needed, needs to be after onSnapshot to be persisted
+ try {
+ log.info('Device rootStorage version', rootStore.version, 'setupRootStore')
+
+ if(rootStore.version < rootStoreModelVersion) {
+ await _runMigrations(rootStore)
+ }
+ } catch (e: any) {
+ log.error(Err.DATABASE_ERROR, e.message)
+ }
+
+ const unsubscribe = () => {
+ _disposer()
+ _disposer = undefined
+ }
+
+ return {rootStore, restoredState, unsubscribe}
+}
+
+/**
+ * Migrations code to execute based on code and on device model version.
+ */
+
+async function _runMigrations(rootStore: RootStore) {
+ const {
+ mintsStore,
+ } = rootStore
+
+ let currentVersion = rootStore.version
+ try {
+ // v1 -> v2 migration
+ if(currentVersion < 2) {
+ log.trace(`Starting rootStore migrations from version ${currentVersion} -> v2`)
+
+ currentVersion = 2
+ log.info(`Completed rootStore migrations to version ${currentVersion}`)
+ }
+
+ // v2 -> v3 migration
+ if(currentVersion < 3) {
+ log.trace(`Starting rootStore migrations from ${currentVersion} -> v3`)
+
+ currentVersion = 3
+ log.trace(`Completed rootStore migrations to version ${currentVersion}`)
+ }
+
+ rootStore.setVersion(rootStoreModelVersion)
+ } catch (e: any) {
+ throw new AppError(
+ Err.DATABASE_ERROR,
+ 'Error when executing rootStore migrations',
+ e.message,
+ )
+ }
+}
+
+/* interface StoreConfig { storageKey: string, store: any }
+
+let _disposers: IDisposer[] = []
+export async function setupRootStore(rootStore: RootStore) {
+
+
+ const storeConfigs: StoreConfig[] = [
+ { storageKey: "mintsStore", store: rootStore.mintsStore },
+
+ { storageKey: "transactionsStore", store: rootStore.transactionsStore },
+ ]
+
+ const restoredStates: { [key: string]: any } = {}
+
+ try {
+ // retrieve key asynchronously
+ const encryptionKey = await MMKVStorage.getOrCreateMMKVEncryptionKey()
+
+ // initialize or reuse storage with encryption key so we can work in synchronous mode
+ MMKVStorage.getInstance(encryptionKey)
+
+ for (const { storageKey, store } of storeConfigs) {
+ const restoredState = (MMKVStorage.load(storageKey)) || {}
+ applySnapshot(store, restoredState)
+ restoredStates[storageKey] = restoredState
+ }
+ } catch (e: any) {
+ // if there's any problems loading, then inform the dev what happened
+ if (__DEV__) {
+ console.error(e.message, null)
+ }
+ }
+
+ // stop tracking state changes if we've already setup
+ _disposers.forEach(disposer => disposer)
+ _disposers = []
+
+ // track changes & save to AsyncStorage for each store
+ for (const { storageKey, store } of storeConfigs) {
+ const disposer = onSnapshot(store, (snapshot) => MMKVStorage.save(storageKey, snapshot))
+ _disposers.push(disposer)
+ }
+
+ const unsubscribe = () => {
+ _disposers.forEach(disposer => disposer())
+ _disposers = []
+ }
+
+ return { rootStore, restoredStates, unsubscribe }
+} */
diff --git a/src/models/helpers/useStores.ts b/src/models/helpers/useStores.ts
new file mode 100644
index 00000000..6c413a2a
--- /dev/null
+++ b/src/models/helpers/useStores.ts
@@ -0,0 +1,83 @@
+import { createContext, useContext, useEffect, useState } from "react"
+// import { setReactotronRootStore } from "../../services/reactotron"
+import { RootStore, RootStoreModel } from "../RootStore"
+import { setupRootStore } from "./setupRootStore"
+
+/**
+ * Create the initial (empty) global RootStore instance here.
+ *
+ * Later, it will be rehydrated in app.tsx with the setupRootStore function.
+ *
+ * If your RootStore requires specific properties to be instantiated,
+ * you can do so here.
+ *
+ * If your RootStore has a _ton_ of sub-stores and properties (the tree is
+ * very large), you may want to use a different strategy than immediately
+ * instantiating it, although that should be rare.
+ */
+const _rootStore = RootStoreModel.create({})
+
+/**
+ * The RootStoreContext provides a way to access
+ * the RootStore in any screen or component.
+ */
+const RootStoreContext = createContext(_rootStore)
+
+/**
+ * You can use this Provider to specify a *different* RootStore
+ * than the singleton version above if you need to. Generally speaking,
+ * this Provider & custom RootStore instances would only be used in
+ * testing scenarios.
+ */
+export const RootStoreProvider = RootStoreContext.Provider
+
+/**
+ * A hook that screens and other components can use to gain access to
+ * our stores:
+ *
+ * const rootStore = useStores()
+ *
+ * or:
+ *
+ * const { someStore, someOtherStore } = useStores()
+ */
+export const useStores = () => useContext(RootStoreContext)
+
+/**
+ * Used only in the app.tsx file, this hook sets up the RootStore
+ * and then rehydrates it. It connects everything with Reactotron
+ * and then lets the app know that everything is ready to go.
+ */
+export const useInitialRootStore = (callback: () => void | Promise) => {
+ const rootStore = useStores()
+ const [rehydrated, setRehydrated] = useState(false)
+
+ // Kick off initial async loading actions, like loading fonts and rehydrating RootStore
+ useEffect(() => {
+ let _unsubscribe
+ ;(async () => {
+ // set up the RootStore (returns the state restored from storage)
+ const { restoredState, unsubscribe } = await setupRootStore(rootStore)
+ _unsubscribe = unsubscribe
+
+ // reactotron integration with the MST root store (DEV only)
+ // setReactotronRootStore(rootStore, restoredState)
+
+ // let the app know we've finished rehydrating
+ setRehydrated(true)
+
+ // invoke the callback, if provided
+ if (callback) callback()
+ })()
+
+ return () => {
+ // cleanup
+ if (_unsubscribe) _unsubscribe()
+ }
+ }, [])
+
+ return { rootStore, rehydrated }
+}
+
+// Access the same root store instace outside of React context
+export const rootStoreInstance = _rootStore
diff --git a/src/models/helpers/withSetPropAction.ts b/src/models/helpers/withSetPropAction.ts
new file mode 100644
index 00000000..d832b5c4
--- /dev/null
+++ b/src/models/helpers/withSetPropAction.ts
@@ -0,0 +1,30 @@
+import { IStateTreeNode, SnapshotIn } from "mobx-state-tree"
+
+/**
+ * If you include this in your model in an action() block just under your props,
+ * it'll allow you to set property values directly while retaining type safety
+ * and also is executed in an action. This is useful because often you find yourself
+ * making a lot of repetitive setter actions that only update one prop.
+ *
+ * E.g.:
+ *
+ * const UserModel = types.model("User")
+ * .props({
+ * name: types.string,
+ * age: types.number
+ * })
+ * .actions(withSetPropAction)
+ *
+ * const user = UserModel.create({ name: "Jamon", age: 40 })
+ *
+ * user.setProp("name", "John") // no type error
+ * user.setProp("age", 30) // no type error
+ * user.setProp("age", "30") // type error -- must be number
+ */
+export const withSetPropAction = (mstInstance: T) => ({
+ // generic setter for all properties
+ setProp, V extends SnapshotIn[K]>(field: K, newValue: V) {
+ // @ts-ignore - for some reason TS complains about this, but it still works fine
+ mstInstance[field] = newValue
+ },
+})
diff --git a/src/models/index.ts b/src/models/index.ts
new file mode 100644
index 00000000..c03cfe96
--- /dev/null
+++ b/src/models/index.ts
@@ -0,0 +1,4 @@
+export * from "./RootStore"
+export * from "./helpers/getRootStore"
+export * from "./helpers/useStores"
+export * from "./helpers/setupRootStore"
diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx
new file mode 100644
index 00000000..1440b485
--- /dev/null
+++ b/src/navigation/AppNavigator.tsx
@@ -0,0 +1,93 @@
+/**
+ * The app navigator (formerly "AppNavigator" and "MainNavigator") is used for the primary
+ * navigation flows of your app.
+ * Generally speaking, it will contain an auth flow (registration, login, forgot password)
+ * and a "main" flow which the user will use once logged in.
+ */
+import {
+ NavigationContainer,
+ NavigatorScreenParams
+} from "@react-navigation/native"
+import { createNativeStackNavigator } from "@react-navigation/native-stack"
+import { StackScreenProps } from "@react-navigation/stack"
+import { observer } from "mobx-react-lite"
+import React from "react"
+import Config from "../config"
+import {
+ WelcomeScreen,
+} from "../screens"
+import { useStores } from "../models"
+import { TabsNavigator, TabsParamList } from "./TabsNavigator"
+import { navigationRef, useBackButtonHandler } from "./navigationUtilities"
+
+/**
+ * This type allows TypeScript to know what routes are defined in this navigator
+ * as well as what properties (if any) they might take when navigating to them.
+ *
+ * If no params are allowed, pass through `undefined`. Generally speaking, we
+ * recommend using your MobX-State-Tree store(s) to keep application state
+ * rather than passing state through navigation params.
+ *
+ * For more information, see this documentation:
+ * https://reactnavigation.org/docs/params/
+ * https://reactnavigation.org/docs/typescript#type-checking-the-navigator
+ * https://reactnavigation.org/docs/typescript/#organizing-types
+ */
+export type AppStackParamList = {
+ Welcome: undefined
+ Tabs: NavigatorScreenParams
+}
+
+/**
+ * This is a list of all the route names that will exit the app if the back button
+ * is pressed while in that screen. Only affects Android.
+ */
+const exitRoutes = Config.exitRoutes
+
+export type AppStackScreenProps = StackScreenProps<
+ AppStackParamList,
+ T
+>
+
+// Documentation: https://reactnavigation.org/docs/stack-navigator/
+const Stack = createNativeStackNavigator()
+
+
+const AppStack = observer(function AppStack() {
+
+ const { userSettingsStore } = useStores()
+
+ return (
+
+ {userSettingsStore.isUserOnboarded ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+ )
+})
+
+interface NavigationProps extends Partial> {}
+
+export const AppNavigator = observer(function AppNavigator(props: NavigationProps) {
+
+ useBackButtonHandler((routeName) => exitRoutes.includes(routeName))
+
+ return (
+
+
+
+ )
+})
diff --git a/src/navigation/TabsNavigator.tsx b/src/navigation/TabsNavigator.tsx
new file mode 100644
index 00000000..34611a1d
--- /dev/null
+++ b/src/navigation/TabsNavigator.tsx
@@ -0,0 +1,189 @@
+import { BottomTabScreenProps, createBottomTabNavigator } from "@react-navigation/bottom-tabs"
+import { CompositeScreenProps, DarkTheme, DefaultTheme, NavigationContainer, NavigatorScreenParams } from "@react-navigation/native"
+import { createNativeStackNavigator } from "@react-navigation/native-stack"
+import { StackScreenProps } from "@react-navigation/stack"
+import React from "react"
+import { TextStyle, ViewStyle } from "react-native"
+import { useSafeAreaInsets } from "react-native-safe-area-context"
+import { Icon } from "../components"
+import { translate } from "../i18n"
+import {
+ WalletScreen,
+ ReceiveScreen,
+ SendScreen,
+ ScanScreen,
+ ContactsScreen,
+ SettingsScreen,
+ MintsScreen,
+ DeveloperScreen,
+ SecurityScreen,
+ BackupScreen,
+ LocalRecoveryScreen,
+ TranDetailScreen,
+ TranHistoryScreen,
+ TransferScreen,
+ TopupScreen,
+} from "../screens"
+import { colors, useThemeColor, spacing, typography } from "../theme"
+import { AppStackParamList, AppStackScreenProps } from "./AppNavigator"
+
+
+export type TabsParamList = {
+ WalletNavigator: NavigatorScreenParams
+ Contacts: undefined
+ SettingsNavigator: NavigatorScreenParams
+}
+
+/**
+ * Helper for automatically generating navigation prop types for each route.
+ *
+ * More info: https://reactnavigation.org/docs/typescript/#organizing-types
+ */
+export type TabScreenProps = CompositeScreenProps<
+ BottomTabScreenProps,
+ AppStackScreenProps
+>
+
+const Tab = createBottomTabNavigator()
+
+export function TabsNavigator() {
+ const { bottom } = useSafeAreaInsets()
+ const bgColor = useThemeColor('menu')
+ const textColor = useThemeColor('text')
+ const activeColor = useThemeColor('tint')
+
+ return (
+
+ ,
+ }}
+ />
+
+ ,
+ }}
+ />
+
+ ,
+ }}
+ />
+
+ )
+}
+
+
+export type WalletStackParamList = {
+ Wallet: undefined
+ Receive: {scannedEncodedToken? : string}
+ Send: undefined
+ Scan: undefined
+ TranDetail: {id: number}
+ TranHistory: undefined
+ Transfer: {scannedEncodedInvoice? : string}
+ Topup: undefined
+}
+
+export type WalletStackScreenProps = StackScreenProps<
+ WalletStackParamList,
+ T
+>
+
+const WalletStack = createNativeStackNavigator()
+
+const WalletNavigator = function WalletNavigator() {
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+
+export type SettingsStackParamList = {
+ Settings: undefined
+ Mints: undefined
+ Security: undefined
+ Backup: undefined
+ LocalRecovery: undefined
+ Developer: undefined
+}
+
+export type SettingsStackScreenProps = StackScreenProps<
+ SettingsStackParamList,
+ T
+>
+
+const SettingsStack = createNativeStackNavigator()
+
+const SettingsNavigator = function SettingsNavigator() {
+
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
+const $tabBar: ViewStyle = {
+ borderTopColor: 'transparent',
+ borderTopWidth: 0,
+ elevation: 0
+}
+
+const $tabBarItem: ViewStyle = {
+ paddingTop: spacing.medium,
+}
+
+const $tabBarLabel: TextStyle = {
+ fontSize: 12,
+ fontFamily: typography.primary?.medium,
+ lineHeight: 16,
+ flex: 1,
+}
diff --git a/src/navigation/index.ts b/src/navigation/index.ts
new file mode 100644
index 00000000..b845a7df
--- /dev/null
+++ b/src/navigation/index.ts
@@ -0,0 +1,4 @@
+export * from "./AppNavigator"
+export * from "./TabsNavigator"
+export * from "./navigationUtilities"
+// export other navigators from here
diff --git a/src/navigation/navigationUtilities.ts b/src/navigation/navigationUtilities.ts
new file mode 100644
index 00000000..ab55c3d1
--- /dev/null
+++ b/src/navigation/navigationUtilities.ts
@@ -0,0 +1,171 @@
+import { useState, useEffect, useRef } from "react"
+import { BackHandler, Platform } from "react-native"
+import {
+ PartialState,
+ NavigationState,
+ NavigationAction,
+ createNavigationContainerRef,
+} from "@react-navigation/native"
+import Config from "../config"
+import type { PersistNavigationConfig } from "../config/config.base"
+// import { useIsMounted } from "../utils/useIsMounted"
+
+/* eslint-disable */
+export const RootNavigation = {
+ navigate(_name: string, _params?: any) {},
+ goBack() {},
+ resetRoot(_state?: PartialState | NavigationState) {},
+ getRootState(): NavigationState {
+ return {} as any
+ },
+ dispatch(_action: NavigationAction) {},
+}
+/* eslint-enable */
+
+export const navigationRef = createNavigationContainerRef()
+
+/**
+ * Gets the current screen from any navigation state.
+ */
+export function getActiveRouteName(state: NavigationState | PartialState) {
+ const route = state.routes[state.index]
+
+ // Found the active route -- return the name
+ if (!route.state) return route.name
+
+ // Recursive call to deal with nested routers
+ return getActiveRouteName(route.state)
+}
+
+/**
+ * Hook that handles Android back button presses and forwards those on to
+ * the navigation or allows exiting the app.
+ */
+export function useBackButtonHandler(canExit: (routeName: string) => boolean) {
+ // ignore if iOS ... no back button!
+ if (Platform.OS === "ios") return
+
+ // The reason we're using a ref here is because we need to be able
+ // to update the canExit function without re-setting up all the listeners
+ const canExitRef = useRef(canExit)
+
+ useEffect(() => {
+ canExitRef.current = canExit
+ }, [canExit])
+
+ useEffect(() => {
+ // We'll fire this when the back button is pressed on Android.
+ const onBackPress = () => {
+ if (!navigationRef.isReady()) {
+ return false
+ }
+
+ // grab the current route
+ const routeName = getActiveRouteName(navigationRef.getRootState())
+
+ // are we allowed to exit?
+ if (canExitRef.current(routeName)) {
+ // exit and let the system know we've handled the event
+ BackHandler.exitApp()
+ return true
+ }
+
+ // we can't exit, so let's turn this into a back action
+ if (navigationRef.canGoBack()) {
+ navigationRef.goBack()
+ return true
+ }
+
+ return false
+ }
+
+ // Subscribe when we come to life
+ BackHandler.addEventListener("hardwareBackPress", onBackPress)
+
+ // Unsubscribe when we're done
+ return () => BackHandler.removeEventListener("hardwareBackPress", onBackPress)
+ }, [])
+}
+
+/**
+ * This helper function will determine whether we should enable navigation persistence
+ * based on a config setting and the __DEV__ environment (dev or prod).
+ */
+function navigationRestoredDefaultState(persistNavigation: PersistNavigationConfig) {
+ if (persistNavigation === "always") return false
+ if (persistNavigation === "dev" && __DEV__) return false
+ if (persistNavigation === "prod" && !__DEV__) return false
+
+ // all other cases, disable restoration by returning true
+ return true
+}
+
+/**
+ * Custom hook for persisting navigation state.
+ */
+export function useNavigationPersistence(storage: any, persistenceKey: string) {
+ const [initialNavigationState, setInitialNavigationState] = useState()
+ // const isMounted = useIsMounted()
+
+ const initNavState = navigationRestoredDefaultState(Config.persistNavigation)
+ const [isRestored, setIsRestored] = useState(initNavState)
+
+ const routeNameRef = useRef()
+
+ const onNavigationStateChange = (state) => {
+ const previousRouteName = routeNameRef.current
+ const currentRouteName = getActiveRouteName(state)
+
+ if (previousRouteName !== currentRouteName) {
+ // track screens.
+ if (__DEV__) {
+ // console.tron.log(currentRouteName)
+ console.log(currentRouteName)
+ }
+ }
+
+ // Save the current route name for later comparison
+ routeNameRef.current = currentRouteName
+
+ // Persist state to storage
+ storage.save(persistenceKey, state)
+ }
+
+ const restoreState = async () => {
+ try {
+ const state = await storage.load(persistenceKey)
+ if (state) setInitialNavigationState(state)
+ } finally {
+ // if (isMounted()) setIsRestored(true)
+ }
+ }
+
+ useEffect(() => {
+ if (!isRestored) restoreState()
+ }, [isRestored])
+
+ return { onNavigationStateChange, restoreState, isRestored, initialNavigationState }
+}
+
+/**
+ * use this to navigate without the navigation
+ * prop. If you have access to the navigation prop, do not use this.
+ * More info: https://reactnavigation.org/docs/navigating-without-navigation-prop/
+ */
+export function navigate(name: any, params?: any) {
+ if (navigationRef.isReady()) {
+ navigationRef.navigate(name as never, params as never)
+ }
+}
+
+export function goBack() {
+ if (navigationRef.isReady() && navigationRef.canGoBack()) {
+ navigationRef.goBack()
+ }
+}
+
+export function resetRoot(params = { index: 0, routes: [] }) {
+ if (navigationRef.isReady()) {
+ navigationRef.resetRoot(params)
+ }
+}
diff --git a/src/screens/BackupScreen.tsx b/src/screens/BackupScreen.tsx
new file mode 100644
index 00000000..32f83f31
--- /dev/null
+++ b/src/screens/BackupScreen.tsx
@@ -0,0 +1,228 @@
+import {observer} from 'mobx-react-lite'
+import React, {FC, useState} from 'react'
+import {Switch, TextStyle, View, ViewStyle} from 'react-native'
+import {colors, spacing, useThemeColor} from '../theme'
+import {SettingsStackScreenProps} from '../navigation'
+import {
+ Icon,
+ ListItem,
+ Screen,
+ Text,
+ Card,
+ Loading,
+ ErrorModal,
+ InfoModal,
+ BottomModal,
+} from '../components'
+import {useHeader} from '../utils/useHeader'
+import {useStores} from '../models'
+import AppError from '../utils/AppError'
+import {ResultModalInfo} from './Wallet/ResultModalInfo'
+import {Database, Wallet} from '../services'
+
+export const BackupScreen: FC> = observer(function BackupScreen(_props) {
+ const {navigation} = _props
+ useHeader({
+ leftIcon: 'faArrowLeft',
+ onLeftPress: () => navigation.goBack(),
+ })
+
+ const {userSettingsStore, proofsStore} = useStores()
+
+ const [isLoading, setIsLoading] = useState(false)
+ const [isLocalBackupOn, setIsLocalBackupOn] = useState(
+ userSettingsStore.isLocalBackupOn,
+ )
+ const [error, setError] = useState()
+ const [info, setInfo] = useState('')
+ const [isBackupModalVisible, setIsBackupModalVisible] =
+ useState(false)
+ const [backupResultMessage, setBackupResultMessage] = useState()
+
+ const toggleBackupSwitch = () => {
+ try {
+ setIsLoading(true)
+ const result = userSettingsStore.setIsLocalBackupOn(!isLocalBackupOn)
+ setIsLocalBackupOn(result)
+
+ if (result === true) {
+ Database.addOrUpdateProofs(proofsStore.allProofs)
+ Database.addOrUpdateProofs(proofsStore.allPendingProofs, true)
+
+ setBackupResultMessage(
+ 'Your minibits tokens were backed up to local database. New tokens will be backed up automatically.',
+ )
+ toggleBackupModal()
+ setIsLoading(false)
+ return
+ }
+
+ Database.removeAllProofs()
+ setIsLoading(false)
+ setBackupResultMessage(
+ 'Your backup of minibits tokens has been deleted. New tokens will not be backed up.',
+ )
+ toggleBackupModal()
+ } catch (e: any) {
+ handleError(e)
+ }
+ }
+
+ const toggleBackupModal = () =>
+ setIsBackupModalVisible(previousState => !previousState)
+
+ const gotoLocalRecovery = function () {
+ navigation.navigate('LocalRecovery')
+ }
+
+ const checkSpent = async function () {
+ setIsLoading(true)
+ const result = (await Wallet.checkSpent()) as {
+ spentCount: number
+ spentAmount: number
+ } | void
+ setIsLoading(false)
+
+ if (result && result.spentAmount > 0) {
+ setInfo(
+ `${result.spentCount} coins, ${result.spentAmount} sats in total were removed from the wallet.`,
+ )
+ return
+ }
+
+ setInfo('No spent coins found in your wallet')
+ }
+
+ const handleError = function (e: AppError): void {
+ setIsLoading(false)
+ setError(e)
+ }
+
+ const headerBg = useThemeColor('header')
+
+ return (
+
+
+
+
+
+
+
+ }
+ RightComponent={
+
+
+
+ }
+ style={$item}
+ />
+ {isLocalBackupOn && (
+
+ }
+ style={$item}
+ onPress={gotoLocalRecovery}
+ />
+ )}
+
+ }
+ style={$item}
+ onPress={checkSpent}
+ />
+ >
+ }
+ />
+ {isLoading && }
+
+
+ }
+ onBackButtonPress={toggleBackupModal}
+ onBackdropPress={toggleBackupModal}
+ />
+ {error && }
+ {info && }
+
+ )
+ },
+)
+
+const $screen: ViewStyle = {}
+
+const $headerContainer: TextStyle = {
+ alignItems: 'center',
+ padding: spacing.medium,
+ height: spacing.screenHeight * 0.1,
+}
+
+const $contentContainer: TextStyle = {
+ // flex: 1,
+ padding: spacing.extraSmall,
+ // alignItems: 'center',
+}
+
+const $card: ViewStyle = {
+ marginBottom: 0,
+}
+
+const $item: ViewStyle = {
+ paddingHorizontal: spacing.small,
+ paddingLeft: 0,
+}
+
+const $rightContainer: ViewStyle = {
+ // padding: spacing.extraSmall,
+ alignSelf: 'center',
+ // marginLeft: spacing.small,
+}
diff --git a/src/screens/ContactsScreen.tsx b/src/screens/ContactsScreen.tsx
new file mode 100644
index 00000000..dce7073e
--- /dev/null
+++ b/src/screens/ContactsScreen.tsx
@@ -0,0 +1,27 @@
+import {observer} from 'mobx-react-lite'
+import React, {FC} from 'react'
+import {ViewStyle} from 'react-native'
+import {spacing} from '../theme'
+import {Card, Screen} from '../components'
+import {TabScreenProps} from '../navigation' // @demo remove-current-line
+import {useHeader} from '../utils/useHeader'
+
+interface ContactsScreenProps extends TabScreenProps<'Contacts'> {}
+
+export const ContactsScreen: FC = observer(function ContactsScreen(_props) {
+ useHeader({}) // default header component
+
+ return (
+
+
+
+ )
+})
+
+const $screen: ViewStyle = {
+ // flex: 1,
+ // backgroundColor: colors.background,
+}
diff --git a/src/screens/DeveloperScreen.tsx b/src/screens/DeveloperScreen.tsx
new file mode 100644
index 00000000..13bb2eb1
--- /dev/null
+++ b/src/screens/DeveloperScreen.tsx
@@ -0,0 +1,197 @@
+import {observer} from 'mobx-react-lite'
+import React, {FC, useState} from 'react'
+import {Alert, TextStyle, View, ViewStyle} from 'react-native'
+import {colors, spacing, useThemeColor} from '../theme'
+import {SettingsStackScreenProps} from '../navigation' // @demo remove-current-line
+import {
+ Icon,
+ ListItem,
+ Screen,
+ Text,
+ Card,
+ Loading,
+ ErrorModal,
+ InfoModal,
+} from '../components'
+import {useHeader} from '../utils/useHeader'
+import {useStores} from '../models'
+import {translate} from '../i18n'
+import AppError from '../utils/AppError'
+import {Database} from '../services'
+import {MMKVStorage} from '../services'
+import {maxTransactionsInModel} from '../models/TransactionsStore'
+
+export const DeveloperScreen: FC> = observer(function DeveloperScreen(_props) {
+ const {navigation} = _props
+ useHeader({
+ leftIcon: 'faArrowLeft',
+ onLeftPress: () => navigation.goBack(),
+ })
+
+ const {transactionsStore} = useStores()
+
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState()
+ const [info, setInfo] = useState('')
+
+ // Reset of whole model state and reload from DB
+ const syncTransactionsFromDb = async function () {
+ setIsLoading(true)
+ try {
+ const result = await Database.getTransactionsAsync(
+ maxTransactionsInModel,
+ 0,
+ )
+
+ if (result && result.length > 0) {
+ // remove all from the transactionsStore model
+ transactionsStore.removeAllTransactions()
+
+ // Add last 10 from database
+ transactionsStore.addTransactionsToModel(result._array)
+
+ setIsLoading(false)
+ setInfo(
+ `Reset completed. ${result.length} recent transactions were loaded from database.`,
+ )
+ return true
+ }
+
+ setInfo('Reset aborted, there are no transactions in local database.')
+ setIsLoading(false)
+ return false
+ } catch (e: any) {
+ handleError(e)
+ }
+ }
+
+ const factoryReset = async function () {
+ Alert.alert(
+ 'Confirmation',
+ 'All application data will be deleted. Are you sure you want to perform a factory reset?',
+ [
+ {
+ text: 'Cancel',
+ style: 'cancel',
+ onPress: () => {
+ // Action canceled
+ },
+ },
+ {
+ text: 'Confirm',
+ onPress: async () => {
+ setIsLoading(true)
+ try {
+ Database.cleanAll()
+ // recreate db schema
+ Database.getInstance()
+ MMKVStorage.clearAll()
+
+ setIsLoading(false)
+ setInfo('Factory reset completed')
+ } catch (e: any) {
+ handleError(e)
+ }
+ },
+ },
+ ],
+ )
+ }
+
+ const handleError = function (e: AppError): void {
+ setIsLoading(false)
+ setError(e)
+ }
+
+ const headerBg = useThemeColor('header')
+
+ return (
+
+
+
+
+
+
+
+ }
+ RightComponent={}
+ style={$item}
+ bottomSeparator={true}
+ onPress={syncTransactionsFromDb}
+ />
+
+ }
+ RightComponent={}
+ style={$item}
+ // bottomSeparator={true}
+ onPress={factoryReset}
+ />
+ >
+ }
+ />
+
+ {isLoading && }
+ {error && }
+ {info && }
+
+ )
+ })
+
+const $screen: ViewStyle = {
+ flex: 1,
+}
+
+const $headerContainer: TextStyle = {
+ alignItems: 'center',
+ padding: spacing.medium,
+ height: spacing.screenHeight * 0.1,
+}
+
+const $contentContainer: TextStyle = {
+ // flex: 1,
+ padding: spacing.extraSmall,
+ // alignItems: 'center',
+}
+
+const $card: ViewStyle = {
+ marginBottom: 0,
+}
+
+const $item: ViewStyle = {
+ paddingHorizontal: spacing.small,
+ paddingLeft: 0,
+}
+
+const $rightContainer: ViewStyle = {
+ padding: spacing.extraSmall,
+ alignSelf: 'center',
+ marginLeft: spacing.small,
+}
diff --git a/src/screens/ErrorScreen/ErrorBoundary.tsx b/src/screens/ErrorScreen/ErrorBoundary.tsx
new file mode 100644
index 00000000..db36548e
--- /dev/null
+++ b/src/screens/ErrorScreen/ErrorBoundary.tsx
@@ -0,0 +1,70 @@
+import React, { Component, ErrorInfo, ReactNode } from "react"
+import { ErrorDetails } from "./ErrorDetails"
+
+interface Props {
+ children: ReactNode
+ catchErrors: "always" | "dev" | "prod" | "never"
+}
+
+interface State {
+ error: Error | null
+ errorInfo: ErrorInfo | null
+}
+
+/**
+ * This component handles whenever the user encounters a JS error in the
+ * app. It follows the "error boundary" pattern in React. We're using a
+ * class component because according to the documentation, only class
+ * components can be error boundaries.
+ *
+ * - [Documentation and Examples](https://github.com/infinitered/ignite/blob/master/docs/Error-Boundary.md)
+ * - [React Error Boundaries](https://reactjs.org/docs/error-boundaries.html)
+ */
+export class ErrorBoundary extends Component {
+ state = { error: null, errorInfo: null }
+
+ // If an error in a child is encountered, this will run
+ componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+ // Catch errors in any components below and re-render with error message
+ this.setState({
+ error,
+ errorInfo,
+ })
+
+ // You can also log error messages to an error reporting service here
+ // This is a great place to put BugSnag, Sentry, crashlytics, etc:
+ // reportCrash(error)
+ }
+
+ // Reset the error back to null
+ resetError = () => {
+ this.setState({ error: null, errorInfo: null })
+ }
+
+ // To avoid unnecessary re-renders
+ shouldComponentUpdate(nextProps: Readonly, nextState: Readonly): boolean {
+ return nextState.error !== this.state.error
+ }
+
+ // Only enable if we're catching errors in the right environment
+ isEnabled(): boolean {
+ return (
+ this.props.catchErrors === "always" ||
+ (this.props.catchErrors === "dev" && __DEV__) ||
+ (this.props.catchErrors === "prod" && !__DEV__)
+ )
+ }
+
+ // Render an error UI if there's an error; otherwise, render children
+ render() {
+ return this.isEnabled() && this.state.error ? (
+
+ ) : (
+ this.props.children
+ )
+ }
+}
diff --git a/src/screens/ErrorScreen/ErrorDetails.tsx b/src/screens/ErrorScreen/ErrorDetails.tsx
new file mode 100644
index 00000000..79873553
--- /dev/null
+++ b/src/screens/ErrorScreen/ErrorDetails.tsx
@@ -0,0 +1,87 @@
+import React, { ErrorInfo } from "react"
+import { ScrollView, TextStyle, View, ViewStyle } from "react-native"
+import { Button, Icon, Screen, Text } from "../../components"
+import { colors, spacing } from "../../theme"
+
+export interface ErrorDetailsProps {
+ error: Error
+ errorInfo: ErrorInfo | null
+ onReset(): void
+}
+
+export function ErrorDetails(props: ErrorDetailsProps) {
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+
+}
+
+const $contentContainer: ViewStyle = {
+ alignItems: "center",
+ paddingHorizontal: spacing.large,
+ paddingTop: spacing.extraLarge,
+ flex: 1,
+}
+
+const $topSection: ViewStyle = {
+ flex: 1,
+ alignItems: "center",
+}
+
+const $heading: TextStyle = {
+ color: colors.palette.angry100,
+ marginBottom: spacing.medium,
+}
+
+const $errorSection: ViewStyle = {
+ flex: 2,
+ backgroundColor: colors.palette.neutral200,
+ marginVertical: spacing.medium,
+ borderRadius: 6,
+}
+
+const $errorSectionContentContainer: ViewStyle = {
+ padding: spacing.medium,
+}
+
+const $errorContent: TextStyle = {
+ color: colors.palette.neutral100,
+}
+
+const $errorBacktrace: TextStyle = {
+ marginTop: spacing.medium,
+ color: colors.palette.neutral100,
+}
+
+const $resetButton: ViewStyle = {
+ backgroundColor: colors.palette.angry100,
+ paddingHorizontal: spacing.huge,
+}
diff --git a/src/screens/LocalRecoveryScreen.tsx b/src/screens/LocalRecoveryScreen.tsx
new file mode 100644
index 00000000..c51330b2
--- /dev/null
+++ b/src/screens/LocalRecoveryScreen.tsx
@@ -0,0 +1,402 @@
+import {observer} from 'mobx-react-lite'
+import React, {FC, useState, useEffect, useRef, useMemo} from 'react'
+import {
+ ImageStyle,
+ TextStyle,
+ ViewStyle,
+ View,
+ ScrollView,
+ Alert,
+} from 'react-native'
+import {formatDistance, toDate} from 'date-fns'
+import {useThemeColor, spacing, colors, typography} from '../theme'
+import {
+ Button,
+ Icon,
+ Screen,
+ Text,
+ Card,
+ ListItem,
+ ErrorModal,
+ InfoModal,
+ Loading,
+} from '../components'
+import {SettingsStackScreenProps} from '../navigation'
+import {useHeader} from '../utils/useHeader'
+import {log} from '../utils/logger'
+import {Database} from '../services'
+import AppError from '../utils/AppError'
+import {BackupProof} from '../models/Proof'
+
+interface LocalRecoveryScreenProps
+ extends SettingsStackScreenProps<'LocalRecovery'> {}
+
+// Number of transactions held in TransactionsStore model
+const limit = 10
+
+export const LocalRecoveryScreen: FC =
+ function LocalRecoveryScreen(_props) {
+
+ const { navigation } = _props
+ // const { proofsStore } = useStores()
+ useHeader({
+ leftIcon: 'faArrowLeft',
+ onLeftPress: () => navigation.goBack(),
+ })
+
+
+ const [showPendingOnly, setShowPendingOnly] = useState(false)
+ const [showDeletedOnly, setShowDeletedOnly] = useState(false)
+ const [proofs, setProofs] = useState([])
+ const [selectedProofs, setSelectedProofs] = useState([])
+ const [info, setInfo] = useState('')
+ const [error, setError] = useState()
+ const [isLoading, setIsLoading] = useState(false)
+ const [offset, setOffset] = useState(0) // load from db those that are not already displayed
+ const [isAll, setIsAll] = useState(false)
+
+ useEffect(() => {
+ getProofsList()
+ // Run on component unmount (cleanup)
+ return () => {}
+ }, [])
+
+ const getProofsList = function (
+ isPending: boolean = false,
+ isDeleted: boolean = false,
+ ) {
+ try {
+ const proofs = Database.getProofs(limit, offset, isPending, isDeleted)
+
+ if (proofs.length === 0) {
+ setProofs([])
+ setIsAll(true)
+ setOffset(0)
+ } else {
+ if (proofs.length < limit) {
+ setIsAll(true)
+ }
+ setProofs(proofs)
+ setOffset(0)
+ }
+ } catch (e: any) {
+ handleError(e)
+ }
+ }
+
+ const getMore = function (
+ isPending: boolean = false,
+ isDeleted: boolean = false,
+ ) {
+ try {
+ const proofs = Database.getProofs(limit, offset, isPending, isDeleted)
+
+ if (proofs.length === 0) {
+ setProofs([])
+ setIsAll(true)
+ setOffset(0)
+ } else {
+ if (proofs.length < limit) {
+ setIsAll(true)
+ }
+ setProofs(prevProofs => [...prevProofs, ...proofs])
+ setOffset(offset + proofs.length)
+ }
+ } catch (e: any) {
+ handleError(e)
+ }
+ }
+
+ const toggleShowPendingOnly = () =>
+ setShowPendingOnly(previousState => {
+
+ if (previousState) {
+ getProofsList()
+ } else {
+ getProofsList(true, false)
+ }
+
+ setShowDeletedOnly(false)
+ return !previousState
+ })
+
+ const toggleShowDeletedOnly = async () =>
+ setShowDeletedOnly(previousState => {
+ if (previousState) {
+ getProofsList()
+ } else {
+ getProofsList(false, true)
+ }
+
+ setShowPendingOnly(false)
+ return !previousState
+ })
+
+ const toggleSelectedProof = function (proof: BackupProof) {
+ setSelectedProofs(prevSelectedProofs => {
+ const isSelected = prevSelectedProofs.some(
+ p => p.secret === proof.secret
+ )
+
+ if (isSelected) {
+ // If the proof is already selected, remove it from the array
+ return prevSelectedProofs.filter(p => p.secret !== proof.secret)
+ } else {
+ // If the proof is not selected, add it to the array
+ return [...prevSelectedProofs, proof]
+ }
+ })
+ }
+
+ /*type ProofGroup = {
+ [date: string]: BackupProof[]
+ }
+
+
+ const proofGroups = proofs.reduce((groups: ProofGroup, proof: BackupProof) => {
+ const date = new Date(proof.updatedAt).toLocaleString() // Get the date part of updatedAt as a string
+
+ if (!groups[date]) {
+ groups[date] = [] // Create an array for the date if it doesn't exist
+ }
+
+ groups[date].push(proof) // Add the proof to the corresponding date group
+ return groups
+ }, {}) */
+
+ const handleError = function (e: AppError): void {
+ setIsLoading(false)
+ setError(e)
+ }
+
+
+ const headerBg = useThemeColor('header')
+ const iconColor = useThemeColor('textDim')
+ const dateColor = useThemeColor('textDim')
+ const iconSelectedColor = useThemeColor('button')
+ const activeIconColor = useThemeColor('button')
+ const hintColor = colors.palette.primary200
+
+
+ return (
+
+
+
+
+
+
+
+
+ }
+ style={$item}
+ onPress={toggleShowPendingOnly}
+ bottomSeparator={true}
+ />
+
+ }
+ style={$item}
+ onPress={toggleShowDeletedOnly}
+ />
+ >
+ }
+ />
+ {(proofs && proofs.length) > 0 && (
+
+ {/*Object.entries(proofGroups).map(([date, proofs], index) => (
+
+ */}
+ {proofs.map((proof: BackupProof, index: number) => {
+ const isSelected = selectedProofs.some(
+ p => p.secret === proof.secret,
+ )
+ return (
+
+ }
+ onPress={() => toggleSelectedProof(proof)}
+ />
+ )
+ })}
+ >
+ }
+ FooterComponent={
+
+ {isAll ? (
+
+ ) : (
+
+ }
+ style={$card}
+ />
+ )}
+ {selectedProofs.length > 0 && (
+
+ {error && }
+ {info && }
+
+ )
+ }
+
+const $screen: ViewStyle = {
+ // borderWidth: 1,
+ // borderColor: 'red'
+}
+
+const $headerContainer: TextStyle = {
+ alignItems: 'center',
+ paddingBottom: spacing.medium,
+ height: spacing.screenHeight * 0.18,
+}
+
+const $contentContainer: TextStyle = {
+ minHeight: spacing.screenHeight * 0.5,
+ padding: spacing.extraSmall,
+}
+
+const $actionCard: ViewStyle = {
+ marginBottom: spacing.extraSmall,
+ marginTop: -spacing.extraLarge * 2,
+ paddingTop: 0,
+}
+
+const $card: ViewStyle = {
+ marginBottom: spacing.small,
+ paddingTop: 0,
+}
+
+const $cardHeading: TextStyle = {
+ fontFamily: typography.primary?.medium,
+ fontSize: 20,
+}
+
+const $iconContainer: ViewStyle = {
+ padding: spacing.extraSmall,
+ alignSelf: 'center',
+ marginRight: spacing.medium,
+}
+
+const $item: ViewStyle = {
+ marginHorizontal: spacing.micro,
+}
+
+const $proofText: TextStyle = {
+ overflow: 'hidden',
+ fontSize: 14,
+}
+
+const $txContainer: ViewStyle = {
+ justifyContent: 'center',
+ alignSelf: 'center',
+ marginRight: spacing.extraSmall,
+}
+
+const $txAmount: TextStyle = {
+ fontFamily: typography.primary?.medium,
+ alignSelf: 'center',
+ marginRight: spacing.small,
+}
+
+const $txIconContainer: ViewStyle = {
+ padding: spacing.extraSmall,
+ alignSelf: 'center',
+ marginRight: spacing.medium,
+}
+
+const $bottomContainer: ViewStyle = {
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ right: 0,
+ flex: 1,
+ justifyContent: 'flex-end',
+ marginBottom: spacing.medium,
+ alignSelf: 'stretch',
+ // opacity: 0,
+}
+
+const $buttonReceive: ViewStyle = {
+ borderTopLeftRadius: 30,
+ borderBottomLeftRadius: 30,
+ borderTopRightRadius: 0,
+ borderBottomRightRadius: 0,
+ minWidth: 130,
+}
diff --git a/src/screens/Mints/MintListItem.tsx b/src/screens/Mints/MintListItem.tsx
new file mode 100644
index 00000000..4a7d29f2
--- /dev/null
+++ b/src/screens/Mints/MintListItem.tsx
@@ -0,0 +1,45 @@
+import React, { useState } from "react"
+import { ScrollView, TextStyle, View, ViewStyle } from "react-native"
+import { observer } from "mobx-react-lite"
+import { Button, Icon, ListItem, Screen, Text } from "../../components"
+import { Mint } from "../../models/Mint"
+import { MintBalance } from "../../models/Mint"
+import { Transaction, TransactionStatus, TransactionType } from "../../models/Transaction"
+import { colors, spacing, typography, useThemeColor } from "../../theme"
+
+export interface TransactionListProps {
+ transaction: Transaction
+}
+
+
+export const MintListItem = observer(function(props: {
+ mint: Mint,
+ mintBalance?: MintBalance,
+ onMintSelect?: any,
+ isSelected?: boolean,
+ isSelectable: boolean
+ isBlocked?: boolean
+ separator: 'bottom' | 'top' | 'both' | undefined
+ }) {
+
+ const iconSelectedColor = useThemeColor('button')
+ const iconColor = useThemeColor('textDim')
+ const iconBlockedColor = colors.palette.angry500
+
+ return (
+ props.onMintSelect(props.mint, props.mintBalance) : undefined}
+ RightComponent={props.mintBalance && }
+ style={{paddingHorizontal: spacing.small}}
+ containerStyle={{alignSelf: 'stretch'}}
+ bottomSeparator={props.separator === 'bottom' || props.separator === 'both'}
+ topSeparator={props.separator === 'top' || props.separator === 'both'}
+ />
+ )})
\ No newline at end of file
diff --git a/src/screens/MintsScreen.tsx b/src/screens/MintsScreen.tsx
new file mode 100644
index 00000000..8e2f7198
--- /dev/null
+++ b/src/screens/MintsScreen.tsx
@@ -0,0 +1,452 @@
+import {observer} from 'mobx-react-lite'
+import React, {FC, useState} from 'react'
+import {Alert, TextStyle, View, ViewStyle} from 'react-native'
+import Clipboard from '@react-native-clipboard/clipboard'
+import {spacing, typography, useThemeColor, colors} from '../theme'
+import {SettingsStackScreenProps} from '../navigation'
+import {
+ Button,
+ Icon,
+ Card,
+ Screen,
+ Loading,
+ InfoModal,
+ ErrorModal,
+ ListItem,
+ BottomModal,
+ Text,
+} from '../components'
+import {Mint} from '../models/Mint'
+import {useStores} from '../models'
+import {useHeader} from '../utils/useHeader'
+import {MintKeys, MintKeySets, MintClient} from '../services'
+import {log} from '../utils/logger'
+import AppError from '../utils/AppError'
+import {translate} from '../i18n'
+import {MintListItem} from './Mints/MintListItem'
+
+export const MintsScreen: FC> = observer(function MintsScreen(_props) {
+ const {navigation} = _props
+ useHeader({
+ leftIcon: 'faArrowLeft',
+ onLeftPress: () => navigation.goBack(),
+ })
+
+ const {mintsStore, proofsStore} = useStores()
+
+ const [mintUrl, setMintUrl] = useState('')
+ const [selectedMint, setSelectedMint] = useState()
+ const [info, setInfo] = useState('')
+ const [error, setError] = useState()
+ const [isLoading, setIsLoading] = useState(false)
+ const [isAddMintVisible, setIsAddMintVisible] = useState(false)
+
+ const toggleAddMintModal = async function () {
+ if (isAddMintVisible) {
+ setIsAddMintVisible(false)
+ setMintUrl('')
+ } else {
+ setIsAddMintVisible(true)
+ }
+ }
+
+ const pasteMintUrl = async () => {
+ const url = await Clipboard.getString()
+ try {
+ new URL(url)
+ setMintUrl(url)
+ } catch (e) {
+ setInfo(translate('mintsScreen.invalidUrl'))
+ }
+ }
+
+ const pasteTestMintUrl = async () => {
+ // Pastes mintUrl provided here
+ setMintUrl('')
+ }
+
+ const addMint = async function () {
+ setIsAddMintVisible(false)
+
+ if (mintsStore.alreadyExists(mintUrl)) {
+ const msg = translate('mintsScreen.mintExists')
+ log.info(msg)
+ setInfo(msg)
+ return
+ }
+
+ try {
+ setIsLoading(true)
+ // log.trace('Snapshot before add mint', getSnapshot(mintsStore))
+
+ const mintKeys: {
+ keys: MintKeys
+ keysets: MintKeySets
+ } = await MintClient.getMintKeys(mintUrl)
+
+ const newMint: Mint = {
+ mintUrl,
+ keys: mintKeys.keys,
+ keysets: mintKeys.keysets.keysets,
+ }
+
+ mintsStore.addMint(newMint)
+
+ // log.trace('Snapshot after add mint', getSnapshot(mintsStore))
+
+ setInfo(translate('mintsScreen.mintAdded'))
+ } catch (e: any) {
+ setMintUrl('')
+ handleError(e)
+ } finally {
+ setMintUrl('')
+ setIsLoading(false)
+ }
+ }
+
+
+ const removeMint = async function () {
+ if (!selectedMint) {return}
+
+ const proofsByMint = proofsStore.getByMint(selectedMint.mintUrl)
+
+
+ if (proofsByMint && proofsByMint.length > 0) {
+ setInfo('Your wallet has a positive balance with this mint. Send or transfer your tokens before removing.')
+ return
+ }
+
+ Alert.alert(
+ 'Confirmation',
+ 'Do you want to remove this mint from the wallet?',
+ [
+ {
+ text: 'Cancel',
+ style: 'cancel',
+ onPress: () => {
+ // Action canceled
+ },
+ },
+ {
+ text: 'Confirm',
+ onPress: () => {
+ try {
+ onMintUnselect()
+ mintsStore.removeMint(selectedMint as Mint)
+ setInfo(translate('mintsScreen.mintRemoved'))
+ } catch (e: any) {
+ handleError(e)
+ }
+ },
+ },
+ ],
+ )
+ }
+
+ const blockMint = async function () {
+ if (!selectedMint) return
+ try {
+ mintsStore.blockMint(selectedMint as Mint)
+ setInfo(translate('mintsScreen.mintBlocked'))
+ } catch (e: any) {
+ handleError(e)
+ } finally {
+ onMintUnselect()
+ }
+ }
+
+ const unblockMint = async function () {
+ if (!selectedMint) return
+ try {
+ mintsStore.unblockMint(selectedMint as Mint)
+ setInfo(translate('mintsScreen.mintUnblocked'))
+ } catch (e: any) {
+ handleError(e)
+ } finally {
+ onMintUnselect()
+ }
+ }
+
+ const onMintSelect = function (mint: Mint) {
+ setSelectedMint(mint)
+ }
+
+ const onMintUnselect = function () {
+ setSelectedMint(undefined)
+ }
+
+ const handleError = function (e: AppError) {
+ setIsLoading(false)
+ setError(e)
+ }
+
+ const headerBg = useThemeColor('header')
+ const iconColor = useThemeColor('textDim')
+
+ return (
+
+
+
+
+
+
+ }
+ style={$actionItem}
+ onPress={toggleAddMintModal}
+ />
+ }
+ />
+ {mintsStore.mintCount > 0 && (
+
+ {mintsStore.mints.map((mint: Mint, index: number) => (
+ onMintSelect(mint)}
+ isSelectable={true}
+ isSelected={selectedMint?.mintUrl === mint.mintUrl}
+ isBlocked={mintsStore.isBlocked(mint.mintUrl as string)}
+ separator={index !== 0 ? 'top' : undefined}
+ />
+ ))}
+ >
+ }
+ />
+ )}
+ {isLoading && }
+
+
+ Alert.alert('Not yet implemented')}
+ tx={'mintsScreen.mintInfo'}
+ bottomSeparator={true}
+ style={{paddingHorizontal: spacing.medium}}
+ />
+ {mintsStore.isBlocked(selectedMint?.mintUrl as string) ? (
+
+ ) : (
+
+ )}
+ Alert.alert('Not yet implemented')}
+ tx={'mintsScreen.rename'}
+ bottomSeparator={true}
+ style={{paddingHorizontal: spacing.medium}}
+ />
+ Alert.alert('Not yet implemented')}
+ tx={'mintsScreen.setColor'}
+ bottomSeparator={true}
+ style={{paddingHorizontal: spacing.medium}}
+ />
+ Alert.alert('Not yet implemented')}
+ tx={'mintsScreen.copy'}
+ bottomSeparator={true}
+ style={{paddingHorizontal: spacing.medium}}
+ />
+
+
+ }
+ onBackButtonPress={onMintUnselect}
+ onBackdropPress={onMintUnselect}
+ />
+
+ {mintUrl.length > 0 ? (
+
+ ) : (
+
+ )}
+
+ }
+ onBackButtonPress={toggleAddMintModal}
+ onBackdropPress={toggleAddMintModal}
+ top={spacing.screenHeight * 0.6}
+ />
+ {error && }
+ {info && }
+
+ )
+})
+
+
+const PasteMintUrlBlock = function (props: {
+ mintUrl: string
+ addMint: any
+ toggleAddMintModal: any
+}) {
+ return (
+
+
+
+
+
+
+
+
+ )
+}
+
+const AddMintUrlBlock = function (props: {
+ pasteMintUrl: any
+ pasteTestMintUrl: any
+}) {
+ return (
+
+
+
+
+
+ {/**/}
+
+ )
+}
+
+const $screen: ViewStyle = {
+
+}
+
+const $headerContainer: TextStyle = {
+ alignItems: 'center',
+ padding: spacing.medium,
+ height: spacing.screenHeight * 0.18,
+}
+
+const $contentContainer: TextStyle = {
+ padding: spacing.extraSmall,
+}
+
+const $actionCard: ViewStyle = {
+ marginBottom: spacing.extraSmall,
+ marginTop: -spacing.extraLarge * 1.5,
+ // padding: 0,
+}
+
+const $actionItem: ViewStyle = {
+ paddingHorizontal: spacing.small,
+ paddingLeft: 0,
+}
+
+const $card: ViewStyle = {
+ marginBottom: spacing.small,
+ paddingTop: 0,
+}
+
+const $cardHeading: TextStyle = {
+ fontFamily: typography.primary?.medium,
+ fontSize: 20,
+}
+
+const $cardContent: TextStyle = {
+ fontSize: 14,
+}
+
+const $bottomContainer: ViewStyle = {
+ flex: 1,
+ justifyContent: 'flex-end',
+ marginBottom: spacing.large,
+ alignSelf: 'stretch',
+}
+
+const $bottomModal: ViewStyle = {
+ padding: spacing.small,
+}
+
+const $textField: ViewStyle = {
+ marginBottom: spacing.large,
+}
+
+const $buttonContainer: ViewStyle = {
+ flexDirection: 'row',
+ alignSelf: 'center',
+ marginTop: spacing.medium,
+}
+
+const $iconContainer: ViewStyle = {
+ padding: spacing.extraSmall,
+ alignSelf: 'center',
+ marginRight: spacing.medium,
+}
diff --git a/src/screens/ReceiveScreen.tsx b/src/screens/ReceiveScreen.tsx
new file mode 100644
index 00000000..197b7ded
--- /dev/null
+++ b/src/screens/ReceiveScreen.tsx
@@ -0,0 +1,464 @@
+import {observer} from 'mobx-react-lite'
+import React, {FC, useState, useCallback} from 'react'
+import {useFocusEffect} from '@react-navigation/native'
+import {Alert, TextStyle, View, ViewStyle} from 'react-native'
+import Clipboard from '@react-native-clipboard/clipboard'
+import {spacing, useThemeColor, colors} from '../theme'
+import {WalletStackScreenProps} from '../navigation'
+import {
+ Button,
+ Icon,
+ Card,
+ Screen,
+ Loading,
+ InfoModal,
+ ErrorModal,
+ ListItem,
+ BottomModal,
+ Text,
+} from '../components'
+import {Mint} from '../models/Mint'
+import {Token} from '../models/Token'
+import {Transaction, TransactionStatus} from '../models/Transaction'
+import {useStores} from '../models'
+import {useHeader} from '../utils/useHeader'
+import {Wallet} from '../services'
+import {log} from '../utils/logger'
+import AppError from '../utils/AppError'
+
+import {
+ decodeToken,
+ getTokenAmounts,
+ getMintsFromToken,
+} from '../services/cashuHelpers'
+import {ResultModalInfo} from './Wallet/ResultModalInfo'
+import {MintListItem} from './Mints/MintListItem'
+
+export const ReceiveScreen: FC> = observer(
+ function ReceiveScreen({route, navigation}) {
+ useHeader({
+ leftIcon: 'faArrowLeft',
+ onLeftPress: () => navigation.goBack(),
+ })
+
+ const {mintsStore} = useStores()
+
+ const [token, setToken] = useState()
+ const [encodedToken, setEncodedToken] = useState()
+ const [amountToReceive, setAmountToReceive] = useState(0)
+ const [receivedAmount, setReceivedAmount] = useState(0)
+ const [transactionStatus, setTransactionStatus] = useState<
+ TransactionStatus | undefined
+ >()
+ const [memo, setMemo] = useState('')
+ const [info, setInfo] = useState('')
+ const [error, setError] = useState()
+ const [isLoading, setIsLoading] = useState(false)
+ const [isResultModalVisible, setIsResultModalVisible] = useState(false)
+ const [resultModalInfo, setResultModalInfo] = useState<
+ {status: TransactionStatus; message: string} | undefined
+ >()
+
+ useFocusEffect(
+ useCallback(() => {
+ log.trace('scanned effect triggered')
+
+ if (!route.params || !route.params?.scannedEncodedToken) {
+ return
+ }
+
+ const encoded = route.params?.scannedEncodedToken
+ setEncodedToken(encoded)
+ onEncodedToken(encoded)
+
+ }, [route.params?.scannedEncodedToken]),
+ )
+
+ const resetState = function () {
+ setToken(undefined)
+ setEncodedToken(undefined)
+ setAmountToReceive(0)
+ setReceivedAmount(0)
+ setTransactionStatus(undefined)
+ setMemo('')
+ setInfo('')
+ setError(undefined)
+ setIsLoading(false)
+ setIsResultModalVisible(false)
+ setResultModalInfo(undefined)
+ // setIsQRCodeModalVisible(false)
+ }
+
+ const gotoScan = function () {
+ navigation.navigate('Scan')
+ }
+
+ // const toggleQRCodeModal = () => setIsQRCodeModalVisible(previousState => !previousState)
+ const toggleResultModal = () =>
+ setIsResultModalVisible(previousState => !previousState)
+
+ const onPasteEncodedToken = async function () {
+ const encoded = await Clipboard.getString()
+ if (!encoded) {
+ setInfo('Copy received token first, then paste')
+ return
+ }
+
+ setEncodedToken(encoded)
+ return onEncodedToken(encoded)
+ }
+
+ const onEncodedToken = async function (encoded: string) {
+ try {
+ const decoded: Token = decodeToken(encoded)
+ const tokenAmounts = getTokenAmounts(decoded)
+
+ log.trace('decoded token', decoded)
+ log.trace('tokenAmounts', tokenAmounts)
+
+ setToken(decoded)
+ setAmountToReceive(tokenAmounts.totalAmount)
+
+ if (decoded.memo && decoded.memo.length > 0) {
+ setMemo(decoded.memo as string)
+ }
+ } catch (e: any) {
+ handleError(e)
+ }
+ }
+
+ const receiveToken = async function () {
+ setIsLoading(true)
+
+ const {transaction, message, error, receivedAmount} =
+ await Wallet.receive(
+ token as Token,
+ amountToReceive,
+ memo,
+ encodedToken as string,
+ )
+
+ const {status} = transaction as Transaction
+ setTransactionStatus(status)
+
+ if (error) {
+ setResultModalInfo({
+ status,
+ message: error.message,
+ })
+ } else {
+ setResultModalInfo({
+ status,
+ message,
+ })
+ }
+
+ if (receivedAmount) {
+ setReceivedAmount(receivedAmount)
+ }
+
+ setIsLoading(false)
+ toggleResultModal()
+ }
+
+ const handleError = function (e: AppError): void {
+ resetState()
+ log.error(e.name, e.message)
+ setError(e)
+ }
+
+ const headerBg = useThemeColor('header')
+ const iconColor = useThemeColor('textDim')
+
+ return (
+
+
+ {token && amountToReceive > 0 ? (
+
+ {receivedAmount > 0 ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+
+ >
+ )}
+
+ ) : (
+
+ )}
+
+
+ {!token && amountToReceive === 0 && (
+
+
+ }
+ style={$item}
+ bottomSeparator={true}
+ onPress={() => Alert.alert('Not yet implemented')}
+ />
+
+ }
+ style={$item}
+ bottomSeparator={true}
+ onPress={gotoScan}
+ />
+
+ }
+ style={$item}
+ onPress={onPasteEncodedToken}
+ />
+ >
+ }
+ />
+ )}
+ {token && amountToReceive > 0 && (
+ <>
+ {memo && (
+
+ }
+ style={$item}
+ />
+ }
+ />
+ )}
+
+ {getMintsFromToken(token).map((mintUrl, index) => {
+ const mint = mintsStore.findByUrl(mintUrl)
+ if (!mint) {
+ return (
+ }
+ />
+ )
+ } else {
+ return (
+
+ )
+ }
+ })}
+ >
+ }
+ />
+ {transactionStatus === TransactionStatus.COMPLETED ? (
+
+
+
+ ) : (
+
+
+ )}
+ >
+ )}
+ {isLoading && }
+
+
+ {resultModalInfo?.status === TransactionStatus.COMPLETED && (
+ <>
+
+
+
+
+ >
+ )}
+ {(resultModalInfo?.status === TransactionStatus.ERROR ||
+ resultModalInfo?.status === TransactionStatus.BLOCKED) && (
+ <>
+
+
+
+
+ >
+ )}
+ >
+ }
+ onBackButtonPress={toggleResultModal}
+ onBackdropPress={toggleResultModal}
+ />
+ {error && }
+ {info && }
+
+ )
+ },
+)
+
+const $screen: ViewStyle = {
+ flex: 1,
+}
+
+const $headerContainer: TextStyle = {
+ alignItems: 'center',
+ padding: spacing.medium,
+ height: spacing.screenHeight * 0.18,
+}
+
+const $contentContainer: TextStyle = {
+ // flex: 1,
+ padding: spacing.extraSmall,
+ // alignItems: 'center',
+}
+
+const $optionsCard: ViewStyle = {
+ marginTop: -spacing.extraLarge * 2,
+ marginBottom: spacing.small,
+ paddingTop: 0,
+}
+
+const $card: ViewStyle = {
+ // marginTop: - spacing.extraLarge * 2,
+ marginBottom: spacing.small,
+ paddingTop: 0,
+}
+
+const $item: ViewStyle = {
+ paddingHorizontal: spacing.small,
+ paddingLeft: 0,
+}
+
+const $iconContainer: ViewStyle = {
+ padding: spacing.extraSmall,
+ alignSelf: 'center',
+ marginRight: spacing.medium,
+}
+
+const $buttonContainer: ViewStyle = {
+ flexDirection: 'row',
+ alignSelf: 'center',
+}
+
+const $amountContainer: ViewStyle = {
+ alignItems: 'center',
+ justifyContent: 'center',
+}
+
+const $amountToReceive: TextStyle = {
+ flex: 1,
+ paddingTop: spacing.extraLarge + 10,
+ fontSize: 52,
+ fontWeight: '400',
+ textAlignVertical: 'center',
+ color: 'white',
+}
diff --git a/src/screens/ScanScreen.tsx b/src/screens/ScanScreen.tsx
new file mode 100644
index 00000000..97ee3448
--- /dev/null
+++ b/src/screens/ScanScreen.tsx
@@ -0,0 +1,207 @@
+import React, {FC, useEffect, useState} from 'react'
+import {
+ ImageStyle,
+ TextStyle,
+ View,
+ ViewStyle,
+ useColorScheme,
+ StyleSheet,
+ Alert,
+} from 'react-native'
+import {WalletStackScreenProps} from '../navigation'
+import {CameraScreen} from 'react-native-camera-kit'
+import {spacing, typography} from '../theme'
+import {Token} from '../models/Token'
+import {useHeader} from '../utils/useHeader'
+import {log} from '../utils/logger'
+import AppError, {Err} from '../utils/AppError'
+import {decodeInvoice, decodeToken} from '../services/cashuHelpers'
+
+export const ScanScreen: FC> = function ScanScreen(_props) {
+ const {navigation, route} = _props
+ useHeader({
+ title: 'Scan to receive',
+ titleStyle: {fontFamily: typography.primary?.medium},
+ leftIcon: 'faArrowLeft',
+ onLeftPress: () => navigation.goBack(),
+ })
+
+ const [isScanned, setIsScanned] = useState(false)
+ const [error, setError] = useState()
+
+
+ const onReadCode = function(event: any) {
+ setIsScanned(true)
+ const scanned = event.nativeEvent.codeStringValue
+ log.trace('Scanned', scanned)
+
+ const routes = navigation.getState()?.routes
+ const prevRouteName = routes[routes.length - 2].name
+
+ switch (prevRouteName) {
+ case 'Receive':
+ const tokenResult = handleToken(scanned)
+ if (tokenResult?.isToken) {
+ log.trace('Got token')
+ return navigation.navigate('Receive', {
+ scannedEncodedToken: tokenResult.token,
+ })
+ }
+ handleError(scanned, 'This is not a valid cashu token')
+ break
+ case 'Transfer':
+ const invoiceResult = handleInvoice(scanned)
+ if (invoiceResult?.isInvoice) {
+ log.trace('Got invoice')
+ return navigation.navigate('Transfer', {
+ scannedEncodedInvoice: invoiceResult.invoice,
+ })
+ }
+ handleError(scanned, 'This is not a valid lightning invoice')
+ break
+ default:
+ // generic scan button on wallet screen
+ const tokenResult2 = handleToken(scanned)
+ if (tokenResult2?.isToken) {
+ log.trace('Got token')
+ return navigation.navigate('Receive', {
+ scannedEncodedToken: tokenResult2.token,
+ })
+ }
+
+
+ const invoiceResult2 = handleInvoice(scanned)
+ log.trace('Got invoice')
+ if (invoiceResult2?.isInvoice) {
+ return navigation.navigate('Transfer', {
+ scannedEncodedInvoice: invoiceResult2.invoice,
+ })
+ }
+
+ handleError(
+ scanned,
+ 'This is not a valid cashu token nor lightning invoice',
+ )
+ }
+ }
+
+ const handleToken = function (scanned: string) {
+ try {
+ let validToken: Token | undefined = undefined
+
+ // URL token format
+ const urlToken: string = extractTokenFromURL(scanned) as string
+
+ if (urlToken) {
+ validToken = decodeToken(urlToken) //throws
+
+ return {
+ isToken: true,
+ token: urlToken,
+ }
+ }
+
+ // raw encoded token
+ const token: Token = decodeToken(scanned) // throws
+
+ if (token) {
+ return {
+ isToken: true,
+ token: scanned,
+ }
+ }
+
+ } catch (tokenError: any) {
+ return {
+ isToken: false,
+ error: tokenError.message,
+ }
+ }
+ }
+
+
+ const handleInvoice = function (scanned: string) {
+ try {
+ // Attempt to decode the scanned content as a lightning invoice
+ let invoice: any = {}
+
+ if (scanned.startsWith('lightning:')) {
+ const trimmed = scanned.replace('lightning:', '')
+ invoice = decodeInvoice(trimmed)
+
+ if (invoice) {
+ return {
+ isInvoice: true,
+ invoice: trimmed,
+ }
+ }
+ } else {
+ invoice = decodeInvoice(scanned)
+
+ if (invoice) {
+ return {
+ isInvoice: true,
+ invoice: scanned
+ }
+
+ }
+ }
+ } catch (invoiceError: any) {
+ return {
+ isInvoice: false,
+ error: invoiceError.message,
+ }
+ }
+ }
+
+ const handleError = (scanned: string, message: string) => {
+ Alert.alert(message, scanned, [
+ {
+ text: 'OK',
+ onPress: () => setIsScanned(false),
+ },
+ ])
+ }
+
+ const extractTokenFromURL = (url: string) => {
+ try {
+ const parsedURL = new URL(url)
+ const tokenParam = parsedURL.searchParams.get('token')
+
+ if (tokenParam) {
+ return tokenParam
+ }
+
+ return undefined // No token parameter found
+ } catch (e: any) {
+ return undefined // Invalid URL
+ }
+ }
+
+ return (
+ onBottomButtonPressed(event)}
+ scanBarcode
+ onReadCode={event => (isScanned ? undefined : onReadCode(event))}
+ hideControls
+ // showFrame
+ />
+ )
+}
+
+const $container: ViewStyle = {
+ alignItems: 'center',
+}
+
+const $barcodes: TextStyle = {
+ fontSize: 20,
+ color: 'white',
+ fontWeight: 'bold',
+}
+
+const $bottomContainer: ViewStyle = {
+ flex: 1,
+ justifyContent: 'flex-end',
+ alignSelf: 'stretch',
+}
diff --git a/src/screens/SecurityScreen.tsx b/src/screens/SecurityScreen.tsx
new file mode 100644
index 00000000..795fc79f
--- /dev/null
+++ b/src/screens/SecurityScreen.tsx
@@ -0,0 +1,174 @@
+import {observer} from 'mobx-react-lite'
+import React, {FC, useState} from 'react'
+import {Switch, TextStyle, View, ViewStyle} from 'react-native'
+import {colors, spacing, useThemeColor} from '../theme'
+import {SettingsStackScreenProps} from '../navigation' // @demo remove-current-line
+import {
+ Icon,
+ ListItem,
+ Screen,
+ Text,
+ Card,
+ Loading,
+ ErrorModal,
+ InfoModal,
+ BottomModal,
+} from '../components'
+import {useHeader} from '../utils/useHeader'
+import {useStores} from '../models'
+import AppError from '../utils/AppError'
+import {ResultModalInfo} from './Wallet/ResultModalInfo'
+
+export const SecurityScreen: FC> = observer(function SecurityScreen(_props) {
+ const {navigation} = _props
+ useHeader({
+ leftIcon: 'faArrowLeft',
+ onLeftPress: () => navigation.goBack(),
+ })
+
+ const {userSettingsStore} = useStores()
+
+ const [isLoading, setIsLoading] = useState(false)
+ const [isStorageEncrypted, setIsStorageEncrypted] = useState(
+ userSettingsStore.isStorageEncrypted,
+ )
+ const [error, setError] = useState()
+ const [isEncryptionModalVisible, setIsEncryptionModalVisible] =
+ useState(false)
+ const [encryptionResultMessage, setEncryptionResultMessage] =
+ useState()
+
+ const toggleEncryptedSwitch = async () => {
+ try {
+ setIsLoading(true)
+ const result = await userSettingsStore.setIsStorageEncrypted(
+ !isStorageEncrypted,
+ )
+
+ setIsStorageEncrypted(result)
+ setIsLoading(false)
+
+ if (result === true) {
+ setEncryptionResultMessage(
+ 'Storage has been AES encrypted with the key stored in the device secure keys storage',
+ )
+ toggleEncryptionModal()
+ return
+ }
+
+ setEncryptionResultMessage('Storage encryption has been disabled')
+ toggleEncryptionModal()
+ } catch (e: any) {
+ handleError(e)
+ }
+ }
+
+ const toggleEncryptionModal = () =>
+ setIsEncryptionModalVisible(previousState => !previousState)
+
+ const handleError = function (e: AppError): void {
+ setIsLoading(false)
+ setError(e)
+ }
+
+ const headerBg = useThemeColor('header')
+
+ return (
+
+
+
+
+
+
+ }
+ RightComponent={
+
+
+
+ }
+ style={$item}
+ />
+ }
+ />
+ {isLoading && }
+
+
+ }
+ onBackButtonPress={toggleEncryptionModal}
+ onBackdropPress={toggleEncryptionModal}
+ />
+ {error && }
+
+ )
+ })
+
+const $screen: ViewStyle = {}
+
+const $headerContainer: TextStyle = {
+ alignItems: 'center',
+ padding: spacing.medium,
+ height: spacing.screenHeight * 0.1,
+}
+
+const $contentContainer: TextStyle = {
+ // flex: 1,
+ padding: spacing.extraSmall,
+ // alignItems: 'center',
+}
+
+const $card: ViewStyle = {
+ marginBottom: 0,
+}
+
+const $bottomModal: ViewStyle = {
+ flex: 1,
+ alignItems: 'center',
+ paddingVertical: spacing.large,
+ paddingHorizontal: spacing.small,
+}
+
+const $item: ViewStyle = {
+ paddingHorizontal: spacing.small,
+ paddingLeft: 0,
+}
+
+const $rightContainer: ViewStyle = {
+ // padding: spacing.extraSmall,
+ alignSelf: 'center',
+ // marginLeft: spacing.small,
+}
diff --git a/src/screens/SendScreen.tsx b/src/screens/SendScreen.tsx
new file mode 100644
index 00000000..70900eb1
--- /dev/null
+++ b/src/screens/SendScreen.tsx
@@ -0,0 +1,807 @@
+import {observer} from 'mobx-react-lite'
+import {getSnapshot} from 'mobx-state-tree'
+import React, {
+ FC,
+ useEffect,
+ useState,
+ useCallback,
+ useRef,
+ useMemo,
+} from 'react'
+import {useFocusEffect} from '@react-navigation/native'
+import {
+ UIManager,
+ Platform,
+ Alert,
+ TextInput,
+ TextStyle,
+ View,
+ ViewStyle,
+ LayoutAnimation,
+ ScrollView,
+ Share,
+} from 'react-native'
+import Clipboard from '@react-native-clipboard/clipboard'
+import QRCode from 'react-native-qrcode-svg'
+import {spacing, typography, useThemeColor, colors} from '../theme'
+import {useSafeAreaInsetsStyle} from '../utils/useSafeAreaInsetsStyle'
+import {WalletStackScreenProps} from '../navigation'
+import {
+ Button,
+ Icon,
+ Card,
+ Screen,
+ Loading,
+ InfoModal,
+ ErrorModal,
+ ListItem,
+ BottomModal,
+ Text,
+} from '../components'
+import {TransactionStatus, Transaction} from '../models/Transaction'
+import {useStores} from '../models'
+import {useHeader} from '../utils/useHeader'
+import {MintKeys, MintKeySets, MintClient, Wallet} from '../services'
+import {log} from '../utils/logger'
+import AppError, {Err} from '../utils/AppError'
+import {translate} from '../i18n'
+
+import {MintBalance} from '../models/Mint'
+import {MintListItem} from './Mints/MintListItem'
+import EventEmitter from '../utils/eventEmitter'
+import {ResultModalInfo} from './Wallet/ResultModalInfo'
+
+if (
+ Platform.OS === 'android' &&
+ UIManager.setLayoutAnimationEnabledExperimental
+) {
+ UIManager.setLayoutAnimationEnabledExperimental(true)
+}
+
+export const SendScreen: FC> = observer(
+ function SendScreen({route, navigation}) {
+
+ useHeader({
+ leftIcon: 'faArrowLeft',
+ onLeftPress: () => navigation.goBack(),
+ })
+
+ const {proofsStore, mintsStore} = useStores()
+ const amountInputRef = useRef(null)
+ const memoInputRef = useRef(null)
+ // const tokenInputRef = useRef(null)
+
+
+ const [encodedTokenToSend, setEncodedTokenToSend] = useState()
+ const [amountToSend, setAmountToSend] = useState('')
+ const [memo, setMemo] = useState('')
+ const [availableMintBalances, setAvailableMintBalances] = useState<
+ MintBalance[]
+ >([])
+ const [mintBalanceToSendFrom, setMintBalanceToSendFrom] = useState<
+ MintBalance | undefined
+ >()
+ const [transactionStatus, setTransactionStatus] = useState<
+ TransactionStatus | undefined
+ >()
+ const [transactionId, setTransactionId] = useState()
+ const [info, setInfo] = useState('')
+ const [error, setError] = useState()
+ const [isAmountEndEditing, setIsAmountEndEditing] = useState(false)
+ const [isSharedAsText, setIsSharedAsText] = useState(false)
+ const [isSharedAsQRCode, setIsSharedAsQRCode] = useState(false)
+ const [resultModalInfo, setResultModalInfo] = useState<{status: TransactionStatus, message: string} | undefined>()
+ const [isMemoEndEditing, setIsMemoEndEditing] = useState(false)
+ const [isLoading, setIsLoading] = useState(false)
+
+ const [isMintSelectorVisible, setIsMintSelectorVisible] = useState(false)
+ const [isSendModalVisible, setIsSendModalVisible] = useState(false)
+ const [isQRModalVisible, setIsQRModalVisible] = useState(false)
+ const [isResultModalVisible, setIsResultModalVisible] = useState(false)
+
+
+ useEffect(() => {
+ const focus = () => {
+ amountInputRef && amountInputRef.current
+ ? amountInputRef.current.focus()
+ : false
+ }
+
+
+ const timer = setTimeout(() => focus(), 100)
+
+ return () => {
+ clearTimeout(timer)
+ }
+ }, [])
+
+ useEffect(() => {
+ const handleSendCompleted = (transactionIds: number[]) => {
+ log.trace('handleSendCompleted event handler trigerred')
+
+ if (!transactionId) return
+ // Filter and handle events for a specific transactionId
+ if (transactionIds.includes(transactionId)) {
+ log.trace(
+ 'Sent coins have been claimed by receiver for tx',
+ transactionId,
+ )
+
+ setResultModalInfo({
+ status: TransactionStatus.COMPLETED,
+ message: `Done! ${amountToSend} sats were received by the payee.`,
+ })
+
+ setTransactionStatus(TransactionStatus.COMPLETED)
+ setIsQRModalVisible(false)
+ setIsSendModalVisible(false)
+ setIsResultModalVisible(true)
+ }
+ }
+
+ // Subscribe to the 'sendCompleted' event
+ EventEmitter.on('sendCompleted', handleSendCompleted)
+
+ // Unsubscribe from the 'sendCompleted' event on component unmount
+ return () => {
+ EventEmitter.off('sendCompleted', handleSendCompleted)
+ }
+ }, [transactionId])
+
+ const toggleSendModal = () =>
+ setIsSendModalVisible(previousState => !previousState)
+ const toggleQRModal = () =>
+ setIsQRModalVisible(previousState => !previousState)
+ const toggleResultModal = () =>
+ setIsResultModalVisible(previousState => !previousState)
+
+
+ const onAmountEndEditing = function () {
+ try {
+ const amount = parseInt(amountToSend)
+
+ if (!amount || amount === 0) {
+ setInfo('Amount should be positive number')
+ return
+ }
+
+ const availableBalances =
+ proofsStore.getMintBalancesWithEnoughBalance(amount)
+
+ if (availableBalances.length === 0) {
+ setInfo('There is not enough funds to send this amount')
+ return
+ }
+
+ log.trace(
+ 'availableBalances',
+ availableBalances.length
+ )
+
+ setAvailableMintBalances(availableBalances)
+
+ // Set mint to send from immediately if only one is available
+ if (availableBalances.length === 1) {
+ setMintBalanceToSendFrom(availableBalances[0])
+ }
+
+ setIsAmountEndEditing(true)
+
+ memoInputRef && memoInputRef.current
+ ? memoInputRef.current.focus()
+ : false
+ } catch (e: any) {
+ handleError(e)
+ }
+ }
+
+ const onMemoEndEditing = function () {
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+ setIsMemoEndEditing(true)
+ }
+
+ const onMemoDone = function () {
+ if (parseInt(amountToSend) > 0) {
+ memoInputRef && memoInputRef.current
+ ? memoInputRef.current.blur()
+ : false
+ amountInputRef && amountInputRef.current
+ ? amountInputRef.current.blur()
+ : false
+ onMemoEndEditing()
+ } else {
+ amountInputRef && amountInputRef.current
+ ? amountInputRef.current.focus()
+ : false
+ }
+ }
+ const onShareAsText = function () {
+ // if tx has been already executed, re-open SendModal
+ if (transactionStatus === TransactionStatus.PENDING) {
+ toggleSendModal() // open
+ return
+ }
+
+ setIsSharedAsText(true)
+ setIsSharedAsQRCode(false)
+ // pass share kind directly to avoid delayed state update
+ return onShare('TEXT')
+ }
+
+ const onShareAsQRCode = function () {
+ // if tx has been already executed, re-open QRCodeModal
+ if (transactionStatus === TransactionStatus.PENDING) {
+ toggleQRModal() // open
+ return
+ }
+
+ setIsSharedAsQRCode(true)
+ setIsSharedAsText(false)
+ // pass share kind directly to avoid delayed state update
+ return onShare('QRCODE')
+ }
+
+ const onShare = async function (as: 'TEXT' | 'QRCODE'): Promise {
+ if (amountToSend.length === 0) {
+ setInfo('Provide the amount you want to send')
+ return
+ }
+
+ // Skip mint modal and send immediately if only one mint is available
+ if (availableMintBalances.length === 1) {
+
+ const result = await send()
+
+ if (result.error) {
+ setResultModalInfo({
+ status: result.transaction?.status as TransactionStatus,
+ message: result.error.message,
+ })
+ setIsResultModalVisible(true)
+ return
+ }
+
+ if (as === 'TEXT') {
+ toggleSendModal()
+ }
+ if (as === 'QRCODE') {
+ toggleQRModal()
+ }
+ return
+ }
+
+ // Pre-select mint with highest balance and show mint modal to confirm which mint to send from
+ setMintBalanceToSendFrom(availableMintBalances[0])
+ setIsMintSelectorVisible(true)
+ // toggleMintModal() // open
+ }
+
+ const onMintBalanceSelect = function (balance: MintBalance) {
+ setMintBalanceToSendFrom(balance)
+ }
+
+ const onMintBalanceConfirm = async function () {
+ if (!mintBalanceToSendFrom) return
+
+ const result = await send()
+
+ if (result.error) {
+ setResultModalInfo({
+ status: result.transaction?.status as TransactionStatus,
+ message: result.error.message,
+ })
+ setIsResultModalVisible(true)
+ return
+ }
+
+ isSharedAsText && toggleSendModal() // open
+ isSharedAsQRCode && toggleQRModal() // open
+
+ setIsMintSelectorVisible(false)
+ }
+
+ const onMintBalanceCancel = async function () {
+ setIsMintSelectorVisible(false)
+ }
+
+ const send = async function () {
+ setIsLoading(true)
+
+ const result = await Wallet.send(
+ mintBalanceToSendFrom as MintBalance,
+ parseInt(amountToSend),
+ memo,
+ )
+
+ const {status, id} = result.transaction as Transaction
+ setTransactionStatus(status)
+ setTransactionId(id)
+
+ if (result.encodedTokenToSend) {
+ setEncodedTokenToSend(result.encodedTokenToSend)
+ }
+
+ setIsLoading(false)
+ return result
+ }
+
+ const onShareToApp = async () => {
+ try {
+ const result = await Share.share({
+ message: encodedTokenToSend as string,
+ })
+
+ if (result.action === Share.sharedAction) {
+ toggleSendModal()
+ setTimeout(
+ () =>
+ setInfo(
+ 'Coins have been shared, waiting to be claimed by receiver',
+ ),
+ 500,
+ )
+ } else if (result.action === Share.dismissedAction) {
+ setInfo(
+ 'Sharing cancelled, coins are waiting to be claimed by receiver',
+ )
+ }
+ } catch (e: any) {
+ handleError(e)
+ }
+ }
+
+ const onCopy = function () {
+ try {
+ Clipboard.setString(encodedTokenToSend as string)
+ } catch (e: any) {
+ setInfo(`Could not copy: ${e.message}`)
+ }
+ }
+
+
+ const handleError = function(e: AppError): void {
+ // TODO resetState() on all tx data on error? Or save txId to state and allow retry / recovery?
+ setIsSendModalVisible(false)
+ setIsQRModalVisible(false)
+ setIsLoading(false)
+ setError(e)
+ }
+
+ const headerBg = useThemeColor('header')
+ // const inputBg = useThemeColor('background')
+
+ return (
+
+
+
+
+ setAmountToSend(amount)}
+ // onFocus={() => setIsAmountEndEditing(false)}
+ onEndEditing={onAmountEndEditing}
+ value={amountToSend}
+ style={$amountInput}
+ maxLength={9}
+ keyboardType="numeric"
+ selectTextOnFocus={true}
+ editable={
+ transactionStatus === TransactionStatus.PENDING ? false : true
+ }
+ />
+
+
+
+
+ setMemo(memo)}
+ onEndEditing={onMemoEndEditing}
+ value={`${memo}`}
+ style={$memoInput}
+ maxLength={200}
+ keyboardType="default"
+ selectTextOnFocus={true}
+ placeholder="Memo for recipient"
+ editable={
+ transactionStatus === TransactionStatus.PENDING
+ ? false
+ : true
+ }
+ />
+
+
+ }
+ />
+ {isAmountEndEditing && isMemoEndEditing && !isMintSelectorVisible && (
+
+
+ }
+ style={$item}
+ bottomSeparator={true}
+ onPress={() => Alert.alert('Not implemented yet')}
+ />
+
+ }
+ style={$item}
+ bottomSeparator={true}
+ onPress={onShareAsQRCode}
+ />
+
+ }
+ style={$item}
+ onPress={onShareAsText}
+ />
+ >
+ }
+ />
+ )}
+
+ {isMintSelectorVisible &&
+ transactionStatus !== TransactionStatus.PENDING && (
+
+ )}
+ {isLoading && }
+
+
+ }
+ onBackButtonPress={toggleSendModal}
+ onBackdropPress={toggleSendModal}
+ />
+
+ }
+ onBackButtonPress={toggleQRModal}
+ onBackdropPress={toggleQRModal}
+ />
+
+ {resultModalInfo &&
+ transactionStatus === TransactionStatus.COMPLETED && (
+ <>
+
+
+ navigation.navigate('Wallet')}
+ />
+
+ >
+ )}
+ {resultModalInfo &&
+ transactionStatus === TransactionStatus.ERROR && (
+ <>
+
+
+
+
+ >
+ )}
+ >
+ }
+ onBackButtonPress={toggleResultModal}
+ onBackdropPress={toggleResultModal}
+ />
+ {error && }
+ {info && }
+
+ )
+ }
+)
+
+const MintBalanceSelector = observer(function (props: {
+ availableMintBalances: MintBalance[]
+ mintBalanceToSendFrom: MintBalance
+ onMintBalanceSelect: any
+ onCancel: any
+ findByUrl: any
+ onMintBalanceConfirm: any
+}) {
+
+ const onMintSelect = function (balance: MintBalance) {
+ log.trace('onMintBalanceSelect', balance.mint)
+ return props.onMintBalanceSelect(balance)
+ }
+
+ return (
+ <>
+
+ {props.availableMintBalances.map(
+ (balance: MintBalance, index: number) => (
+ onMintSelect(balance)}
+ isSelectable={true}
+ isSelected={props.mintBalanceToSendFrom.mint === balance.mint}
+ separator={'top'}
+ />
+ )
+ )}
+ >
+ }
+ />
+
+ }
+ />
+
+
+ >
+ )
+})
+
+const SendAsTextBlock = observer(function (props: {
+ toggleSendModal: any
+ encodedTokenToSend: string
+ onShareToApp: any
+ onCopy: any
+}) {
+ const sendBg = useThemeColor('background')
+ const tokenTextColor = useThemeColor('textDim')
+ const $bottomContainerInsets = useSafeAreaInsetsStyle(['bottom'])
+
+ return (
+
+
+
+
+
+
+ (
+
+ )}
+ />
+
+
+
+
+ )
+})
+
+const SendAsQRCodeBlock = observer(function (props: {
+ toggleQRModal: any
+ encodedTokenToSend: string
+ onCopy: any
+}) {
+
+ const sendBg = useThemeColor('background')
+ const tokenTextColor = useThemeColor('textDim')
+ const $bottomContainerInsets = useSafeAreaInsetsStyle(['bottom'])
+
+ return (
+
+
+
+
+
+
+
+
+
+ )
+})
+
+const $screen: ViewStyle = {
+ flex: 1,
+}
+
+const $headerContainer: TextStyle = {
+ alignItems: 'center',
+ padding: spacing.medium,
+ height: spacing.screenHeight * 0.18,
+}
+
+const $contentContainer: TextStyle = {
+ padding: spacing.extraSmall,
+}
+
+const $amountContainer: ViewStyle = {
+ height: 90,
+ alignSelf: 'center',
+}
+
+const $amountInput: TextStyle = {
+ flex: 1,
+ borderRadius: spacing.small,
+ fontSize: 52,
+ fontWeight: '400',
+ textAlignVertical: 'center',
+ textAlign: 'center',
+ color: 'white',
+}
+
+const $memoCard: ViewStyle = {
+ marginBottom: spacing.small,
+}
+
+const $memoContainer: ViewStyle = {
+ flex: 1,
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
+}
+
+const $memoInput: TextStyle = {
+ flex: 1,
+ borderRadius: spacing.small,
+ fontSize: 16,
+ textAlignVertical: 'center',
+ marginRight: spacing.small,
+}
+
+const $tokenContainer: ViewStyle = {
+ borderRadius: spacing.small,
+ alignSelf: 'stretch',
+ padding: spacing.small,
+ maxHeight: 150,
+ marginTop: spacing.small,
+ marginBottom: spacing.large,
+}
+
+const $memoButton: ViewStyle = {
+ maxHeight: 50,
+}
+
+const $card: ViewStyle = {
+ marginBottom: spacing.small,
+ paddingTop: 0,
+}
+
+const $item: ViewStyle = {
+ paddingHorizontal: spacing.small,
+ paddingLeft: 0,
+}
+
+const $bottomModal: ViewStyle = {
+ flex: 1,
+ alignItems: 'center',
+ paddingVertical: spacing.large,
+ // paddingHorizontal: spacing.small,
+}
+
+const $qrCodeContainer: ViewStyle = {
+ backgroundColor: 'white',
+ padding: spacing.small,
+ margin: spacing.small,
+}
+
+const $buttonContainer: ViewStyle = {
+ flexDirection: 'row',
+ alignSelf: 'center',
+}
+
+const $receiveMsg: ViewStyle = {
+ flexDirection: 'row',
+ borderRadius: spacing.large,
+ justifyContent: 'flex-start',
+ padding: spacing.small,
+}
+
diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx
new file mode 100644
index 00000000..ee9f04ee
--- /dev/null
+++ b/src/screens/SettingsScreen.tsx
@@ -0,0 +1,154 @@
+import {observer} from 'mobx-react-lite'
+import React, {FC} from 'react'
+import {TextStyle, View, ViewStyle} from 'react-native'
+import {colors, spacing, useThemeColor} from '../theme'
+import {SettingsStackScreenProps} from '../navigation' // @demo remove-current-line
+import {Icon, ListItem, Screen, Text, Card} from '../components'
+import {useHeader} from '../utils/useHeader'
+import {useStores} from '../models'
+import {translate} from '../i18n'
+
+interface SettingsScreenProps extends SettingsStackScreenProps<'Settings'> {}
+
+export const SettingsScreen: FC = observer(
+ function SettingsScreen(_props) {
+ const {navigation} = _props
+ useHeader({}) // default header component
+ const {mintsStore} = useStores()
+
+ function gotoMints() {
+ navigation.navigate('Mints')
+ }
+
+ function gotoSecurity() {
+ navigation.navigate('Security')
+ }
+
+ function gotoDevOptions() {
+ navigation.navigate('Developer')
+ }
+
+ function gotoBackupRestore() {
+ navigation.navigate('Backup')
+ }
+
+ const $itemRight = {color: useThemeColor('textDim')}
+ const headerBg = useThemeColor('header')
+
+ return (
+
+
+
+
+
+
+
+ }
+ RightComponent={
+
+
+
+ }
+ style={$item}
+ bottomSeparator={true}
+ onPress={gotoMints}
+ />
+
+ }
+ style={$item}
+ bottomSeparator={true}
+ onPress={gotoBackupRestore}
+ />
+
+ }
+ style={$item}
+ bottomSeparator={true}
+ onPress={gotoSecurity}
+ />
+
+ }
+ style={$item}
+ // bottomSeparator={true}
+ onPress={gotoDevOptions}
+ />
+ >
+ }
+ />
+
+
+ )
+ },
+)
+
+const $screen: ViewStyle = {
+ flex: 1,
+}
+
+const $headerContainer: TextStyle = {
+ alignItems: 'center',
+ padding: spacing.medium,
+ height: spacing.screenHeight * 0.1,
+}
+
+const $contentContainer: TextStyle = {
+ // flex: 1,
+ padding: spacing.extraSmall,
+ // alignItems: 'center',
+}
+
+const $card: ViewStyle = {
+ // marginVertical: 0,
+}
+
+const $item: ViewStyle = {
+ // paddingHorizontal: spacing.small,
+ paddingLeft: 0,
+}
+
+const $rightContainer: ViewStyle = {
+ padding: spacing.extraSmall,
+ alignSelf: 'center',
+ marginLeft: spacing.small,
+}
diff --git a/src/screens/TopupScreen.tsx b/src/screens/TopupScreen.tsx
new file mode 100644
index 00000000..25da7f5e
--- /dev/null
+++ b/src/screens/TopupScreen.tsx
@@ -0,0 +1,766 @@
+import {observer} from 'mobx-react-lite'
+import React, {FC, useEffect, useState, useRef} from 'react'
+import {
+ UIManager,
+ Platform,
+ Alert,
+ TextInput,
+ TextStyle,
+ View,
+ ViewStyle,
+ LayoutAnimation,
+ ScrollView,
+ Share,
+} from 'react-native'
+import Clipboard from '@react-native-clipboard/clipboard'
+import QRCode from 'react-native-qrcode-svg'
+import {spacing, useThemeColor, colors} from '../theme'
+import {WalletStackScreenProps} from '../navigation'
+import {
+ Button,
+ Icon,
+ Card,
+ Screen,
+ Loading,
+ InfoModal,
+ ErrorModal,
+ ListItem,
+ BottomModal,
+ Text,
+} from '../components'
+import {TransactionStatus, Transaction} from '../models/Transaction'
+import {useStores} from '../models'
+import {useHeader} from '../utils/useHeader'
+import {Wallet} from '../services'
+import {log} from '../utils/logger'
+import AppError from '../utils/AppError'
+
+import {MintBalance} from '../models/Mint'
+import {MintListItem} from './Mints/MintListItem'
+import EventEmitter from '../utils/eventEmitter'
+import {ResultModalInfo} from './Wallet/ResultModalInfo'
+import {Invoice} from '../models/Invoice'
+
+if (
+ Platform.OS === 'android' &&
+ UIManager.setLayoutAnimationEnabledExperimental
+) {
+ UIManager.setLayoutAnimationEnabledExperimental(true)
+}
+
+export const TopupScreen: FC> = observer(
+ function TopupScreen({navigation}) {
+ useHeader({
+ leftIcon: 'faArrowLeft',
+ onLeftPress: () => navigation.goBack(),
+ })
+
+ const {proofsStore, mintsStore} = useStores()
+ const amountInputRef = useRef(null)
+ const memoInputRef = useRef(null)
+ // const tokenInputRef = useRef(null)
+
+ const [amountToTopup, setAmountToTopup] = useState('')
+ const [memo, setMemo] = useState('')
+ const [availableMintBalances, setAvailableMintBalances] = useState<
+ MintBalance[]
+ >([])
+ const [mintBalanceToTopup, setMintBalanceToTopup] = useState<
+ MintBalance | undefined
+ >()
+ const [transactionStatus, setTransactionStatus] = useState<
+ TransactionStatus | undefined
+ >()
+ const [transactionId, setTransactionId] = useState()
+ const [invoiceToPay, setInvoiceToPay] = useState('')
+ const [info, setInfo] = useState('')
+ const [error, setError] = useState()
+ const [isAmountEndEditing, setIsAmountEndEditing] = useState(false)
+ const [resultModalInfo, setResultModalInfo] = useState<
+ {status: TransactionStatus; message: string} | undefined
+ >()
+ const [isSharedAsText, setIsSharedAsText] = useState(false)
+ const [isSharedAsQRCode, setIsSharedAsQRCode] = useState(false)
+
+ const [isMemoEndEditing, setIsMemoEndEditing] = useState(false)
+ const [isLoading, setIsLoading] = useState(false)
+
+ const [isMintSelectorVisible, setIsMintSelectorVisible] = useState(false)
+ const [isShareModalVisible, setIsShareModalVisible] = useState(false)
+ const [isQRModalVisible, setIsQRModalVisible] = useState(false)
+ const [isResultModalVisible, setIsResultModalVisible] = useState(false)
+
+ useEffect(() => {
+ const focus = () => {
+ amountInputRef && amountInputRef.current
+ ? amountInputRef.current.focus()
+ : false
+ }
+ const timer = setTimeout(() => focus(), 500)
+
+ return () => {
+ clearTimeout(timer)
+ }
+ }, [])
+
+ useEffect(() => {
+ const handleCompleted = (invoice: Invoice) => {
+ log.trace('handleCompleted event handler trigerred')
+
+ if (!transactionId) {
+ return
+ }
+ // Filter and handle events only for this topup transactionId
+ if (invoice.transactionId === transactionId) {
+ log.trace('Invoice has been paid and new proofs received')
+
+ setResultModalInfo({
+ status: TransactionStatus.COMPLETED,
+ message: `Payment received! Your wallet has been credited with ${invoice.amount} sats.`,
+ })
+
+ setTransactionStatus(TransactionStatus.COMPLETED)
+ setIsQRModalVisible(false)
+ setIsShareModalVisible(false)
+ setIsResultModalVisible(true)
+ }
+ }
+
+ // Subscribe to the 'tokenEntryAdded' event
+ EventEmitter.on('topupCompleted', handleCompleted)
+
+ // Unsubscribe from the 'tokenEntryAdded' event on component unmount
+ return () => {
+ EventEmitter.off('topupCompleted', handleCompleted)
+ }
+ }, [transactionId])
+
+ const toggleShareModal = () =>
+ setIsShareModalVisible(previousState => !previousState)
+ const toggleQRModal = () =>
+ setIsQRModalVisible(previousState => !previousState)
+ const toggleResultModal = () =>
+ setIsResultModalVisible(previousState => !previousState)
+
+ const onAmountEndEditing = function () {
+ try {
+ const amount = parseInt(amountToTopup)
+
+ if (!amount || amount === 0) {
+ setInfo('Amount should be positive number')
+ return
+ }
+
+ const mintBalances = proofsStore.getBalances().mintBalances
+
+ if (mintBalances.length === 0) {
+ setInfo(
+ 'There is no mint connected to your wallet that you would receive your coins from. Add the mint first.',
+ )
+ return
+ }
+
+ log.trace('onAmountEndEditing() availableBalances', mintBalances.length)
+
+ setAvailableMintBalances(mintBalances)
+
+ // Set mint to send from immediately if only one is available
+ if (mintBalances.length === 1) {
+ setMintBalanceToTopup(mintBalances[0])
+ }
+
+ setIsAmountEndEditing(true)
+
+ memoInputRef && memoInputRef.current
+ ? memoInputRef.current.focus()
+ : false
+ } catch (e: any) {
+ handleError(e)
+ }
+ }
+
+ const onMemoEndEditing = function () {
+ LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
+ setIsMemoEndEditing(true)
+ }
+
+ const onMemoDone = function () {
+ if (parseInt(amountToTopup) > 0) {
+ memoInputRef && memoInputRef.current
+ ? memoInputRef.current.blur()
+ : false
+ amountInputRef && amountInputRef.current
+ ? amountInputRef.current.blur()
+ : false
+ onMemoEndEditing()
+ } else {
+ amountInputRef && amountInputRef.current
+ ? amountInputRef.current.focus()
+ : false
+ }
+ }
+ const onShareAsText = async function () {
+ // if tx has been already executed, re-open SendModal
+ if (transactionStatus === TransactionStatus.PENDING) {
+ toggleShareModal() // open
+ return
+ }
+
+ setIsSharedAsText(true)
+ setIsSharedAsQRCode(false)
+ // pass share kind directly to avoid delayed state update
+ return onShare('TEXT')
+ }
+
+ const onShareAsQRCode = async function () {
+ // if tx has been already executed, re-open QRCodeModal
+ if (transactionStatus === TransactionStatus.PENDING) {
+ toggleQRModal() // open
+ return
+ }
+
+ setIsSharedAsQRCode(true)
+ setIsSharedAsText(false)
+ // pass share kind directly to avoid delayed state update
+ return onShare('QRCODE')
+ }
+
+ const onShare = async function (as: 'TEXT' | 'QRCODE'): Promise {
+ if (amountToTopup.length === 0) {
+ setInfo('Provide the top-up amount')
+ return
+ }
+
+ // Skip mint modal and send immediately if only one mint is available
+ if (availableMintBalances.length === 1) {
+ const result = await requestTopup()
+
+ if (result.error) {
+ setResultModalInfo({
+ status: result.transaction?.status as TransactionStatus,
+ message: result.error.message,
+ })
+ setIsResultModalVisible(true)
+ return
+ }
+
+ if (as === 'TEXT') {
+ toggleShareModal()
+ }
+ if (as === 'QRCODE') {
+ toggleQRModal()
+ }
+ return
+ }
+
+ // Pre-select mint with highest balance and show mint modal to confirm which mint to send from
+ setMintBalanceToTopup(availableMintBalances[0])
+ setIsMintSelectorVisible(true)
+ // toggleMintModal() // open
+ }
+
+ const onMintBalanceSelect = function (balance: MintBalance) {
+ setMintBalanceToTopup(balance)
+ }
+
+ const onMintBalanceConfirm = async function () {
+ if (mintBalanceToTopup) {
+ const result = await requestTopup()
+
+ if (result.error) {
+ setResultModalInfo({
+ status: result.transaction?.status as TransactionStatus,
+ message: result.error.message,
+ })
+ setIsResultModalVisible(true)
+ return
+ }
+
+ isSharedAsText && toggleShareModal() // open
+ isSharedAsQRCode && toggleQRModal() // open
+ }
+ setIsMintSelectorVisible(false)
+ }
+
+ const onMintBalanceCancel = async function () {
+ setIsMintSelectorVisible(false)
+ }
+
+ const requestTopup = async function () {
+ setIsLoading(true)
+
+ const result = await Wallet.topup(
+ mintBalanceToTopup as MintBalance,
+ parseInt(amountToTopup),
+ memo,
+ )
+
+ const {status, id} = result.transaction as Transaction
+ setTransactionStatus(status)
+ setTransactionId(id)
+
+ if (result.encodedInvoice) {
+ setInvoiceToPay(result.encodedInvoice)
+ }
+
+ setIsLoading(false)
+ return result
+ }
+
+ const onShareToApp = async () => {
+ try {
+ const result = await Share.share({
+ message: invoiceToPay as string,
+ })
+
+ if (result.action === Share.sharedAction) {
+ toggleShareModal()
+ setTimeout(
+ () =>
+ setInfo(
+ 'Lightning invoice has been shared, waiting to be paid by receiver',
+ ),
+ 500,
+ )
+ } else if (result.action === Share.dismissedAction) {
+ setInfo('Sharing cancelled')
+ }
+ } catch (e: any) {
+ handleError(e)
+ }
+ }
+
+ const onCopy = function () {
+ try {
+ Clipboard.setString(invoiceToPay as string)
+ } catch (e: any) {
+ setInfo(`Could not copy: ${e.message}`)
+ }
+ }
+
+ const handleError = function (e: AppError): void {
+ setIsLoading(false)
+ setError(e)
+ }
+
+ const headerBg = useThemeColor('header')
+ // const inputBg = useThemeColor('background')
+
+ return (
+
+
+
+
+ setAmountToTopup(amount)}
+ // onFocus={() => setIsAmountEndEditing(false)}
+ onEndEditing={onAmountEndEditing}
+ value={amountToTopup}
+ style={$amountInput}
+ maxLength={9}
+ keyboardType="numeric"
+ selectTextOnFocus={true}
+ editable={
+ transactionStatus === TransactionStatus.PENDING ? false : true
+ }
+ />
+
+
+
+
+ setMemo(memo)}
+ onEndEditing={onMemoEndEditing}
+ value={`${memo}`}
+ style={$memoInput}
+ maxLength={200}
+ keyboardType="default"
+ selectTextOnFocus={true}
+ placeholder="Memo for the payer"
+ editable={
+ transactionStatus === TransactionStatus.PENDING
+ ? false
+ : true
+ }
+ />
+
+
+ }
+ />
+ {isAmountEndEditing && isMemoEndEditing && !isMintSelectorVisible && (
+
+
+ }
+ style={$item}
+ bottomSeparator={true}
+ onPress={() => Alert.alert('Not implemented yet')}
+ />
+
+ }
+ style={$item}
+ bottomSeparator={true}
+ onPress={onShareAsQRCode}
+ />
+
+ }
+ style={$item}
+ onPress={onShareAsText}
+ />
+ >
+ }
+ />
+ )}
+ {isMintSelectorVisible &&
+ transactionStatus !== TransactionStatus.PENDING && (
+
+ )}
+ {isLoading && }
+
+
+ }
+ onBackButtonPress={toggleShareModal}
+ onBackdropPress={toggleShareModal}
+ />
+
+ }
+ onBackButtonPress={toggleQRModal}
+ onBackdropPress={toggleQRModal}
+ />
+
+ {resultModalInfo &&
+ transactionStatus === TransactionStatus.COMPLETED && (
+ <>
+
+
+ navigation.navigate('Wallet')}
+ />
+
+ >
+ )}
+ {resultModalInfo &&
+ transactionStatus === TransactionStatus.ERROR && (
+ <>
+
+
+
+
+ >
+ )}
+ >
+ }
+ onBackButtonPress={toggleResultModal}
+ onBackdropPress={toggleResultModal}
+ />
+ {error && }
+ {info && }
+
+ )
+ },
+)
+
+const MintBalanceSelector = observer(function (props: {
+ availableMintBalances: MintBalance[]
+ mintBalanceToTopup: MintBalance
+ onMintBalanceSelect: any
+ onCancel: any
+ findByUrl: any
+ onMintBalanceConfirm: any
+}) {
+ const onMintSelect = function (balance: MintBalance) {
+ log.trace('onMintBalanceSelect', balance.mint)
+ return props.onMintBalanceSelect(balance)
+ }
+
+ return (
+ <>
+
+ {props.availableMintBalances.map(
+ (balance: MintBalance) => (
+ onMintSelect(balance)}
+ isSelectable={true}
+ isSelected={props.mintBalanceToTopup.mint === balance.mint}
+ separator={'top'}
+ />
+ ),
+ )}
+ >
+ }
+ />
+
+ }
+ />
+
+
+ >
+ )
+})
+
+const ShareAsTextBlock = observer(function (props: {
+ toggleSendModal: any
+ invoiceToPay: string
+ onShareToApp: any
+ onCopy: any
+}) {
+ const sendBg = useThemeColor('background')
+ const tokenTextColor = useThemeColor('textDim')
+
+ return (
+
+
+
+
+
+
+ (
+
+ )}
+ />
+
+
+
+
+ )
+})
+
+const ShareAsQRCodeBlock = observer(function (props: {
+ toggleQRModal: any
+ encodedTokenToSend: string
+ onCopy: any
+}) {
+ return (
+
+
+
+
+
+
+
+
+
+ )
+})
+
+const $screen: ViewStyle = {
+ flex: 1,
+}
+
+const $headerContainer: TextStyle = {
+ alignItems: 'center',
+ padding: spacing.medium,
+ height: spacing.screenHeight * 0.18,
+}
+
+const $contentContainer: TextStyle = {
+ padding: spacing.extraSmall,
+}
+
+const $amountContainer: ViewStyle = {
+ height: 90,
+ alignSelf: 'center',
+}
+
+const $amountInput: TextStyle = {
+ flex: 1,
+ borderRadius: spacing.small,
+ fontSize: 52,
+ fontWeight: '400',
+ textAlignVertical: 'center',
+ textAlign: 'center',
+ color: 'white',
+}
+
+const $memoCard: ViewStyle = {
+ marginBottom: spacing.small,
+}
+
+const $memoContainer: ViewStyle = {
+ flex: 1,
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
+}
+
+const $memoInput: TextStyle = {
+ flex: 1,
+ borderRadius: spacing.small,
+ fontSize: 16,
+ textAlignVertical: 'center',
+ marginRight: spacing.small,
+}
+
+const $tokenContainer: ViewStyle = {
+ borderRadius: spacing.small,
+ alignSelf: 'stretch',
+ padding: spacing.small,
+ maxHeight: 150,
+ marginTop: spacing.small,
+ marginBottom: spacing.large,
+}
+
+const $memoButton: ViewStyle = {
+ maxHeight: 50,
+}
+
+const $card: ViewStyle = {
+ marginBottom: spacing.small,
+ paddingTop: 0,
+}
+
+const $item: ViewStyle = {
+ paddingHorizontal: spacing.small,
+ paddingLeft: 0,
+}
+
+const $bottomModal: ViewStyle = {
+ flex: 1,
+ alignItems: 'center',
+ paddingVertical: spacing.large,
+ // paddingHorizontal: spacing.small,
+}
+
+const $qrCodeContainer: ViewStyle = {
+ backgroundColor: 'white',
+ padding: spacing.small,
+ margin: spacing.small,
+}
+
+const $buttonContainer: ViewStyle = {
+ flexDirection: 'row',
+ alignSelf: 'center',
+}
diff --git a/src/screens/TranDetailScreen.tsx b/src/screens/TranDetailScreen.tsx
new file mode 100644
index 00000000..fc2d9782
--- /dev/null
+++ b/src/screens/TranDetailScreen.tsx
@@ -0,0 +1,932 @@
+import {observer} from 'mobx-react-lite'
+import React, {FC, useEffect, useRef, useState} from 'react'
+import {
+ Alert,
+ TextInput,
+ TextStyle,
+ View,
+ ViewStyle,
+} from 'react-native'
+import Clipboard from '@react-native-clipboard/clipboard'
+import JSONTree from 'react-native-json-tree'
+import {colors, spacing, useThemeColor} from '../theme'
+import {WalletStackScreenProps} from '../navigation'
+import {
+ Button,
+ Icon,
+ ListItem,
+ Screen,
+ Text,
+ Card,
+ ErrorModal,
+ BottomModal,
+ InfoModal,
+} from '../components'
+import {useHeader} from '../utils/useHeader'
+import {useStores} from '../models'
+import {translate, TxKeyPath} from '../i18n'
+import {
+ Transaction,
+ TransactionStatus,
+ TransactionType,
+} from '../models/Transaction'
+import AppError, {Err} from '../utils/AppError'
+import {log} from '../utils/logger'
+import {getMintsFromToken} from '../services/cashuHelpers'
+import {Token} from '../models/Token'
+import {isArray} from 'lodash'
+import {Database} from '../services'
+import {BackupProof, Proof} from '../models/Proof'
+
+type ProofsByStatus = {
+ isSpent: Proof[]
+ isPending: Proof[]
+ isReceived: Proof[]
+}
+
+export const TranDetailScreen: FC> =
+ observer(function TranDetailScreen(_props) {
+ const {navigation, route} = _props
+ const {transactionsStore, userSettingsStore} = useStores()
+ useHeader({
+ leftIcon: 'faArrowLeft',
+ onLeftPress: () => navigation.goBack(),
+ })
+
+ const noteInputRef = useRef(null)
+
+ const [transaction, setTransaction] = useState()
+ const [proofsByStatus, setProofsByStatus] = useState<
+ ProofsByStatus | undefined
+ >(undefined)
+ const [error, setError] = useState()
+ const [isNoteModalVisible, setIsNoteModalVisible] = useState(false)
+ const [isDataParsable, setIsDataParsable] = useState(true)
+ const [info, setInfo] = useState('')
+ const [note, setNote] = useState('')
+ const [savedNote, setSavedNote] = useState('')
+
+ useEffect(() => {
+ try {
+ const {id} = route.params
+ const tx = transactionsStore.findById(id)
+
+ if (!tx) {
+ throw new AppError(
+ Err.VALIDATION_ERROR,
+ 'Could not retrieve transaction from transactionsStore',
+ )
+ }
+
+ try {
+ JSON.parse(tx.data)
+ } catch (e: any) {
+ setIsDataParsable(false)
+ }
+
+
+ setTransaction(tx)
+ } catch (e: any) {
+ handleError(e)
+ }
+ }, [])
+
+ useEffect(() => {
+ try {
+ const {id} = route.params
+
+ if (userSettingsStore.isLocalBackupOn === true) {
+ const proofs = Database.getProofsByTransaction(id)
+
+ if (proofs.length > 0) {
+ const proofsByStatus = proofs.reduce(
+ (result: ProofsByStatus, proof: BackupProof) => {
+ if (proof.isSpent) {
+ result.isSpent.push(proof)
+ } else if (proof.isPending) {
+ result.isPending.push(proof)
+ } else {
+ result.isReceived.push(proof)
+ }
+ return result
+ },
+ {isReceived: [], isPending: [], isSpent: []},
+ )
+
+ log.trace(proofsByStatus)
+
+ setProofsByStatus(proofsByStatus)
+ }
+ }
+ } catch (e: any) {
+ log.error(e.name, e.message)
+ }
+ }, [])
+
+ useEffect(() => {
+ const focus = () => {
+ noteInputRef && noteInputRef.current
+ ? noteInputRef.current.focus()
+ : false
+ }
+
+ if (isNoteModalVisible) {
+ setTimeout(() => focus(), 100)
+ }
+ }, [isNoteModalVisible])
+
+
+
+ const toggleNoteModal = function () {
+ if (isNoteModalVisible) {
+ setIsNoteModalVisible(false)
+ } else {
+ setIsNoteModalVisible(true)
+ }
+ }
+
+ const saveNote = async function () {
+ try {
+ setIsNoteModalVisible(false)
+ await transactionsStore.saveNote(transaction?.id as number, note)
+ setSavedNote(note)
+ } catch (e: any) {
+ handleError(e)
+ }
+ }
+
+ const copyAuditTrail = function (transaction: Transaction) {
+ try {
+ Clipboard.setString(JSON.stringify(getAuditTrail(transaction)))
+ } catch (e: any) {
+ setInfo(`Could not copy: ${e.message}`)
+ }
+ }
+
+ const copyToken = function (transaction: Transaction) {
+ try {
+ const encoded = getEncodedTokenToSend(transaction)
+
+ if (!encoded) {
+ throw new AppError(
+ Err.VALIDATION_ERROR,
+ 'Could not get encoded coins from transaction',
+ )
+ }
+
+ Clipboard.setString(encoded)
+
+ } catch (e: any) {
+ setInfo(`Could not copy: ${e.message}`)
+ }
+ }
+
+ const handleError = function (e: AppError): void {
+ setIsNoteModalVisible(false)
+ setError(e)
+ }
+
+
+ const headerBg = useThemeColor('header')
+ const iconColor = useThemeColor('textDim')
+ const labelColor = useThemeColor('textDim')
+ const inputBg = useThemeColor('background')
+ const tokenTextColor = useThemeColor('textDim')
+
+
+
+ const getFormattedAmount = function(amount: number): string {
+ if (!transaction) {
+ return ''
+ }
+
+ switch (transaction?.type) {
+ case TransactionType.RECEIVE || TransactionType.RECEIVE_NOSTR:
+ return `+${transaction.amount}`
+ case TransactionType.SEND:
+ return `-${transaction.amount}`
+ case TransactionType.TOPUP:
+ return `+${transaction.amount}`
+ case TransactionType.TRANSFER:
+ return `-${transaction.amount}`
+ default:
+ return `${transaction?.amount}`
+ }
+ }
+
+
+
+ return (
+
+ {transaction && (
+ <>
+
+ {transaction && (
+
+ )}
+
+
+
+
+ }
+ style={$item}
+ bottomSeparator={true}
+ onPress={toggleNoteModal}
+ />
+
+ }
+ style={$item}
+ bottomSeparator={false}
+ onPress={() => Alert.alert('Not yet implemented')}
+ />
+ >
+ }
+ />
+
+ {transaction.type === TransactionType.RECEIVE && (
+
+ )}
+ {transaction.type === TransactionType.SEND && (
+
+ )}
+ {transaction.type === TransactionType.TOPUP && (
+
+ )}
+ {transaction.type === TransactionType.TRANSFER && (
+
+ )}
+ {proofsByStatus && (
+
+
+
+ >
+ }
+ FooterComponent={
+ Alert.alert('Not yet implemented')}
+ text="Recover coins"
+ style={{
+ minHeight: 25,
+ paddingVertical: spacing.extraSmall,
+ marginTop: spacing.small,
+ alignSelf: 'center',
+ }}
+ textStyle={{fontSize: 14}}
+ />
+ }
+ />
+ )}
+
+ >
+ )}
+
+
+
+ setNote(note)}
+ value={note}
+ style={[$noteInput, {backgroundColor: inputBg}]}
+ maxLength={200}
+ />
+
+
+
+ }
+ onBackButtonPress={toggleNoteModal}
+ onBackdropPress={toggleNoteModal}
+ />
+ {error && }
+ {info && }
+
+ )
+ })
+
+const ReceiveInfoBlock = function (props: {
+ transaction: Transaction
+ isDataParsable: boolean
+ copyAuditTrail: any
+}) {
+ const {transaction, isDataParsable, copyAuditTrail} = props
+ const labelColor = useThemeColor('textDim')
+
+ return (
+ <>
+
+
+
+
+
+
+ {transaction.status !== TransactionStatus.ERROR && (
+
+ )}
+
+
+ >
+ }
+ />
+ {isDataParsable && (
+ <>
+
+ }
+ />
+
+
+
+ >
+ }
+ FooterComponent={
+ copyAuditTrail(transaction)}
+ tx="common.copy"
+ style={{
+ minHeight: 25,
+ paddingVertical: spacing.extraSmall,
+ marginTop: spacing.small,
+ alignSelf: 'center',
+ }}
+ textStyle={{fontSize: 14}}
+ />
+ }
+ />
+ >
+ )}
+ >
+ )
+ }
+
+ const SendInfoBlock = function (props: {
+ transaction: Transaction
+ isDataParsable: boolean
+ copyToken: any
+ copyAuditTrail: any
+ }) {
+ const {transaction, isDataParsable, copyToken, copyAuditTrail} = props
+ const labelColor = useThemeColor('textDim')
+
+ return (
+ <>
+
+
+
+
+ {transaction.status === TransactionStatus.PENDING ? (
+
+
+
+ Alert.alert('Not yet implemented. Copy the token instead.')
+ }
+ />
+
+ ) : (
+
+ )}
+ {transaction.status !== TransactionStatus.ERROR && (
+
+ )}
+
+
+ >
+ }
+ />
+ {isDataParsable && (
+ <>
+
+
+ {transaction.status === TransactionStatus.PENDING && (
+ <>
+
+
+ >
+ )}
+ >
+ }
+ FooterComponent={
+ <>
+ {transaction.status === TransactionStatus.PENDING && (
+ copyToken(transaction)}
+ text="Copy"
+ style={{
+ minHeight: 25,
+ paddingVertical: spacing.extraSmall,
+ marginTop: spacing.small,
+ alignSelf: 'center',
+ }}
+ textStyle={{fontSize: 14}}
+ />
+ )}
+ >
+ }
+ />
+
+
+
+ >
+ }
+ // footerStyle={{borderWidth: 10, borderColor: 'red'}}
+ FooterComponent={
+ copyAuditTrail(transaction)}
+ text="Copy"
+ style={{
+ minHeight: 25,
+ paddingVertical: spacing.extraSmall,
+ marginTop: spacing.small,
+ alignSelf: 'center',
+ }}
+ textStyle={{fontSize: 14}}
+ />
+ }
+ />
+ >
+ )}
+ >
+ )
+}
+
+const TopupInfoBlock = function (props: {
+ transaction: Transaction
+ isDataParsable: boolean
+ copyAuditTrail: any
+}) {
+ const {transaction, isDataParsable, copyAuditTrail} = props
+
+ const labelColor = useThemeColor('textDim')
+
+ return (
+ <>
+
+
+
+
+ {transaction.status === TransactionStatus.COMPLETED && (
+
+ )}
+
+
+ >
+ }
+ />
+ {isDataParsable && (
+ <>
+
+
+
+ >
+ }
+ FooterComponent={
+ copyAuditTrail(transaction)}
+ text="Copy"
+ style={{
+ minHeight: 25,
+ paddingVertical: spacing.extraSmall,
+ marginTop: spacing.small,
+ alignSelf: 'center',
+ }}
+ textStyle={{fontSize: 14}}
+ />
+ }
+ />
+ >
+ )}
+ >
+ )
+}
+
+const TransferInfoBlock = function (props: {
+ transaction: Transaction
+ isDataParsable: boolean
+ copyAuditTrail: any
+}) {
+ const {transaction, isDataParsable, copyAuditTrail} = props
+
+ const labelColor = useThemeColor('textDim')
+
+ // log.trace(JSON.parse(transaction.data)[0])
+
+ return (
+ <>
+
+
+
+
+
+
+ {transaction.status !== TransactionStatus.ERROR && (
+
+ )}
+
+
+
+ >
+ }
+ />
+ {isDataParsable && (
+ <>
+
+ }
+ />
+
+
+
+ >
+ }
+ // footerStyle={{borderWidth: 10, borderColor: 'red'}}
+ FooterComponent={
+ copyAuditTrail(transaction)}
+ text="Copy"
+ style={{
+ minHeight: 25,
+ paddingVertical: spacing.extraSmall,
+ marginTop: spacing.small,
+ alignSelf: 'center',
+ }}
+ textStyle={{fontSize: 14}}
+ />
+ }
+ />
+ >
+ )}
+ >
+ )
+}
+
+const TranItem = function (props: {
+ label: TxKeyPath
+ value: string
+ isFirst?: boolean
+ isLast?: boolean
+}) {
+
+ const labelColor = useThemeColor('textDim')
+ const margin = !props.isFirst ? {marginTop: spacing.small} : null
+ return (
+
+
+
+
+ )
+}
+
+const getMints = function (transaction: Transaction) {
+ try {
+ const mints = JSON.parse(transaction.data)[0].tokenMints || []
+ return mints
+ } catch (e) {
+ return []
+ }
+}
+
+
+const getAuditTrail = function (transaction: Transaction) {
+ try {
+ const data = JSON.parse(transaction.data)
+
+ if (data && isArray(data)) {
+ return data
+ /* for (const item of data) {
+ if(item.status === TransactionStatus.ERROR) {
+ return item
+ }
+ }*/
+ }
+ return false
+ } catch (e) {
+ // silent
+ return false
+ }
+}
+
+const getEncodedTokenToSend = (
+ transaction: Transaction,
+): string | undefined => {
+ try {
+ const data = JSON.parse(transaction.data)
+ const pendingRecord = data.find(
+ (record: any) => record.status === 'PENDING',
+ )
+
+ if (pendingRecord) {
+ return pendingRecord.encodedTokenToSend
+ }
+
+ return undefined // No pending record found
+ } catch (e) {
+ // silent
+ return undefined
+ }
+}
+
+const $screen: ViewStyle = {}
+
+const $headerContainer: TextStyle = {
+ alignItems: 'center',
+ padding: spacing.medium,
+ height: spacing.screenHeight * 0.18,
+}
+
+const $contentContainer: TextStyle = {
+ padding: spacing.extraSmall,
+}
+
+const $actionCard: ViewStyle = {
+ marginBottom: spacing.extraSmall,
+ marginTop: -spacing.extraLarge * 2,
+ paddingTop: 0,
+}
+
+const $dataCard: ViewStyle = {
+ padding: spacing.medium,
+ marginBottom: spacing.extraSmall,
+}
+
+const $item: ViewStyle = {
+ paddingHorizontal: spacing.small,
+ paddingLeft: 0,
+}
+
+
+const $iconContainer: ViewStyle = {
+ padding: spacing.extraSmall,
+ alignSelf: 'center',
+ marginRight: spacing.medium,
+}
+
+const $noteContainer: TextStyle = {
+ padding: spacing.small,
+ alignItems: 'center',
+}
+
+const $noteInput: TextStyle = {
+ flex: 1,
+ margin: spacing.small,
+ borderRadius: spacing.small,
+ fontSize: 16,
+ padding: spacing.small,
+ alignSelf: 'stretch',
+ textAlignVertical: 'top',
+}
diff --git a/src/screens/TranHistoryScreen.tsx b/src/screens/TranHistoryScreen.tsx
new file mode 100644
index 00000000..e256f80c
--- /dev/null
+++ b/src/screens/TranHistoryScreen.tsx
@@ -0,0 +1,242 @@
+import {observer} from 'mobx-react-lite'
+import React, {FC, useState, useEffect, useRef, useMemo} from 'react'
+import {
+ ImageStyle,
+ TextStyle,
+ ViewStyle,
+ View,
+ ScrollView,
+ Alert,
+} from 'react-native'
+import {formatDistance, toDate} from 'date-fns'
+import {useThemeColor, spacing, colors, typography} from '../theme'
+import {
+ Button,
+ Icon,
+ Screen,
+ Text,
+ Card,
+ ListItem,
+ ErrorModal,
+ InfoModal,
+ Loading,
+} from '../components'
+import {WalletStackScreenProps} from '../navigation'
+import {useHeader} from '../utils/useHeader'
+import {useStores} from '../models'
+import {maxTransactionsInModel} from '../models/TransactionsStore'
+import {Database} from '../services'
+import AppError from '../utils/AppError'
+import {TransactionListItem} from './Transactions/TransactionListItem'
+
+interface TranHistoryScreenProps
+ extends WalletStackScreenProps<'TranHistory'> {}
+
+// Number of transactions held in TransactionsStore model
+const limit = maxTransactionsInModel
+
+export const TranHistoryScreen: FC = observer(function TranHistoryScreen(_props) {
+ const {navigation} = _props
+ const {transactionsStore, proofsStore} = useStores()
+ useHeader({
+ leftIcon: 'faArrowLeft',
+ onLeftPress: () => navigation.goBack(),
+ })
+
+ const [showPendingOnly, setShowPendingOnly] = useState(false)
+ const [info, setInfo] = useState('')
+ const [error, setError] = useState()
+ const [isLoading, setIsLoading] = useState(false)
+ const [offset, setOffset] = useState(transactionsStore.count) // load from db those that are not already displayed
+ const [isAll, setIsAll] = useState(false)
+
+ useEffect(() => {
+ if (transactionsStore.count < limit) {
+ setIsAll(true)
+ }
+ // Run on component unmount (cleanup)
+ return () => {
+ /* When leaving screen we remove all transactions over numTransactionsInModel
+ * from the transactionsStore that might have been sourced from sqlite db while browsing older records
+ */
+ transactionsStore.removeOldTransactions()
+ }
+ }, [])
+
+ const getTransactionsList = async function () {
+ setIsLoading(true)
+ try {
+ const result = await Database.getTransactionsAsync(limit, offset)
+
+ if (result && result.length > 0) {
+ // Add new transaction to the transactions store so mobx refreshes UI
+ transactionsStore.addTransactionsToModel(result._array)
+
+ setOffset(offset + result.length)
+
+ if (result?.length < limit) {
+ setIsAll(true)
+ }
+ }
+
+ setIsLoading(false)
+ } catch (e: any) {
+ handleError(e)
+ }
+ }
+
+ const toggleShowPendingOnly = function () {
+ if (showPendingOnly) {
+ setShowPendingOnly(false)
+ } else {
+ setShowPendingOnly(true)
+ }
+ }
+
+ const gotoTranDetail = function (id: number) {
+ navigation.navigate('TranDetail', {id})
+ }
+
+ const handleError = function (e: AppError): void {
+ setIsLoading(false)
+ setError(e)
+ }
+
+ const headerBg = useThemeColor('header')
+ const iconColor = useThemeColor('textDim')
+ const activeIconColor = useThemeColor('button')
+ const pendingBalance = proofsStore.getBalances().totalPendingBalance
+
+ return (
+
+
+
+
+
+
+
+ }
+ RightComponent={
+
+ }
+ style={$item}
+ bottomSeparator={true}
+ onPress={toggleShowPendingOnly}
+ />
+
+ }
+ style={$item}
+ bottomSeparator={false}
+ onPress={() => Alert.alert('Not implemented yet')}
+ />
+ >
+ }
+ />
+ {transactionsStore.count > 0 && (
+
+ {(showPendingOnly
+ ? transactionsStore.pending
+ : transactionsStore.all
+ ).map((tx, index: number) => (
+
+ ))}
+ >
+ }
+ FooterComponent={
+
+ {isAll ? (
+
+ ) : (
+
+ )}
+
+ }
+ style={$card}
+ />
+ )}
+ {isLoading && }
+
+ {error && }
+ {info && }
+
+ )
+ },
+)
+
+const $screen: ViewStyle = {
+ // borderWidth: 1,
+ // borderColor: 'red'
+}
+
+const $headerContainer: TextStyle = {
+ alignItems: 'center',
+ paddingBottom: spacing.medium,
+ height: spacing.screenHeight * 0.18,
+}
+
+const $contentContainer: TextStyle = {
+ minHeight: spacing.screenHeight * 0.5,
+ padding: spacing.extraSmall,
+}
+
+const $actionCard: ViewStyle = {
+ marginBottom: spacing.extraSmall,
+ marginTop: -spacing.extraLarge * 2,
+ paddingTop: 0,
+}
+
+const $card: ViewStyle = {
+ marginBottom: spacing.small,
+ paddingTop: 0,
+}
+
+
+const $iconContainer: ViewStyle = {
+ padding: spacing.extraSmall,
+ alignSelf: 'center',
+ marginRight: spacing.medium,
+}
+
+const $item: ViewStyle = {
+ marginHorizontal: spacing.micro,
+}
+
+
+const $txAmount: TextStyle = {
+ fontFamily: typography.primary?.medium,
+ alignSelf: 'center',
+ marginRight: spacing.small,
+}
+
diff --git a/src/screens/Transactions/TransactionListItem.tsx b/src/screens/Transactions/TransactionListItem.tsx
new file mode 100644
index 00000000..bfafa68b
--- /dev/null
+++ b/src/screens/Transactions/TransactionListItem.tsx
@@ -0,0 +1,179 @@
+import formatDistance from "date-fns/formatDistance"
+import { observer } from "mobx-react-lite"
+import { refStructEnhancer } from "mobx/dist/internal"
+import React from "react"
+import { ScrollView, TextStyle, View, ViewStyle } from "react-native"
+import { Button, Icon, ListItem, Screen, Text } from "../../components"
+import { Transaction, TransactionStatus, TransactionType } from "../../models/Transaction"
+import { colors, spacing, typography, useThemeColor } from "../../theme"
+
+export interface TransactionListProps {
+ transaction: Transaction
+}
+
+export const TransactionListItem = observer(function (props: {tx: Transaction, isFirst: boolean, gotoTranDetail: any}) {
+
+ const { tx } = props
+
+ const txReceiveColor = colors.palette.success300
+ const txSendColor = useThemeColor('amount')
+ const txErrorColor = useThemeColor('textDim')
+ const txPendingColor = useThemeColor('textDim')
+
+ const getText = function(tx: Transaction) {
+ if(tx.noteToSelf) return tx.noteToSelf
+ if(tx.memo) return tx.memo
+
+ switch(tx.type) {
+ case TransactionType.RECEIVE || TransactionType.RECEIVE_NOSTR:
+ return 'You received'
+ case TransactionType.SEND:
+ return 'You paid'
+ case TransactionType.TOPUP:
+ return 'You funded your wallet'
+ case TransactionType.TRANSFER:
+ return 'You transfered'
+ default:
+ return 'Uknown transaction'
+ }
+ }
+
+
+ const getSubText = function(tx: Transaction) {
+
+ const distance = formatDistance(tx.createdAt as Date, new Date(), {addSuffix: true})
+
+ switch(tx.status) {
+ case TransactionStatus.COMPLETED:
+ return distance + ` · Completed`
+ case TransactionStatus.DRAFT:
+ return distance + ` · Draft`
+ case TransactionStatus.ERROR:
+ return distance + ` · Error`
+ case TransactionStatus.PENDING:
+ return distance + ` · Pending`
+ case TransactionStatus.PREPARED:
+ return distance + ` · Prepared`
+ case TransactionStatus.EXPIRED:
+ return distance + ` · Expired`
+ default:
+ return distance
+ }
+ }
+
+
+ const getLeftIcon = function(tx: Transaction) {
+ if([TransactionStatus.ERROR, TransactionStatus.EXPIRED, TransactionStatus.BLOCKED].includes(tx.status)) {
+ return ()
+ }
+
+ if([TransactionType.TOPUP].includes(tx.type) && tx.status === TransactionStatus.PENDING) {
+ return ()
+ }
+
+ if([TransactionType.RECEIVE, TransactionType.TOPUP].includes(tx.type)) {
+ return ()
+ }
+
+ return ()
+ }
+
+
+ return (
+
+ {([TransactionType.RECEIVE].includes(tx.type)) && (
+ <>
+ {[TransactionStatus.COMPLETED].includes(tx.status) && (
+ {tx.amount.toLocaleString()}
+ )}
+ {[TransactionStatus.ERROR, TransactionStatus.BLOCKED].includes(tx.status) && (
+ {tx.amount.toLocaleString()}
+ )}
+ >
+ )}
+ {([TransactionType.TOPUP].includes(tx.type)) && (
+ <>
+ {[TransactionStatus.PENDING, TransactionStatus.EXPIRED].includes(tx.status) && (
+ {tx.amount.toLocaleString()}
+ )}
+ {tx.status === TransactionStatus.COMPLETED && (
+ {tx.amount.toLocaleString()}
+ )}
+ {tx.status === TransactionStatus.ERROR && (
+ {tx.amount.toLocaleString()}
+ )}
+ >
+ )}
+ {([TransactionType.SEND, TransactionType.TRANSFER].includes(tx.type)) && (
+ -{tx.amount.toLocaleString()}
+ )}
+
+ }
+ topSeparator={props.isFirst ? false : true}
+ style={$item}
+ onPress={() => props.gotoTranDetail(tx.id)}
+ />
+ )
+
+})
+
+
+const $contentContainer: TextStyle = {
+ flex: 1,
+ padding: spacing.extraSmall,
+ alignItems: 'center',
+ }
+
+ const $card: ViewStyle = {
+ marginBottom: spacing.small,
+ paddingTop: 0,
+ }
+
+ const $cardHeading: TextStyle = {
+ fontFamily: typography.primary?.medium,
+ fontSize: 20,
+ }
+
+
+ const $item: ViewStyle = {
+ marginHorizontal: spacing.micro,
+ }
+
+ const $mintText: TextStyle = {
+ overflow: 'hidden',
+ fontSize: 14,
+ }
+
+ const $balanceContainer: ViewStyle = {
+ justifyContent: 'center',
+ alignSelf: 'center',
+ marginRight: spacing.extraSmall
+ }
+
+ const $balance: TextStyle = {
+ fontSize: 20,
+ fontFamily: typography.primary?.medium
+ }
+
+ const $txContainer: ViewStyle = {
+ justifyContent: 'center',
+ alignSelf: 'center',
+ marginRight: spacing.extraSmall
+ }
+
+ const $txAmount: TextStyle = {
+ fontFamily: typography.primary?.medium
+ }
+
+ const $txIconContainer: ViewStyle = {
+ padding: spacing.extraSmall,
+ alignSelf: "center",
+ marginRight: spacing.medium,
+ }
\ No newline at end of file
diff --git a/src/screens/TransferScreen.tsx b/src/screens/TransferScreen.tsx
new file mode 100644
index 00000000..6b2ebb78
--- /dev/null
+++ b/src/screens/TransferScreen.tsx
@@ -0,0 +1,590 @@
+import {observer} from 'mobx-react-lite'
+import React, {FC, useEffect, useState, useCallback, useRef} from 'react'
+import {useFocusEffect} from '@react-navigation/native'
+import {
+ UIManager,
+ Platform,
+ Alert,
+ TextInput,
+ TextStyle,
+ View,
+ ViewStyle,
+ LayoutAnimation,
+ ScrollView,
+ Share,
+} from 'react-native'
+import Clipboard from '@react-native-clipboard/clipboard'
+import {spacing, typography, useThemeColor, colors} from '../theme'
+import {useSafeAreaInsetsStyle} from '../utils/useSafeAreaInsetsStyle'
+import {WalletStackScreenProps} from '../navigation'
+import {
+ Button,
+ Icon,
+ Card,
+ Screen,
+ Loading,
+ InfoModal,
+ ErrorModal,
+ ListItem,
+ BottomModal,
+ Text,
+ IconTypes,
+} from '../components'
+import {Mint} from '../models/Mint'
+import {Transaction, TransactionStatus} from '../models/Transaction'
+import {useStores} from '../models'
+import {useHeader} from '../utils/useHeader'
+import {MintClient, Wallet} from '../services'
+import {log} from '../utils/logger'
+import AppError, {Err} from '../utils/AppError'
+import {translate} from '../i18n'
+import {
+ decodeInvoice,
+ LightningInvoice,
+ getInvoiceData,
+} from '../services/cashuHelpers'
+import {MintBalance} from '../models/Mint'
+import {isNumber} from 'lodash'
+import {MintListItem} from './Mints/MintListItem'
+import {ResultModalInfo} from './Wallet/ResultModalInfo'
+
+if (
+ Platform.OS === 'android' &&
+ UIManager.setLayoutAnimationEnabledExperimental
+) {
+ UIManager.setLayoutAnimationEnabledExperimental(true)
+}
+
+export const TransferScreen: FC> = observer(
+ function TransferScreen({route, navigation}) {
+
+ useHeader({
+ leftIcon: 'faArrowLeft',
+ onLeftPress: () => navigation.goBack(),
+ })
+
+ const {proofsStore, mintsStore, transactionsStore} = useStores()
+
+
+ const [encodedInvoice, setEncodedInvoice] = useState('')
+ const [invoice, setInvoice] = useState()
+ const [amountToTransfer, setAmountToTransfer] = useState(0)
+ const [estimatedFee, setEstimatedFee] = useState(0)
+ const [finalFee, setFinalFee] = useState(0)
+ const [memo, setMemo] = useState('')
+ const [availableMintBalances, setAvailableMintBalances] = useState<
+ MintBalance[]
+ >([])
+ const [mintBalanceToTransferFrom, setMintBalanceToTransferFrom] = useState<
+ MintBalance | undefined
+ >()
+ const [transactionStatus, setTransactionStatus] = useState<
+ TransactionStatus | undefined
+ >()
+ const [info, setInfo] = useState('')
+ const [error, setError] = useState()
+ const [isLoading, setIsLoading] = useState(false)
+ const [isPasteInvoiceModalVisible, setIsPasteInvoiceModalVisible] =
+ useState(false)
+ const [isResultModalVisible, setIsResultModalVisible] = useState(false)
+ const [resultModalInfo, setResultModalInfo] = useState<
+ {status: TransactionStatus; message: string} | undefined
+ >()
+
+
+ useFocusEffect(
+ useCallback(() => {
+ if (!route.params || !route.params?.scannedEncodedInvoice) {return}
+
+ const encoded = route.params?.scannedEncodedInvoice
+ onEncodedInvoice(encoded)
+ }, [route.params?.scannedEncodedInvoice]),
+ )
+
+
+ useEffect(() => {
+ const getEstimatedFee = async function () {
+ try {
+ if (!mintBalanceToTransferFrom) return
+ const fee = await MintClient.getLightningFee(
+ mintBalanceToTransferFrom.mint,
+ encodedInvoice,
+ )
+
+ if (amountToTransfer + fee > mintBalanceToTransferFrom.balance) {
+ setInfo(
+ 'There is not enough funds to cover expected lightning network fee. Try to select another mint with higher balance.',
+ )
+ }
+
+ setEstimatedFee(fee)
+ } catch (e: any) {
+ resetState()
+ handleError(e)
+ }
+ }
+ getEstimatedFee()
+ }, [mintBalanceToTransferFrom])
+
+ const resetState = function () {
+ setEncodedInvoice('')
+ setInvoice(undefined)
+ setAmountToTransfer(0)
+ setEstimatedFee(0)
+ setMemo('')
+ setAvailableMintBalances([])
+ setMintBalanceToTransferFrom(undefined)
+ setTransactionStatus(undefined)
+ setInfo('')
+ setError(undefined)
+ setIsLoading(false)
+ setIsPasteInvoiceModalVisible(false)
+ setIsResultModalVisible(false)
+ setResultModalInfo(undefined)
+ }
+
+ const gotoScan = function () {
+ navigation.navigate('Scan')
+ }
+
+ const togglePasteInvoiceModal = () =>
+ setIsPasteInvoiceModalVisible(previousState => !previousState)
+ const toggleResultModal = () =>
+ setIsResultModalVisible(previousState => !previousState)
+
+
+ const onMintBalanceSelect = function (balance: MintBalance) {
+ setMintBalanceToTransferFrom(balance) // this triggers effect to get estimated fees
+ }
+
+ const onPasteInvoice = async function () {
+ const encoded = await Clipboard.getString()
+ if (!encoded) {
+ setInfo('Copy received invoice first, then paste')
+ return
+ }
+ togglePasteInvoiceModal()
+ return onEncodedInvoice(encoded)
+ }
+
+ const onEncodedInvoice = async function (encoded: string) {
+ try {
+ setEncodedInvoice(encoded)
+
+ const invoice = decodeInvoice(encoded)
+ const {amount, expiry, description} = getInvoiceData(invoice)
+
+ log.trace('Decoded invoice', invoice)
+ log.trace('Invoice data', {amount, expiry, description})
+
+ if (!amount || amount === 0) {
+ setInfo('Invoice amount should be positive number')
+ return
+ }
+
+ setInvoice(invoice)
+ setAmountToTransfer(amount)
+ // TODO expiry check or countdown
+ if (description) {setMemo(description)}
+
+ const availableBalances =
+ proofsStore.getMintBalancesWithEnoughBalance(amount)
+
+ if (availableBalances.length === 0) {
+ setInfo('There is not enough funds to send this amount')
+ return
+ }
+
+ log.trace('availableBalances', availableBalances.length)
+
+ setAvailableMintBalances(availableBalances)
+ setMintBalanceToTransferFrom(availableBalances[0])
+ } catch (e: any) {
+ resetState()
+ handleError(e)
+ }
+ }
+
+ const transfer = async function () {
+ setIsLoading(true)
+
+ const {transaction, message, error, finalFee} = await Wallet.transfer(
+ mintBalanceToTransferFrom as MintBalance,
+ amountToTransfer,
+ estimatedFee,
+ memo,
+ encodedInvoice,
+ )
+
+ const {status} = transaction as Transaction
+ setTransactionStatus(status)
+
+ if (error) {
+ setResultModalInfo({
+ status,
+ message: error.message,
+ })
+ } else {
+ setResultModalInfo({
+ status,
+ message,
+ })
+ }
+
+ if (finalFee) {
+ setFinalFee(finalFee)
+ }
+
+ setIsLoading(false)
+ toggleResultModal()
+ }
+
+
+ const handleError = function(e: AppError): void {
+ setIsLoading(false)
+ setError(e)
+ }
+
+ const headerBg = useThemeColor('header')
+ const feeColor = colors.palette.primary200
+
+ return (
+
+
+ {invoice && amountToTransfer > 0 ? (
+
+
+
+ {transactionStatus === TransactionStatus.COMPLETED ? (
+
+ ) : (
+
+ )}
+
+ ) : (
+
+ )}
+
+
+ {!resultModalInfo && !mintBalanceToTransferFrom && (
+
+
+ }
+ style={$item}
+ bottomSeparator={true}
+ onPress={togglePasteInvoiceModal}
+ />
+
+ }
+ style={$item}
+ bottomSeparator={false}
+ onPress={gotoScan}
+ />
+ >
+ }
+ />
+ )}
+
+ {mintBalanceToTransferFrom &&
+ availableMintBalances.length > 0 &&
+ transactionStatus !== TransactionStatus.COMPLETED && (
+
+ )}
+ {transactionStatus === TransactionStatus.COMPLETED && (
+ <>
+
+ balance.mint === mintBalanceToTransferFrom?.mint,
+ )}
+ separator={undefined}
+ />
+ }
+ />
+
+
+
+ >
+ )}
+ {isLoading && }
+
+
+ }
+ onBackButtonPress={togglePasteInvoiceModal}
+ onBackdropPress={togglePasteInvoiceModal}
+ />
+
+ {resultModalInfo &&
+ transactionStatus === TransactionStatus.COMPLETED && (
+ <>
+
+
+
+
+ >
+ )}
+ {resultModalInfo && transactionStatus === TransactionStatus.REVERTED && (
+ <>
+
+
+
+
+ >
+ )}
+ {resultModalInfo &&
+ transactionStatus === TransactionStatus.ERROR && (
+ <>
+
+
+
+
+ >
+ )}
+ >
+
+ }
+ onBackButtonPress={toggleResultModal}
+ onBackdropPress={toggleResultModal}
+ />
+ {error && }
+ {info && }
+
+ )
+ }
+)
+
+const PasteInvoiceBlock = function (props: {
+ togglePasteModal: any
+ onPasteInvoice: any
+}) {
+ return (
+
+
+ props.onPasteInvoice()}
+ style={{marginRight: spacing.medium}}
+ />
+
+
+
+ )
+}
+
+const MintBalanceSelector = observer(function (props: {
+ availableMintBalances: MintBalance[]
+ mintBalanceToSendFrom: MintBalance
+ onMintBalanceSelect: any
+ onCancel: any
+ findByUrl: any
+ onMintBalanceConfirm: any
+}) {
+
+ const onMintSelect = function(balance: MintBalance) {
+ log.trace('onMintBalanceSelect', balance.mint)
+ return props.onMintBalanceSelect(balance)
+ }
+
+ return (
+ <>
+
+ {props.availableMintBalances.map(
+ (balance: MintBalance, index: number) => (
+ onMintSelect(balance)}
+ isSelectable={true}
+ isSelected={props.mintBalanceToSendFrom.mint === balance.mint}
+ separator={'top'}
+ />
+ ),
+ )}
+ >
+ }
+ />
+
+ }
+ />
+
+
+ >
+ )
+})
+
+const $screen: ViewStyle = {
+}
+
+const $headerContainer: TextStyle = {
+ alignItems: 'center',
+ padding: spacing.medium,
+ paddingTop: 0,
+ height: spacing.screenHeight * 0.18,
+}
+
+const $amountContainer: ViewStyle = {
+ alignItems: 'center',
+ justifyContent: 'center',
+}
+
+const $amountToTransfer: TextStyle = {
+ flex: 1,
+ paddingTop: spacing.extraLarge + 10,
+ fontSize: 52,
+ fontWeight: '400',
+ textAlignVertical: 'center',
+ color: 'white',
+}
+
+const $contentContainer: TextStyle = {
+ padding: spacing.extraSmall,
+}
+
+const $optionsCard: ViewStyle = {
+ marginTop: -spacing.extraLarge * 2,
+ marginBottom: spacing.small,
+ paddingTop: 0,
+}
+
+const $card: ViewStyle = {
+ marginBottom: spacing.small,
+ paddingTop: 0,
+}
+
+const $item: ViewStyle = {
+ paddingHorizontal: spacing.small,
+ paddingLeft: 0,
+}
+
+const $bottomModal: ViewStyle = {
+ flex: 1,
+ alignItems: 'center',
+ paddingVertical: spacing.large,
+ paddingHorizontal: spacing.small,
+}
+
+const $buttonContainer: ViewStyle = {
+ flexDirection: 'row',
+ alignSelf: 'center',
+}
+
+const $receiveMsg: ViewStyle = {
+ flexDirection: 'row',
+ borderRadius: spacing.large,
+ justifyContent: 'flex-start',
+ padding: spacing.small,
+}
+
diff --git a/src/screens/Wallet/ResultModalInfo.tsx b/src/screens/Wallet/ResultModalInfo.tsx
new file mode 100644
index 00000000..2934290c
--- /dev/null
+++ b/src/screens/Wallet/ResultModalInfo.tsx
@@ -0,0 +1,34 @@
+import React from "react"
+import { View, ViewStyle } from "react-native"
+import { Button, Icon, IconTypes, ListItem, Screen, Text } from "../../components"
+import { colors, spacing, typography, useThemeColor } from "../../theme"
+
+
+export const ResultModalInfo = function(props: {
+ icon: IconTypes,
+ iconColor: string,
+ title: string,
+ message: string
+ }
+ ) {
+
+ const textColor = useThemeColor('textDim')
+
+ return (
+
+
+
+
+
+ )
+ }
+
+ const $bottomModal: ViewStyle = {
+ // flex:1,
+ alignItems: 'center',
+ paddingVertical: spacing.large,
+ paddingHorizontal: spacing.small,
+ }
\ No newline at end of file
diff --git a/src/screens/WalletScreen.tsx b/src/screens/WalletScreen.tsx
new file mode 100644
index 00000000..83bd6a5c
--- /dev/null
+++ b/src/screens/WalletScreen.tsx
@@ -0,0 +1,513 @@
+import {observer} from 'mobx-react-lite'
+import React, {FC, useState, useEffect, useRef, useMemo} from 'react'
+import {
+ ImageStyle,
+ TextStyle,
+ ViewStyle,
+ View,
+ ScrollView,
+ Alert,
+ Pressable,
+ Text as RNText,
+} from 'react-native'
+import Animated, {
+ useAnimatedScrollHandler,
+ useSharedValue,
+ useAnimatedStyle,
+} from 'react-native-reanimated'
+import {useThemeColor, spacing, colors, typography} from '../theme'
+import {
+ Button,
+ Icon,
+ Screen,
+ Text,
+ Card,
+ ListItem,
+ InfoModal,
+ Loading,
+ BottomModal,
+ ErrorModal,
+} from '../components'
+import {useStores} from '../models'
+import {WalletStackScreenProps} from '../navigation'
+import {useHeader} from '../utils/useHeader'
+import {Mint} from '../models/Mint'
+import {MintsByHostname} from '../models/MintsStore'
+import {log} from '../utils/logger'
+import {Transaction} from '../models/Transaction'
+import {TransactionListItem} from './Transactions/TransactionListItem'
+import {Database, MintClient, MintKeys, MintKeySets, Wallet} from '../services'
+import {translate} from '../i18n'
+import AppError from '../utils/AppError'
+
+interface WalletScreenProps extends WalletStackScreenProps<'Wallet'> {}
+
+type MintBalance = {
+ mint: string
+ balance: number
+}
+
+export const WalletScreen: FC = observer(
+ function WalletScreen(_props) {
+ const {navigation} = _props
+ const {mintsStore, proofsStore, transactionsStore} = useStores()
+
+ useHeader({
+ rightIcon: 'faBolt',
+ rightIconColor: colors.palette.primary100,
+ onRightPress: () => toggleLightningModal(),
+ leftIcon: 'faListUl',
+ leftIconColor: colors.palette.primary100,
+ onLeftPress: () => gotoTranHistory(),
+ })
+
+ const scrollY = useSharedValue(0)
+
+ const handleScroll = useAnimatedScrollHandler(event => {
+ scrollY.value = event.contentOffset.y
+ })
+
+ const stylez = useAnimatedStyle(() => {
+ return {
+ transform: [
+ {
+ translateY: scrollY.value,
+ },
+ ],
+ }
+ })
+
+ const [info, setInfo] = useState('')
+ const [error, setError] = useState()
+ const [isLoading, setIsLoading] = useState(false)
+ const [isLightningModalVisible, setIsLightningModalVisible] =
+ useState(false)
+
+ useEffect(() => {
+ // Check with mints if some of pending SEND and TOPUP transactions
+ // were completed and update balances accordingly
+ Wallet.checkPendingSpent()
+ Wallet.checkPendingTopups()
+ }, [])
+
+ const toggleLightningModal = () =>
+ setIsLightningModalVisible(previousState => !previousState)
+
+ const addMint = async function () {
+ const mintUrl = 'https://mint.minibits.cash/Bitcoin'
+
+ if (mintsStore.alreadyExists(mintUrl)) {
+ const msg = translate('mintsScreen.mintExists')
+ log.info(msg)
+ setInfo(msg)
+ return
+ }
+
+ try {
+ setIsLoading(true)
+
+ const mintKeys: {
+ keys: MintKeys
+ keysets: MintKeySets
+ } = await MintClient.getMintKeys(mintUrl)
+
+ const newMint: Mint = {
+ mintUrl,
+ keys: mintKeys.keys,
+ keysets: mintKeys.keysets.keysets,
+ }
+
+ mintsStore.addMint(newMint)
+
+ // log.trace('Snapshot after add mint', getSnapshot(mintsStore))
+
+ // setInfo(translate('mintsScreen.mintAdded'))
+ } catch (e: any) {
+ handleError(e)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const gotoReceive = function () {
+ navigation.navigate('Receive')
+ }
+
+ const gotoScan = function () {
+ navigation.navigate('Scan')
+ }
+
+ const gotoSend = function () {
+ navigation.navigate('Send')
+ }
+
+ const gotoTranHistory = function () {
+ navigation.navigate('TranHistory')
+ }
+
+ const gotoTranDetail = function (id: number) {
+ navigation.navigate('TranDetail', {id} as any)
+ }
+
+ const gotoWithdraw = function () {
+ toggleLightningModal() // close
+ navigation.navigate('Transfer')
+ }
+
+ const gotoTopup = function () {
+ toggleLightningModal() // close
+ navigation.navigate('Topup')
+ }
+
+ const handleError = function (e: AppError) {
+ setIsLoading(false)
+ setError(e)
+ }
+
+ const balances = proofsStore.getBalances()
+ const screenBg = useThemeColor('background')
+
+ return (
+
+
+
+
+ {mintsStore.mintCount === 0 &&