@nexub/react-native 0.0.0-stage → 2.2.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.
Files changed (134) hide show
  1. package/README.md +113 -2
  2. package/lib/module/components/actions.js +222 -0
  3. package/lib/module/components/actions.js.map +1 -0
  4. package/lib/module/components/containers.js +300 -0
  5. package/lib/module/components/containers.js.map +1 -0
  6. package/lib/module/components/display.js +299 -0
  7. package/lib/module/components/display.js.map +1 -0
  8. package/lib/module/components/feedback.js +484 -0
  9. package/lib/module/components/feedback.js.map +1 -0
  10. package/lib/module/components/inputs.js +792 -0
  11. package/lib/module/components/inputs.js.map +1 -0
  12. package/lib/module/components/mobile.js +818 -0
  13. package/lib/module/components/mobile.js.map +1 -0
  14. package/lib/module/components/overlays.js +421 -0
  15. package/lib/module/components/overlays.js.map +1 -0
  16. package/lib/module/components/shared.js +57 -0
  17. package/lib/module/components/shared.js.map +1 -0
  18. package/lib/module/hooks/useControllableState.js +23 -0
  19. package/lib/module/hooks/useControllableState.js.map +1 -0
  20. package/lib/module/index.js +31 -0
  21. package/lib/module/index.js.map +1 -0
  22. package/lib/module/package.json +1 -0
  23. package/lib/module/patterns/app.js +709 -0
  24. package/lib/module/patterns/app.js.map +1 -0
  25. package/lib/module/patterns/auth.js +224 -0
  26. package/lib/module/patterns/auth.js.map +1 -0
  27. package/lib/module/patterns/home.js +201 -0
  28. package/lib/module/patterns/home.js.map +1 -0
  29. package/lib/module/patterns/index.js +8 -0
  30. package/lib/module/patterns/index.js.map +1 -0
  31. package/lib/module/patterns/money.js +419 -0
  32. package/lib/module/patterns/money.js.map +1 -0
  33. package/lib/module/primitives/Icon.js +17 -0
  34. package/lib/module/primitives/Icon.js.map +1 -0
  35. package/lib/module/primitives/Layout.js +79 -0
  36. package/lib/module/primitives/Layout.js.map +1 -0
  37. package/lib/module/primitives/Pressable.js +79 -0
  38. package/lib/module/primitives/Pressable.js.map +1 -0
  39. package/lib/module/primitives/ScreenContainer.js +58 -0
  40. package/lib/module/primitives/ScreenContainer.js.map +1 -0
  41. package/lib/module/primitives/Surface.js +57 -0
  42. package/lib/module/primitives/Surface.js.map +1 -0
  43. package/lib/module/primitives/Text.js +70 -0
  44. package/lib/module/primitives/Text.js.map +1 -0
  45. package/lib/module/theme/ThemeProvider.js +127 -0
  46. package/lib/module/theme/ThemeProvider.js.map +1 -0
  47. package/lib/module/theme/fonts.js +56 -0
  48. package/lib/module/theme/fonts.js.map +1 -0
  49. package/lib/module/theme/haptics.js +56 -0
  50. package/lib/module/theme/haptics.js.map +1 -0
  51. package/lib/module/theme/motion.js +89 -0
  52. package/lib/module/theme/motion.js.map +1 -0
  53. package/lib/module/theme/navigation.js +99 -0
  54. package/lib/module/theme/navigation.js.map +1 -0
  55. package/lib/typescript/components/actions.d.ts +53 -0
  56. package/lib/typescript/components/actions.d.ts.map +1 -0
  57. package/lib/typescript/components/containers.d.ts +59 -0
  58. package/lib/typescript/components/containers.d.ts.map +1 -0
  59. package/lib/typescript/components/display.d.ts +68 -0
  60. package/lib/typescript/components/display.d.ts.map +1 -0
  61. package/lib/typescript/components/feedback.d.ts +96 -0
  62. package/lib/typescript/components/feedback.d.ts.map +1 -0
  63. package/lib/typescript/components/inputs.d.ts +141 -0
  64. package/lib/typescript/components/inputs.d.ts.map +1 -0
  65. package/lib/typescript/components/mobile.d.ts +173 -0
  66. package/lib/typescript/components/mobile.d.ts.map +1 -0
  67. package/lib/typescript/components/overlays.d.ts +74 -0
  68. package/lib/typescript/components/overlays.d.ts.map +1 -0
  69. package/lib/typescript/components/shared.d.ts +20 -0
  70. package/lib/typescript/components/shared.d.ts.map +1 -0
  71. package/lib/typescript/hooks/useControllableState.d.ts +6 -0
  72. package/lib/typescript/hooks/useControllableState.d.ts.map +1 -0
  73. package/lib/typescript/index.d.ts +39 -0
  74. package/lib/typescript/index.d.ts.map +1 -0
  75. package/lib/typescript/package.json +1 -0
  76. package/lib/typescript/patterns/app.d.ts +139 -0
  77. package/lib/typescript/patterns/app.d.ts.map +1 -0
  78. package/lib/typescript/patterns/auth.d.ts +54 -0
  79. package/lib/typescript/patterns/auth.d.ts.map +1 -0
  80. package/lib/typescript/patterns/home.d.ts +50 -0
  81. package/lib/typescript/patterns/home.d.ts.map +1 -0
  82. package/lib/typescript/patterns/index.d.ts +9 -0
  83. package/lib/typescript/patterns/index.d.ts.map +1 -0
  84. package/lib/typescript/patterns/money.d.ts +106 -0
  85. package/lib/typescript/patterns/money.d.ts.map +1 -0
  86. package/lib/typescript/primitives/Icon.d.ts +12 -0
  87. package/lib/typescript/primitives/Icon.d.ts.map +1 -0
  88. package/lib/typescript/primitives/Layout.d.ts +26 -0
  89. package/lib/typescript/primitives/Layout.d.ts.map +1 -0
  90. package/lib/typescript/primitives/Pressable.d.ts +21 -0
  91. package/lib/typescript/primitives/Pressable.d.ts.map +1 -0
  92. package/lib/typescript/primitives/ScreenContainer.d.ts +25 -0
  93. package/lib/typescript/primitives/ScreenContainer.d.ts.map +1 -0
  94. package/lib/typescript/primitives/Surface.d.ts +23 -0
  95. package/lib/typescript/primitives/Surface.d.ts.map +1 -0
  96. package/lib/typescript/primitives/Text.d.ts +21 -0
  97. package/lib/typescript/primitives/Text.d.ts.map +1 -0
  98. package/lib/typescript/theme/ThemeProvider.d.ts +69 -0
  99. package/lib/typescript/theme/ThemeProvider.d.ts.map +1 -0
  100. package/lib/typescript/theme/fonts.d.ts +25 -0
  101. package/lib/typescript/theme/fonts.d.ts.map +1 -0
  102. package/lib/typescript/theme/haptics.d.ts +3 -0
  103. package/lib/typescript/theme/haptics.d.ts.map +1 -0
  104. package/lib/typescript/theme/motion.d.ts +79 -0
  105. package/lib/typescript/theme/motion.d.ts.map +1 -0
  106. package/lib/typescript/theme/navigation.d.ts +158 -0
  107. package/lib/typescript/theme/navigation.d.ts.map +1 -0
  108. package/package.json +138 -3
  109. package/src/components/actions.tsx +259 -0
  110. package/src/components/containers.tsx +352 -0
  111. package/src/components/display.tsx +345 -0
  112. package/src/components/feedback.tsx +541 -0
  113. package/src/components/inputs.tsx +899 -0
  114. package/src/components/mobile.tsx +883 -0
  115. package/src/components/overlays.tsx +488 -0
  116. package/src/components/shared.ts +54 -0
  117. package/src/hooks/useControllableState.ts +27 -0
  118. package/src/index.ts +177 -0
  119. package/src/patterns/app.tsx +715 -0
  120. package/src/patterns/auth.tsx +266 -0
  121. package/src/patterns/home.tsx +211 -0
  122. package/src/patterns/index.ts +59 -0
  123. package/src/patterns/money.tsx +458 -0
  124. package/src/primitives/Icon.tsx +15 -0
  125. package/src/primitives/Layout.tsx +71 -0
  126. package/src/primitives/Pressable.tsx +97 -0
  127. package/src/primitives/ScreenContainer.tsx +65 -0
  128. package/src/primitives/Surface.tsx +75 -0
  129. package/src/primitives/Text.tsx +79 -0
  130. package/src/theme/ThemeProvider.tsx +210 -0
  131. package/src/theme/fonts.ts +52 -0
  132. package/src/theme/haptics.ts +57 -0
  133. package/src/theme/motion.ts +105 -0
  134. package/src/theme/navigation.ts +68 -0
