mirror of
https://github.com/minibits-cash/minibits_wallet.git
synced 2026-07-22 07:48:28 +00:00
UX improvements (Wallet, NFC)
This commit is contained in:
@@ -31,7 +31,7 @@
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Release"
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "minibits_wallet",
|
||||
"version": "0.4.3-beta.11",
|
||||
"version": "0.4.3-beta.12",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android:clean": "cd android && ./gradlew clean",
|
||||
|
||||
171
src/components/SegmentedTabBar.tsx
Normal file
171
src/components/SegmentedTabBar.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import React, { useEffect } from "react"
|
||||
import {
|
||||
LayoutChangeEvent,
|
||||
Pressable,
|
||||
StyleProp,
|
||||
TextStyle,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from "react-native"
|
||||
import Animated, {
|
||||
Easing,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from "react-native-reanimated"
|
||||
import type { Route, SceneRendererProps, NavigationState } from "react-native-tab-view"
|
||||
import { spacing } from "../theme"
|
||||
import { Text } from "./Text"
|
||||
|
||||
/**
|
||||
* A modern, platform-agnostic segmented control used as the `renderTabBar` of a
|
||||
* react-native-tab-view `TabView`. A single rounded "pill" slides under the
|
||||
* active segment — native-feeling on iOS (UISegmentedControl) and equally at
|
||||
* home on Android. The default track / pill colors are translucent white so the
|
||||
* control reads well on the colored app header across all themes.
|
||||
*/
|
||||
export type SegmentedTabBarProps<T extends Route> = SceneRendererProps & {
|
||||
navigationState: NavigationState<T>
|
||||
/** Custom content for each segment. Defaults to the route's `title` as text. */
|
||||
renderItem?: (info: { route: T; focused: boolean }) => React.ReactNode
|
||||
/** Fixed per-segment width; the control is then centered. Omit to fill width. */
|
||||
segmentWidth?: number
|
||||
trackColor?: string
|
||||
pillColor?: string
|
||||
/** Label colors for the default text renderer. */
|
||||
activeColor?: string
|
||||
inactiveColor?: string
|
||||
containerStyle?: StyleProp<ViewStyle>
|
||||
}
|
||||
|
||||
// Shared so non-tab surfaces (e.g. MintHeader's selected-unit chip) can match
|
||||
// the active pill exactly.
|
||||
export const SEGMENTED_PILL_RADIUS = 100
|
||||
export const SEGMENTED_PILL_COLOR = "rgba(255,255,255,0.20)"
|
||||
|
||||
const PILL_RADIUS = SEGMENTED_PILL_RADIUS
|
||||
// No padding: the pill fills its segment edge-to-edge so no track color shows
|
||||
// as a margin around the active segment.
|
||||
const TRACK_PADDING = 0
|
||||
const ANIM = { duration: 220, easing: Easing.out(Easing.cubic) }
|
||||
|
||||
const DEFAULT_TRACK = "rgba(255,255,255,0.08)"
|
||||
const DEFAULT_PILL = SEGMENTED_PILL_COLOR
|
||||
|
||||
export function SegmentedTabBar<T extends Route>(props: SegmentedTabBarProps<T>) {
|
||||
const {
|
||||
navigationState,
|
||||
jumpTo,
|
||||
renderItem,
|
||||
segmentWidth,
|
||||
trackColor,
|
||||
pillColor,
|
||||
activeColor = "white",
|
||||
inactiveColor = "rgba(255,255,255,0.6)",
|
||||
containerStyle,
|
||||
} = props
|
||||
|
||||
const routes = navigationState.routes
|
||||
const count = routes.length
|
||||
const index = navigationState.index
|
||||
|
||||
// Inner content width drives the pill geometry. In fixed-width mode we seed it
|
||||
// up front to avoid a first-frame flash; onLayout keeps it exact thereafter.
|
||||
const innerWidth = useSharedValue(segmentWidth ? segmentWidth * count : 0)
|
||||
const animIndex = useSharedValue(index)
|
||||
|
||||
useEffect(() => {
|
||||
animIndex.value = withTiming(index, ANIM)
|
||||
}, [index])
|
||||
|
||||
const onInnerLayout = (e: LayoutChangeEvent) => {
|
||||
innerWidth.value = e.nativeEvent.layout.width
|
||||
}
|
||||
|
||||
const pillStyle = useAnimatedStyle(() => {
|
||||
const segW = count > 0 ? innerWidth.value / count : 0
|
||||
return {
|
||||
width: segW,
|
||||
transform: [{ translateX: animIndex.value * segW }],
|
||||
}
|
||||
})
|
||||
|
||||
const renderLabel = (route: T, focused: boolean) => {
|
||||
if (renderItem) return renderItem({ route, focused })
|
||||
return (
|
||||
<Text
|
||||
text={(route as Route & { title?: string }).title}
|
||||
size="xs"
|
||||
style={[$label, { color: focused ? activeColor : inactiveColor }]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
$track,
|
||||
{ backgroundColor: trackColor ?? DEFAULT_TRACK },
|
||||
segmentWidth
|
||||
? { width: segmentWidth * count, alignSelf: "center" }
|
||||
: { alignSelf: "stretch" },
|
||||
containerStyle,
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[$inner, segmentWidth ? { width: segmentWidth * count } : null]}
|
||||
onLayout={onInnerLayout}
|
||||
>
|
||||
<Animated.View
|
||||
style={[$pill, { backgroundColor: pillColor ?? DEFAULT_PILL }, pillStyle]}
|
||||
/>
|
||||
<View style={$row}>
|
||||
{routes.map((route, i) => (
|
||||
<Pressable
|
||||
key={route.key}
|
||||
style={[$segment, segmentWidth ? { width: segmentWidth } : { flex: 1 }]}
|
||||
onPress={() => jumpTo(route.key)}
|
||||
accessibilityRole="tab"
|
||||
accessibilityState={{ selected: i === index }}
|
||||
>
|
||||
{renderLabel(route, i === index)}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const $track: ViewStyle = {
|
||||
borderRadius: PILL_RADIUS,
|
||||
padding: TRACK_PADDING,
|
||||
marginVertical: spacing.small,
|
||||
}
|
||||
|
||||
const $inner: ViewStyle = {
|
||||
position: "relative",
|
||||
alignSelf: "stretch",
|
||||
}
|
||||
|
||||
const $pill: ViewStyle = {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
borderRadius: PILL_RADIUS,
|
||||
}
|
||||
|
||||
const $row: ViewStyle = {
|
||||
flexDirection: "row",
|
||||
}
|
||||
|
||||
const $segment: ViewStyle = {
|
||||
paddingVertical: spacing.extraSmall,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}
|
||||
|
||||
const $label: TextStyle = {
|
||||
textAlign: "center",
|
||||
}
|
||||
@@ -4,6 +4,7 @@ export * from "./Button"
|
||||
export * from "./Card"
|
||||
export * from "./Header"
|
||||
export * from "./AnimatedHeader"
|
||||
export * from "./SegmentedTabBar"
|
||||
export * from "./StaticHeader"
|
||||
export * from "./Icon"
|
||||
export * from "./ListItem"
|
||||
|
||||
@@ -647,18 +647,13 @@ import {
|
||||
.filter(b => unit in b.balances)
|
||||
.sort((a, b) => (b.balances[unit] || 0) - (a.balances[unit] || 0)),
|
||||
|
||||
getMintBalanceWithMaxBalance: (unit: MintUnit) => {
|
||||
let max: MintBalance | undefined
|
||||
let maxAmt = -1
|
||||
for (const b of self.balances.mintBalances) {
|
||||
const amt = b.balances[unit] || 0
|
||||
if (amt > maxAmt) {
|
||||
maxAmt = amt
|
||||
max = b
|
||||
}
|
||||
}
|
||||
return max
|
||||
},
|
||||
// Highest-balance mint that actually holds the unit (undefined if none do).
|
||||
// Only mints that list the unit are candidates, so a unit whose sole mint
|
||||
// has a zero balance still resolves to that mint rather than an unrelated one.
|
||||
getMintBalanceWithMaxBalance: (unit: MintUnit): MintBalance | undefined =>
|
||||
self.balances.mintBalances
|
||||
.filter(b => unit in b.balances)
|
||||
.sort((a, b) => (b.balances[unit] || 0) - (a.balances[unit] || 0))[0],
|
||||
|
||||
getUnitBalance: (unit: MintUnit) =>
|
||||
self.balances.unitBalances.find(b => b.unit === unit) || { unit, unitBalance: 0 },
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import React, {FC, useEffect, useState} from 'react'
|
||||
import {Image, Pressable, View, ViewStyle} from 'react-native'
|
||||
import { TabBar, TabView, Route } from 'react-native-tab-view'
|
||||
import {colors, spacing, typography, useThemeColor} from '../theme'
|
||||
import {Header, Icon, Screen} from '../components'
|
||||
import { TabView, Route } from 'react-native-tab-view'
|
||||
import {spacing, typography, useThemeColor} from '../theme'
|
||||
import {Header, Icon, Screen, SegmentedTabBar} from '../components'
|
||||
import {useStores} from '../models'
|
||||
import { PrivateContacts } from './Contacts/PrivateContacts'
|
||||
import { PublicContacts } from './Contacts/PublicContacts'
|
||||
@@ -54,16 +54,12 @@ export const ContactsScreen = observer(function ({ route }: Props) {
|
||||
}
|
||||
|
||||
const headerBg = useThemeColor('header')
|
||||
const activeTabIndicator = colors.palette.accent400
|
||||
const {nip05} = walletProfileStore
|
||||
|
||||
const renderTabBar = (props: any) => (
|
||||
<TabBar
|
||||
key={props.key}
|
||||
{...props}
|
||||
indicatorStyle={{ backgroundColor: activeTabIndicator }}
|
||||
style={{ backgroundColor: headerBg }}
|
||||
/>
|
||||
<View style={{ backgroundColor: headerBg, paddingHorizontal: spacing.medium }}>
|
||||
<SegmentedTabBar {...props} />
|
||||
</View>
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -418,11 +418,11 @@ function NutsCard(props: {info: GetInfoResponse}) {
|
||||
|
||||
// detailed nuts are separated from simple ones if we want to show more info abt them in the future
|
||||
for (const [nut, info] of Object.entries(props.info.nuts)) {
|
||||
if (nut === '15' && Array.isArray(info) && info.length > 0) {
|
||||
if (nut === '15' && 'methods' in info && Array.isArray(info.methods) && info.methods.length > 0) {
|
||||
// see https://github.com/cashubtc/nuts/blob/main/15.md - multipath payments
|
||||
// in the future, it might be nice to show for which currencies are multipath payments supported
|
||||
// for example by extending NutItem
|
||||
nutsSimple.push(['15', info[0].mpp ?? false])
|
||||
nutsSimple.push(['15', true])
|
||||
continue;
|
||||
}
|
||||
// see https://github.com/cashubtc/nutshell/issues/588
|
||||
@@ -439,6 +439,10 @@ function NutsCard(props: {info: GetInfoResponse}) {
|
||||
nutsSimple.push(['19', info.cached_endpoints.some(e => e.method === 'POST')])
|
||||
continue;
|
||||
}
|
||||
if (nut === '29' && ('supported' in info && info.supported === true) || ('methods' in info && Array.isArray(info.methods) && info.methods.length > 0)) {
|
||||
nutsSimple.push(['29', true])
|
||||
continue;
|
||||
}
|
||||
if ('disabled' in info && info.disabled === false) { // detailed
|
||||
supportedNutsDetailed.push([nut, info as unknown as DetailedNutInfo])
|
||||
} else if ('supported' in info && typeof info.supported !== 'undefined') { // simple
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from "react"
|
||||
import { TouchableOpacity } from "react-native"
|
||||
import { Header, Text } from "../../components"
|
||||
import { Header, Text, SEGMENTED_PILL_COLOR, SEGMENTED_PILL_RADIUS } from "../../components"
|
||||
import { spacing, useThemeColor } from "../../theme"
|
||||
import { Mint } from "../../models/Mint"
|
||||
import { MintUnit } from "../../services/wallet/currency"
|
||||
@@ -45,9 +45,10 @@ export const MintHeader = observer(function(props: {
|
||||
mintUnit={unit}
|
||||
textStyle={{color: resolvedTextColor}}
|
||||
containerStyle={{
|
||||
borderBottomWidth: 2,
|
||||
paddingVertical: mint ? spacing.tiny : spacing.small,
|
||||
borderBottomColor: resolvedTextColor,
|
||||
backgroundColor: SEGMENTED_PILL_COLOR,
|
||||
borderRadius: SEGMENTED_PILL_RADIUS,
|
||||
paddingVertical: spacing.extraSmall,
|
||||
marginTop: mint ? spacing.tiny : 0,
|
||||
width: tabWidth
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import {
|
||||
ViewStyle,
|
||||
View,
|
||||
TextStyle,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
} from 'react-native'
|
||||
import { PaymentRequest as CashuPaymentRequest, MeltQuoteBolt11Response, PaymentRequestTransportType, decodePaymentRequest, getDecodedToken, getTokenMetadata } from '@cashu/cashu-ts'
|
||||
import NfcManager, { Ndef, NfcEvents } from 'react-native-nfc-manager'
|
||||
@@ -13,7 +15,7 @@ import { log } from '../services/logService'
|
||||
import { IncomingDataType, IncomingParser } from '../services/incomingParser'
|
||||
import AppError, { Err } from '../utils/AppError'
|
||||
import { AmountInput, BottomModal, Button, Card, ErrorModal, Icon, InfoModal, ListItem, ScanIcon, Screen, Text } from '../components'
|
||||
import { SvgXml } from 'react-native-svg'
|
||||
import { SvgXml, Svg, Defs, LinearGradient as SvgLinearGradient, Stop, Rect } from 'react-native-svg'
|
||||
import { formatCurrency, getCurrency, MintUnit, MintUnits } from '../services/wallet/currency'
|
||||
import { useStores } from '../models'
|
||||
import { MintHeader } from './Mints/MintHeader'
|
||||
@@ -57,6 +59,37 @@ type Props = StaticScreenProps<{
|
||||
|
||||
const SYNC_STATE_WITH_MINT_TIMEOUT = 10 * 1000
|
||||
|
||||
// Visual phases of the NFC session. Each drives the header color band so progress is readable
|
||||
// at arm's length. `waiting` is the special Android case: the app was woken by the NFC field
|
||||
// (cold/warm launch) and the OS already consumed the launch tap, so delivering the ecash needs
|
||||
// a SECOND deliberate tap — we signal that with its own attention color and message, distinct
|
||||
// from the initial `ready` arming. Order also defines the dev stepper sequence below.
|
||||
const NFC_PHASES = ['idle', 'ready', 'processing', 'waiting', 'success', 'error'] as const
|
||||
type NfcPhase = (typeof NFC_PHASES)[number]
|
||||
|
||||
// Base phase colors. `ready` is a calm neutral (just darker than the screen background) and is
|
||||
// resolved per-theme in the component (see `phaseColors`); the value here is only a fallback.
|
||||
const PHASE_COLORS: Record<NfcPhase, string> = {
|
||||
idle: colors.palette.neutral500,
|
||||
ready: colors.palette.neutral400,
|
||||
processing: colors.palette.primary400,
|
||||
waiting: colors.palette.accent400,
|
||||
success: colors.palette.success200,
|
||||
error: colors.palette.angry300,
|
||||
}
|
||||
|
||||
// Representative nfcInfo text shown when previewing a phase via the dev stepper. These mirror
|
||||
// the actual strings the screen sets via setNfcInfo (idle/error reuse the messages from the
|
||||
// disabled-NFC and failure paths).
|
||||
const PHASE_DEV_INFO: Record<NfcPhase, string> = {
|
||||
idle: 'Please enable NFC in your device settings',
|
||||
ready: 'Hold your device close to the NFC reader.',
|
||||
processing: 'Processing, keep your device still...',
|
||||
waiting: 'Tap the device again to send the payment.',
|
||||
success: 'Ecash token sent successfully.',
|
||||
error: 'NFC Payment failed',
|
||||
}
|
||||
|
||||
export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
|
||||
const navigation = useNavigation<any>()
|
||||
const { mintsStore, walletStore, proofsStore, transactionsStore, walletProfileStore } = useStores()
|
||||
@@ -113,7 +146,13 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [isPaid, setIsPaid] = useState(false)
|
||||
const [isError, setIsError] = useState(false)
|
||||
// True while we need the user to deliberately tap AGAIN to deliver the ecash — the Android
|
||||
// wake-by-NFC case where the OS consumed the launch tap (see `waiting` phase note above).
|
||||
const [isWaitingForTap, setIsWaitingForTap] = useState(false)
|
||||
const [nfcInfo, setNfcInfo] = useState<string | undefined>()
|
||||
// Dev-only: when set, overrides the derived phase so each state can be previewed on a
|
||||
// simulator without real NFC hardware. Cleared by the "live" button in the dev stepper.
|
||||
const [devPhase, setDevPhase] = useState<NfcPhase | undefined>()
|
||||
//const [readNfcData, setReadNfcData] = useState<string | undefined>()
|
||||
|
||||
const [error, setError] = useState<AppError | undefined>()
|
||||
@@ -210,6 +249,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
|
||||
setNfcInfo(needsLiveWriteBack
|
||||
? 'Tap the device again to send the payment.'
|
||||
: 'Hold your device close to the NFC reader.')
|
||||
setIsWaitingForTap(needsLiveWriteBack)
|
||||
setIsNfcEnabled(true)
|
||||
keepScanningRef.current = true
|
||||
|
||||
@@ -472,6 +512,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
|
||||
// reader-mode session alive (see finally) so the OS chooser can't pop over the
|
||||
// result modal, and we stop re-arming for new taps.
|
||||
paymentLockedRef.current = true
|
||||
setIsWaitingForTap(false)
|
||||
setIsProcessing(true)
|
||||
|
||||
//log.info('NFC Tag found', {tag});
|
||||
@@ -674,8 +715,12 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
|
||||
// from the initial read open, so it skips this.
|
||||
if (!readerSessionActiveRef.current) {
|
||||
log.trace('[NfcScreen] handleSendTaskResult: no open reader session (cold/warm launch), arming one for write-back.')
|
||||
setNfcInfo('Hold your device close to the payee to deliver the ecash...')
|
||||
setNfcInfo('Hold your device close to the reader...')
|
||||
// Token is prepared but undelivered — surface the dedicated "tap again" phase
|
||||
// (takes priority over processing) so the user knows to physically tap once more.
|
||||
setIsWaitingForTap(true)
|
||||
await NfcService.readNdefTag() // opens requestTechnology session, keeps it alive
|
||||
setIsWaitingForTap(false)
|
||||
readerSessionActiveRef.current = true
|
||||
}
|
||||
|
||||
@@ -1043,13 +1088,6 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const handleError = (e: AppError) => {
|
||||
setError(e)
|
||||
setInfo('')
|
||||
setIsProcessing(false)
|
||||
}
|
||||
|
||||
const gotoScan = () => {
|
||||
navigation.navigate('Scan', { unit: unitRef.current })
|
||||
}
|
||||
@@ -1062,7 +1100,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
|
||||
}
|
||||
|
||||
const resetState = function () {
|
||||
//ßsetEncodedInvoice('')
|
||||
//setEncodedInvoice('')
|
||||
setInvoice(undefined)
|
||||
setInvoiceExpiry(undefined)
|
||||
setMeltQuote(undefined)
|
||||
@@ -1075,6 +1113,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
|
||||
setIsResultModalVisible(false)
|
||||
setResultModalInfo(undefined)
|
||||
setIsPaid(false)
|
||||
setIsWaitingForTap(false)
|
||||
setPendingAsyncMeltId(undefined)
|
||||
}
|
||||
|
||||
@@ -1097,14 +1136,39 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
|
||||
// Theme
|
||||
const hintText = useThemeColor('textDim')
|
||||
const headerBg = useThemeColor('background')
|
||||
//const headerBg = useThemeColor('header')
|
||||
const headerTitle = useThemeColor('headerTitle')
|
||||
const scanIcon = useThemeColor('text')
|
||||
const nfcText = useThemeColor('text')
|
||||
const indicatorStandby = colors.palette.primary400
|
||||
const indicatorProcessing = useThemeColor('button')
|
||||
const isLightTheme = headerBg === colors.light.background
|
||||
|
||||
// Derive the current visual phase from session state. `waiting` ranks above `processing`
|
||||
// so the "tap again to deliver" prompt (set while isProcessing is also true) wins. A dev
|
||||
// override lets the simulator step through every phase without NFC hardware.
|
||||
const realPhase: NfcPhase =
|
||||
isError ? 'error'
|
||||
: isPaid ? 'success'
|
||||
: isWaitingForTap ? 'waiting'
|
||||
: isProcessing ? 'processing'
|
||||
: isNfcEnabled ? 'ready'
|
||||
: 'idle'
|
||||
const phase: NfcPhase = devPhase ?? realPhase
|
||||
|
||||
// Resolve per-theme phase colors: `ready` is a neutral band just a step darker than the
|
||||
// screen background (light → darker grey, dark → darker charcoal), fading to the background.
|
||||
const phaseColors = useMemo<Record<NfcPhase, string>>(() => ({
|
||||
...PHASE_COLORS,
|
||||
ready: isLightTheme ? colors.palette.neutral400 : colors.palette.neutral800,
|
||||
}), [isLightTheme])
|
||||
|
||||
// Ink on the band. The band stays fully opaque through its upper portion (where the icon
|
||||
// and text sit), so white reads cleanly on every phase — including the darker-neutral idle
|
||||
// and ready bands in light theme.
|
||||
const onPhaseColor = 'white'
|
||||
// Translucent white disc behind the NFC icon for subtle depth on any band.
|
||||
const phaseDiscColor = 'rgba(255, 255, 255, 0.18)'
|
||||
|
||||
// Metallic card colors - subtle gradient effect via borders
|
||||
const isLightTheme = headerBg === colors.light.background
|
||||
const metalHighlight = isLightTheme ? 'rgba(255, 255, 255, 0.9)' : 'rgba(255, 255, 255, 0.12)'
|
||||
const metalShadow = isLightTheme ? 'rgba(0, 0, 0, 0.15)' : 'rgba(0, 0, 0, 0.5)'
|
||||
const metalBase = isLightTheme ? colors.palette.neutral200 : colors.palette.neutral700
|
||||
@@ -1113,15 +1177,23 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
|
||||
|
||||
return (
|
||||
<Screen preset="fixed" contentContainerStyle={$screen}>
|
||||
{/* Solid phase color behind the header, cross-fading in step with the band below
|
||||
so the whole top region reads as one continuous, phase-aware color block. */}
|
||||
<View>
|
||||
<PhaseColorBand phase={phase} colorsMap={phaseColors} solid />
|
||||
<MintHeader
|
||||
mint={undefined}
|
||||
unit={unitRef.current}
|
||||
onBackPress={gotoWallet}
|
||||
textColor={nfcText as string}
|
||||
backgroundColor={headerBg as string}
|
||||
leftIconColor={nfcText as string}
|
||||
textColor={onPhaseColor}
|
||||
backgroundColor={'transparent'}
|
||||
leftIconColor={onPhaseColor}
|
||||
/>
|
||||
</View>
|
||||
<View style={[$headerContainer, {backgroundColor: headerBg}]}>
|
||||
{/* Color band that communicates the session phase, fading into the screen
|
||||
background at the bottom edge. Cross-fades smoothly between phases. */}
|
||||
<PhaseColorBand phase={phase} colorsMap={phaseColors} />
|
||||
{!isOnline.current && !isPaid && (
|
||||
<>
|
||||
<Text
|
||||
@@ -1148,14 +1220,17 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
|
||||
justifyContent: 'center',
|
||||
width: moderateScale(80),
|
||||
height: moderateScale(80),
|
||||
backgroundColor:isProcessing ? indicatorProcessing : isNfcEnabled ? indicatorStandby : hintText,
|
||||
// Translucent well behind the icon for subtle depth against
|
||||
// whatever phase band is showing (tint follows the ink).
|
||||
backgroundColor: phaseDiscColor,
|
||||
borderRadius: moderateScale(80) / 2,
|
||||
marginVertical: spacing.medium,
|
||||
marginBottom: spacing.medium,
|
||||
}}
|
||||
>
|
||||
<PulsingContactlessIcon
|
||||
isNfcEnabled={isNfcEnabled}
|
||||
size={moderateScale(40)}
|
||||
color={onPhaseColor}
|
||||
/>
|
||||
</View>
|
||||
<Text
|
||||
@@ -1164,7 +1239,7 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.medium,
|
||||
paddingHorizontal: spacing.medium,
|
||||
color: isProcessing ? indicatorProcessing : isNfcEnabled ? indicatorStandby : hintText,
|
||||
color: onPhaseColor,
|
||||
//minHeight: moderateScale(52),
|
||||
}}
|
||||
preset='heading'
|
||||
@@ -1330,6 +1405,16 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
|
||||
text='Close'
|
||||
/>)}
|
||||
</View>
|
||||
{__DEV__ && (
|
||||
<PhaseDevStepper
|
||||
current={phase}
|
||||
colorsMap={phaseColors}
|
||||
onSelect={(p) => {
|
||||
setDevPhase(p)
|
||||
if (p) setNfcInfo(PHASE_DEV_INFO[p])
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<BottomModal
|
||||
@@ -1443,14 +1528,116 @@ export const NfcPayScreen = observer(function NfcPayScreen({ route }: Props) {
|
||||
)
|
||||
})
|
||||
|
||||
// A single full-bleed phase-color layer whose opacity is animated, so stacked layers cross-fade
|
||||
// smoothly when the phase changes. `solid` paints a flat fill (used behind the header);
|
||||
// otherwise it's a vertical gradient: phase color (opaque, top) → transparent (bottom), so it
|
||||
// dissolves into the screen background below the header band.
|
||||
const PhaseLayer = function ({ color, active, solid }: { color: string; active: boolean; solid?: boolean }) {
|
||||
const opacity = useSharedValue(active ? 1 : 0)
|
||||
|
||||
useEffect(() => {
|
||||
opacity.value = withTiming(active ? 1 : 0, {
|
||||
duration: 600,
|
||||
easing: Easing.inOut(Easing.quad),
|
||||
})
|
||||
}, [active, opacity])
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }))
|
||||
// Gradient ids must be unique per layer; phase colors are already distinct.
|
||||
const gradientId = useMemo(() => `nfcPhaseGrad-${color.replace(/[^a-zA-Z0-9]/g, '')}`, [color])
|
||||
|
||||
if (solid) {
|
||||
return (
|
||||
<Animated.View
|
||||
style={[StyleSheet.absoluteFill, { backgroundColor: color }, animatedStyle]}
|
||||
pointerEvents="none"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[StyleSheet.absoluteFill, animatedStyle]} pointerEvents="none">
|
||||
<Svg width="100%" height="100%">
|
||||
<Defs>
|
||||
<SvgLinearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<Stop offset="0" stopColor={color} stopOpacity={1} />
|
||||
<Stop offset="0.6" stopColor={color} stopOpacity={1} />
|
||||
<Stop offset="0.85" stopColor={color} stopOpacity={0.4} />
|
||||
<Stop offset="1" stopColor={color} stopOpacity={0} />
|
||||
</SvgLinearGradient>
|
||||
</Defs>
|
||||
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
|
||||
</Svg>
|
||||
</Animated.View>
|
||||
)
|
||||
}
|
||||
|
||||
// Stacks one layer per phase and lights up the active one. Only the active layer is at full
|
||||
// opacity, so the others sit invisible underneath and a phase change reads as a smooth color
|
||||
// cross-fade rather than a hard swap. `solid` selects the flat (header) vs gradient variant.
|
||||
const PhaseColorBand = function ({
|
||||
phase,
|
||||
colorsMap,
|
||||
solid,
|
||||
}: {
|
||||
phase: NfcPhase
|
||||
colorsMap: Record<NfcPhase, string>
|
||||
solid?: boolean
|
||||
}) {
|
||||
return (
|
||||
<View style={StyleSheet.absoluteFill} pointerEvents="none">
|
||||
{NFC_PHASES.map((p) => (
|
||||
<PhaseLayer key={p} color={colorsMap[p]} active={p === phase} solid={solid} />
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// Dev-only control to preview each phase on a simulator without NFC hardware. "live" releases
|
||||
// the override and returns to the real session-derived phase.
|
||||
const PhaseDevStepper = function ({
|
||||
current,
|
||||
colorsMap,
|
||||
onSelect,
|
||||
}: {
|
||||
current: NfcPhase
|
||||
colorsMap: Record<NfcPhase, string>
|
||||
onSelect: (phase: NfcPhase | undefined) => void
|
||||
}) {
|
||||
return (
|
||||
<View style={$devStepper}>
|
||||
{NFC_PHASES.map((p) => (
|
||||
<Pressable
|
||||
key={p}
|
||||
onPress={() => onSelect(p)}
|
||||
style={[
|
||||
$devChip,
|
||||
{ backgroundColor: colorsMap[p], opacity: current === p ? 1 : 0.4 },
|
||||
]}
|
||||
>
|
||||
<Text text={p} size="xxs" style={$devChipText} />
|
||||
</Pressable>
|
||||
))}
|
||||
<Pressable
|
||||
onPress={() => onSelect(undefined)}
|
||||
style={[$devChip, { backgroundColor: colors.palette.neutral800 }]}
|
||||
>
|
||||
<Text text="live" size="xxs" style={$devChipText} />
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
interface PulsingContactlessIconProps {
|
||||
isNfcEnabled: boolean;
|
||||
size?: number;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export const PulsingContactlessIcon: React.FC<PulsingContactlessIconProps> = ({
|
||||
isNfcEnabled,
|
||||
size,
|
||||
color = 'white',
|
||||
}) => {
|
||||
const scale = useSharedValue(1);
|
||||
|
||||
@@ -1474,18 +1661,14 @@ export const PulsingContactlessIcon: React.FC<PulsingContactlessIconProps> = ({
|
||||
transform: [{ scale: scale.value }],
|
||||
}));
|
||||
|
||||
const color = isNfcEnabled
|
||||
? colors.palette.success300
|
||||
: colors.light.text
|
||||
|
||||
return (
|
||||
<Animated.View style={[animatedStyle, { alignSelf: 'center' }]}>
|
||||
<SvgXml
|
||||
width={size || spacing.medium}
|
||||
height={size || spacing.medium}
|
||||
xml={NfcIcon}
|
||||
stroke={'white'}
|
||||
fill={'white'}
|
||||
stroke={color}
|
||||
fill={color}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
@@ -1493,8 +1676,8 @@ export const PulsingContactlessIcon: React.FC<PulsingContactlessIconProps> = ({
|
||||
|
||||
|
||||
const $screen: ViewStyle = { }
|
||||
const $contentContainer: ViewStyle = { flex: 1, padding: spacing.extraSmall }
|
||||
const $headerContainer: TextStyle = { alignItems: 'center', paddingVertical: spacing.medium,}
|
||||
const $contentContainer: ViewStyle = { flex: 1, padding: spacing.extraSmall, marginTop: -spacing.extraLarge * 2 }
|
||||
const $headerContainer: TextStyle = { alignItems: 'center', paddingVertical: spacing.medium, height: spacing.screenHeight * 0.3}
|
||||
const $buttonContainer: ViewStyle = { flexDirection: 'row', alignSelf: 'center' }
|
||||
const $bottomContainer: ViewStyle = {
|
||||
position: 'absolute',
|
||||
@@ -1566,3 +1749,19 @@ const $cardBalanceRow: ViewStyle = {
|
||||
const $cardBottomRow: ViewStyle = {
|
||||
marginTop: spacing.small,
|
||||
}
|
||||
const $devStepper: ViewStyle = {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
alignSelf: 'center',
|
||||
marginTop: spacing.small,
|
||||
}
|
||||
const $devChip: ViewStyle = {
|
||||
paddingHorizontal: spacing.small,
|
||||
paddingVertical: spacing.tiny,
|
||||
borderRadius: spacing.extraSmall,
|
||||
margin: spacing.tiny,
|
||||
}
|
||||
const $devChipText: TextStyle = {
|
||||
color: 'white',
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import {observer} from 'mobx-react-lite'
|
||||
import React, {FC, useCallback, useState} from 'react'
|
||||
import {TextStyle, View, ViewStyle } from 'react-native'
|
||||
import { TabView, Route, TabBar } from 'react-native-tab-view'
|
||||
import {colors, spacing, useThemeColor} from '../theme'
|
||||
import {Screen, Text} from '../components'
|
||||
import { TabView, Route } from 'react-native-tab-view'
|
||||
import {spacing, useThemeColor} from '../theme'
|
||||
import {Screen, Text, SegmentedTabBar} from '../components'
|
||||
import {RandomName} from './Contacts/RandomName'
|
||||
import {OwnName} from './Contacts/OwnName'
|
||||
import {useStores} from '../models'
|
||||
@@ -41,15 +41,11 @@ export const WalletNameScreen = observer(function WalletNameScreen({ route }: Pr
|
||||
])
|
||||
|
||||
const headerBg = useThemeColor('header')
|
||||
const activeTabIndicator = colors.palette.accent400
|
||||
|
||||
const renderTabBar = (props: any) => (
|
||||
<TabBar
|
||||
key={props.key}
|
||||
{...props}
|
||||
indicatorStyle={{ backgroundColor: activeTabIndicator }}
|
||||
style={{ backgroundColor: headerBg }}
|
||||
/>
|
||||
<View style={{ backgroundColor: headerBg, paddingHorizontal: spacing.medium }}>
|
||||
<SegmentedTabBar {...props} />
|
||||
</View>
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from 'react-native'
|
||||
import {moderateScale, verticalScale} from '@gocodingnow/rn-size-matters'
|
||||
import { SvgXml } from 'react-native-svg'
|
||||
import { TabBar, TabView } from 'react-native-tab-view'
|
||||
import { TabView } from 'react-native-tab-view'
|
||||
import {useThemeColor, spacing, colors, typography} from '../theme'
|
||||
import {
|
||||
Button,
|
||||
@@ -29,7 +29,8 @@ import {
|
||||
ErrorModal,
|
||||
Header,
|
||||
ScanIcon,
|
||||
MintIcon
|
||||
MintIcon,
|
||||
SegmentedTabBar,
|
||||
} from '../components'
|
||||
import EventEmitter from '../utils/eventEmitter'
|
||||
import {useStores} from '../models'
|
||||
@@ -661,32 +662,27 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
|
||||
return null
|
||||
}
|
||||
|
||||
const tabWidth = moderateScale(75)
|
||||
const tabWidth = moderateScale(82)
|
||||
|
||||
const renderTabBar = useCallback((props: any) => {
|
||||
// A single unit tab needs no switcher.
|
||||
if (routes.length <= 1) return null
|
||||
return(
|
||||
<View style={{
|
||||
backgroundColor: headerBg,
|
||||
marginTop: -spacing.small,
|
||||
}}>
|
||||
<View style={{width: routes.length * tabWidth, alignSelf: 'center', backgroundColor: headerBg}}>
|
||||
<TabBar
|
||||
<View style={{backgroundColor: headerBg, marginTop: -spacing.small}}>
|
||||
<SegmentedTabBar
|
||||
{...props}
|
||||
tabStyle={{width: tabWidth}}
|
||||
renderTabBarItem={({ route }) => (
|
||||
segmentWidth={tabWidth}
|
||||
renderItem={({ route, focused }) => (
|
||||
<CurrencySign
|
||||
mintUnit={route.key as MintUnit}
|
||||
textStyle={{color: 'white'}}
|
||||
containerStyle={{padding: spacing.small, width: tabWidth}}
|
||||
textStyle={{color: 'white', opacity: focused ? 1 : 0.6}}
|
||||
//containerStyle={{paddingVertical: spacing.extraSmall}}
|
||||
/>
|
||||
)}
|
||||
indicatorStyle={{backgroundColor: headerTitleColor}}
|
||||
style={{backgroundColor: headerBg, shadowColor: 'transparent'}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}, [headerBg, routes.length, tabWidth, headerTitleColor])
|
||||
}, [headerBg, routes.length, tabWidth])
|
||||
|
||||
|
||||
const HeaderTitle = function (props: any) {
|
||||
@@ -727,6 +723,7 @@ export const WalletScreen = observer(function WalletScreen({ route }: Props) {
|
||||
return (
|
||||
<Screen contentContainerStyle={$screen} preset='fixed'>
|
||||
<Header
|
||||
//style={{borderBottomWidth: 2, borderBottomColor: 'white'}}
|
||||
LeftActionComponent={<LeftProfileHeader
|
||||
gotoProfile={gotoProfile}
|
||||
isAvatarVisible={false}
|
||||
@@ -1009,6 +1006,7 @@ const MintsByUnitSummary = observer(function (props: {
|
||||
useEffect(() => {
|
||||
const setSelected = async () => {
|
||||
const balance = proofsStore.getMintBalanceWithMaxBalance(props.mintsByUnit.unit)
|
||||
|
||||
if(!balance) return
|
||||
|
||||
const mint = mintsStore.findByUrl(balance?.mintUrl)
|
||||
|
||||
Reference in New Issue
Block a user