@proulr/sdui-components 0.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.
Files changed (57) hide show
  1. package/README.md +29 -0
  2. package/package.json +60 -0
  3. package/src/bdc/compile-bdc-to-wire.ts +123 -0
  4. package/src/bdc/components/discover.tsx +55 -0
  5. package/src/bdc/components/forms.tsx +20 -0
  6. package/src/bdc/components/native-blocks.tsx +35 -0
  7. package/src/bdc/components/social.tsx +27 -0
  8. package/src/bdc/components/ui.tsx +114 -0
  9. package/src/bdc/index.ts +41 -0
  10. package/src/bdc/jsx-runtime.ts +49 -0
  11. package/src/catalog/block-types.ts +18 -0
  12. package/src/catalog/style-tokens.ts +48 -0
  13. package/src/global.d.ts +1 -0
  14. package/src/renderer/SduiScreenRenderer.tsx +28 -0
  15. package/src/renderer/ads/native-ad-catalog.ts +23 -0
  16. package/src/renderer/blocks/SduiDiscoverBlocks.tsx +470 -0
  17. package/src/renderer/blocks/SduiLayoutBlocks.tsx +177 -0
  18. package/src/renderer/blocks/SduiNativeAdBlock.tsx +201 -0
  19. package/src/renderer/blocks/SduiNativeAdBlock.web.tsx +6 -0
  20. package/src/renderer/blocks/SduiNativeDomainBlocks.tsx +141 -0
  21. package/src/renderer/blocks/SduiPrimitiveBlocks.tsx +158 -0
  22. package/src/renderer/blocks/SduiSocialBlocks.tsx +64 -0
  23. package/src/renderer/index.ts +45 -0
  24. package/src/renderer/lib/brazilian-date-input.ts +22 -0
  25. package/src/renderer/lib/discover-profile-formatters.ts +75 -0
  26. package/src/renderer/lib/resolve-data-ref.ts +21 -0
  27. package/src/renderer/sdui-chrome-scroll-context.tsx +40 -0
  28. package/src/renderer/sdui-component-registry.tsx +129 -0
  29. package/src/renderer/sdui-hybrid-renderer-context.tsx +91 -0
  30. package/src/renderer/sdui-layout-debug.tsx +107 -0
  31. package/src/renderer/theme/proulr-theme.ts +164 -0
  32. package/src/renderer/theme/resolve-style-props.ts +85 -0
  33. package/src/renderer/ui/AppButton.tsx +71 -0
  34. package/src/renderer/ui/AppLoadingSpinner.tsx +38 -0
  35. package/src/renderer/ui/AuthStepShell.tsx +128 -0
  36. package/src/renderer/ui/Card.tsx +13 -0
  37. package/src/renderer/ui/DateField.tsx +36 -0
  38. package/src/renderer/ui/InfoBanner.tsx +51 -0
  39. package/src/renderer/ui/TextField.tsx +81 -0
  40. package/src/renderer/ui/compose-audience-pill.tsx +47 -0
  41. package/src/renderer/ui/compose-glyphs.tsx +400 -0
  42. package/src/renderer/ui/compose-media-carousel.tsx +116 -0
  43. package/src/renderer/ui/compose-media-shortcut.tsx +80 -0
  44. package/src/renderer/ui/compose-recent-media-grid.tsx +268 -0
  45. package/src/renderer/ui/compose-reply-audience-sheet.tsx +155 -0
  46. package/src/renderer/ui/compose-screen-header.tsx +115 -0
  47. package/src/renderer/ui/compose-text-area.tsx +94 -0
  48. package/src/renderer/ui/compose-toolbar-icon.tsx +45 -0
  49. package/src/renderer/ui/compose-toolbar.tsx +57 -0
  50. package/src/renderer/ui/compose-visibility-row.tsx +51 -0
  51. package/src/renderer/ui/floating-create-menu.tsx +165 -0
  52. package/src/renderer/ui/index.ts +44 -0
  53. package/src/renderer/ui/story-compose-header.tsx +72 -0
  54. package/src/renderer/ui/story-create-shortcut-row.tsx +89 -0
  55. package/src/renderer/ui/story-editor-chrome.tsx +231 -0
  56. package/src/renderer/ui/story-glyphs.tsx +144 -0
  57. package/src/renderer/ui/story-surface-picker.tsx +124 -0