@@ -0,0 +1,715 @@
1
+ import * as React from 'react';
2
+ import { FlatList, KeyboardAvoidingView, Platform, TextInput, View } from 'react-native';
3
+ import { ArrowUp, Bell, Check, Sparkles, type NativeIconProps } from '@nexub/icons/native';
4
+ import type { GlassMode } from '@nexub/core';
5
+ import { Button, IconButton } from '../components/actions';
6
+ import { Card, ListItem, ListSection, type ListItemProps } from '../components/containers';
7
+ import { Avatar, Badge } from '../components/display';
8
+ import { EmptyState } from '../components/feedback';
9
+ import { SearchField, SegmentedControl, Chip } from '../components/inputs';
10
+ import { BottomSheet } from '../components/overlays';
11
+ import { Carousel, StickyFooter } from '../components/mobile';
12
+ import { Pressable } from '../primitives/Pressable';
13
+ import { ScreenContainer } from '../primitives/ScreenContainer';
14
+ import { Heading, Text } from '../primitives/Text';
15
+ import { fontStyle } from '../theme/fonts';
16
+ import { useTheme, type Scheme } from '../theme/ThemeProvider';
17
+
18
+ // ─── Onboarding ──────────────────────────────────────────────────────────────
19
+
20
+ export interface OnboardingSlide {
21
+ title: string;
22
+ body: string;
23
+ /** Illustration or icon above the text. */
24
+ art?: React.ReactNode;
25
+ }
26
+
27
+ export interface OnboardingScreenProps {
28
+ slides: OnboardingSlide[];
29
+ onGetStarted: () => void;
30
+ onSignIn?: () => void;
31
+ }
32
+
33
+ /** Swipeable value slides + Get started / Sign in. */
34
+ export function OnboardingScreen({ slides, onGetStarted, onSignIn }: OnboardingScreenProps) {
35
+ const { tokens } = useTheme();
36
+ return (
37
+ <StickyFooter
38
+ footer={
39
+ <>
40
+ <Button size="lg" fullWidth onPress={onGetStarted}>
41
+ Get started
42
+ </Button>
43
+ {onSignIn && (
44
+ <Button size="lg" fullWidth variant="text" onPress={onSignIn}>
45
+ I already have an account
46
+ </Button>
47
+ )}
48
+ </>
49
+ }
50
+ >
51
+ <View style={{ flex: 1, justifyContent: 'center' }}>
52
+ <Carousel
53
+ data={slides}
54
+ renderItem={({ item }) => (
55
+ <View
56
+ style={{ padding: tokens.spacing[6], gap: tokens.spacing[4], alignItems: 'center' }}
57
+ >
58
+ {item.art}
59
+ <Heading variant="headline-md" style={{ textAlign: 'center' }}>
60
+ {item.title}
61
+ </Heading>
62
+ <Text variant="body-lg" color="on-surface-variant" style={{ textAlign: 'center' }}>
63
+ {item.body}
64
+ </Text>
65
+ </View>
66
+ )}
67
+ />
68
+ </View>
69
+ </StickyFooter>
70
+ );
71
+ }
72
+
73
+ export interface PermissionPrimerProps {
74
+ icon?: React.ComponentType<NativeIconProps>;
75
+ title: string;
76
+ /** Why the app needs it, in the user's terms. */
77
+ body: string;
78
+ /** Calls the OS prompt. */
79
+ onAllow: () => void;
80
+ onNotNow: () => void;
81
+ }
82
+
83
+ /** Explain a permission before the one-shot OS prompt (notifications, camera, location). */
84
+ export function PermissionPrimer({
85
+ icon: Glyph = Bell,
86
+ title,
87
+ body,
88
+ onAllow,
89
+ onNotNow,
90
+ }: PermissionPrimerProps) {
91
+ const { roles, tokens } = useTheme();
92
+ return (
93
+ <StickyFooter
94
+ footer={
95
+ <>
96
+ <Button size="lg" fullWidth onPress={onAllow}>
97
+ Continue
98
+ </Button>
99
+ <Button size="lg" fullWidth variant="text" onPress={onNotNow}>
100
+ Not now
101
+ </Button>
102
+ </>
103
+ }
104
+ >
105
+ <View
106
+ style={{
107
+ flex: 1,
108
+ alignItems: 'center',
109
+ justifyContent: 'center',
110
+ gap: tokens.spacing[4],
111
+ padding: tokens.spacing[6],
112
+ }}
113
+ >
114
+ <View
115
+ style={{
116
+ width: 88,
117
+ height: 88,
118
+ borderRadius: 44,
119
+ alignItems: 'center',
120
+ justifyContent: 'center',
121
+ backgroundColor: roles['primary-container'],
122
+ }}
123
+ >
124
+ <Glyph size={44} color={roles['on-primary-container']} />
125
+ </View>
126
+ <Heading variant="headline-sm" style={{ textAlign: 'center' }}>
127
+ {title}
128
+ </Heading>
129
+ <Text variant="body-md" color="on-surface-variant" style={{ textAlign: 'center' }}>
130
+ {body}
131
+ </Text>
132
+ </View>
133
+ </StickyFooter>
134
+ );
135
+ }
136
+
137
+ // ─── Settings & profile ──────────────────────────────────────────────────────
138
+
139
+ export interface SettingsGroup {
140
+ header?: string;
141
+ footer?: string;
142
+ items: ListItemProps[];
143
+ }
144
+
145
+ export interface SettingsScreenProps {
146
+ /** Optional profile row/card above the groups. */
147
+ header?: React.ReactNode;
148
+ groups: SettingsGroup[];
149
+ }
150
+
151
+ /** Grouped settings (web SettingsLayout + SettingsSection). */
152
+ export function SettingsScreen({ header, groups }: SettingsScreenProps) {
153
+ return (
154
+ <ScreenContainer gap="6">
155
+ {header}
156
+ {groups.map((g, i) => (
157
+ <ListSection
158
+ key={g.header ?? i}
159
+ header={g.header}
160
+ footer={g.footer}
161
+ inset={g.items.some((it) => it.leading) ? 56 : 16}
162
+ >
163
+ {g.items.map((it) => (
164
+ <ListItem key={it.title} {...it} />
165
+ ))}
166
+ </ListSection>
167
+ ))}
168
+ </ScreenContainer>
169
+ );
170
+ }
171
+
172
+ export interface AppearanceSectionProps {
173
+ scheme: Scheme | 'system';
174
+ onSchemeChange: (scheme: Scheme | 'system') => void;
175
+ glass: GlassMode;
176
+ onGlassChange: (glass: GlassMode) => void;
177
+ haptics: boolean;
178
+ onHapticsChange: (on: boolean) => void;
179
+ }
180
+
181
+ /** Theme, glass intensity dial and haptics: the user preferences NexubNativeProvider takes. */
182
+ export function AppearanceSection({
183
+ scheme,
184
+ onSchemeChange,
185
+ glass,
186
+ onGlassChange,
187
+ haptics,
188
+ onHapticsChange,
189
+ }: AppearanceSectionProps) {
190
+ const { tokens, messages } = useTheme();
191
+ return (
192
+ <ListSection header="Appearance">
193
+ <View style={{ padding: tokens.spacing[4], gap: tokens.spacing[3] }}>
194
+ <SegmentedControl
195
+ label="Theme"
196
+ value={scheme}
197
+ onValueChange={onSchemeChange}
198
+ options={[
199
+ { value: 'system', label: 'System' },
200
+ { value: 'light', label: 'Light' },
201
+ { value: 'dark', label: 'Dark' },
202
+ ]}
203
+ />
204
+ <SegmentedControl
205
+ label={messages.glass}
206
+ value={glass}
207
+ onValueChange={onGlassChange}
208
+ options={[
209
+ { value: 'clear', label: messages.glassClear },
210
+ { value: 'balanced', label: messages.glassBalanced },
211
+ { value: 'tinted', label: messages.glassTinted },
212
+ { value: 'off', label: messages.glassSolid },
213
+ ]}
214
+ />
215
+ </View>
216
+ <ListItem
217
+ variant="switch"
218
+ title="Haptics"
219
+ subtitle="Vibration feedback on selections"
220
+ checked={haptics}
221
+ onCheckedChange={onHapticsChange}
222
+ />
223
+ </ListSection>
224
+ );
225
+ }
226
+
227
+ export interface ProfileHeaderProps {
228
+ name: string;
229
+ detail?: string;
230
+ image?: string;
231
+ onPress?: () => void;
232
+ }
233
+
234
+ /** Account card at the top of Settings / Profile (web UserCard). */
235
+ export function ProfileHeader({ name, detail, image, onPress }: ProfileHeaderProps) {
236
+ const { tokens } = useTheme();
237
+ return (
238
+ <Card
239
+ onPress={onPress}
240
+ accessibilityLabel={[name, detail].filter(Boolean).join(', ')}
241
+ style={{ flexDirection: 'row', alignItems: 'center', gap: tokens.spacing[4] }}
242
+ >
243
+ <Avatar name={name} src={image} size="lg" />
244
+ <View style={{ flex: 1, gap: 2 }}>
245
+ <Text variant="title-lg">{name}</Text>
246
+ {detail && (
247
+ <Text variant="body-sm" color="on-surface-variant">
248
+ {detail}
249
+ </Text>
250
+ )}
251
+ </View>
252
+ </Card>
253
+ );
254
+ }
255
+
256
+ // ─── Notifications ───────────────────────────────────────────────────────────
257
+
258
+ export interface NotificationEntry {
259
+ id: string;
260
+ title: string;
261
+ body?: string;
262
+ /** Preformatted, e.g. "2 min ago". */
263
+ time: string;
264
+ unread?: boolean;
265
+ icon?: React.ComponentType<NativeIconProps>;
266
+ }
267
+
268
+ export interface NotificationsInboxProps {
269
+ notifications: NotificationEntry[];
270
+ onPress?: (n: NotificationEntry) => void;
271
+ onMarkAllRead?: () => void;
272
+ }
273
+
274
+ /** Inbox list with unread dots (web NotificationItem). */
275
+ export function NotificationsInbox({
276
+ notifications,
277
+ onPress,
278
+ onMarkAllRead,
279
+ }: NotificationsInboxProps) {
280
+ const { roles, tokens } = useTheme();
281
+ const unread = notifications.filter((n) => n.unread).length;
282
+ return (
283
+ <ScreenContainer>
284
+ {unread > 0 && onMarkAllRead && (
285
+ <Button
286
+ variant="text"
287
+ size="sm"
288
+ leadingIcon={Check}
289
+ onPress={onMarkAllRead}
290
+ style={{ alignSelf: 'flex-end' }}
291
+ >
292
+ Mark all as read
293
+ </Button>
294
+ )}
295
+ {notifications.length === 0 ? (
296
+ <EmptyState icon={Bell} title="You’re all caught up" />
297
+ ) : (
298
+ <ListSection inset={56}>
299
+ {notifications.map((n) => {
300
+ const Glyph = n.icon ?? Bell;
301
+ return (
302
+ <ListItem
303
+ key={n.id}
304
+ title={n.title}
305
+ subtitle={[n.body, n.time].filter(Boolean).join(' · ')}
306
+ leading={
307
+ <View>
308
+ <Glyph size="md" color={roles['on-surface-variant']} />
309
+ {n.unread && (
310
+ <View
311
+ style={{
312
+ position: 'absolute',
313
+ top: -2,
314
+ end: -2,
315
+ width: 8,
316
+ height: 8,
317
+ borderRadius: 4,
318
+ backgroundColor: roles.primary,
319
+ }}
320
+ />
321
+ )}
322
+ </View>
323
+ }
324
+ trailing={
325
+ n.unread ? (
326
+ <Badge tone="info" size="sm">
327
+ New
328
+ </Badge>
329
+ ) : null
330
+ }
331
+ onPress={onPress && (() => onPress(n))}
332
+ variant={onPress ? 'button' : 'static'}
333
+ />
334
+ );
335
+ })}
336
+ </ListSection>
337
+ )}
338
+ <View style={{ height: tokens.spacing[4] }} />
339
+ </ScreenContainer>
340
+ );
341
+ }
342
+
343
+ // ─── Search ──────────────────────────────────────────────────────────────────
344
+
345
+ export interface SearchScreenProps<T> {
346
+ query: string;
347
+ onQueryChange: (q: string) => void;
348
+ results: T[];
349
+ renderResult: (item: T) => React.ReactNode;
350
+ keyOf: (item: T) => string;
351
+ recent?: string[];
352
+ placeholder?: string;
353
+ }
354
+
355
+ /** Search field, recent searches, results and a no-results state. */
356
+ export function SearchScreen<T>({
357
+ query,
358
+ onQueryChange,
359
+ results,
360
+ renderResult,
361
+ keyOf,
362
+ recent = [],
363
+ placeholder,
364
+ }: SearchScreenProps<T>) {
365
+ const { tokens, messages } = useTheme();
366
+ return (
367
+ <ScreenContainer scroll={false}>
368
+ <SearchField value={query} onChangeText={onQueryChange} placeholder={placeholder} autoFocus />
369
+ {!query && recent.length > 0 && (
370
+ <View style={{ gap: tokens.spacing[2] }}>
371
+ <Text variant="title-sm" color="on-surface-variant" accessibilityRole="header">
372
+ Recent
373
+ </Text>
374
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: tokens.spacing[2] }}>
375
+ {recent.map((r) => (
376
+ <Chip key={r} label={r} onPress={() => onQueryChange(r)} />
377
+ ))}
378
+ </View>
379
+ </View>
380
+ )}
381
+ {!!query && (
382
+ <FlatList
383
+ data={results}
384
+ keyExtractor={keyOf}
385
+ renderItem={({ item }) => <>{renderResult(item)}</>}
386
+ keyboardDismissMode="on-drag"
387
+ ListEmptyComponent={<EmptyState title={messages.noResultsFor(query)} />}
388
+ />
389
+ )}
390
+ </ScreenContainer>
391
+ );
392
+ }
393
+
394
+ // ─── Chat / AI assistant ─────────────────────────────────────────────────────
395
+
396
+ export interface ChatMessage {
397
+ id: string;
398
+ role: 'user' | 'assistant';
399
+ text: string;
400
+ /** Still streaming in. */
401
+ streaming?: boolean;
402
+ }
403
+
404
+ export interface ChatScreenProps {
405
+ messages: ChatMessage[];
406
+ onSend: (text: string) => void;
407
+ /** Assistant is thinking (before the first token). */
408
+ thinking?: boolean;
409
+ placeholder?: string;
410
+ empty?: React.ReactNode;
411
+ }
412
+
413
+ /** Conversation with a composer above the keyboard (web Conversation + PromptInput + MessageBubble). */
414
+ export function ChatScreen({
415
+ messages: list,
416
+ onSend,
417
+ thinking,
418
+ placeholder,
419
+ empty,
420
+ }: ChatScreenProps) {
421
+ const { roles, tokens, fonts, messages } = useTheme();
422
+ const [text, setText] = React.useState('');
423
+ const send = () => {
424
+ const t = text.trim();
425
+ if (!t) return;
426
+ onSend(t);
427
+ setText('');
428
+ };
429
+ return (
430
+ <KeyboardAvoidingView
431
+ behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
432
+ keyboardVerticalOffset={Platform.OS === 'ios' ? 100 : 0}
433
+ style={{ flex: 1, backgroundColor: roles.surface }}
434
+ >
435
+ <FlatList
436
+ data={[...list].reverse()}
437
+ inverted
438
+ keyExtractor={(m) => m.id}
439
+ contentContainerStyle={{ padding: tokens.spacing[4], gap: tokens.spacing[3], flexGrow: 1 }}
440
+ ListHeaderComponent={thinking ? <ThinkingDots /> : undefined}
441
+ ListEmptyComponent={
442
+ <View style={{ flex: 1, transform: [{ scaleY: -1 }] }}>
443
+ {empty ?? <EmptyState icon={Sparkles} title="Ask anything" />}
444
+ </View>
445
+ }
446
+ renderItem={({ item }) => {
447
+ const mine = item.role === 'user';
448
+ return (
449
+ <View
450
+ accessible
451
+ accessibilityLabel={`${mine ? messages.youSaid : messages.assistantSaid}: ${item.text}`}
452
+ style={{
453
+ alignSelf: mine ? 'flex-end' : 'flex-start',
454
+ maxWidth: '85%',
455
+ paddingHorizontal: tokens.spacing[4],
456
+ paddingVertical: tokens.spacing[3],
457
+ borderRadius: tokens.shape.container,
458
+ borderBottomEndRadius: mine ? tokens.shape.min : tokens.shape.container,
459
+ borderBottomStartRadius: mine ? tokens.shape.container : tokens.shape.min,
460
+ backgroundColor: roles[mine ? 'primary' : 'surface-container-high'],
461
+ }}
462
+ >
463
+ <Text color={mine ? 'on-primary' : 'on-surface'}>
464
+ {item.text}
465
+ {item.streaming ? ' ▍' : ''}
466
+ </Text>
467
+ </View>
468
+ );
469
+ }}
470
+ />
471
+ <View
472
+ style={{
473
+ flexDirection: 'row',
474
+ alignItems: 'flex-end',
475
+ gap: tokens.spacing[2],
476
+ padding: tokens.spacing[3],
477
+ borderTopWidth: 1,
478
+ borderTopColor: roles['outline-variant'],
479
+ backgroundColor: roles.surface,
480
+ }}
481
+ >
482
+ <TextInput
483
+ accessibilityLabel={messages.promptLabel}
484
+ value={text}
485
+ onChangeText={setText}
486
+ placeholder={placeholder ?? messages.promptLabel}
487
+ placeholderTextColor={roles['on-surface-muted']}
488
+ multiline
489
+ style={{
490
+ flex: 1,
491
+ minHeight: 44,
492
+ maxHeight: 140,
493
+ paddingHorizontal: tokens.spacing[4],
494
+ paddingTop: 12,
495
+ paddingBottom: 12,
496
+ borderRadius: 22,
497
+ backgroundColor: roles['surface-container-high'],
498
+ color: roles['on-surface'],
499
+ fontSize: tokens.type['body-md'].size,
500
+ ...fontStyle('sans', 400, fonts),
501
+ }}
502
+ />
503
+ <IconButton
504
+ icon={ArrowUp}
505
+ label={messages.sendMessage}
506
+ variant="filled"
507
+ disabled={!text.trim()}
508
+ onPress={send}
509
+ />
510
+ </View>
511
+ </KeyboardAvoidingView>
512
+ );
513
+ }
514
+
515
+ function ThinkingDots() {
516
+ const { roles, messages } = useTheme();
517
+ return (
518
+ <View
519
+ accessible
520
+ accessibilityLabel={messages.thinking}
521
+ accessibilityLiveRegion="polite"
522
+ style={{ flexDirection: 'row', gap: 4, padding: 12, alignSelf: 'flex-start' }}
523
+ >
524
+ {[0, 1, 2].map((i) => (
525
+ <View
526
+ key={i}
527
+ style={{
528
+ width: 8,
529
+ height: 8,
530
+ borderRadius: 4,
531
+ backgroundColor: roles['on-surface-variant'],
532
+ opacity: 0.4 + i * 0.2,
533
+ }}
534
+ />
535
+ ))}
536
+ </View>
537
+ );
538
+ }
539
+
540
+ // ─── Paywall ─────────────────────────────────────────────────────────────────
541
+
542
+ export interface Plan {
543
+ id: string;
544
+ name: string;
545
+ /** Preformatted, e.g. "$9.99 / month". */
546
+ price: string;
547
+ features: string[];
548
+ badge?: string;
549
+ }
550
+
551
+ export interface PaywallScreenProps {
552
+ title: string;
553
+ plans: Plan[];
554
+ defaultPlan?: string;
555
+ onSubscribe: (planId: string) => void;
556
+ onRestore?: () => void;
557
+ /** Legal copy (auto-renewal terms). */
558
+ terms?: string;
559
+ }
560
+
561
+ /** Plan choice + subscribe (web PlanCard). */
562
+ export function PaywallScreen({
563
+ title,
564
+ plans,
565
+ defaultPlan,
566
+ onSubscribe,
567
+ onRestore,
568
+ terms,
569
+ }: PaywallScreenProps) {
570
+ const { roles, tokens } = useTheme();
571
+ const [plan, setPlan] = React.useState(defaultPlan ?? plans[0]?.id);
572
+ return (
573
+ <StickyFooter
574
+ footer={
575
+ <>
576
+ <Button size="lg" fullWidth onPress={() => plan && onSubscribe(plan)}>
577
+ Continue
578
+ </Button>
579
+ {onRestore && (
580
+ <Button size="lg" fullWidth variant="text" onPress={onRestore}>
581
+ Restore purchases
582
+ </Button>
583
+ )}
584
+ {terms && (
585
+ <Text variant="label-sm" color="on-surface-variant" style={{ textAlign: 'center' }}>
586
+ {terms}
587
+ </Text>
588
+ )}
589
+ </>
590
+ }
591
+ >
592
+ <ScreenContainer>
593
+ <Heading variant="headline-md">{title}</Heading>
594
+ <View accessibilityRole="radiogroup" style={{ gap: tokens.spacing[3] }}>
595
+ {plans.map((p) => {
596
+ const on = p.id === plan;
597
+ return (
598
+ <Pressable
599
+ key={p.id}
600
+ accessibilityRole="radio"
601
+ accessibilityLabel={`${p.name}, ${p.price}`}
602
+ accessibilityState={{ checked: on }}
603
+ haptic={on ? undefined : 'selection'}
604
+ onPress={() => setPlan(p.id)}
605
+ pressScale={0.98}
606
+ style={{
607
+ padding: tokens.spacing[4],
608
+ gap: tokens.spacing[2],
609
+ borderRadius: tokens.shape.container,
610
+ borderWidth: on ? 2 : 1,
611
+ borderColor: on ? roles.primary : roles['outline-variant'],
612
+ backgroundColor: roles['surface-container-lowest'],
613
+ }}
614
+ >
615
+ <View
616
+ style={{ flexDirection: 'row', alignItems: 'center', gap: tokens.spacing[2] }}
617
+ >
618
+ <Text variant="title-md" style={{ flex: 1 }}>
619
+ {p.name}
620
+ </Text>
621
+ {p.badge && <Badge tone="info">{p.badge}</Badge>}
622
+ </View>
623
+ <Text variant="title-lg" tabular>
624
+ {p.price}
625
+ </Text>
626
+ {p.features.map((f) => (
627
+ <View key={f} style={{ flexDirection: 'row', gap: tokens.spacing[2] }}>
628
+ <Check size="sm" color={roles.success} />
629
+ <Text variant="body-sm" color="on-surface-variant" style={{ flex: 1 }}>
630
+ {f}
631
+ </Text>
632
+ </View>
633
+ ))}
634
+ </Pressable>
635
+ );
636
+ })}
637
+ </View>
638
+ </ScreenContainer>
639
+ </StickyFooter>
640
+ );
641
+ }
642
+
643
+ // ─── App switcher ────────────────────────────────────────────────────────────
644
+
645
+ export interface NexubApp {
646
+ id: string;
647
+ name: string;
648
+ /** Tile art (e.g. @nexub/brand icon as an Image), else initials on the product tone. */
649
+ icon?: React.ReactNode;
650
+ tone?: 'leap' | 'bookub' | 'payub' | 'founderub' | 'primary';
651
+ }
652
+
653
+ export interface AppSwitcherSheetProps {
654
+ open: boolean;
655
+ onOpenChange: (open: boolean) => void;
656
+ apps: NexubApp[];
657
+ current?: string;
658
+ onOpenApp: (app: NexubApp) => void;
659
+ }
660
+
661
+ /** Nexub apps in a sheet (web AppLauncher). */
662
+ export function AppSwitcherSheet({
663
+ open,
664
+ onOpenChange,
665
+ apps,
666
+ current,
667
+ onOpenApp,
668
+ }: AppSwitcherSheetProps) {
669
+ const { roles, tokens } = useTheme();
670
+ return (
671
+ <BottomSheet open={open} onOpenChange={onOpenChange} title="Nexub apps">
672
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', rowGap: tokens.spacing[4] }}>
673
+ {apps.map((a) => {
674
+ const tone = a.tone ?? 'primary';
675
+ return (
676
+ <Pressable
677
+ key={a.id}
678
+ accessibilityLabel={a.name}
679
+ accessibilityState={{ selected: a.id === current }}
680
+ onPress={() => {
681
+ onOpenChange(false);
682
+ onOpenApp(a);
683
+ }}
684
+ stateLayer={false}
685
+ style={{ width: '25%', alignItems: 'center', gap: 6 }}
686
+ >
687
+ <View
688
+ style={{
689
+ width: 56,
690
+ height: 56,
691
+ borderRadius: 14,
692
+ overflow: 'hidden',
693
+ alignItems: 'center',
694
+ justifyContent: 'center',
695
+ backgroundColor: roles[`${tone}-container`],
696
+ borderWidth: a.id === current ? 2 : 0,
697
+ borderColor: roles.primary,
698
+ }}
699
+ >
700
+ {a.icon ?? (
701
+ <Text variant="title-md" color={`on-${tone}-container`}>
702
+ {a.name.slice(0, 2)}
703
+ </Text>
704
+ )}
705
+ </View>
706
+ <Text variant="label-md" numberOfLines={1}>
707
+ {a.name}
708
+ </Text>
709
+ </Pressable>
710
+ );
711
+ })}
712
+ </View>
713
+ </BottomSheet>
714
+ );
715
+ }