New POS mode implementation

This commit is contained in:
minibits-cash
2026-01-27 23:47:34 +01:00
parent cfbb9a6335
commit 1ac6690920
23 changed files with 841 additions and 111 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "minibits_wallet",
"version": "0.3.4-beta.19",
"version": "0.3.4-beta.20",
"private": true,
"scripts": {
"android:clean": "cd android && ./gradlew clean",
+3 -1
View File
@@ -8,6 +8,7 @@ import Animated, {
} from 'react-native-reanimated'
import { spacing, typography, useThemeColor } from '../theme'
import { translate, TxKeyPath } from '../i18n'
import { verticalScale } from '@gocodingnow/rn-size-matters'
export interface AnimatedHeaderProps {
/**
@@ -107,6 +108,7 @@ const $headerContainer: ViewStyle = {
}
const $headerTitle: TextStyle = {
fontSize: spacing.extraLarge,
fontSize: verticalScale(32),
lineHeight: verticalScale(44),
fontFamily: typography.primary?.medium,
}
+2 -1
View File
@@ -41,6 +41,7 @@ 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 { faCheck } from '@fortawesome/free-solid-svg-icons/faCheck'
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'
@@ -108,7 +109,7 @@ export type IconTypes = keyof typeof iconRegistry
// TODO remove need for manual iconregistry?
// would be best to just import all of them, i guess, or figure out something smart
export const iconRegistry = { faWifi, faNfcSymbol, faAddressCard, faAddressBook, faWallet, faQrcode, faClipboard, faSliders, faCoins, faEllipsisVertical, faEllipsis, faArrowUp, faArrowDown, faArrowLeft, faXmark, faInfoCircle, faBug, faCheckCircle, faArrowTurnUp, faArrowTurnDown, faPencil, faTags, faShareFromSquare, faRotate, faCode, faBan, faCircle, faPaperPlane, faBolt, faArrowUpFromBracket, faArrowRightToBracket, faPlus, faShieldHalved, faCloudArrowUp, faPaintbrush, faCopy, faBurst, faUserShield, faLock, faLockOpen, faTriangleExclamation, faDownload, faUpload, faRecycle, faListUl, faExpand, faFingerprint, faWandMagicSparkles, faCircleUser, faComment, faKey, faCircleNodes, faBullseye, faEyeSlash, faUpRightFromSquare, faShareNodes, faPaste, faKeyboard, faMoneyBill1, faGears, faTag, faBank, faChevronDown, faChevronUp, faCircleExclamation, faCircleQuestion, faEnvelope, faTwitter, faTelegramPlane, faDiscord, faGithub, faReddit, faCircleArrowUp, faCircleArrowDown, faGlobe, faCubes, faClock, faArrowRotateLeft, faHeartPulse, faSeedling, faChevronLeft, faArrowRightArrowLeft }
export const iconRegistry = { faWifi, faNfcSymbol, faAddressCard, faAddressBook, faWallet, faQrcode, faClipboard, faSliders, faCoins, faEllipsisVertical, faEllipsis, faArrowUp, faArrowDown, faArrowLeft, faXmark, faInfoCircle, faBug, faCheckCircle, faCheck, faArrowTurnUp, faArrowTurnDown, faPencil, faTags, faShareFromSquare, faRotate, faCode, faBan, faCircle, faPaperPlane, faBolt, faArrowUpFromBracket, faArrowRightToBracket, faPlus, faShieldHalved, faCloudArrowUp, faPaintbrush, faCopy, faBurst, faUserShield, faLock, faLockOpen, faTriangleExclamation, faDownload, faUpload, faRecycle, faListUl, faExpand, faFingerprint, faWandMagicSparkles, faCircleUser, faComment, faKey, faCircleNodes, faBullseye, faEyeSlash, faUpRightFromSquare, faShareNodes, faPaste, faKeyboard, faMoneyBill1, faGears, faTag, faBank, faChevronDown, faChevronUp, faCircleExclamation, faCircleQuestion, faEnvelope, faTwitter, faTelegramPlane, faDiscord, faGithub, faReddit, faCircleArrowUp, faCircleArrowDown, faGlobe, faCubes, faClock, faArrowRotateLeft, faHeartPulse, faSeedling, faChevronLeft, faArrowRightArrowLeft }
interface IconProps extends TouchableOpacityProps {
+118
View File
@@ -0,0 +1,118 @@
import React from 'react'
import { View, ViewStyle, TextStyle, Pressable } from 'react-native'
import { verticalScale, moderateScale } from '@gocodingnow/rn-size-matters'
import { spacing, useThemeColor, typography } from '../theme'
import { Text } from './Text'
import { Icon } from './Icon'
export interface NumericKeypadProps {
onKeyPress: (key: string) => void
onClear?: () => void
onLongPressBackspace?: () => void
disabled?: boolean
}
const KEYS = [
['1', '2', '3'],
['4', '5', '6'],
['7', '8', '9'],
['clear', '0', 'backspace'],
]
export function NumericKeypad({ onKeyPress, onClear, onLongPressBackspace, disabled = false }: NumericKeypadProps) {
const keyBg = useThemeColor('card')
const keyBgPressed = useThemeColor('buttonSecondary')
const textColor = useThemeColor('text')
const textDimColor = useThemeColor('textDim')
const renderKey = (key: string) => {
const isBackspace = key === 'backspace'
const isClear = key === 'clear'
return (
<Pressable
key={key}
onPress={() => {
if (disabled) return
if (isClear && onClear) {
onClear()
} else {
onKeyPress(key)
}
}}
onLongPress={isBackspace && onLongPressBackspace ? onLongPressBackspace : undefined}
delayLongPress={500}
disabled={disabled}
style={({ pressed }) => [
$key,
{ backgroundColor: pressed ? keyBgPressed : keyBg },
disabled && $keyDisabled,
]}
>
{isBackspace ? (
<Icon
icon="faChevronLeft"
size={moderateScale(24)}
color={disabled ? textDimColor : textColor}
/>
) : isClear ? (
<Icon
icon="faXmark"
size={moderateScale(24)}
color={disabled ? textDimColor : textColor}
/>
) : (
<Text
style={[
$keyText,
{ color: disabled ? textDimColor : textColor },
]}
preset='heading'
>
{key}
</Text>
)}
</Pressable>
)
}
return (
<View style={$container}>
{KEYS.map((row, rowIndex) => (
<View key={rowIndex} style={$row}>
{row.map(renderKey)}
</View>
))}
</View>
)
}
const $container: ViewStyle = {
width: '100%',
paddingHorizontal: spacing.medium,
}
const $row: ViewStyle = {
flexDirection: 'row',
justifyContent: 'center',
marginVertical: spacing.small,
}
const $key: ViewStyle = {
width: moderateScale(80),
height: verticalScale(56),
borderRadius: spacing.small,
justifyContent: 'center',
alignItems: 'center',
marginHorizontal: spacing.small,
}
const $keyDisabled: ViewStyle = {
opacity: 0.5,
}
const $keyText: TextStyle = {
//fontFamily: typography.primary.medium,
//fontSize: moderateScale(28),
}
+14
View File
@@ -0,0 +1,14 @@
export const PosIcon = `<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="256px" height="256px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6.5" y="4.5" width="11" height="15" rx="1.5" />
<rect x="8.5" y="6.5" width="7" height="5" stroke-linejoin="round"/>
<path d="M8.5 17.5H9.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.5 17.5H15.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.5 17.5H12.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.5 15.5H9.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.5 15.5H15.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.5 15.5H12.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.5 13.5H9.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.5 13.5H15.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11.5 13.5H12.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`
+60
View File
@@ -0,0 +1,60 @@
import React from 'react'
import { TextStyle, View, ViewStyle } from 'react-native'
import Animated, {
SharedValue,
useAnimatedStyle,
interpolate,
Extrapolation,
} from 'react-native-reanimated'
import { spacing, typography, useThemeColor } from '../theme'
import { translate, TxKeyPath } from '../i18n'
import { verticalScale } from '@gocodingnow/rn-size-matters'
import { Text } from './Text'
export interface StaticHeaderProps {
/**
* Title text to display.
*/
title?: string
/**
* Title text which is looked up via i18n.
*/
titleTx?: TxKeyPath
height?: number
}
export function StaticHeader(props: StaticHeaderProps) {
const {
title,
titleTx,
height = spacing.screenHeight * 0.15,
} = props
const headerBg = useThemeColor('header')
const headerTitleColor = useThemeColor('headerTitle')
const titleContent = titleTx ? translate(titleTx) : title
return (
<View style={[$headerContainer, { height, backgroundColor: headerBg }]}>
<Text style={[$headerTitle, { color: headerTitleColor }]}>
{titleContent}
</Text>
</View>
)
}
const $headerContainer: ViewStyle = {
alignItems: 'center',
paddingBottom: spacing.medium,
}
const $headerTitle: TextStyle = {
fontSize: verticalScale(32),
lineHeight: verticalScale(44),
fontFamily: typography.primary?.medium,
}
+1 -1
View File
@@ -76,7 +76,7 @@ export function Text(props: TextProps) {
}
export const $sizeStyles = {
xxl: { fontSize: verticalScale(36), lineHeight: verticalScale(44) } as TextStyle,
xxl: { fontSize: verticalScale(32), lineHeight: verticalScale(44) } as TextStyle,
xl: { fontSize: verticalScale(24), lineHeight: verticalScale(34) } as TextStyle,
lg: { fontSize: verticalScale(20), lineHeight: verticalScale(32) } as TextStyle,
md: { fontSize: verticalScale(18), lineHeight: verticalScale(26) } as TextStyle,
+3 -1
View File
@@ -4,6 +4,7 @@ export * from "./Button"
export * from "./Card"
export * from "./Header"
export * from "./AnimatedHeader"
export * from "./StaticHeader"
export * from "./Icon"
export * from "./ListItem"
export * from "./Screen"
@@ -25,4 +26,5 @@ export * from "./MintIcon"
export * from "./HeaderBg"
export * from "./NwcIcon"
export * from "./ContactlessIcon"
export * from "./PosIcon"
export * from "./NumericKeypad"
+3
View File
@@ -310,6 +310,9 @@
"payCommon_requestPayment": "Request payment",
"payCommon_send": "Send",
"payCommon_withdraw": "Withdraw",
"pos_exchangeRateUnavailable": "Exchange rate unavailable",
"pos_newTransaction": "New transaction",
"pos_waitingForPayment": "Waiting for payment...",
"payerMemo": "Memo for the payer",
"payWithLightningDesc": "Enter or paste Lightning address, invoice or LNURL pay code you want to pay to.",
"pictureRetrieveFail": "Could not retrieve pictures from the server, try again later.",
+3
View File
@@ -309,6 +309,9 @@
"payCommon_requestPayment": "Solicitar pago",
"payCommon_send": "Enviar",
"payCommon_withdraw": "Retirar",
"pos_exchangeRateUnavailable": "Tipo de cambio no disponible",
"pos_newTransaction": "Nueva transacción",
"pos_waitingForPayment": "Esperando el pago...",
"payerMemo": "Memo para el pagador",
"payWithLightningDesc": "Ingrese o pegue la dirección Lightning, la factura o el código de pago LNURL al que desea pagar.",
"pictureRetrieveFail": "No se pudieron recuperar las imágenes del servidor, inténtelo nuevamente más tarde.",
+3
View File
@@ -310,6 +310,9 @@
"payCommon_requestPayment": "Solicitar pagamento",
"payCommon_send": "Enviar",
"payCommon_withdraw": "Retirar",
"pos_exchangeRateUnavailable": "Taxa de câmbio indisponível",
"pos_newTransaction": "Nova transação",
"pos_waitingForPayment": "Aguardando pagamento...",
"payerMemo": "Memorando para o pagador",
"payWithLightningDesc": "Insira ou cole endereço Lightning, invoice ou código de pagamento LNURL para pagar.",
"pictureRetrieveFail": "Não foi possível recuperar imagens do servidor, tente mais tarde.",
+3
View File
@@ -310,6 +310,9 @@
"payCommon_requestPayment": "Vyžiadaj platbu",
"payCommon_send": "Pošli",
"payCommon_withdraw": "Vyber",
"pos_exchangeRateUnavailable": "Výmenný kurz nie je k dispozícii",
"pos_newTransaction": "Nová transakcia",
"pos_waitingForPayment": "Čakám na platbu...",
"payerMemo": "Poznámka pre príjemcu",
"payWithLightningDesc": "Zadaj Lightning adresu, invoice alebo LNURL kód ktorý chceš zaplatiť.",
"pictureRetrieveFail": "Nejde načítať obrázky zo servera, skús neskôr.",
+1 -1
View File
@@ -27,7 +27,7 @@ import { Proof } from './Proof'
import { InFlightRequest, Mint } from './Mint'
import { getRootStore } from './helpers/getRootStore'
import { Transaction } from './Transaction'
// refresh refresh
//
/*
Not persisted, in-memory only model of the cashu-ts wallet instances and wallet keys persisted in the device secure store.
+3 -1
View File
@@ -37,7 +37,8 @@ import {
TokenReceiveScreen,
NwcScreen,
CashuPaymentRequestScreen,
RecoveryOptionsScreen
RecoveryOptionsScreen,
POSScreen
} from "../screens"
import { colors, spacing, typography } from "../theme"
@@ -74,6 +75,7 @@ const WalletStack = createNativeStackNavigator({
Transfer: TransferScreen,
Topup: TopupScreen,
CashuPaymentRequest: CashuPaymentRequestScreen,
POS: POSScreen,
}
})
+5
View File
@@ -352,6 +352,11 @@ export const PrivateContacts = observer(function (props: {
}
value={newContactName}
autoCapitalize="none"
autoComplete='off'
importantForAutofill='no'
autoCorrect={false}
spellCheck={false}
smartInsertDelete={false}
keyboardType="email-address"
maxLength={80}
selectTextOnFocus={true}
+1 -1
View File
@@ -34,7 +34,7 @@ import RNExitApp from 'react-native-exit-app'
import { TransactionStatus } from '../models/Transaction'
import { maxTransactionsInHistory } from '../models/TransactionsStore'
import { StaticScreenProps, useNavigation } from '@react-navigation/native'
// refresh
//
type Props = StaticScreenProps<undefined>
+34 -27
View File
@@ -1,4 +1,5 @@
import React from "react"
import React, { useState } from "react"
import { TouchableOpacity } from "react-native"
import { Header, Text } from "../../components"
import { spacing, useThemeColor } from "../../theme"
import { Mint } from "../../models/Mint"
@@ -15,44 +16,38 @@ export const MintHeader = observer(function(props: {
mint?: Mint
hideBalance?: boolean
onBackPress?: () => void
backgroundColor?: string
textColor?: string
leftIconColor?: string
}
) {
const navigation = useNavigation()
const {mint, unit, hideBalance, onBackPress} = props
const getActiveUnitColor = () => {
/* switch (props.unit) {
case 'usd':
return useThemeColor('usd')
case 'eur':
return useThemeColor('eur')
default:
return useThemeColor('btc')
} */
return useThemeColor('headerTitle')
}
const {mint, unit, hideBalance, onBackPress, backgroundColor, textColor, leftIconColor} = props
const [isBalanceHidden, setIsBalanceHidden] = useState(false)
const tabWidth = moderateScale(80)
const headerTitle = useThemeColor('headerTitle')
const resolvedTextColor = textColor || 'white'
return (
<Header
backgroundColor={backgroundColor}
leftIconColor={leftIconColor}
TitleActionComponent={
<>
{mint && (<Text
text={mint && mint.shortname}
style={{color: headerTitle}}
style={{color: textColor || headerTitle}}
size='xxs'
/>)}
<CurrencySign
mintUnit={unit}
textStyle={{color: 'white'}}
textStyle={{color: resolvedTextColor}}
containerStyle={{
borderBottomWidth: 2,
paddingVertical: mint ? spacing.tiny : spacing.small,
borderBottomColor: getActiveUnitColor(),
borderBottomColor: resolvedTextColor,
width: tabWidth
}}
/>
@@ -63,14 +58,26 @@ export const MintHeader = observer(function(props: {
onBackPress ? onBackPress() : navigation.goBack()
}}
RightActionComponent={mint && unit && !hideBalance ? (
<CurrencyAmount
mintUnit={unit}
amount={mint?.balances?.balances[unit as MintUnit] || 0}
amountStyle={{color: 'white'}}
symbolStyle={{color: 'white'}}
containerStyle={{marginRight: spacing.medium}}
size='medium'
/>
<TouchableOpacity
onPress={() => setIsBalanceHidden(!isBalanceHidden)}
style={{marginRight: spacing.medium}}
>
{isBalanceHidden ? (
<Text
text="***"
style={{color: resolvedTextColor}}
size='md'
/>
) : (
<CurrencyAmount
mintUnit={unit}
amount={mint?.balances?.balances[unit as MintUnit] || 0}
amountStyle={{color: resolvedTextColor}}
symbolStyle={{color: resolvedTextColor}}
size='medium'
/>
)}
</TouchableOpacity>
) : undefined}
/>
)
+494
View File
@@ -0,0 +1,494 @@
import { observer } from 'mobx-react-lite'
import React, { useEffect, useState, useRef } from 'react'
import {
View,
ViewStyle,
TextStyle,
} from 'react-native'
import Animated, {
useSharedValue,
useAnimatedStyle,
withSequence,
withTiming,
withDelay,
Easing,
} from 'react-native-reanimated'
import { spacing, useThemeColor, colors, typography } from '../theme'
import {
Button,
Icon,
Screen,
Loading,
InfoModal,
ErrorModal,
BottomModal,
Text,
NumericKeypad,
} from '../components'
import { TransactionStatus, Transaction } from '../models/Transaction'
import { useStores } from '../models'
import {
HANDLE_PENDING_TOPUP_TASK,
TransactionTaskResult,
WalletTask,
} from '../services'
import { log } from '../services/logService'
import AppError from '../utils/AppError'
import { MintBalance } from '../models/Mint'
import EventEmitter from '../utils/eventEmitter'
import { ResultModalInfo } from './Wallet/ResultModalInfo'
import { StaticScreenProps, useNavigation } from '@react-navigation/native'
import { infoMessage } from '../utils/utils'
import { round } from '../utils/number'
import { verticalScale, moderateScale } from '@gocodingnow/rn-size-matters'
import {
CurrencyCode,
MintUnit,
convertToFromSats,
Currencies,
formatCurrency,
} from '../services/wallet/currency'
import { MintHeader } from './Mints/MintHeader'
import useIsInternetReachable from '../utils/useIsInternetReachable'
import { QRCodeBlock } from './Wallet/QRCode'
import { translate } from '../i18n'
type Props = StaticScreenProps<{
unit: MintUnit
mintUrl?: string
}>
export const POSScreen = observer(function POSScreen({ route }: Props) {
const navigation = useNavigation()
const isInternetReachable = useIsInternetReachable()
const {
proofsStore,
mintsStore,
walletStore,
userSettingsStore,
} = useStores()
const unitRef = useRef<MintUnit>('sat')
// Amount state - stored as integer cents (e.g., 123 = $1.23)
const [amountCents, setAmountCents] = useState<number>(0)
const [satsAmount, setSatsAmount] = useState<number>(0)
// Max digits for amount entry (e.g., 7 = max $99,999.99)
const MAX_CENTS_DIGITS = 7
// Mint selection
const [mintBalanceToTopup, setMintBalanceToTopup] = useState<MintBalance | undefined>()
// Transaction state
const [transactionStatus, setTransactionStatus] = useState<TransactionStatus | undefined>()
const [transactionId, setTransactionId] = useState<number | undefined>()
const [transaction, setTransaction] = useState<Transaction | undefined>()
const [invoiceToPay, setInvoiceToPay] = useState<string>('')
// UI state
const [info, setInfo] = useState('')
const [error, setError] = useState<AppError | undefined>()
const [isLoading, setIsLoading] = useState(false)
// Success icon animation
const iconScale = useSharedValue(0.8)
// Get fiat currency settings
const fiatCode = userSettingsStore.exchangeCurrency || CurrencyCode.USD
const fiatCurrency = Currencies[fiatCode]
const fiatSymbol = fiatCurrency?.symbol || '$'
const fiatPrecision = fiatCurrency?.precision ?? 100
const fiatMantissa = fiatCurrency?.mantissa ?? 2
// Initialize unit from route params
useEffect(() => {
if (route.params?.unit) {
unitRef.current = route.params.unit
}
}, [route.params?.unit])
// Convert cents to sats whenever amountCents or exchange rate changes
useEffect(() => {
if (!walletStore.exchangeRate) {
setSatsAmount(0)
return
}
const sats = convertToFromSats(amountCents, fiatCode, walletStore.exchangeRate)
setSatsAmount(round(sats, 0))
}, [amountCents, walletStore.exchangeRate, fiatCode])
// Trigger success icon animation when transaction completes
useEffect(() => {
if (transactionStatus === TransactionStatus.COMPLETED) {
iconScale.value = 0.8
iconScale.value = withDelay(
300,
withSequence(
withTiming(1.15, { duration: 400, easing: Easing.out(Easing.ease) }),
withTiming(1, { duration: 300, easing: Easing.inOut(Easing.ease) })
)
)
}
}, [transactionStatus])
const iconAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: iconScale.value }],
}))
// Payment detection via EventEmitter
useEffect(() => {
const handlePendingTopupTaskResult = (result: TransactionTaskResult) => {
log.trace('[POSScreen] handlePendingTopupTaskResult triggered')
if (!transactionId) return
if (result.transaction?.id !== transactionId) return
if (result.transaction.status !== TransactionStatus.COMPLETED) return
log.trace('[POSScreen] Invoice paid, proofs received')
setTransactionStatus(TransactionStatus.COMPLETED)
setTransaction(result.transaction)
}
if (transactionId) {
EventEmitter.on(
`ev_${HANDLE_PENDING_TOPUP_TASK}_result`,
handlePendingTopupTaskResult
)
}
return () => {
EventEmitter.off(
`ev_${HANDLE_PENDING_TOPUP_TASK}_result`,
handlePendingTopupTaskResult
)
}
}, [transactionId])
// Handle keypad input - cents-first entry (each digit shifts left)
const handleKeyPress = (key: string) => {
if (transactionStatus === TransactionStatus.PENDING) return
if (key === 'backspace') {
// Shift right - remove last digit
setAmountCents(prev => Math.floor(prev / 10))
} else {
// Only process digit keys
const digit = parseInt(key, 10)
if (isNaN(digit)) return
// Check max digits limit
if (amountCents.toString().length >= MAX_CENTS_DIGITS) return
// Shift left and add new digit
setAmountCents(prev => prev * 10 + digit)
}
}
// Clear amount (also used for long-press backspace)
const onClearAmount = () => {
setAmountCents(0)
setSatsAmount(0)
}
// Confirm amount and create invoice immediately with default mint
const onConfirmAmount = async () => {
if (!isInternetReachable) {
infoMessage(translate('commonOfflinePretty'))
return
}
if (satsAmount <= 0) {
infoMessage(translate('payCommon_amountZeroOrNegative'))
return
}
const availableBalances = proofsStore.getMintBalancesWithUnit(unitRef.current)
if (availableBalances.length === 0) {
infoMessage(
translate('topup_missingMintAddFirst'),
translate('topup_missingMintAddFirstDesc')
)
return
}
// Use first available mint (highest balance) and create invoice immediately
const selectedMint = availableBalances[0]
setMintBalanceToTopup(selectedMint)
setIsLoading(true)
const result = await WalletTask.topupQueueAwaitable(
selectedMint,
satsAmount,
unitRef.current,
'' // empty memo for POS
)
await handleTopupTaskResult(result)
}
// Handle invoice creation result
const handleTopupTaskResult = async (result: TransactionTaskResult) => {
log.trace('[POSScreen] handleTopupTaskResult')
setIsLoading(false)
const { status, id } = result.transaction as Transaction
setTransactionStatus(status)
setTransactionId(id)
if (result.encodedInvoice) {
setInvoiceToPay(result.encodedInvoice)
}
if (result.error) {
setError(result.error)
return
}
}
// Reset for new transaction
const resetState = () => {
setAmountCents(0)
setSatsAmount(0)
setTransactionStatus(undefined)
setTransactionId(undefined)
setTransaction(undefined)
setInvoiceToPay('')
//setMintBalanceToTopup(undefined)
}
const gotoWallet = () => {
resetState()
navigation.goBack()
}
const handleError = (e: AppError) => {
setError(e)
}
// Get colors
const headerBg = useThemeColor('background')
const textColor = useThemeColor('text')
const textDimColor = useThemeColor('textDim')
const mainButtonColor = useThemeColor('button')
// Get selected mint info
const selectedMint = mintBalanceToTopup
? mintsStore.findByUrl(mintBalanceToTopup.mintUrl)
: undefined
// Format display amount from cents to decimal string (e.g., 123 → "1.23")
const formatCentsToDisplay = (cents: number): string => {
const divisor = fiatPrecision // e.g., 100 for USD
const whole = Math.floor(cents / divisor)
const fraction = cents % divisor
return `${whole}.${fraction.toString().padStart(fiatMantissa, '0')}`
}
const displayFiatAmount = formatCentsToDisplay(amountCents)
return (
<Screen preset="fixed" contentContainerStyle={$screen}>
<MintHeader
mint={selectedMint}
unit={unitRef.current}
onBackPress={gotoWallet}
textColor={textColor as string}
backgroundColor={headerBg as string}
leftIconColor={textColor as string}
/>
{/* Amount Display */}
<View style={[$amountContainer]}>
<Text
style={[$fiatAmountText, { color: textColor }]}
text={`${fiatSymbol} ${displayFiatAmount}`}
/>
{walletStore.exchangeRate && (
<View
style={{
flexDirection: "row",
justifyContent: "center",
}}
>
<Text
style={[$satsAmountText, { color: textDimColor }]}
text={`${Currencies[CurrencyCode.SAT]!.symbol} ${formatCurrency(satsAmount, CurrencyCode.SAT, true)}`}
/>
</View>
)}
</View>
{/* Content Area */}
<View style={$contentContainer}>
{/* Show keypad when no invoice yet */}
{!invoiceToPay && (
<View style={$keypadContainer}>
<NumericKeypad
onKeyPress={handleKeyPress}
onClear={onClearAmount}
onLongPressBackspace={onClearAmount}
disabled={transactionStatus === TransactionStatus.PENDING}
/>
<View style={$buttonContainer}>
<Button
LeftAccessory={() => (
<Icon
icon='faCheck'
color={'white'}
size={spacing.large}
/>
)}
onPress={onConfirmAmount}
style={$buttonSend}
/>
</View>
</View>
)}
{/* Show QR code when invoice is created */}
{invoiceToPay && transactionStatus === TransactionStatus.PENDING && (
<View style={$qrContainer}>
<QRCodeBlock
qrCodeData={invoiceToPay}
title={`Lightning invoice`}
type="Bolt11Invoice"
size={spacing.screenWidth - spacing.large * 4}
/>
<Button
text={translate('commonCancel')}
preset="secondary"
onPress={resetState}
style={{ marginTop: spacing.medium, minHeight: verticalScale(40), paddingVertical: verticalScale(spacing.tiny) }}
/>
</View>
)}
{/* Show success when completed */}
{transactionStatus === TransactionStatus.COMPLETED && (
<>
<View style={$successContainer}>
<Animated.View style={iconAnimatedStyle}>
<Icon
icon="faCheckCircle"
size={80}
color={mainButtonColor}
/>
</Animated.View>
<Text
style={[$successText, { color: textColor }]}
text={translate('payCommon_completed')}
/>
</View>
<View style={$bottomContainer}>
<View style={[$buttonContainer]}>
<Button
text={translate('pos_newTransaction')}
preset="default"
onPress={resetState}
style={[$buttonSend, { marginTop: spacing.large }]}
/>
</View>
</View>
</>
)}
</View>
{/* Error/Info modals */}
{error && <ErrorModal error={error} />}
{info && <InfoModal message={info} />}
{isLoading && <Loading />}
</Screen>
)
})
const $screen: ViewStyle = {
flex: 1,
}
const $amountContainer: ViewStyle = {
alignItems: 'center',
paddingVertical: spacing.extraLarge,
paddingHorizontal: spacing.medium,
}
const $fiatAmountText: TextStyle = {
fontFamily: typography.primary?.medium,
fontSize: moderateScale(48),
lineHeight: moderateScale(56),
}
const $satsAmountText: TextStyle = {
fontFamily: typography.primary?.normal,
fontSize: moderateScale(16),
marginTop: spacing.tiny,
}
const $contentContainer: ViewStyle = {
flex: 1,
//paddingTop: spacing.medium,
}
const $keypadContainer: ViewStyle = {
flex: 1,
justifyContent: 'center',
}
const $qrContainer: ViewStyle = {
flex: 1,
alignItems: 'center',
paddingHorizontal: spacing.medium,
}
const $waitingText: TextStyle = {
marginTop: spacing.small,
textAlign: 'center',
}
const $successContainer: ViewStyle = {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.large,
//paddingTop: spacing.large,
}
const $successText: TextStyle = {
fontFamily: typography.primary?.medium,
fontSize: moderateScale(20),
marginTop: spacing.medium,
textAlign: 'center',
}
const $buttonContainer: ViewStyle = {
flexDirection: 'row',
alignSelf: 'center',
marginVertical: spacing.medium,
}
const $buttonSend: ViewStyle = {
borderRadius: verticalScale(60 / 2),
height: verticalScale(60),
minWidth: verticalScale(140),
}
const $bottomContainer: ViewStyle = {
// position: 'absolute',
bottom: 0,
left: 0,
right: 0,
flex: 1,
justifyContent: 'flex-end',
marginBottom: spacing.medium,
alignSelf: 'stretch',
// opacity: 0,
}
+56 -36
View File
@@ -10,10 +10,10 @@ import {colors, spacing, useThemeColor} from '../theme'
import {log} from '../services/logService'
import { IncomingDataType, IncomingParser } from '../services/incomingParser'
import AppError, { Err } from '../utils/AppError'
import { Button, Card, ErrorModal, Icon, ListItem, ScanIcon, Screen, Text } from '../components'
import { Button, Card, ErrorModal, Icon, ListItem, PosIcon, ScanIcon, Screen, StaticHeader, Text } from '../components'
import { infoMessage } from '../utils/utils'
import Clipboard from '@react-native-clipboard/clipboard'
import { SvgXml } from 'react-native-svg'
import Svg, { SvgXml } from 'react-native-svg'
import { useStores } from '../models'
import { Mint } from '../models/Mint'
import { MintHeader } from './Mints/MintHeader'
@@ -139,6 +139,14 @@ export const TokenReceiveScreen = function TokenReceiveScreen({ route }: Props)
})
}
const gotoPOS = async function () {
//@ts-ignore
navigation.navigate('POS', {
unit,
mintUrl: mint?.mintUrl
})
}
const handleError = function(e: AppError): void {
setError(e)
}
@@ -158,13 +166,9 @@ export const TokenReceiveScreen = function TokenReceiveScreen({ route }: Props)
mint={mint}
unit={unit!}
/>
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
<Text
preset="heading"
tx="payCommon_receiveEcash"
style={{color: headerTitle}}
/>
</View>
<StaticHeader
titleTx="payCommon_receiveEcash"
/>
<View style={$contentContainer}>
<Card
ContentComponent={
@@ -233,27 +237,49 @@ export const TokenReceiveScreen = function TokenReceiveScreen({ route }: Props)
style={$card}
//style={{marginBottom: spacing.medium}}
/>
<Button
tx="tokenReceiveScreen_topupWithLightning"
LeftAccessory={() => (
<Icon
icon='faBolt'
color={hintText}
size={spacing.medium}
/>
)}
textStyle={{fontSize: 14, color: hintText}}
preset='secondary'
onPress={gotoTopup}
style={{
minHeight: verticalScale(40),
paddingVertical: verticalScale(spacing.tiny),
marginRight: spacing.tiny,
alignSelf: 'center',
marginTop: spacing.medium
}}
/>
<View style={[$buttonContainer, {marginTop: spacing.medium}]}>
<Button
tx="tokenReceiveScreen_topupWithLightning"
LeftAccessory={() => (
<Icon
icon='faBolt'
color={hintText}
size={spacing.medium}
/>
)}
textStyle={{fontSize: 14, color: hintText}}
preset='secondary'
onPress={gotoTopup}
style={{
minHeight: verticalScale(40),
paddingVertical: verticalScale(spacing.tiny),
marginRight: spacing.tiny,
alignSelf: 'center',
marginTop: spacing.medium
}}
/>
<Button
text="POS mode"
LeftAccessory={() => (
<SvgXml
width={spacing.large}
height={spacing.large}
xml={PosIcon}
stroke={hintText}
/>
)}
textStyle={{fontSize: 14, color: hintText}}
preset='secondary'
onPress={gotoPOS}
style={{
minHeight: verticalScale(40),
paddingVertical: verticalScale(spacing.tiny),
marginRight: spacing.tiny,
alignSelf: 'center',
marginTop: spacing.medium
}}
/>
</View>
</View>
<View style={$bottomContainer}>
<View style={$buttonContainer}>
@@ -289,12 +315,6 @@ const $contentContainer: ViewStyle = {
flex: 1
}
const $headerContainer: TextStyle = {
alignItems: 'center',
paddingBottom: spacing.medium,
height: spacing.screenHeight * 0.15,
}
const $buttonContainer: ViewStyle = {
//marginTop: spacing.large,
flexDirection: 'row',
+17 -24
View File
@@ -275,34 +275,27 @@ export const TopupScreen = observer(function TopupScreen({ route }: Props) {
const handlePendingTopupTaskResult = (result: TransactionTaskResult) => {
log.trace('[handlePendingTopupTaskResult] event handler triggered')
if (!transactionId) {
return
}
if (!transactionId) return
if (result.transaction?.id !== transactionId) return
if (result.transaction.status !== TransactionStatus.COMPLETED) return
// Filter and handle events only for this topup transactionId
if (result.transaction?.id === transactionId) {
// Show result modal only on completed topup
if (result.transaction.status !== TransactionStatus.COMPLETED) {
return
}
log.trace(
'[handlePendingTopupTaskResult]',
'Invoice has been paid and new proofs received',
)
log.trace(
'[handlePendingTopupTaskResult]',
'Invoice has been paid and new proofs received',
)
setResultModalInfo({
status: result.transaction.status,
message: result.message,
})
setResultModalInfo({
status: result.transaction.status,
message: result.message,
})
setTransactionStatus(TransactionStatus.COMPLETED)
setTransaction(result.transaction)
setIsQRModalVisible(false)
setIsNostrDMModalVisible(false)
setIsWithdrawModalVisible(false)
setIsResultModalVisible(true)
setTransactionStatus(TransactionStatus.COMPLETED)
setTransaction(result.transaction)
setIsQRModalVisible(false)
setIsNostrDMModalVisible(false)
setIsWithdrawModalVisible(false)
setIsResultModalVisible(true)
}
}
if(transactionId) {
+1 -2
View File
@@ -7,12 +7,11 @@ import { infoMessage } from "../../utils/utils"
import { Button, Card, Icon, ListItem } from "../../components"
import QRCode from "react-native-qrcode-svg"
import Clipboard from "@react-native-clipboard/clipboard"
import { moderateScale, verticalScale } from "@gocodingnow/rn-size-matters"
import { verticalScale } from "@gocodingnow/rn-size-matters"
import { colors, spacing } from "../../theme"
import { translate, TxKeyPath } from "../../i18n"
import { log } from "../../services"
import { Token, getDecodedToken, getEncodedToken } from '@cashu/cashu-ts'
import { isSupported } from '@react-native-firebase/messaging';
import { NfcService } from '../../services/nfcService';
+1
View File
@@ -19,6 +19,7 @@ export * from './LightningPayScreen'
export * from './TokenReceiveScreen'
export * from './CashuPaymentRequestScreen'
export * from './NfcPayScreen'
export * from './POSScreen'
// ContactsNavigator
export * from './ContactsScreen'
+1 -1
View File
@@ -18,7 +18,7 @@ import { Platform } from "react-native"
import AppError, { Err } from "../utils/AppError"
const { userSettingsStore } = rootStoreInstance
//
// refresh
if (!__DEV__) {
Sentry.init({