@@ -0,0 +1,470 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import {
3
+ FlatList,
4
+ Image,
5
+ Pressable,
6
+ ScrollView,
7
+ StyleSheet,
8
+ Text,
9
+ View,
10
+ useWindowDimensions,
11
+ } from 'react-native';
12
+ import { LinearGradient } from 'expo-linear-gradient';
13
+ import type { SduiBlock } from '@proulr/sdui-contract';
14
+ import { TextField as UiTextField } from '../ui/TextField';
15
+ import { colors, fonts, gradients, m3Colors, m3Elevation, m3Shape, spacing } from '../theme/proulr-theme';
16
+ import { resolveDataRef, resolveDataRefString } from '../lib/resolve-data-ref';
17
+ import { useSduiHybridRenderer } from '../sdui-hybrid-renderer-context';
18
+ import {
19
+ formatDistanceLabel,
20
+ getPresenceStatus,
21
+ profileAgeFromBirthDate,
22
+ readDiscoverGridItems,
23
+ type DiscoverGridItemWire,
24
+ type DiscoverNearbyProfileWire,
25
+ } from '../lib/discover-profile-formatters';
26
+ import { SduiNativeAdBlock } from './SduiNativeAdBlock';
27
+ import type { SduiBlockRenderProps } from './SduiPrimitiveBlocks';
28
+
29
+ const GRID_COLUMNS = 3;
30
+ const GRID_GAP = spacing.sm;
31
+ const GRID_INITIAL_VISIBLE = 24;
32
+ const GRID_VISIBLE_BATCH = 24;
33
+
34
+ function readStringProp(block: SduiBlock, key: string): string | undefined {
35
+ const value = block.props[key];
36
+ return typeof value === 'string' ? value : undefined;
37
+ }
38
+
39
+ function readBooleanProp(block: SduiBlock, key: string): boolean {
40
+ return block.props[key] === true;
41
+ }
42
+
43
+ function readStringRecordProp(block: SduiBlock, key: string): Record<string, string> | undefined {
44
+ const value = block.props[key];
45
+ if (!value || typeof value !== 'object') return undefined;
46
+ const entries = Object.entries(value as Record<string, unknown>).filter(
47
+ (entry): entry is [string, string] => typeof entry[1] === 'string',
48
+ );
49
+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
50
+ }
51
+
52
+ function readNumberProp(block: SduiBlock, key: string, fallback: number): number {
53
+ const value = block.props[key];
54
+ return typeof value === 'number' ? value : fallback;
55
+ }
56
+
57
+ function readProfile(data: Record<string, unknown>, ref?: string): DiscoverNearbyProfileWire | null {
58
+ if (!ref) return null;
59
+ const value = ref.split('.').reduce<unknown>((acc, key) => {
60
+ if (acc && typeof acc === 'object' && key in (acc as Record<string, unknown>)) {
61
+ return (acc as Record<string, unknown>)[key];
62
+ }
63
+ return undefined;
64
+ }, data);
65
+ if (!value || typeof value !== 'object') return null;
66
+ const profile = value as DiscoverNearbyProfileWire;
67
+ return typeof profile.id === 'string' ? profile : null;
68
+ }
69
+
70
+ function PresenceDot({ status }: { status: ReturnType<typeof getPresenceStatus> }) {
71
+ const dotColor =
72
+ status === 'online' ? m3Colors.online : status === 'recent' ? m3Colors.secondary : m3Colors.outline;
73
+ return <View style={[styles.presenceDot, { backgroundColor: dotColor }]} />;
74
+ }
75
+
76
+ export function SduiDiscoverProfileCardBlock({ block, data, onAction }: SduiBlockRenderProps) {
77
+ const hybrid = useSduiHybridRenderer();
78
+ const profileRef = readStringProp(block, 'profile_ref');
79
+ const actionId = readStringProp(block, 'action_id');
80
+ const width = readNumberProp(block, 'width', 120);
81
+ const person = readProfile(data, profileRef);
82
+
83
+ if (!person) return null;
84
+
85
+ const handlePress = () => {
86
+ if (!actionId) return;
87
+ onAction(actionId, { profile_id: person.id });
88
+ };
89
+
90
+ if (hybrid.renderDiscoverProfileCard) {
91
+ return (
92
+ <>
93
+ {hybrid.renderDiscoverProfileCard({
94
+ person,
95
+ width,
96
+ onPress: handlePress,
97
+ })}
98
+ </>
99
+ );
100
+ }
101
+
102
+ const age = profileAgeFromBirthDate(person.birth_date);
103
+ const photoUri = person.photo_url ?? person.primary_photo_path ?? null;
104
+ const presence = getPresenceStatus(person.last_seen);
105
+ const online = presence === 'online';
106
+ const borderColor = person.has_new_media
107
+ ? m3Colors.primary
108
+ : online
109
+ ? m3Colors.online
110
+ : m3Colors.outline;
111
+
112
+ return (
113
+ <Pressable
114
+ style={[styles.profileCard, { width, borderColor }]}
115
+ onPress={handlePress}
116
+ disabled={!actionId}
117
+ >
118
+ {photoUri ? (
119
+ <Image source={{ uri: photoUri }} style={StyleSheet.absoluteFill} />
120
+ ) : (
121
+ <LinearGradient colors={gradients.brand} style={StyleSheet.absoluteFill} />
122
+ )}
123
+ <LinearGradient colors={['transparent', 'rgba(0,0,0,0.85)']} style={styles.profileShade} />
124
+ {person.has_new_media ? (
125
+ <View style={styles.newBadge}>
126
+ <Text style={styles.newBadgeText}>Novo</Text>
127
+ </View>
128
+ ) : null}
129
+ <View style={styles.profileInfo}>
130
+ <Text style={styles.profileName} numberOfLines={1}>
131
+ {person.display_name ?? 'Alguém'}
132
+ {age ? `, ${age}` : ''}
133
+ </Text>
134
+ <View style={styles.distRow}>
135
+ <PresenceDot status={presence} />
136
+ <Text style={styles.distText}>{formatDistanceLabel(person.distance_m)}</Text>
137
+ </View>
138
+ </View>
139
+ </Pressable>
140
+ );
141
+ }
142
+
143
+ function DiscoverGridCell({
144
+ item,
145
+ cardWidth,
146
+ profileActionId,
147
+ adPlacement,
148
+ data,
149
+ onAction,
150
+ }: {
151
+ item: DiscoverGridItemWire;
152
+ cardWidth: number;
153
+ profileActionId?: string;
154
+ adPlacement: string;
155
+ data: Record<string, unknown>;
156
+ onAction: SduiBlockRenderProps['onAction'];
157
+ }) {
158
+ if (item.kind === 'ad') {
159
+ return (
160
+ <SduiNativeAdBlock
161
+ block={{
162
+ id: item.id,
163
+ type: 'native_ad',
164
+ props: { placement: item.placement ?? adPlacement, width: cardWidth },
165
+ children: [],
166
+ }}
167
+ data={data}
168
+ onAction={onAction}
169
+ />
170
+ );
171
+ }
172
+
173
+ return (
174
+ <SduiDiscoverProfileCardBlock
175
+ block={{
176
+ id: item.id,
177
+ type: 'discover_profile_card',
178
+ props: {
179
+ profile_ref: `__inline_profile_${item.id}`,
180
+ action_id: profileActionId,
181
+ width: cardWidth,
182
+ },
183
+ children: [],
184
+ }}
185
+ data={{ ...data, [`__inline_profile_${item.id}`]: item.person }}
186
+ onAction={onAction}
187
+ />
188
+ );
189
+ }
190
+
191
+ export function SduiDiscoverGridBlock({ block, data, onAction }: SduiBlockRenderProps) {
192
+ const itemsRef = readStringProp(block, 'items_ref') ?? 'grid_items';
193
+ const columns = readNumberProp(block, 'columns', GRID_COLUMNS);
194
+ const profileActionId = readStringProp(block, 'profile_action_id');
195
+ const adPlacement = readStringProp(block, 'ad_placement') ?? 'discover_grid';
196
+ const { width: screenWidth } = useWindowDimensions();
197
+ const horizontalPadding = spacing.sm * 2;
198
+ const cardWidth =
199
+ (screenWidth - horizontalPadding - GRID_GAP * (columns - 1)) / columns;
200
+
201
+ const items = useMemo(() => readDiscoverGridItems(data, itemsRef), [data, itemsRef]);
202
+ const [visibleCount, setVisibleCount] = useState(GRID_INITIAL_VISIBLE);
203
+ const visibleItems = useMemo(() => items.slice(0, visibleCount), [items, visibleCount]);
204
+ const hasMore = visibleCount < items.length;
205
+
206
+ return (
207
+ <View>
208
+ <FlatList
209
+ data={visibleItems}
210
+ keyExtractor={(item) => item.id}
211
+ numColumns={columns}
212
+ columnWrapperStyle={{ gap: GRID_GAP }}
213
+ contentContainerStyle={styles.gridContent}
214
+ scrollEnabled={false}
215
+ removeClippedSubviews
216
+ windowSize={5}
217
+ maxToRenderPerBatch={9}
218
+ initialNumToRender={GRID_INITIAL_VISIBLE}
219
+ renderItem={({ item }) => (
220
+ <DiscoverGridCell
221
+ item={item}
222
+ cardWidth={cardWidth}
223
+ profileActionId={profileActionId}
224
+ adPlacement={adPlacement}
225
+ data={data}
226
+ onAction={onAction}
227
+ />
228
+ )}
229
+ />
230
+ {hasMore ? (
231
+ <Pressable
232
+ style={styles.loadMore}
233
+ onPress={() => setVisibleCount((count) => Math.min(count + GRID_VISIBLE_BATCH, items.length))}
234
+ >
235
+ <Text style={styles.loadMoreText}>Carregar mais perfis</Text>
236
+ </Pressable>
237
+ ) : null}
238
+ </View>
239
+ );
240
+ }
241
+
242
+ export function SduiChipBlock({ block, onAction }: SduiBlockRenderProps) {
243
+ const label = readStringProp(block, 'label') ?? '';
244
+ const actionId = readStringProp(block, 'action_id');
245
+ const actionParams = readStringRecordProp(block, 'action_params');
246
+ const active = readBooleanProp(block, 'active');
247
+ const variant = readStringProp(block, 'variant') ?? 'default';
248
+ const isHeader = variant === 'on_header';
249
+
250
+ return (
251
+ <Pressable
252
+ style={[styles.chip, isHeader ? styles.chipOnHeader : null, active && styles.chipActive]}
253
+ onPress={() => actionId && onAction(actionId, actionParams)}
254
+ disabled={!actionId}
255
+ >
256
+ <Text style={[styles.chipLabel, active && styles.chipLabelActive]}>{label}</Text>
257
+ </Pressable>
258
+ );
259
+ }
260
+
261
+ export function SduiChipRowBlock({ block, children }: SduiBlockRenderProps) {
262
+ const variant = readStringProp(block, 'variant') ?? 'default';
263
+ const isHeader = variant === 'on_header';
264
+
265
+ if (children) {
266
+ return (
267
+ <ScrollView
268
+ horizontal
269
+ showsHorizontalScrollIndicator={false}
270
+ contentContainerStyle={[styles.chipRow, isHeader && styles.chipRowOnHeader]}
271
+ >
272
+ {children}
273
+ </ScrollView>
274
+ );
275
+ }
276
+
277
+ return <View style={[styles.chipRow, isHeader && styles.chipRowOnHeader]}>{children}</View>;
278
+ }
279
+
280
+ export function SduiTextFieldBlock({ block, data, onAction }: SduiBlockRenderProps) {
281
+ const hybrid = useSduiHybridRenderer();
282
+ const placeholder = readStringProp(block, 'placeholder') ?? '';
283
+ const dataRef = readStringProp(block, 'data_ref');
284
+ const submitActionId = readStringProp(block, 'submit_action_id');
285
+ const value = dataRef ? resolveDataRefString(data, dataRef) : '';
286
+
287
+ if (hybrid.renderTextField && dataRef) {
288
+ return (
289
+ <>
290
+ {hybrid.renderTextField({
291
+ dataRef,
292
+ placeholder,
293
+ value,
294
+ submitActionId,
295
+ onSubmit: () => submitActionId && onAction(submitActionId),
296
+ })}
297
+ </>
298
+ );
299
+ }
300
+
301
+ return (
302
+ <UiTextField
303
+ placeholder={placeholder}
304
+ value={value}
305
+ onChangeText={() => {
306
+ // Hybrid: app-side state for text input until api_call wiring lands.
307
+ }}
308
+ onSubmitEditing={() => submitActionId && onAction(submitActionId)}
309
+ returnKeyType={submitActionId ? 'search' : 'done'}
310
+ autoCapitalize="words"
311
+ autoCorrect={false}
312
+ containerStyle={styles.textField}
313
+ />
314
+ );
315
+ }
316
+
317
+ export function SduiDiscoverMapBlock({ block, data }: SduiBlockRenderProps) {
318
+ const hybrid = useSduiHybridRenderer();
319
+ const centerRefProp = readStringProp(block, 'center_ref') ?? 'browse_center';
320
+ const labelRef = readStringProp(block, 'label_ref') ?? 'map_label';
321
+ const height = readNumberProp(block, 'height', 340);
322
+ const regionLabel = resolveDataRefString(data, labelRef) || 'Região selecionada';
323
+ const centerValue = resolveDataRef(data, centerRefProp);
324
+ const center =
325
+ centerValue &&
326
+ typeof centerValue === 'object' &&
327
+ typeof (centerValue as { lat?: unknown }).lat === 'number' &&
328
+ typeof (centerValue as { lng?: unknown }).lng === 'number'
329
+ ? (centerValue as { lat: number; lng: number })
330
+ : { lat: -23.5505, lng: -46.6333 };
331
+ const centerMoveToken =
332
+ typeof data.map_center_move_token === 'number' ? data.map_center_move_token : 0;
333
+
334
+ if (hybrid.renderDiscoverMap) {
335
+ return (
336
+ <>
337
+ {hybrid.renderDiscoverMap({
338
+ center,
339
+ label: regionLabel,
340
+ height,
341
+ centerMoveToken,
342
+ onCenterChange: hybrid.onDiscoverMapCenterChange ?? (() => {}),
343
+ })}
344
+ </>
345
+ );
346
+ }
347
+
348
+ return (
349
+ <View style={[styles.mapShell, { height }]}>
350
+ <LinearGradient colors={gradients.brand} style={StyleSheet.absoluteFill} />
351
+ <View style={styles.mapOverlay}>
352
+ <Text style={styles.mapTitle}>{regionLabel}</Text>
353
+ <Text style={styles.mapCoords}>{`${center.lat.toFixed(4)}, ${center.lng.toFixed(4)}`}</Text>
354
+ <Text style={styles.mapHint}>Mapa interativo — render nativo no app (híbrido).</Text>
355
+ </View>
356
+ </View>
357
+ );
358
+ }
359
+
360
+ const styles = StyleSheet.create({
361
+ profileCard: {
362
+ aspectRatio: 0.82,
363
+ borderRadius: m3Shape.cornerMedium,
364
+ overflow: 'hidden',
365
+ backgroundColor: m3Colors.surfaceContainerHigh,
366
+ borderWidth: 1,
367
+ ...m3Elevation.level1,
368
+ },
369
+ profileShade: { position: 'absolute', left: 0, right: 0, bottom: 0, height: 80 },
370
+ newBadge: {
371
+ position: 'absolute',
372
+ top: spacing.sm,
373
+ left: spacing.sm,
374
+ backgroundColor: m3Colors.primary,
375
+ paddingHorizontal: 8,
376
+ paddingVertical: 3,
377
+ borderRadius: m3Shape.cornerSmall,
378
+ },
379
+ newBadgeText: { color: m3Colors.onPrimary, fontSize: 11, fontWeight: '700' },
380
+ profileInfo: { position: 'absolute', left: spacing.sm, bottom: spacing.sm, right: spacing.sm },
381
+ profileName: { color: colors.white, fontSize: 13, fontWeight: '700' },
382
+ distRow: { flexDirection: 'row', alignItems: 'center', gap: 6, marginTop: 2 },
383
+ distText: { color: colors.textSecondary, fontSize: 11, fontWeight: '500' },
384
+ presenceDot: { width: 8, height: 8, borderRadius: 4 },
385
+ gridContent: {
386
+ gap: GRID_GAP,
387
+ paddingHorizontal: spacing.sm,
388
+ paddingBottom: spacing.sm,
389
+ },
390
+ loadMore: {
391
+ alignItems: 'center',
392
+ paddingVertical: spacing.md,
393
+ marginHorizontal: spacing.sm,
394
+ },
395
+ loadMoreText: {
396
+ color: m3Colors.primary,
397
+ fontSize: 14,
398
+ fontWeight: '600',
399
+ fontFamily: fonts.semibold,
400
+ },
401
+ chipRow: {
402
+ flexDirection: 'row',
403
+ gap: spacing.sm,
404
+ paddingHorizontal: spacing.sm,
405
+ },
406
+ chipRowOnHeader: {
407
+ paddingHorizontal: 0,
408
+ },
409
+ chip: {
410
+ paddingHorizontal: spacing.md,
411
+ paddingVertical: spacing.sm,
412
+ borderRadius: m3Shape.cornerFull,
413
+ backgroundColor: m3Colors.surfaceContainerHigh,
414
+ borderWidth: 1,
415
+ borderColor: m3Colors.outline,
416
+ },
417
+ chipOnHeader: {
418
+ backgroundColor: 'rgba(255,255,255,0.06)',
419
+ borderColor: 'rgba(255,255,255,0.08)',
420
+ },
421
+ chipActive: {
422
+ backgroundColor: m3Colors.primaryContainer,
423
+ borderColor: m3Colors.primary,
424
+ },
425
+ chipLabel: {
426
+ color: m3Colors.onSurface,
427
+ fontSize: 13,
428
+ fontFamily: fonts.medium,
429
+ },
430
+ chipLabelActive: {
431
+ color: m3Colors.onPrimaryContainer,
432
+ fontFamily: fonts.semibold,
433
+ },
434
+ textField: {
435
+ flex: 1,
436
+ marginBottom: 0,
437
+ },
438
+ mapShell: {
439
+ borderRadius: m3Shape.cornerLarge,
440
+ overflow: 'hidden',
441
+ borderWidth: 1,
442
+ borderColor: m3Colors.outline,
443
+ },
444
+ mapOverlay: {
445
+ flex: 1,
446
+ alignItems: 'center',
447
+ justifyContent: 'center',
448
+ padding: spacing.lg,
449
+ backgroundColor: 'rgba(0,0,0,0.45)',
450
+ gap: spacing.sm,
451
+ },
452
+ mapTitle: {
453
+ color: colors.white,
454
+ fontSize: 16,
455
+ fontFamily: fonts.semibold,
456
+ textAlign: 'center',
457
+ },
458
+ mapCoords: {
459
+ color: colors.textSecondary,
460
+ fontSize: 14,
461
+ fontFamily: fonts.regular,
462
+ textAlign: 'center',
463
+ },
464
+ mapHint: {
465
+ color: colors.textSecondary,
466
+ fontSize: 12,
467
+ fontFamily: fonts.regular,
468
+ textAlign: 'center',
469
+ },
470
+ });
@@ -0,0 +1,177 @@
1
+ import React from 'react';
2
+ import { Pressable, StyleSheet, Switch, Text, View } from 'react-native';
3
+ import type { SduiBlock } from '@proulr/sdui-contract';
4
+ import { Card } from '../ui/Card';
5
+ import { colors, fonts, m3Colors, m3Type, spacing } from '../theme/proulr-theme';
6
+ import {
7
+ resolveDataRefBoolean,
8
+ resolveDataRefString,
9
+ } from '../lib/resolve-data-ref';
10
+ import { resolveViewStyleProps } from '../theme/resolve-style-props';
11
+ import type { SduiBlockRenderProps } from './SduiPrimitiveBlocks';
12
+
13
+ function readStringProp(block: SduiBlock, key: string): string | undefined {
14
+ const value = block.props[key];
15
+ return typeof value === 'string' ? value : undefined;
16
+ }
17
+
18
+ function readBooleanProp(block: SduiBlock, key: string): boolean {
19
+ return block.props[key] === true;
20
+ }
21
+
22
+ export function SduiCardBlock({ block, children }: SduiBlockRenderProps) {
23
+ return (
24
+ <Card style={StyleSheet.flatten([styles.card, resolveViewStyleProps(block.props.style as Record<string, unknown>)])}>
25
+ {children}
26
+ </Card>
27
+ );
28
+ }
29
+
30
+ export function SduiSectionHeaderBlock({ block, onAction }: SduiBlockRenderProps) {
31
+ const title = readStringProp(block, 'title') ?? '';
32
+ const actionLabel = readStringProp(block, 'action_label');
33
+ const actionId = readStringProp(block, 'action_id');
34
+
35
+ return (
36
+ <View style={styles.sectionHeaderRow}>
37
+ <Text style={styles.sectionHeader}>{title}</Text>
38
+ {actionLabel && actionId ? (
39
+ <Pressable onPress={() => onAction(actionId)} hitSlop={8}>
40
+ <Text style={styles.sectionAction}>{actionLabel}</Text>
41
+ </Pressable>
42
+ ) : null}
43
+ </View>
44
+ );
45
+ }
46
+
47
+ export function SduiListItemBlock({ block, data, onAction }: SduiBlockRenderProps) {
48
+ const title = readStringProp(block, 'title') ?? '';
49
+ const subtitleRef = readStringProp(block, 'subtitle_ref');
50
+ const subtitle = subtitleRef ? resolveDataRefString(data, subtitleRef) : readStringProp(block, 'subtitle');
51
+ const trailingRef = readStringProp(block, 'trailing_ref');
52
+ const trailingText = trailingRef
53
+ ? resolveDataRefString(data, trailingRef)
54
+ : readStringProp(block, 'trailing_text');
55
+ const actionId = readStringProp(block, 'action_id');
56
+ const showDivider = readBooleanProp(block, 'show_divider');
57
+ const showChevron = block.props.show_chevron !== false;
58
+ const navChevron = Boolean(actionId) && showChevron && !trailingText;
59
+
60
+ const inner = (
61
+ <>
62
+ <View style={styles.listContent}>
63
+ <Text style={styles.listTitle} numberOfLines={1}>
64
+ {title}
65
+ </Text>
66
+ {subtitle ? (
67
+ <Text style={styles.listSubtitle} numberOfLines={1}>
68
+ {subtitle}
69
+ </Text>
70
+ ) : null}
71
+ </View>
72
+ {navChevron ? <Text style={styles.chevron}>{'\u203a'}</Text> : null}
73
+ {trailingText ? <Text style={styles.trailingText}>{trailingText}</Text> : null}
74
+ </>
75
+ );
76
+
77
+ if (actionId) {
78
+ return (
79
+ <Pressable
80
+ style={[styles.listItem, showDivider && styles.listItemDivider]}
81
+ onPress={() => onAction(actionId)}
82
+ >
83
+ {inner}
84
+ </Pressable>
85
+ );
86
+ }
87
+
88
+ return <View style={[styles.listItem, showDivider && styles.listItemDivider]}>{inner}</View>;
89
+ }
90
+
91
+ export function SduiToggleRowBlock({ block, data, onAction }: SduiBlockRenderProps) {
92
+ const label = readStringProp(block, 'label') ?? '';
93
+ const hint = readStringProp(block, 'hint');
94
+ const dataRef = readStringProp(block, 'data_ref');
95
+ const toggleActionId = readStringProp(block, 'toggle_action_id');
96
+ const disabled = readBooleanProp(block, 'disabled');
97
+ const value = resolveDataRefBoolean(data, dataRef);
98
+
99
+ return (
100
+ <View style={styles.toggleRow}>
101
+ <View style={styles.toggleCopy}>
102
+ <Text style={styles.toggleLabel}>{label}</Text>
103
+ {hint ? <Text style={styles.toggleHint}>{hint}</Text> : null}
104
+ </View>
105
+ <Switch
106
+ value={value}
107
+ disabled={disabled || !toggleActionId}
108
+ onValueChange={(next) => {
109
+ if (!toggleActionId) return;
110
+ onAction(toggleActionId, { value: String(next) });
111
+ }}
112
+ trackColor={{ false: m3Colors.surfaceContainerHighest, true: m3Colors.primary }}
113
+ thumbColor={m3Colors.onPrimary}
114
+ />
115
+ </View>
116
+ );
117
+ }
118
+
119
+ const styles = StyleSheet.create({
120
+ card: {
121
+ paddingVertical: spacing.xs,
122
+ paddingHorizontal: spacing.sm,
123
+ },
124
+ sectionHeaderRow: {
125
+ flexDirection: 'row',
126
+ alignItems: 'center',
127
+ justifyContent: 'space-between',
128
+ marginBottom: spacing.sm,
129
+ marginTop: spacing.xl,
130
+ },
131
+ sectionHeader: {
132
+ color: colors.textSecondary,
133
+ fontSize: 14,
134
+ fontFamily: fonts.semibold,
135
+ },
136
+ sectionAction: {
137
+ color: colors.accentText,
138
+ fontSize: 13,
139
+ fontFamily: fonts.semibold,
140
+ },
141
+ listItem: {
142
+ flexDirection: 'row',
143
+ alignItems: 'center',
144
+ gap: spacing.md,
145
+ paddingVertical: spacing.md,
146
+ },
147
+ listItemDivider: {
148
+ borderBottomWidth: StyleSheet.hairlineWidth,
149
+ borderBottomColor: colors.border,
150
+ },
151
+ listContent: { flex: 1 },
152
+ listTitle: { color: colors.textPrimary, fontSize: 15, fontFamily: fonts.semibold },
153
+ listSubtitle: { color: colors.textSecondary, fontSize: 13, marginTop: 2 },
154
+ trailingText: { ...m3Type.bodySmall, color: colors.textSecondary },
155
+ chevron: { color: m3Colors.onSurfaceVariant, fontSize: 22 },
156
+ toggleRow: {
157
+ flexDirection: 'row',
158
+ alignItems: 'center',
159
+ gap: spacing.md,
160
+ paddingVertical: spacing.xs,
161
+ },
162
+ toggleCopy: {
163
+ flex: 1,
164
+ gap: spacing.xs,
165
+ },
166
+ toggleLabel: {
167
+ color: m3Colors.onSurface,
168
+ fontSize: 15,
169
+ fontFamily: fonts.semibold,
170
+ },
171
+ toggleHint: {
172
+ color: m3Colors.onSurfaceVariant,
173
+ fontSize: 12,
174
+ lineHeight: 17,
175
+ fontFamily: fonts.regular,
176
+ },
177
+ });