arlo-react-native 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.
package/README.md ADDED
@@ -0,0 +1,63 @@
1
+ # Arlo React Native
2
+
3
+ React Native renderer for Arlo flows.
4
+
5
+ ## Current scope
6
+
7
+ - Renders the active screen from an `arlo-sdk` flow session
8
+ - Handles default interactions for common component types
9
+ - Lets host apps override per-component rendering
10
+
11
+ ## Example
12
+
13
+ ```tsx
14
+ import { useEffect, useState } from "react";
15
+ import { createArloClient, createArloPresenter } from "arlo-sdk";
16
+ import { ArloPresenterRenderer, createArloRegistry, createReactNativeFlowCache } from "./src";
17
+ import AsyncStorage from "@react-native-async-storage/async-storage";
18
+
19
+ const arlo = createArloClient({
20
+ apiKey: "ob_live_xxx",
21
+ projectId: "proj_123",
22
+ baseUrl: "https://your-arlo-domain.com",
23
+ cache: createReactNativeFlowCache({
24
+ storage: AsyncStorage,
25
+ }),
26
+ });
27
+
28
+ export function OnboardingScreen() {
29
+ const registry = createArloRegistry();
30
+ registry.registerScreen("paywall_v1", ({ session }) => {
31
+ return null;
32
+ });
33
+
34
+ const [presenter] = useState(() =>
35
+ createArloPresenter({
36
+ client: arlo,
37
+ })
38
+ );
39
+
40
+ useEffect(() => {
41
+ async function load() {
42
+ await presenter.presentFlow("welcome");
43
+ }
44
+
45
+ void load();
46
+ }, [presenter]);
47
+
48
+ return (
49
+ <ArloPresenterRenderer
50
+ presenter={presenter}
51
+ registry={registry}
52
+ handlers={{
53
+ onOpenUrl({ url }) {
54
+ console.log("Open URL", url);
55
+ },
56
+ onCompleted({ snapshot }) {
57
+ console.log("Flow completed", snapshot.values);
58
+ },
59
+ }}
60
+ />
61
+ );
62
+ }
63
+ ```
package/package.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "arlo-react-native",
3
+ "version": "0.1.0",
4
+ "main": "./src/index.ts",
5
+ "types": "./src/index.ts",
6
+ "sideEffects": false,
7
+ "dependencies": {
8
+ "arlo-sdk": "^0.1.0"
9
+ },
10
+ "peerDependencies": {
11
+ "react": ">=18",
12
+ "react-native": ">=0.73"
13
+ }
14
+ }
@@ -0,0 +1,763 @@
1
+ import { useEffect, useMemo, useState } from "react";
2
+ import {
3
+ Image,
4
+ Pressable,
5
+ ScrollView,
6
+ StyleSheet,
7
+ Text,
8
+ TextInput,
9
+ View,
10
+ } from "react-native";
11
+
12
+ import { applyFlowSessionEffect } from "arlo-sdk";
13
+ import type {
14
+ FlowComponent,
15
+ FlowSessionSnapshot,
16
+ Screen,
17
+ } from "arlo-sdk";
18
+
19
+ import type {
20
+ ArloComponentRenderContext,
21
+ ArloComponentRendererMap,
22
+ ArloFlowRendererProps,
23
+ } from "./types";
24
+
25
+ const IMPORTED_SCREEN_KEYS = new Set([
26
+ "__arlo_imported_code__",
27
+ "__arlo_imported_figma__",
28
+ ]);
29
+
30
+ function isImportedPreviewPayload(
31
+ value: unknown
32
+ ): value is { kind: "imported-code" | "imported-figma"; version: 1; previewScreen?: Screen } {
33
+ return Boolean(
34
+ value &&
35
+ typeof value === "object" &&
36
+ !Array.isArray(value) &&
37
+ ["imported-code", "imported-figma"].includes(
38
+ String((value as { kind?: unknown }).kind ?? "")
39
+ ) &&
40
+ (value as { version?: unknown }).version === 1
41
+ );
42
+ }
43
+
44
+ function getImportedPreviewScreen(screen: Screen): Screen | null {
45
+ if (!screen.customScreenKey || !IMPORTED_SCREEN_KEYS.has(screen.customScreenKey)) return null;
46
+ return isImportedPreviewPayload(screen.customPayload) && screen.customPayload.previewScreen
47
+ ? screen.customPayload.previewScreen
48
+ : null;
49
+ }
50
+
51
+ function getScreenContainerStyle(screen: Screen) {
52
+ return {
53
+ backgroundColor: screen.style?.backgroundColor ?? "#0b0b0d",
54
+ paddingTop: screen.style?.paddingTop ?? screen.style?.padding ?? 24,
55
+ paddingBottom: screen.style?.paddingBottom ?? screen.style?.padding ?? 24,
56
+ paddingHorizontal: screen.style?.paddingHorizontal ?? screen.style?.padding ?? 20,
57
+ justifyContent: screen.style?.justifyContent ?? "flex-start",
58
+ alignItems: screen.style?.alignItems ?? "stretch",
59
+ } as const;
60
+ }
61
+
62
+ function getComponentWrapperStyle(component: FlowComponent, isAbsoluteScreen: boolean) {
63
+ const layout = component.layout;
64
+
65
+ if (!layout) {
66
+ return isAbsoluteScreen ? { position: "absolute" as const, zIndex: component.order } : styles.componentBlock;
67
+ }
68
+
69
+ const baseStyle = {
70
+ display: layout.visible === false ? ("none" as const) : ("flex" as const),
71
+ zIndex: layout.zIndex ?? component.order,
72
+ };
73
+
74
+ if (!isAbsoluteScreen && layout.position !== "absolute") {
75
+ return {
76
+ ...styles.componentBlock,
77
+ ...baseStyle,
78
+ };
79
+ }
80
+
81
+ return {
82
+ ...baseStyle,
83
+ position: "absolute" as const,
84
+ left: layout.x ?? 0,
85
+ top: layout.y ?? 0,
86
+ width: layout.width,
87
+ height: layout.height,
88
+ transform: layout.rotation ? [{ rotate: `${layout.rotation}deg` }] : undefined,
89
+ };
90
+ }
91
+
92
+ function coerceStringValue(value: unknown): string {
93
+ return typeof value === "string" ? value : "";
94
+ }
95
+
96
+ function coerceStringArrayValue(value: unknown): string[] {
97
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
98
+ }
99
+
100
+ function coerceNumberValue(value: unknown, fallback: number): number {
101
+ return typeof value === "number" ? value : fallback;
102
+ }
103
+
104
+ function getFieldError(snapshot: FlowSessionSnapshot, fieldKey: string): string | null {
105
+ return snapshot.validationErrorsByField[fieldKey] ?? null;
106
+ }
107
+
108
+ function DefaultTextComponent({ component }: { component: Extract<FlowComponent, { type: "TEXT" }> }) {
109
+ return (
110
+ <Text
111
+ style={{
112
+ color: component.props.color ?? "#ffffff",
113
+ fontSize: component.props.fontSize ?? 16,
114
+ fontWeight: component.props.fontWeight ?? "normal",
115
+ textAlign: component.props.textAlign ?? "left",
116
+ lineHeight:
117
+ component.props.lineHeight && component.props.fontSize
118
+ ? component.props.lineHeight * component.props.fontSize
119
+ : undefined,
120
+ opacity: component.props.opacity ?? 1,
121
+ }}
122
+ >
123
+ {component.props.content}
124
+ </Text>
125
+ );
126
+ }
127
+
128
+ function DefaultImageComponent({ component }: { component: Extract<FlowComponent, { type: "IMAGE" }> }) {
129
+ return (
130
+ <Image
131
+ source={{ uri: component.props.src }}
132
+ accessibilityLabel={component.props.alt}
133
+ resizeMode={component.props.resizeMode ?? "cover"}
134
+ style={{
135
+ width: component.props.width ?? "100%",
136
+ height: component.props.height ?? 220,
137
+ borderRadius: component.props.borderRadius ?? 0,
138
+ }}
139
+ />
140
+ );
141
+ }
142
+
143
+ function DefaultButtonComponent({
144
+ component,
145
+ onPress,
146
+ }: {
147
+ component: Extract<FlowComponent, { type: "BUTTON" }>;
148
+ onPress: () => Promise<void>;
149
+ }) {
150
+ return (
151
+ <Pressable
152
+ onPress={() => {
153
+ void onPress();
154
+ }}
155
+ style={[
156
+ styles.button,
157
+ {
158
+ backgroundColor: component.props.style?.backgroundColor ?? "#ffffff",
159
+ borderRadius: component.props.style?.borderRadius ?? 14,
160
+ borderColor: component.props.style?.borderColor ?? "transparent",
161
+ borderWidth: component.props.style?.borderWidth ?? 0,
162
+ },
163
+ ]}
164
+ >
165
+ <Text
166
+ style={[
167
+ styles.buttonText,
168
+ {
169
+ color: component.props.style?.textColor ?? "#111111",
170
+ },
171
+ ]}
172
+ >
173
+ {component.props.label}
174
+ </Text>
175
+ </Pressable>
176
+ );
177
+ }
178
+
179
+ function DefaultTextInputComponent({
180
+ component,
181
+ context,
182
+ }: {
183
+ component: Extract<FlowComponent, { type: "TEXT_INPUT" }>;
184
+ context: ArloComponentRenderContext;
185
+ }) {
186
+ const value = coerceStringValue(context.snapshot.values[component.props.fieldKey]);
187
+ const error = getFieldError(context.snapshot, component.props.fieldKey);
188
+
189
+ return (
190
+ <View style={styles.fieldGroup}>
191
+ {component.props.label ? <Text style={styles.fieldLabel}>{component.props.label}</Text> : null}
192
+ <TextInput
193
+ value={value}
194
+ onChangeText={(nextValue) => context.onValueChange(component.props.fieldKey, nextValue)}
195
+ placeholder={component.props.placeholder}
196
+ placeholderTextColor="#7a7a85"
197
+ keyboardType={
198
+ component.props.keyboardType === "email"
199
+ ? "email-address"
200
+ : component.props.keyboardType === "numeric"
201
+ ? "numeric"
202
+ : component.props.keyboardType === "phone"
203
+ ? "phone-pad"
204
+ : "default"
205
+ }
206
+ maxLength={component.props.maxLength}
207
+ style={[styles.input, error ? styles.inputError : undefined]}
208
+ />
209
+ {error ? <Text style={styles.fieldError}>{error}</Text> : null}
210
+ </View>
211
+ );
212
+ }
213
+
214
+ function OptionPill({
215
+ label,
216
+ selected,
217
+ onPress,
218
+ }: {
219
+ label: string;
220
+ selected: boolean;
221
+ onPress: () => void;
222
+ }) {
223
+ return (
224
+ <Pressable
225
+ onPress={onPress}
226
+ style={[styles.optionPill, selected ? styles.optionPillSelected : undefined]}
227
+ >
228
+ <Text style={[styles.optionPillText, selected ? styles.optionPillTextSelected : undefined]}>
229
+ {label}
230
+ </Text>
231
+ </Pressable>
232
+ );
233
+ }
234
+
235
+ function DefaultSingleSelectComponent({
236
+ component,
237
+ context,
238
+ }: {
239
+ component: Extract<FlowComponent, { type: "SINGLE_SELECT" }>;
240
+ context: ArloComponentRenderContext;
241
+ }) {
242
+ const value = coerceStringValue(context.snapshot.values[component.props.fieldKey]);
243
+ const error = getFieldError(context.snapshot, component.props.fieldKey);
244
+
245
+ return (
246
+ <View style={styles.fieldGroup}>
247
+ {component.props.label ? <Text style={styles.fieldLabel}>{component.props.label}</Text> : null}
248
+ <View style={styles.optionGroup}>
249
+ {component.props.options.map((option) => (
250
+ <OptionPill
251
+ key={option.id}
252
+ label={option.label}
253
+ selected={value === option.id}
254
+ onPress={() => context.onValueChange(component.props.fieldKey, option.id)}
255
+ />
256
+ ))}
257
+ </View>
258
+ {error ? <Text style={styles.fieldError}>{error}</Text> : null}
259
+ </View>
260
+ );
261
+ }
262
+
263
+ function DefaultMultiSelectComponent({
264
+ component,
265
+ context,
266
+ }: {
267
+ component: Extract<FlowComponent, { type: "MULTI_SELECT" }>;
268
+ context: ArloComponentRenderContext;
269
+ }) {
270
+ const values = coerceStringArrayValue(context.snapshot.values[component.props.fieldKey]);
271
+ const error = getFieldError(context.snapshot, component.props.fieldKey);
272
+
273
+ return (
274
+ <View style={styles.fieldGroup}>
275
+ {component.props.label ? <Text style={styles.fieldLabel}>{component.props.label}</Text> : null}
276
+ <View style={styles.optionGroup}>
277
+ {component.props.options.map((option) => {
278
+ const selected = values.includes(option.id);
279
+
280
+ return (
281
+ <OptionPill
282
+ key={option.id}
283
+ label={option.label}
284
+ selected={selected}
285
+ onPress={() => {
286
+ const nextValues = selected
287
+ ? values.filter((value) => value !== option.id)
288
+ : [...values, option.id];
289
+
290
+ context.onValueChange(component.props.fieldKey, nextValues);
291
+ }}
292
+ />
293
+ );
294
+ })}
295
+ </View>
296
+ {error ? <Text style={styles.fieldError}>{error}</Text> : null}
297
+ </View>
298
+ );
299
+ }
300
+
301
+ function DefaultSliderComponent({
302
+ component,
303
+ context,
304
+ }: {
305
+ component: Extract<FlowComponent, { type: "SLIDER" }>;
306
+ context: ArloComponentRenderContext;
307
+ }) {
308
+ const currentValue = coerceNumberValue(
309
+ context.snapshot.values[component.props.fieldKey],
310
+ component.props.defaultValue ?? component.props.min
311
+ );
312
+
313
+ const step = component.props.step ?? 1;
314
+ const nextValue = Math.min(component.props.max, currentValue + step);
315
+ const previousValue = Math.max(component.props.min, currentValue - step);
316
+
317
+ return (
318
+ <View style={styles.fieldGroup}>
319
+ {component.props.label ? <Text style={styles.fieldLabel}>{component.props.label}</Text> : null}
320
+ <View style={styles.sliderCard}>
321
+ <Text style={styles.sliderValue}>{String(currentValue)}</Text>
322
+ <View style={styles.sliderActions}>
323
+ <OptionPill
324
+ label={component.props.minLabel ?? "-"}
325
+ selected={false}
326
+ onPress={() => context.onValueChange(component.props.fieldKey, previousValue)}
327
+ />
328
+ <OptionPill
329
+ label={component.props.maxLabel ?? "+"}
330
+ selected={false}
331
+ onPress={() => context.onValueChange(component.props.fieldKey, nextValue)}
332
+ />
333
+ </View>
334
+ </View>
335
+ </View>
336
+ );
337
+ }
338
+
339
+ function DefaultProgressBarComponent({
340
+ snapshot,
341
+ component,
342
+ }: {
343
+ snapshot: FlowSessionSnapshot;
344
+ component: Extract<FlowComponent, { type: "PROGRESS_BAR" }>;
345
+ }) {
346
+ const progress =
347
+ snapshot.totalScreens > 1
348
+ ? ((snapshot.currentScreenIndex + 1) / snapshot.totalScreens) * 100
349
+ : 100;
350
+
351
+ return (
352
+ <View
353
+ style={[
354
+ styles.progressTrack,
355
+ {
356
+ backgroundColor: component.props.backgroundColor ?? "#26262b",
357
+ height: component.props.height ?? 6,
358
+ },
359
+ ]}
360
+ >
361
+ <View
362
+ style={{
363
+ width: `${progress}%`,
364
+ backgroundColor: component.props.color ?? "#ffffff",
365
+ height: "100%",
366
+ borderRadius: 999,
367
+ }}
368
+ />
369
+ </View>
370
+ );
371
+ }
372
+
373
+ function DefaultPageIndicatorComponent({
374
+ snapshot,
375
+ component,
376
+ }: {
377
+ snapshot: FlowSessionSnapshot;
378
+ component: Extract<FlowComponent, { type: "PAGE_INDICATOR" }>;
379
+ }) {
380
+ const size = component.props.size ?? 8;
381
+
382
+ return (
383
+ <View style={styles.pageIndicatorRow}>
384
+ {Array.from({ length: snapshot.totalScreens }).map((_, index) => (
385
+ <View
386
+ key={index}
387
+ style={{
388
+ width: size,
389
+ height: size,
390
+ borderRadius: size / 2,
391
+ backgroundColor:
392
+ index === snapshot.currentScreenIndex
393
+ ? component.props.activeColor ?? "#ffffff"
394
+ : component.props.inactiveColor ?? "#4b4b55",
395
+ }}
396
+ />
397
+ ))}
398
+ </View>
399
+ );
400
+ }
401
+
402
+ function renderDefaultComponent(
403
+ component: FlowComponent,
404
+ context: ArloComponentRenderContext,
405
+ registry?: ArloFlowRendererProps["registry"]
406
+ ) {
407
+ switch (component.type) {
408
+ case "TEXT":
409
+ return <DefaultTextComponent component={component} />;
410
+ case "IMAGE":
411
+ return <DefaultImageComponent component={component} />;
412
+ case "BUTTON":
413
+ return <DefaultButtonComponent component={component} onPress={() => context.onPressButton(component.id)} />;
414
+ case "TEXT_INPUT":
415
+ return <DefaultTextInputComponent component={component} context={context} />;
416
+ case "SINGLE_SELECT":
417
+ return <DefaultSingleSelectComponent component={component} context={context} />;
418
+ case "MULTI_SELECT":
419
+ return <DefaultMultiSelectComponent component={component} context={context} />;
420
+ case "SLIDER":
421
+ return <DefaultSliderComponent component={component} context={context} />;
422
+ case "PROGRESS_BAR":
423
+ return <DefaultProgressBarComponent component={component} snapshot={context.snapshot} />;
424
+ case "PAGE_INDICATOR":
425
+ return <DefaultPageIndicatorComponent component={component} snapshot={context.snapshot} />;
426
+ case "CUSTOM_COMPONENT": {
427
+ const registered = registry?.getComponent(component.props.registryKey);
428
+ return registered
429
+ ? registered({
430
+ session: context.session,
431
+ snapshot: context.snapshot,
432
+ screen: context.snapshot.currentScreen!,
433
+ component,
434
+ })
435
+ : null;
436
+ }
437
+ default:
438
+ return null;
439
+ }
440
+ }
441
+
442
+ export function ArloFlowRenderer({
443
+ session,
444
+ handlers,
445
+ componentRenderers,
446
+ registry,
447
+ autoStart = true,
448
+ emptyState = null,
449
+ unsupportedComponent,
450
+ unsupportedScreen,
451
+ onSnapshotChange,
452
+ }: ArloFlowRendererProps) {
453
+ const [snapshot, setSnapshot] = useState(() => session.getSnapshot());
454
+
455
+ useEffect(() => {
456
+ const nextSnapshot = session.getSnapshot();
457
+ setSnapshot(nextSnapshot);
458
+ onSnapshotChange?.(nextSnapshot);
459
+
460
+ if (autoStart && session.getSnapshot().status === "idle") {
461
+ const effect = session.start();
462
+ const startedSnapshot = session.getSnapshot();
463
+ setSnapshot(startedSnapshot);
464
+ onSnapshotChange?.(startedSnapshot);
465
+ void applyFlowSessionEffect(session, effect, handlers);
466
+ }
467
+ }, [autoStart, handlers, onSnapshotChange, session]);
468
+
469
+ const sortedComponents = useMemo(
470
+ () =>
471
+ [...(snapshot.currentScreen?.components ?? [])].sort((a, b) => a.order - b.order),
472
+ [snapshot.currentScreen]
473
+ );
474
+
475
+ const context: ArloComponentRenderContext = {
476
+ session,
477
+ snapshot,
478
+ handlers,
479
+ onValueChange: (fieldKey, value) => {
480
+ const nextSnapshot = session.setValue(fieldKey, value);
481
+ setSnapshot(nextSnapshot);
482
+ onSnapshotChange?.(nextSnapshot);
483
+ },
484
+ onPressButton: async (componentId) => {
485
+ const effect = session.pressButton(componentId);
486
+ const immediateSnapshot = session.getSnapshot();
487
+ setSnapshot(immediateSnapshot);
488
+ onSnapshotChange?.(immediateSnapshot);
489
+ await applyFlowSessionEffect(session, effect, handlers);
490
+ const finalSnapshot = session.getSnapshot();
491
+ setSnapshot(finalSnapshot);
492
+ onSnapshotChange?.(finalSnapshot);
493
+ },
494
+ };
495
+
496
+ if (!snapshot.currentScreen) {
497
+ return <>{emptyState}</>;
498
+ }
499
+
500
+ if (snapshot.currentScreen.customScreenKey) {
501
+ const registeredScreen = registry?.getScreen(snapshot.currentScreen.customScreenKey);
502
+ const importedPreviewScreen = getImportedPreviewScreen(snapshot.currentScreen);
503
+
504
+ if (registeredScreen) {
505
+ return (
506
+ <>
507
+ {registeredScreen({
508
+ session,
509
+ snapshot,
510
+ screen: snapshot.currentScreen,
511
+ })}
512
+ </>
513
+ );
514
+ }
515
+
516
+ if (importedPreviewScreen) {
517
+ const previewComponents = [...(importedPreviewScreen.components ?? [])].sort(
518
+ (a, b) => a.order - b.order
519
+ );
520
+ const previewContext: ArloComponentRenderContext = {
521
+ ...context,
522
+ onValueChange: () => undefined,
523
+ onPressButton: async () => undefined,
524
+ };
525
+
526
+ return (
527
+ <ScrollView
528
+ contentContainerStyle={[
529
+ styles.container,
530
+ getScreenContainerStyle(importedPreviewScreen),
531
+ ]}
532
+ >
533
+ {previewComponents.map((component) => {
534
+ const customRenderer = componentRenderers?.[component.type] as
535
+ | ArloComponentRendererMap[typeof component.type]
536
+ | undefined;
537
+
538
+ const content = customRenderer
539
+ ? customRenderer(component as never, previewContext as never)
540
+ : renderDefaultComponent(component, previewContext, registry);
541
+
542
+ if (content === null) {
543
+ return null;
544
+ }
545
+
546
+ return <View key={component.id}>{content}</View>;
547
+ })}
548
+ </ScrollView>
549
+ );
550
+ }
551
+
552
+ return (
553
+ <>
554
+ {unsupportedScreen ? (
555
+ unsupportedScreen(snapshot.currentScreen)
556
+ ) : (
557
+ <View style={styles.unsupported}>
558
+ <Text style={styles.unsupportedText}>
559
+ Unsupported screen: {snapshot.currentScreen.customScreenKey}
560
+ </Text>
561
+ </View>
562
+ )}
563
+ </>
564
+ );
565
+ }
566
+
567
+ if (snapshot.currentScreen.layoutMode === "absolute") {
568
+ return (
569
+ <View
570
+ style={[
571
+ styles.absoluteContainer,
572
+ getScreenContainerStyle(snapshot.currentScreen),
573
+ ]}
574
+ >
575
+ {sortedComponents.map((component) => {
576
+ const customRenderer = componentRenderers?.[component.type] as
577
+ | ArloComponentRendererMap[typeof component.type]
578
+ | undefined;
579
+
580
+ const content = customRenderer
581
+ ? customRenderer(component as never, context as never)
582
+ : renderDefaultComponent(component, context, registry);
583
+
584
+ if (content === null) {
585
+ return (
586
+ <View key={component.id} style={styles.unsupported}>
587
+ {unsupportedComponent ? (
588
+ unsupportedComponent(component)
589
+ ) : (
590
+ <Text style={styles.unsupportedText}>
591
+ Unsupported component: {component.type}
592
+ </Text>
593
+ )}
594
+ </View>
595
+ );
596
+ }
597
+
598
+ return (
599
+ <View
600
+ key={component.id}
601
+ style={getComponentWrapperStyle(component, true)}
602
+ >
603
+ {content}
604
+ </View>
605
+ );
606
+ })}
607
+ </View>
608
+ );
609
+ }
610
+
611
+ return (
612
+ <ScrollView
613
+ contentContainerStyle={[
614
+ styles.container,
615
+ getScreenContainerStyle(snapshot.currentScreen),
616
+ ]}
617
+ >
618
+ {sortedComponents.map((component) => {
619
+ const customRenderer = componentRenderers?.[component.type] as
620
+ | ArloComponentRendererMap[typeof component.type]
621
+ | undefined;
622
+
623
+ const content = customRenderer
624
+ ? customRenderer(component as never, context as never)
625
+ : renderDefaultComponent(component, context, registry);
626
+
627
+ if (content === null) {
628
+ return (
629
+ <View key={component.id} style={styles.unsupported}>
630
+ {unsupportedComponent ? (
631
+ unsupportedComponent(component)
632
+ ) : (
633
+ <Text style={styles.unsupportedText}>
634
+ Unsupported component: {component.type}
635
+ </Text>
636
+ )}
637
+ </View>
638
+ );
639
+ }
640
+
641
+ return (
642
+ <View key={component.id} style={getComponentWrapperStyle(component, false)}>
643
+ {content}
644
+ </View>
645
+ );
646
+ })}
647
+ </ScrollView>
648
+ );
649
+ }
650
+
651
+ const styles = StyleSheet.create({
652
+ container: {
653
+ flexGrow: 1,
654
+ gap: 16,
655
+ },
656
+ componentBlock: {
657
+ width: "100%",
658
+ },
659
+ absoluteContainer: {
660
+ flex: 1,
661
+ position: "relative",
662
+ overflow: "hidden",
663
+ },
664
+ fieldGroup: {
665
+ gap: 8,
666
+ },
667
+ fieldLabel: {
668
+ color: "#f3f3f5",
669
+ fontSize: 14,
670
+ fontWeight: "600",
671
+ },
672
+ input: {
673
+ borderWidth: 1,
674
+ borderColor: "#2c2c34",
675
+ borderRadius: 14,
676
+ paddingHorizontal: 14,
677
+ paddingVertical: 12,
678
+ color: "#ffffff",
679
+ backgroundColor: "#141419",
680
+ },
681
+ inputError: {
682
+ borderColor: "#f36b8d",
683
+ },
684
+ fieldError: {
685
+ color: "#f59cb3",
686
+ fontSize: 12,
687
+ fontWeight: "500",
688
+ },
689
+ button: {
690
+ minHeight: 52,
691
+ alignItems: "center",
692
+ justifyContent: "center",
693
+ paddingHorizontal: 16,
694
+ paddingVertical: 12,
695
+ },
696
+ buttonText: {
697
+ fontSize: 16,
698
+ fontWeight: "700",
699
+ },
700
+ optionGroup: {
701
+ flexDirection: "row",
702
+ flexWrap: "wrap",
703
+ gap: 10,
704
+ },
705
+ optionPill: {
706
+ paddingHorizontal: 14,
707
+ paddingVertical: 10,
708
+ borderRadius: 999,
709
+ borderWidth: 1,
710
+ borderColor: "#30303a",
711
+ backgroundColor: "#15151b",
712
+ },
713
+ optionPillSelected: {
714
+ backgroundColor: "#ffffff",
715
+ borderColor: "#ffffff",
716
+ },
717
+ optionPillText: {
718
+ color: "#f1f1f3",
719
+ fontSize: 14,
720
+ fontWeight: "600",
721
+ },
722
+ optionPillTextSelected: {
723
+ color: "#111111",
724
+ },
725
+ sliderCard: {
726
+ borderRadius: 18,
727
+ backgroundColor: "#15151b",
728
+ borderWidth: 1,
729
+ borderColor: "#2b2b34",
730
+ padding: 14,
731
+ gap: 12,
732
+ },
733
+ sliderValue: {
734
+ color: "#ffffff",
735
+ fontSize: 28,
736
+ fontWeight: "700",
737
+ },
738
+ sliderActions: {
739
+ flexDirection: "row",
740
+ gap: 10,
741
+ },
742
+ progressTrack: {
743
+ width: "100%",
744
+ borderRadius: 999,
745
+ overflow: "hidden",
746
+ },
747
+ pageIndicatorRow: {
748
+ flexDirection: "row",
749
+ gap: 8,
750
+ alignItems: "center",
751
+ },
752
+ unsupported: {
753
+ padding: 12,
754
+ borderRadius: 12,
755
+ borderWidth: 1,
756
+ borderColor: "#3a2430",
757
+ backgroundColor: "#21151a",
758
+ },
759
+ unsupportedText: {
760
+ color: "#f5b4c6",
761
+ fontSize: 13,
762
+ },
763
+ });
@@ -0,0 +1,59 @@
1
+ import type { ReactNode } from "react";
2
+ import { Text, View } from "react-native";
3
+
4
+ import type { ArloPresenter } from "arlo-sdk";
5
+
6
+ import { ArloFlowRenderer } from "./ArloFlowRenderer";
7
+ import type { ArloFlowRendererProps } from "./types";
8
+ import { useArloPresenter } from "./useArloPresenter";
9
+
10
+ export interface ArloPresenterRendererProps
11
+ extends Omit<ArloFlowRendererProps, "session"> {
12
+ presenter: ArloPresenter;
13
+ loadingState?: ReactNode;
14
+ errorState?: (message: string) => ReactNode;
15
+ }
16
+
17
+ export function ArloPresenterRenderer({
18
+ presenter,
19
+ loadingState = (
20
+ <View>
21
+ <Text>Loading...</Text>
22
+ </View>
23
+ ),
24
+ errorState,
25
+ ...rendererProps
26
+ }: ArloPresenterRendererProps) {
27
+ const state = useArloPresenter(presenter);
28
+
29
+ if (state.status === "loading") {
30
+ return <>{loadingState}</>;
31
+ }
32
+
33
+ if (state.status === "error") {
34
+ if (errorState) {
35
+ return <>{errorState(state.error?.message ?? "Unknown error")}</>;
36
+ }
37
+
38
+ return (
39
+ <View>
40
+ <Text>{state.error?.message ?? "Failed to load flow"}</Text>
41
+ </View>
42
+ );
43
+ }
44
+
45
+ if (!state.session) {
46
+ return <>{rendererProps.emptyState ?? null}</>;
47
+ }
48
+
49
+ return (
50
+ <ArloFlowRenderer
51
+ session={state.session}
52
+ {...rendererProps}
53
+ onSnapshotChange={(snapshot) => {
54
+ rendererProps.onSnapshotChange?.(snapshot);
55
+ presenter.syncSession();
56
+ }}
57
+ />
58
+ );
59
+ }
package/src/index.ts ADDED
@@ -0,0 +1,23 @@
1
+ export { ArloFlowRenderer } from "./ArloFlowRenderer";
2
+ export { ArloPresenterRenderer } from "./ArloPresenterRenderer";
3
+ export { createArloRegistry } from "./registry";
4
+ export { createReactNativeFlowCache } from "./storage-cache";
5
+ export { useArloPresenter } from "./useArloPresenter";
6
+ export type {
7
+ ArloComponentRenderContext,
8
+ ArloComponentRenderer,
9
+ ArloComponentRendererMap,
10
+ ArloCustomScreenRenderer,
11
+ ArloFlowRendererProps,
12
+ } from "./types";
13
+ export type {
14
+ ArloRegistry,
15
+ ArloRegisteredComponent,
16
+ ArloRegisteredScreen,
17
+ ArloRegistryComponentContext,
18
+ ArloRegistryScreenContext,
19
+ } from "./registry";
20
+ export type {
21
+ AsyncStorageLike,
22
+ CreateReactNativeFlowCacheOptions,
23
+ } from "./storage-cache";
@@ -0,0 +1,45 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import type { FlowComponent, FlowSession, FlowSessionSnapshot, Screen } from "arlo-sdk";
4
+
5
+ export interface ArloRegistryScreenContext {
6
+ session: FlowSession;
7
+ snapshot: FlowSessionSnapshot;
8
+ screen: Screen;
9
+ }
10
+
11
+ export interface ArloRegistryComponentContext extends ArloRegistryScreenContext {
12
+ component: Extract<FlowComponent, { type: "CUSTOM_COMPONENT" }>;
13
+ }
14
+
15
+ export type ArloRegisteredScreen = (context: ArloRegistryScreenContext) => ReactNode;
16
+ export type ArloRegisteredComponent = (
17
+ context: ArloRegistryComponentContext
18
+ ) => ReactNode;
19
+
20
+ export interface ArloRegistry {
21
+ registerScreen(key: string, renderer: ArloRegisteredScreen): void;
22
+ registerComponent(key: string, renderer: ArloRegisteredComponent): void;
23
+ getScreen(key: string): ArloRegisteredScreen | null;
24
+ getComponent(key: string): ArloRegisteredComponent | null;
25
+ }
26
+
27
+ export function createArloRegistry(): ArloRegistry {
28
+ const screens = new Map<string, ArloRegisteredScreen>();
29
+ const components = new Map<string, ArloRegisteredComponent>();
30
+
31
+ return {
32
+ registerScreen(key: string, renderer: ArloRegisteredScreen): void {
33
+ screens.set(key, renderer);
34
+ },
35
+ registerComponent(key: string, renderer: ArloRegisteredComponent): void {
36
+ components.set(key, renderer);
37
+ },
38
+ getScreen(key: string): ArloRegisteredScreen | null {
39
+ return screens.get(key) ?? null;
40
+ },
41
+ getComponent(key: string): ArloRegisteredComponent | null {
42
+ return components.get(key) ?? null;
43
+ },
44
+ };
45
+ }
@@ -0,0 +1,29 @@
1
+ import { createPersistentFlowCache, type ArloCacheStorage } from "arlo-sdk";
2
+
3
+ export interface AsyncStorageLike {
4
+ getItem(key: string): Promise<string | null>;
5
+ setItem(key: string, value: string): Promise<void>;
6
+ removeItem(key: string): Promise<void>;
7
+ }
8
+
9
+ export interface CreateReactNativeFlowCacheOptions {
10
+ storage: AsyncStorageLike;
11
+ namespace?: string;
12
+ maxAgeMs?: number;
13
+ }
14
+
15
+ export function createReactNativeFlowCache(
16
+ options: CreateReactNativeFlowCacheOptions
17
+ ) {
18
+ const storage: ArloCacheStorage = {
19
+ getItem: options.storage.getItem.bind(options.storage),
20
+ setItem: options.storage.setItem.bind(options.storage),
21
+ removeItem: options.storage.removeItem.bind(options.storage),
22
+ };
23
+
24
+ return createPersistentFlowCache({
25
+ storage,
26
+ namespace: options.namespace,
27
+ maxAgeMs: options.maxAgeMs,
28
+ });
29
+ }
package/src/types.ts ADDED
@@ -0,0 +1,41 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import type {
4
+ FlowBridgeHandlers,
5
+ FlowComponent,
6
+ FlowSession,
7
+ FlowSessionSnapshot,
8
+ Screen,
9
+ } from "arlo-sdk";
10
+ import type { ArloRegistry } from "./registry";
11
+
12
+ export interface ArloComponentRenderContext {
13
+ session: FlowSession;
14
+ snapshot: FlowSessionSnapshot;
15
+ handlers?: FlowBridgeHandlers;
16
+ onValueChange: (fieldKey: string, value: string | string[] | number | boolean | null) => void;
17
+ onPressButton: (componentId: string) => Promise<void>;
18
+ }
19
+
20
+ export type ArloComponentRenderer<T extends FlowComponent = FlowComponent> = (
21
+ component: T,
22
+ context: ArloComponentRenderContext
23
+ ) => ReactNode;
24
+
25
+ export type ArloComponentRendererMap = Partial<{
26
+ [K in FlowComponent["type"]]: ArloComponentRenderer<Extract<FlowComponent, { type: K }>>;
27
+ }>;
28
+
29
+ export type ArloCustomScreenRenderer = (screen: Screen, context: ArloComponentRenderContext) => ReactNode;
30
+
31
+ export interface ArloFlowRendererProps {
32
+ session: FlowSession;
33
+ handlers?: FlowBridgeHandlers;
34
+ componentRenderers?: ArloComponentRendererMap;
35
+ registry?: ArloRegistry;
36
+ autoStart?: boolean;
37
+ emptyState?: ReactNode;
38
+ unsupportedComponent?: (component: FlowComponent) => ReactNode;
39
+ unsupportedScreen?: (screen: Screen) => ReactNode;
40
+ onSnapshotChange?: (snapshot: FlowSessionSnapshot) => void;
41
+ }
@@ -0,0 +1,11 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ import type { ArloPresentationState, ArloPresenter } from "arlo-sdk";
4
+
5
+ export function useArloPresenter(presenter: ArloPresenter): ArloPresentationState {
6
+ const [state, setState] = useState<ArloPresentationState>(() => presenter.getState());
7
+
8
+ useEffect(() => presenter.subscribe(setState), [presenter]);
9
+
10
+ return state;
11
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "rootDir": "./src",
6
+ "module": "esnext",
7
+ "target": "ES2020",
8
+ "lib": ["ES2020", "DOM"],
9
+ "jsx": "react-jsx",
10
+ "baseUrl": ".",
11
+ "paths": {
12
+ "arlo-sdk": ["../arlo-sdk/src/index.ts"]
13
+ }
14
+ },
15
+ "include": ["src/**/*.ts", "src/**/*.tsx"]
16
+ }