UX improvements (Wallet, NFC)

This commit is contained in:
minibits-cash
2026-06-14 23:25:15 +02:00
parent 4a73b4c877
commit 33d7bc07c0
11 changed files with 465 additions and 104 deletions

View File

@@ -31,7 +31,7 @@
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Release"
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"

View File

@@ -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",

View 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",
}

View File

@@ -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"

View File

@@ -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 },

View File

@@ -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 (

View File

@@ -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

View File

@@ -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
}}
/>

View File

@@ -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',
}

View File

@@ -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 (

View File

@@ -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)