@nexub/react-native 0.0.0-stage → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -2
- package/lib/module/components/actions.js +222 -0
- package/lib/module/components/actions.js.map +1 -0
- package/lib/module/components/containers.js +292 -0
- package/lib/module/components/containers.js.map +1 -0
- package/lib/module/components/display.js +266 -0
- package/lib/module/components/display.js.map +1 -0
- package/lib/module/components/feedback.js +464 -0
- package/lib/module/components/feedback.js.map +1 -0
- package/lib/module/components/inputs.js +746 -0
- package/lib/module/components/inputs.js.map +1 -0
- package/lib/module/components/mobile.js +811 -0
- package/lib/module/components/mobile.js.map +1 -0
- package/lib/module/components/overlays.js +418 -0
- package/lib/module/components/overlays.js.map +1 -0
- package/lib/module/components/shared.js +57 -0
- package/lib/module/components/shared.js.map +1 -0
- package/lib/module/hooks/useControllableState.js +23 -0
- package/lib/module/hooks/useControllableState.js.map +1 -0
- package/lib/module/index.js +30 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/patterns/app.js +709 -0
- package/lib/module/patterns/app.js.map +1 -0
- package/lib/module/patterns/auth.js +224 -0
- package/lib/module/patterns/auth.js.map +1 -0
- package/lib/module/patterns/index.js +7 -0
- package/lib/module/patterns/index.js.map +1 -0
- package/lib/module/patterns/money.js +419 -0
- package/lib/module/patterns/money.js.map +1 -0
- package/lib/module/primitives/Icon.js +17 -0
- package/lib/module/primitives/Icon.js.map +1 -0
- package/lib/module/primitives/Layout.js +79 -0
- package/lib/module/primitives/Layout.js.map +1 -0
- package/lib/module/primitives/Pressable.js +77 -0
- package/lib/module/primitives/Pressable.js.map +1 -0
- package/lib/module/primitives/ScreenContainer.js +58 -0
- package/lib/module/primitives/ScreenContainer.js.map +1 -0
- package/lib/module/primitives/Surface.js +57 -0
- package/lib/module/primitives/Surface.js.map +1 -0
- package/lib/module/primitives/Text.js +70 -0
- package/lib/module/primitives/Text.js.map +1 -0
- package/lib/module/theme/ThemeProvider.js +127 -0
- package/lib/module/theme/ThemeProvider.js.map +1 -0
- package/lib/module/theme/fonts.js +56 -0
- package/lib/module/theme/fonts.js.map +1 -0
- package/lib/module/theme/haptics.js +56 -0
- package/lib/module/theme/haptics.js.map +1 -0
- package/lib/module/theme/navigation.js +99 -0
- package/lib/module/theme/navigation.js.map +1 -0
- package/lib/typescript/components/actions.d.ts +53 -0
- package/lib/typescript/components/actions.d.ts.map +1 -0
- package/lib/typescript/components/containers.d.ts +59 -0
- package/lib/typescript/components/containers.d.ts.map +1 -0
- package/lib/typescript/components/display.d.ts +62 -0
- package/lib/typescript/components/display.d.ts.map +1 -0
- package/lib/typescript/components/feedback.d.ts +93 -0
- package/lib/typescript/components/feedback.d.ts.map +1 -0
- package/lib/typescript/components/inputs.d.ts +141 -0
- package/lib/typescript/components/inputs.d.ts.map +1 -0
- package/lib/typescript/components/mobile.d.ts +173 -0
- package/lib/typescript/components/mobile.d.ts.map +1 -0
- package/lib/typescript/components/overlays.d.ts +74 -0
- package/lib/typescript/components/overlays.d.ts.map +1 -0
- package/lib/typescript/components/shared.d.ts +20 -0
- package/lib/typescript/components/shared.d.ts.map +1 -0
- package/lib/typescript/hooks/useControllableState.d.ts +6 -0
- package/lib/typescript/hooks/useControllableState.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +37 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/patterns/app.d.ts +139 -0
- package/lib/typescript/patterns/app.d.ts.map +1 -0
- package/lib/typescript/patterns/auth.d.ts +54 -0
- package/lib/typescript/patterns/auth.d.ts.map +1 -0
- package/lib/typescript/patterns/index.d.ts +7 -0
- package/lib/typescript/patterns/index.d.ts.map +1 -0
- package/lib/typescript/patterns/money.d.ts +106 -0
- package/lib/typescript/patterns/money.d.ts.map +1 -0
- package/lib/typescript/primitives/Icon.d.ts +12 -0
- package/lib/typescript/primitives/Icon.d.ts.map +1 -0
- package/lib/typescript/primitives/Layout.d.ts +26 -0
- package/lib/typescript/primitives/Layout.d.ts.map +1 -0
- package/lib/typescript/primitives/Pressable.d.ts +21 -0
- package/lib/typescript/primitives/Pressable.d.ts.map +1 -0
- package/lib/typescript/primitives/ScreenContainer.d.ts +25 -0
- package/lib/typescript/primitives/ScreenContainer.d.ts.map +1 -0
- package/lib/typescript/primitives/Surface.d.ts +23 -0
- package/lib/typescript/primitives/Surface.d.ts.map +1 -0
- package/lib/typescript/primitives/Text.d.ts +21 -0
- package/lib/typescript/primitives/Text.d.ts.map +1 -0
- package/lib/typescript/theme/ThemeProvider.d.ts +69 -0
- package/lib/typescript/theme/ThemeProvider.d.ts.map +1 -0
- package/lib/typescript/theme/fonts.d.ts +25 -0
- package/lib/typescript/theme/fonts.d.ts.map +1 -0
- package/lib/typescript/theme/haptics.d.ts +3 -0
- package/lib/typescript/theme/haptics.d.ts.map +1 -0
- package/lib/typescript/theme/navigation.d.ts +158 -0
- package/lib/typescript/theme/navigation.d.ts.map +1 -0
- package/package.json +138 -3
- package/src/components/actions.tsx +259 -0
- package/src/components/containers.tsx +341 -0
- package/src/components/display.tsx +314 -0
- package/src/components/feedback.tsx +522 -0
- package/src/components/inputs.tsx +851 -0
- package/src/components/mobile.tsx +879 -0
- package/src/components/overlays.tsx +485 -0
- package/src/components/shared.ts +54 -0
- package/src/hooks/useControllableState.ts +27 -0
- package/src/index.ts +175 -0
- package/src/patterns/app.tsx +715 -0
- package/src/patterns/auth.tsx +266 -0
- package/src/patterns/index.ts +57 -0
- package/src/patterns/money.tsx +458 -0
- package/src/primitives/Icon.tsx +15 -0
- package/src/primitives/Layout.tsx +71 -0
- package/src/primitives/Pressable.tsx +94 -0
- package/src/primitives/ScreenContainer.tsx +65 -0
- package/src/primitives/Surface.tsx +75 -0
- package/src/primitives/Text.tsx +79 -0
- package/src/theme/ThemeProvider.tsx +210 -0
- package/src/theme/fonts.ts +52 -0
- package/src/theme/haptics.ts +57 -0
- package/src/theme/navigation.ts +68 -0
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { SectionList, View } from 'react-native';
|
|
3
|
+
import { moneyFormatter } from '@nexub/core';
|
|
4
|
+
import { CheckCircle2, ShieldCheck, type NativeIconProps } from '@nexub/icons/native';
|
|
5
|
+
import { Button } from '../components/actions';
|
|
6
|
+
import { ListItem, ListSection } from '../components/containers';
|
|
7
|
+
import { Amount, Avatar, Badge } from '../components/display';
|
|
8
|
+
import { EmptyState, Progress } from '../components/feedback';
|
|
9
|
+
import {
|
|
10
|
+
ExpressiveMoneyInput,
|
|
11
|
+
RefreshControl,
|
|
12
|
+
StickyFooter,
|
|
13
|
+
SwipeableRow,
|
|
14
|
+
type SwipeAction,
|
|
15
|
+
} from '../components/mobile';
|
|
16
|
+
import { ScreenContainer } from '../primitives/ScreenContainer';
|
|
17
|
+
import { Heading, Text } from '../primitives/Text';
|
|
18
|
+
import { useTheme } from '../theme/ThemeProvider';
|
|
19
|
+
|
|
20
|
+
// ─── Flow step frame ─────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export interface FlowStepProps {
|
|
23
|
+
/** 1-based. */
|
|
24
|
+
step: number;
|
|
25
|
+
total: number;
|
|
26
|
+
title: string;
|
|
27
|
+
children: React.ReactNode;
|
|
28
|
+
footer: React.ReactNode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** One step of a multi-step flow: progress, title, content, sticky primary action. */
|
|
32
|
+
export function FlowStep({ step, total, title, children, footer }: FlowStepProps) {
|
|
33
|
+
const { tokens, messages } = useTheme();
|
|
34
|
+
return (
|
|
35
|
+
<StickyFooter footer={footer}>
|
|
36
|
+
<ScreenContainer gap="5">
|
|
37
|
+
<View style={{ gap: tokens.spacing[2] }}>
|
|
38
|
+
<Progress value={step / total} label={messages.pageOf(step, total)} />
|
|
39
|
+
<Text variant="label-md" color="on-surface-variant">
|
|
40
|
+
{messages.pageOf(step, total)}
|
|
41
|
+
</Text>
|
|
42
|
+
<Heading variant="headline-sm">{title}</Heading>
|
|
43
|
+
</View>
|
|
44
|
+
{children}
|
|
45
|
+
</ScreenContainer>
|
|
46
|
+
</StickyFooter>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ─── Send money ──────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export interface Quote {
|
|
53
|
+
fee: number;
|
|
54
|
+
/** Units of `to` per unit of `from`. */
|
|
55
|
+
rate: number;
|
|
56
|
+
/** e.g. "In seconds", "By Friday". */
|
|
57
|
+
arrives: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface AmountStepProps {
|
|
61
|
+
from: string;
|
|
62
|
+
to: string;
|
|
63
|
+
/** Available balance in `from`. */
|
|
64
|
+
balance: number;
|
|
65
|
+
/** Quote for an amount (fees and rate shown before anything else, like Wise). */
|
|
66
|
+
quote: (amount: number) => Quote;
|
|
67
|
+
onContinue: (amount: number) => void;
|
|
68
|
+
step?: number;
|
|
69
|
+
total?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Amount entry with the fee, rate and arrival shown live. */
|
|
73
|
+
export function AmountStep({
|
|
74
|
+
from,
|
|
75
|
+
to,
|
|
76
|
+
balance,
|
|
77
|
+
quote,
|
|
78
|
+
onContinue,
|
|
79
|
+
step = 1,
|
|
80
|
+
total = 3,
|
|
81
|
+
}: AmountStepProps) {
|
|
82
|
+
const { locale } = useTheme();
|
|
83
|
+
const [amount, setAmount] = React.useState<number | null>(null);
|
|
84
|
+
const fmt = (n: number, c: string) => moneyFormatter(c, locale).format(n);
|
|
85
|
+
const over = amount != null && amount > balance;
|
|
86
|
+
const q = amount ? quote(amount) : undefined;
|
|
87
|
+
return (
|
|
88
|
+
<FlowStep
|
|
89
|
+
step={step}
|
|
90
|
+
total={total}
|
|
91
|
+
title="How much?"
|
|
92
|
+
footer={
|
|
93
|
+
<Button
|
|
94
|
+
size="lg"
|
|
95
|
+
fullWidth
|
|
96
|
+
disabled={!amount || over}
|
|
97
|
+
onPress={() => amount && onContinue(amount)}
|
|
98
|
+
>
|
|
99
|
+
Continue
|
|
100
|
+
</Button>
|
|
101
|
+
}
|
|
102
|
+
>
|
|
103
|
+
<ExpressiveMoneyInput
|
|
104
|
+
label="Amount to send"
|
|
105
|
+
currency={from}
|
|
106
|
+
value={amount}
|
|
107
|
+
onValueChange={setAmount}
|
|
108
|
+
error={over}
|
|
109
|
+
caption={
|
|
110
|
+
over ? `More than your balance (${fmt(balance, from)})` : `Balance ${fmt(balance, from)}`
|
|
111
|
+
}
|
|
112
|
+
/>
|
|
113
|
+
{q && amount && !over && (
|
|
114
|
+
<ListSection>
|
|
115
|
+
<ListItem variant="value" title="Fee" value={fmt(q.fee, from)} />
|
|
116
|
+
<ListItem variant="value" title="Rate" value={`1 ${from} = ${q.rate} ${to}`} />
|
|
117
|
+
<ListItem
|
|
118
|
+
variant="value"
|
|
119
|
+
title="Recipient gets"
|
|
120
|
+
value={fmt((amount - q.fee) * q.rate, to)}
|
|
121
|
+
/>
|
|
122
|
+
<ListItem variant="value" title="Arrives" value={q.arrives} />
|
|
123
|
+
</ListSection>
|
|
124
|
+
)}
|
|
125
|
+
</FlowStep>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface ReviewRow {
|
|
130
|
+
label: string;
|
|
131
|
+
value: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface ReviewStepProps {
|
|
135
|
+
rows: ReviewRow[];
|
|
136
|
+
/** "Confirm and send". */
|
|
137
|
+
confirmLabel: string;
|
|
138
|
+
onConfirm: () => void;
|
|
139
|
+
/** Biometric confirmation, named: "Confirm with Face ID". Shown as the hint under the button. */
|
|
140
|
+
confirmHint?: string;
|
|
141
|
+
loading?: boolean;
|
|
142
|
+
step?: number;
|
|
143
|
+
total?: number;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Summary before money moves: every number visible, one confirming action. */
|
|
147
|
+
export function ReviewStep({
|
|
148
|
+
rows,
|
|
149
|
+
confirmLabel,
|
|
150
|
+
onConfirm,
|
|
151
|
+
confirmHint,
|
|
152
|
+
loading,
|
|
153
|
+
step = 2,
|
|
154
|
+
total = 3,
|
|
155
|
+
}: ReviewStepProps) {
|
|
156
|
+
return (
|
|
157
|
+
<FlowStep
|
|
158
|
+
step={step}
|
|
159
|
+
total={total}
|
|
160
|
+
title="Check the details"
|
|
161
|
+
footer={
|
|
162
|
+
<>
|
|
163
|
+
<Button
|
|
164
|
+
size="lg"
|
|
165
|
+
fullWidth
|
|
166
|
+
loading={loading}
|
|
167
|
+
leadingIcon={confirmHint ? ShieldCheck : undefined}
|
|
168
|
+
onPress={onConfirm}
|
|
169
|
+
>
|
|
170
|
+
{confirmLabel}
|
|
171
|
+
</Button>
|
|
172
|
+
{confirmHint && (
|
|
173
|
+
<Text variant="body-sm" color="on-surface-variant" style={{ textAlign: 'center' }}>
|
|
174
|
+
{confirmHint}
|
|
175
|
+
</Text>
|
|
176
|
+
)}
|
|
177
|
+
</>
|
|
178
|
+
}
|
|
179
|
+
>
|
|
180
|
+
<ListSection>
|
|
181
|
+
{rows.map((r) => (
|
|
182
|
+
<ListItem key={r.label} variant="value" title={r.label} value={r.value} />
|
|
183
|
+
))}
|
|
184
|
+
</ListSection>
|
|
185
|
+
</FlowStep>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─── Activity ────────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
export interface Transaction {
|
|
192
|
+
id: string;
|
|
193
|
+
title: string;
|
|
194
|
+
subtitle?: string;
|
|
195
|
+
/** Negative = money out. */
|
|
196
|
+
amount: number;
|
|
197
|
+
currency: string;
|
|
198
|
+
date: Date;
|
|
199
|
+
status?: 'pending' | 'failed';
|
|
200
|
+
/** Avatar image URL. */
|
|
201
|
+
image?: string;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface ActivityListProps {
|
|
205
|
+
transactions: Transaction[];
|
|
206
|
+
onPress?: (t: Transaction) => void;
|
|
207
|
+
onRefresh?: () => void;
|
|
208
|
+
refreshing?: boolean;
|
|
209
|
+
onEndReached?: () => void;
|
|
210
|
+
/** Swipe actions per row (also accessibility actions). */
|
|
211
|
+
actions?: (t: Transaction) => SwipeAction[];
|
|
212
|
+
header?: React.ReactElement;
|
|
213
|
+
empty?: React.ReactElement;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Transactions grouped by day, with pull-to-refresh, paging and swipe actions (web ActivityFeed). */
|
|
217
|
+
export function ActivityList({
|
|
218
|
+
transactions,
|
|
219
|
+
onPress,
|
|
220
|
+
onRefresh,
|
|
221
|
+
refreshing = false,
|
|
222
|
+
onEndReached,
|
|
223
|
+
actions,
|
|
224
|
+
header,
|
|
225
|
+
empty,
|
|
226
|
+
}: ActivityListProps) {
|
|
227
|
+
const { roles, tokens, locale } = useTheme();
|
|
228
|
+
const day = new Intl.DateTimeFormat(locale, { dateStyle: 'medium' });
|
|
229
|
+
const sections = React.useMemo(() => {
|
|
230
|
+
const map = new Map<string, Transaction[]>();
|
|
231
|
+
for (const t of [...transactions].sort((a, b) => +b.date - +a.date)) {
|
|
232
|
+
const key = day.format(t.date);
|
|
233
|
+
map.set(key, [...(map.get(key) ?? []), t]);
|
|
234
|
+
}
|
|
235
|
+
return [...map].map(([title, data]) => ({ title, data }));
|
|
236
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
237
|
+
}, [transactions, locale]);
|
|
238
|
+
|
|
239
|
+
const row = (t: Transaction) => {
|
|
240
|
+
const label = `${t.title}, ${moneyFormatter(t.currency, locale, { signDisplay: 'exceptZero' }).format(t.amount)}${t.status ? `, ${t.status}` : ''}`;
|
|
241
|
+
const item = (
|
|
242
|
+
<ListItem
|
|
243
|
+
title={t.title}
|
|
244
|
+
subtitle={t.subtitle}
|
|
245
|
+
leading={<Avatar name={t.title} src={t.image} />}
|
|
246
|
+
trailing={
|
|
247
|
+
<View style={{ alignItems: 'flex-end', gap: 2 }}>
|
|
248
|
+
<Amount
|
|
249
|
+
value={t.amount}
|
|
250
|
+
currency={t.currency}
|
|
251
|
+
locale={locale}
|
|
252
|
+
tone="auto"
|
|
253
|
+
signDisplay="exceptZero"
|
|
254
|
+
/>
|
|
255
|
+
{t.status && (
|
|
256
|
+
<Badge tone={t.status === 'failed' ? 'danger' : 'warning'} size="sm">
|
|
257
|
+
{t.status === 'failed' ? 'Failed' : 'Pending'}
|
|
258
|
+
</Badge>
|
|
259
|
+
)}
|
|
260
|
+
</View>
|
|
261
|
+
}
|
|
262
|
+
variant={onPress ? 'navigation' : 'static'}
|
|
263
|
+
onPress={onPress && (() => onPress(t))}
|
|
264
|
+
/>
|
|
265
|
+
);
|
|
266
|
+
return actions ? (
|
|
267
|
+
<SwipeableRow label={label} onPress={onPress && (() => onPress(t))} actions={actions(t)}>
|
|
268
|
+
{item}
|
|
269
|
+
</SwipeableRow>
|
|
270
|
+
) : (
|
|
271
|
+
item
|
|
272
|
+
);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
return (
|
|
276
|
+
<SectionList
|
|
277
|
+
sections={sections}
|
|
278
|
+
keyExtractor={(t) => t.id}
|
|
279
|
+
contentInsetAdjustmentBehavior="automatic"
|
|
280
|
+
style={{ backgroundColor: roles.surface }}
|
|
281
|
+
contentContainerStyle={{ padding: tokens.spacing[4], gap: 0 }}
|
|
282
|
+
ListHeaderComponent={header}
|
|
283
|
+
ListEmptyComponent={
|
|
284
|
+
empty ?? (
|
|
285
|
+
<EmptyState
|
|
286
|
+
title="No activity yet"
|
|
287
|
+
description="Your payments and transfers will show up here."
|
|
288
|
+
/>
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
renderSectionHeader={({ section }) => (
|
|
292
|
+
<Text
|
|
293
|
+
variant="title-sm"
|
|
294
|
+
color="on-surface-variant"
|
|
295
|
+
accessibilityRole="header"
|
|
296
|
+
style={{
|
|
297
|
+
paddingTop: tokens.spacing[4],
|
|
298
|
+
paddingBottom: tokens.spacing[2],
|
|
299
|
+
paddingHorizontal: tokens.spacing[4],
|
|
300
|
+
backgroundColor: roles.surface,
|
|
301
|
+
}}
|
|
302
|
+
>
|
|
303
|
+
{section.title}
|
|
304
|
+
</Text>
|
|
305
|
+
)}
|
|
306
|
+
renderItem={({ item, index, section }) => (
|
|
307
|
+
<View
|
|
308
|
+
style={{
|
|
309
|
+
backgroundColor: roles['surface-container-lowest'],
|
|
310
|
+
borderTopStartRadius: index === 0 ? tokens.shape.container : 0,
|
|
311
|
+
borderTopEndRadius: index === 0 ? tokens.shape.container : 0,
|
|
312
|
+
borderBottomStartRadius: index === section.data.length - 1 ? tokens.shape.container : 0,
|
|
313
|
+
borderBottomEndRadius: index === section.data.length - 1 ? tokens.shape.container : 0,
|
|
314
|
+
overflow: 'hidden',
|
|
315
|
+
}}
|
|
316
|
+
>
|
|
317
|
+
{row(item)}
|
|
318
|
+
</View>
|
|
319
|
+
)}
|
|
320
|
+
refreshControl={
|
|
321
|
+
onRefresh ? <RefreshControl refreshing={refreshing} onRefresh={onRefresh} /> : undefined
|
|
322
|
+
}
|
|
323
|
+
onEndReached={onEndReached}
|
|
324
|
+
onEndReachedThreshold={0.5}
|
|
325
|
+
stickySectionHeadersEnabled={false}
|
|
326
|
+
/>
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export interface TransactionDetailProps {
|
|
331
|
+
transaction: Transaction;
|
|
332
|
+
/** Extra rows: reference, account, category… */
|
|
333
|
+
rows?: ReviewRow[];
|
|
334
|
+
actions?: React.ReactNode;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** One transaction: hero amount, status, summary rows (web InfoList). */
|
|
338
|
+
export function TransactionDetail({ transaction: t, rows = [], actions }: TransactionDetailProps) {
|
|
339
|
+
const { tokens, locale } = useTheme();
|
|
340
|
+
const when = new Intl.DateTimeFormat(locale, { dateStyle: 'full', timeStyle: 'short' }).format(
|
|
341
|
+
t.date,
|
|
342
|
+
);
|
|
343
|
+
return (
|
|
344
|
+
<ScreenContainer gap="5">
|
|
345
|
+
<View
|
|
346
|
+
style={{ alignItems: 'center', gap: tokens.spacing[2], paddingVertical: tokens.spacing[4] }}
|
|
347
|
+
>
|
|
348
|
+
<Avatar name={t.title} src={t.image} size="xl" />
|
|
349
|
+
<Text variant="title-md">{t.title}</Text>
|
|
350
|
+
<Amount
|
|
351
|
+
value={t.amount}
|
|
352
|
+
currency={t.currency}
|
|
353
|
+
locale={locale}
|
|
354
|
+
size="display"
|
|
355
|
+
tone="auto"
|
|
356
|
+
signDisplay="exceptZero"
|
|
357
|
+
/>
|
|
358
|
+
{t.status ? (
|
|
359
|
+
<Badge tone={t.status === 'failed' ? 'danger' : 'warning'} dot>
|
|
360
|
+
{t.status === 'failed' ? 'Failed' : 'Pending'}
|
|
361
|
+
</Badge>
|
|
362
|
+
) : (
|
|
363
|
+
<Badge tone="success" dot>
|
|
364
|
+
Completed
|
|
365
|
+
</Badge>
|
|
366
|
+
)}
|
|
367
|
+
</View>
|
|
368
|
+
<ListSection>
|
|
369
|
+
<ListItem variant="value" title="Date" value={when} />
|
|
370
|
+
{t.subtitle && <ListItem variant="value" title="Details" value={t.subtitle} />}
|
|
371
|
+
{rows.map((r) => (
|
|
372
|
+
<ListItem key={r.label} variant="value" title={r.label} value={r.value} />
|
|
373
|
+
))}
|
|
374
|
+
</ListSection>
|
|
375
|
+
{actions}
|
|
376
|
+
</ScreenContainer>
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ─── Result screens ──────────────────────────────────────────────────────────
|
|
381
|
+
|
|
382
|
+
export interface ResultScreenProps {
|
|
383
|
+
/** @default 'success' */
|
|
384
|
+
kind?: 'success' | 'error' | 'offline' | 'maintenance' | 'update';
|
|
385
|
+
title: string;
|
|
386
|
+
description?: string;
|
|
387
|
+
/** Hero figure (e.g. the amount sent). */
|
|
388
|
+
amount?: { value: number; currency: string };
|
|
389
|
+
primary: { label: string; onPress: () => void };
|
|
390
|
+
secondary?: { label: string; onPress: () => void };
|
|
391
|
+
icon?: React.ComponentType<NativeIconProps>;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Full-screen outcome: success, error, offline, maintenance, or "update required". */
|
|
395
|
+
export function ResultScreen({
|
|
396
|
+
kind = 'success',
|
|
397
|
+
title,
|
|
398
|
+
description,
|
|
399
|
+
amount,
|
|
400
|
+
primary,
|
|
401
|
+
secondary,
|
|
402
|
+
icon,
|
|
403
|
+
}: ResultScreenProps) {
|
|
404
|
+
const { roles, tokens, locale } = useTheme();
|
|
405
|
+
const tone = kind === 'success' ? 'success' : kind === 'error' ? 'error' : 'primary';
|
|
406
|
+
const Glyph = icon ?? CheckCircle2;
|
|
407
|
+
return (
|
|
408
|
+
<StickyFooter
|
|
409
|
+
footer={
|
|
410
|
+
<>
|
|
411
|
+
<Button size="lg" fullWidth onPress={primary.onPress}>
|
|
412
|
+
{primary.label}
|
|
413
|
+
</Button>
|
|
414
|
+
{secondary && (
|
|
415
|
+
<Button size="lg" fullWidth variant="text" onPress={secondary.onPress}>
|
|
416
|
+
{secondary.label}
|
|
417
|
+
</Button>
|
|
418
|
+
)}
|
|
419
|
+
</>
|
|
420
|
+
}
|
|
421
|
+
>
|
|
422
|
+
<View
|
|
423
|
+
accessibilityLiveRegion="polite"
|
|
424
|
+
style={{
|
|
425
|
+
flex: 1,
|
|
426
|
+
alignItems: 'center',
|
|
427
|
+
justifyContent: 'center',
|
|
428
|
+
gap: tokens.spacing[4],
|
|
429
|
+
padding: tokens.spacing[6],
|
|
430
|
+
}}
|
|
431
|
+
>
|
|
432
|
+
<View
|
|
433
|
+
style={{
|
|
434
|
+
width: 88,
|
|
435
|
+
height: 88,
|
|
436
|
+
borderRadius: 44,
|
|
437
|
+
alignItems: 'center',
|
|
438
|
+
justifyContent: 'center',
|
|
439
|
+
backgroundColor: roles[`${tone}-container`],
|
|
440
|
+
}}
|
|
441
|
+
>
|
|
442
|
+
<Glyph size={44} color={roles[tone]} />
|
|
443
|
+
</View>
|
|
444
|
+
<Heading variant="headline-md" style={{ textAlign: 'center' }}>
|
|
445
|
+
{title}
|
|
446
|
+
</Heading>
|
|
447
|
+
{amount && (
|
|
448
|
+
<Amount value={amount.value} currency={amount.currency} locale={locale} size="display" />
|
|
449
|
+
)}
|
|
450
|
+
{description && (
|
|
451
|
+
<Text variant="body-md" color="on-surface-variant" style={{ textAlign: 'center' }}>
|
|
452
|
+
{description}
|
|
453
|
+
</Text>
|
|
454
|
+
)}
|
|
455
|
+
</View>
|
|
456
|
+
</StickyFooter>
|
|
457
|
+
);
|
|
458
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import type { NativeIconProps } from '@nexub/icons/native';
|
|
3
|
+
import { useTheme, type RoleName } from '../theme/ThemeProvider';
|
|
4
|
+
|
|
5
|
+
export interface IconProps extends Omit<NativeIconProps, 'color'> {
|
|
6
|
+
/** An icon from `@nexub/icons/native`, e.g. `Search`. */
|
|
7
|
+
icon: React.ComponentType<NativeIconProps>;
|
|
8
|
+
/** Colour role. @default 'on-surface-variant' */
|
|
9
|
+
color?: RoleName;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** An `@nexub/icons/native` icon coloured by a role. Decorative unless `label` is set. */
|
|
13
|
+
export function Icon({ icon: Glyph, color = 'on-surface-variant', ...rest }: IconProps) {
|
|
14
|
+
return <Glyph color={useTheme().roles[color]} {...rest} />;
|
|
15
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { View, type ViewProps, type ViewStyle } from 'react-native';
|
|
3
|
+
import type { NativeTokens } from '@nexub/tokens/native';
|
|
4
|
+
import { useTheme, type RoleName } from '../theme/ThemeProvider';
|
|
5
|
+
import type { ShapeName } from './Surface';
|
|
6
|
+
|
|
7
|
+
export type Space = keyof NativeTokens['spacing'];
|
|
8
|
+
|
|
9
|
+
export interface BoxProps extends ViewProps {
|
|
10
|
+
/** Padding on every side (spacing token). */
|
|
11
|
+
padding?: Space;
|
|
12
|
+
/** Horizontal padding (start + end, so RTL is free). */
|
|
13
|
+
paddingX?: Space;
|
|
14
|
+
paddingY?: Space;
|
|
15
|
+
/** Gap between children. */
|
|
16
|
+
gap?: Space;
|
|
17
|
+
background?: RoleName;
|
|
18
|
+
radius?: ShapeName;
|
|
19
|
+
align?: ViewStyle['alignItems'];
|
|
20
|
+
justify?: ViewStyle['justifyContent'];
|
|
21
|
+
flex?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function useBoxStyle({
|
|
25
|
+
padding,
|
|
26
|
+
paddingX,
|
|
27
|
+
paddingY,
|
|
28
|
+
gap,
|
|
29
|
+
background,
|
|
30
|
+
radius,
|
|
31
|
+
align,
|
|
32
|
+
justify,
|
|
33
|
+
flex,
|
|
34
|
+
}: BoxProps): ViewStyle {
|
|
35
|
+
const { tokens, roles } = useTheme();
|
|
36
|
+
const s = (k?: Space) => (k === undefined ? undefined : tokens.spacing[k]);
|
|
37
|
+
return {
|
|
38
|
+
padding: s(padding),
|
|
39
|
+
paddingHorizontal: s(paddingX),
|
|
40
|
+
paddingVertical: s(paddingY),
|
|
41
|
+
gap: s(gap),
|
|
42
|
+
backgroundColor: background && roles[background],
|
|
43
|
+
borderRadius: radius && tokens.shape[radius],
|
|
44
|
+
alignItems: align,
|
|
45
|
+
justifyContent: justify,
|
|
46
|
+
flex,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function Box({ style, ...props }: BoxProps) {
|
|
51
|
+
return <View style={[useBoxStyle(props), style]} {...props} />;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Children in a column. @default gap '3' */
|
|
55
|
+
export function Stack({ gap = '3', style, ...props }: BoxProps) {
|
|
56
|
+
return <View style={[useBoxStyle({ gap, ...props }), style]} {...props} />;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Children in a row that wraps, centred on the cross axis. @default gap '2' */
|
|
60
|
+
export function Inline({ gap = '2', align = 'center', style, ...props }: BoxProps) {
|
|
61
|
+
return (
|
|
62
|
+
<View
|
|
63
|
+
style={[
|
|
64
|
+
useBoxStyle({ gap, align, ...props }),
|
|
65
|
+
{ flexDirection: 'row', flexWrap: 'wrap' },
|
|
66
|
+
style,
|
|
67
|
+
]}
|
|
68
|
+
{...props}
|
|
69
|
+
/>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Pressable as RNPressable,
|
|
4
|
+
StyleSheet,
|
|
5
|
+
View,
|
|
6
|
+
type GestureResponderEvent,
|
|
7
|
+
type PressableProps as RNPressableProps,
|
|
8
|
+
type StyleProp,
|
|
9
|
+
type ViewStyle,
|
|
10
|
+
} from 'react-native';
|
|
11
|
+
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
|
|
12
|
+
import type { HapticToken } from '../theme/haptics';
|
|
13
|
+
import { useHaptics, useTheme, type RoleName } from '../theme/ThemeProvider';
|
|
14
|
+
|
|
15
|
+
// The touchable itself animates and takes `style`, so layout props (flex,
|
|
16
|
+
// position, margins) apply to the element that sits in the parent's layout.
|
|
17
|
+
const AnimatedPressable = Animated.createAnimatedComponent(RNPressable);
|
|
18
|
+
const AnimatedView = Animated.createAnimatedComponent(View);
|
|
19
|
+
|
|
20
|
+
export interface PressableProps extends Omit<RNPressableProps, 'style' | 'children'> {
|
|
21
|
+
style?: StyleProp<ViewStyle>;
|
|
22
|
+
children?: React.ReactNode;
|
|
23
|
+
/** Haptic played on press (Apple: not for plain taps — toggles, selections, outcomes). */
|
|
24
|
+
haptic?: HapticToken;
|
|
25
|
+
/** On-colour of the state layer, or false for none. @default 'on-surface' */
|
|
26
|
+
stateLayer?: RoleName | false;
|
|
27
|
+
/** Press-scale target, or false for none. @default 0.97 */
|
|
28
|
+
pressScale?: number | false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The base of every Nexub control: a state layer (on-colour at the pressed
|
|
33
|
+
* opacity) and a 0.97 press-scale on the spatial-fast spring. No ripple.
|
|
34
|
+
* Reduce Motion is honoured by Reanimated (ReduceMotion.System).
|
|
35
|
+
*/
|
|
36
|
+
export function Pressable({
|
|
37
|
+
style,
|
|
38
|
+
children,
|
|
39
|
+
haptic,
|
|
40
|
+
stateLayer = 'on-surface',
|
|
41
|
+
pressScale = 0.97,
|
|
42
|
+
disabled,
|
|
43
|
+
onPress,
|
|
44
|
+
onPressIn,
|
|
45
|
+
onPressOut,
|
|
46
|
+
...rest
|
|
47
|
+
}: PressableProps) {
|
|
48
|
+
const { roles, tokens } = useTheme();
|
|
49
|
+
const play = useHaptics();
|
|
50
|
+
const pressed = useSharedValue(0);
|
|
51
|
+
const spring = tokens.motion.spring.spatial.fast;
|
|
52
|
+
const opacity = tokens.state.pressed;
|
|
53
|
+
const scale = pressScale === false ? 1 : pressScale;
|
|
54
|
+
|
|
55
|
+
const container = useAnimatedStyle(() => ({
|
|
56
|
+
transform: [{ scale: 1 - (1 - scale) * pressed.value }],
|
|
57
|
+
}));
|
|
58
|
+
const layer = useAnimatedStyle(() => ({ opacity: pressed.value * opacity }));
|
|
59
|
+
const radius = StyleSheet.flatten(style)?.borderRadius;
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<AnimatedPressable
|
|
63
|
+
accessibilityRole="button"
|
|
64
|
+
accessibilityState={{ disabled: !!disabled }}
|
|
65
|
+
disabled={disabled}
|
|
66
|
+
onPressIn={(e: GestureResponderEvent) => {
|
|
67
|
+
pressed.value = withSpring(1, spring);
|
|
68
|
+
onPressIn?.(e);
|
|
69
|
+
}}
|
|
70
|
+
onPressOut={(e: GestureResponderEvent) => {
|
|
71
|
+
pressed.value = withSpring(0, spring);
|
|
72
|
+
onPressOut?.(e);
|
|
73
|
+
}}
|
|
74
|
+
onPress={(e: GestureResponderEvent) => {
|
|
75
|
+
if (haptic) play(haptic);
|
|
76
|
+
onPress?.(e);
|
|
77
|
+
}}
|
|
78
|
+
style={[style, container]}
|
|
79
|
+
{...rest}
|
|
80
|
+
>
|
|
81
|
+
{children}
|
|
82
|
+
{stateLayer && (
|
|
83
|
+
<AnimatedView
|
|
84
|
+
pointerEvents="none"
|
|
85
|
+
style={[
|
|
86
|
+
StyleSheet.absoluteFill,
|
|
87
|
+
{ backgroundColor: roles[stateLayer], borderRadius: radius },
|
|
88
|
+
layer,
|
|
89
|
+
]}
|
|
90
|
+
/>
|
|
91
|
+
)}
|
|
92
|
+
</AnimatedPressable>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { ScrollView, View, type ScrollViewProps, type ViewStyle } from 'react-native';
|
|
3
|
+
import { useSafeAreaInsets, type Edge } from 'react-native-safe-area-context';
|
|
4
|
+
import { useTheme, type RoleName } from '../theme/ThemeProvider';
|
|
5
|
+
import type { Space } from './Layout';
|
|
6
|
+
|
|
7
|
+
export interface ScreenContainerProps extends Omit<ScrollViewProps, 'contentContainerStyle'> {
|
|
8
|
+
/** Scrolls (and lets a native large title collapse). @default true */
|
|
9
|
+
scroll?: boolean;
|
|
10
|
+
/** Safe-area edges to pad. A native header already covers 'top'. @default ['left', 'right', 'bottom'] */
|
|
11
|
+
edges?: Edge[];
|
|
12
|
+
/** @default 'surface' */
|
|
13
|
+
background?: RoleName;
|
|
14
|
+
/** Content padding. @default '4' */
|
|
15
|
+
padding?: Space;
|
|
16
|
+
/** Gap between children. @default '4' */
|
|
17
|
+
gap?: Space;
|
|
18
|
+
contentStyle?: ViewStyle;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The root of every screen: background, edge-to-edge safe-area insets and
|
|
23
|
+
* content padding. With `scroll`, iOS adjusts insets itself
|
|
24
|
+
* (contentInsetAdjustmentBehavior) so native large titles collapse.
|
|
25
|
+
*/
|
|
26
|
+
export function ScreenContainer({
|
|
27
|
+
scroll = true,
|
|
28
|
+
edges = ['left', 'right', 'bottom'],
|
|
29
|
+
background = 'surface',
|
|
30
|
+
padding = '4',
|
|
31
|
+
gap = '4',
|
|
32
|
+
contentStyle,
|
|
33
|
+
children,
|
|
34
|
+
style,
|
|
35
|
+
...rest
|
|
36
|
+
}: ScreenContainerProps) {
|
|
37
|
+
const { roles, tokens } = useTheme();
|
|
38
|
+
const insets = useSafeAreaInsets();
|
|
39
|
+
const pad = tokens.spacing[padding];
|
|
40
|
+
const inner: ViewStyle = {
|
|
41
|
+
paddingTop: pad + (edges.includes('top') ? insets.top : 0),
|
|
42
|
+
paddingBottom: pad + (edges.includes('bottom') ? insets.bottom : 0),
|
|
43
|
+
paddingStart: pad + (edges.includes('left') ? insets.left : 0),
|
|
44
|
+
paddingEnd: pad + (edges.includes('right') ? insets.right : 0),
|
|
45
|
+
gap: tokens.spacing[gap],
|
|
46
|
+
};
|
|
47
|
+
if (!scroll) {
|
|
48
|
+
return (
|
|
49
|
+
<View style={[{ flex: 1, backgroundColor: roles[background] }, inner, contentStyle, style]}>
|
|
50
|
+
{children}
|
|
51
|
+
</View>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
return (
|
|
55
|
+
<ScrollView
|
|
56
|
+
contentInsetAdjustmentBehavior="automatic"
|
|
57
|
+
keyboardShouldPersistTaps="handled"
|
|
58
|
+
style={[{ flex: 1, backgroundColor: roles[background] }, style]}
|
|
59
|
+
contentContainerStyle={[inner, contentStyle]}
|
|
60
|
+
{...rest}
|
|
61
|
+
>
|
|
62
|
+
{children}
|
|
63
|
+
</ScrollView>
|
|
64
|
+
);
|
|
65
|
+
}
|