Skip to content

Commit cd8bf95

Browse files
authored
feat(spark): account migration to non-custodial UI screens (#3742)
## Summary Implements the UI screens for the **Custodial → Non-custodial** migration flow as described in [#636](blinkbitcoin/blink-wip#636). This is a UI-only implementation — backend integration is tracked separately. ### Screens implemented | Ticket requirement | Status | |---|---| | **Settings entry point** — "Move to non-custodial" row in Account section | Done | | **Explainer screen** — Key icon, title, numbered list with "learn more" link, "Let's move" CTA | Done | | **Backup method selection** — Already exists in parent branch (ticket #572) | Reused | | **Transferring funds screen** — Clock icon with warning background, auto-redirects to success after transfer | Done | | **Welcome success screen** — Already exists in parent branch | Reused | | **Flow checkpoints** — Resume migration from last visited step when user exits and returns | Done | ### New components - **StatusScreenLayout** (`app/components/status-screen-layout/`) — Generic centered layout with icon + text for loading/status screens. Accepts optional `iconBackgroundColor` for themed icon circles. - **MigrationExplainerLayout** (`app/screens/account-migration/migration-explainer-layout.tsx`) — Reusable layout for explainer screens with icon, title, numbered steps list, and CTA button. - **NumberedStepsList** (`app/components/numbered-steps-list/`) — Reusable numbered list component extracted from MigrationExplainerLayout. - **useMigrationCheckpoint** (`app/screens/account-migration/hooks/`) — Thin React wrapper around a pure persistence module (`migration-checkpoint-storage.ts`). Persists flow progress in AsyncStorage with a **uniform 48-hour expiration** for all checkpoint steps. Validates stored data on load, clears expired/corrupt checkpoints, and includes unmount-race protection. - **migration-checkpoint-storage** (`app/screens/account-migration/utils/`) — Pure module (no React) for checkpoint persistence: load, save, clear, validate, expiration check, and route resolution. Environment-namespaced storage keys. iOS-aware route fallback for CloudBackup. - **openExternalUrl** (`app/utils/external.ts`) — Shared helper: InAppBrowser with Linking fallback. Replaces duplicate patterns in explainer and backup-phrase screens. ### Theme additions - `_warningLight: "#FFF9E5"` — Static color for warning/loading icon backgrounds - `ClockIcon` registered in GaloyIcon ### Navigation flow ``` Settings → "Move to non-custodial" → Explainer (or resume from checkpoint) → "Let's move" → Backup method → ... backup flow ... → If user has funds → Transferring funds screen (2s mock) → Success → If no funds → Success directly ``` ### Checkpoint system - Saves current step to AsyncStorage on each screen mount - Settings entry reads checkpoint and navigates to last visited screen - **All checkpoints expire after 48 hours** - Storage key namespaced by environment (staging/mainnet/signet) - iOS CloudBackup checkpoint falls back to explainer (cloud backup not yet available on iOS) - Cleared on successful completion - Graceful handling of corrupt/missing storage data ### i18n - `AccountMigration` namespace with 8 keys translated across all 28 languages - All translations use proper diacritical marks ### Existing component improvements - **IconHero** — Removed fixed `width: 264` to respect parent padding - **StatusScreenLayout** — Always wraps icon in circle View (default transparent background), removed conditional branch ### Test coverage - 50+ tests across 8 suites - Checkpoint storage module: 23 tests (validation, 48h expiration boundary, route resolution with iOS fallback, load with rejection/corrupt data, environment-namespaced keys) - Checkpoint hook: 9 tests (load, save, clear, route resolution, unmount race, save→unmount→remount→resume integration) - MigrationExplainerLayout: 5 tests - Explainer screen: 5 tests - Transferring funds screen: 3 tests - Settings row: 1 test - StatusScreenLayout: 3 tests - Backup screens: saveCheckpoint assertions on mount
1 parent 8382e49 commit cd8bf95

71 files changed

Lines changed: 1757 additions & 26 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import React from "react"
2+
import { Text } from "react-native"
3+
import { render, screen } from "@testing-library/react-native"
4+
5+
import { StatusScreenLayout } from "@app/components/status-screen-layout"
6+
import { ContextForScreen } from "../screens/helper"
7+
8+
jest.mock("@app/components/atomic/galoy-icon", () => ({
9+
GaloyIcon: ({ name }: { name: string }) => {
10+
const { Text } = jest.requireActual("react-native")
11+
return <Text testID="galoy-icon">{name}</Text>
12+
},
13+
}))
14+
15+
describe("StatusScreenLayout", () => {
16+
it("renders icon and children", () => {
17+
render(
18+
<ContextForScreen>
19+
<StatusScreenLayout icon="clock" iconBackgroundColor="#FFF9E5">
20+
<Text>Loading message</Text>
21+
</StatusScreenLayout>
22+
</ContextForScreen>,
23+
)
24+
25+
expect(screen.getByText("Loading message")).toBeTruthy()
26+
expect(screen.getByTestId("galoy-icon")).toBeTruthy()
27+
})
28+
29+
it("renders without icon background when not provided", () => {
30+
render(
31+
<ContextForScreen>
32+
<StatusScreenLayout icon="payment-success">
33+
<Text>Success</Text>
34+
</StatusScreenLayout>
35+
</ContextForScreen>,
36+
)
37+
38+
expect(screen.getByText("Success")).toBeTruthy()
39+
})
40+
41+
it("renders footer when provided", () => {
42+
render(
43+
<ContextForScreen>
44+
<StatusScreenLayout
45+
icon="clock"
46+
iconBackgroundColor="#FFF9E5"
47+
footer={<Text>Footer</Text>}
48+
>
49+
<Text>Content</Text>
50+
</StatusScreenLayout>
51+
</ContextForScreen>,
52+
)
53+
54+
expect(screen.getByText("Footer")).toBeTruthy()
55+
})
56+
})
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { renderHook, act, waitFor } from "@testing-library/react-native"
2+
3+
import {
4+
useMigrationCheckpoint,
5+
MigrationCheckpoint,
6+
} from "@app/screens/account-migration/hooks"
7+
8+
const mockLoadCheckpoint = jest.fn()
9+
const mockSaveCheckpointToStorage = jest.fn()
10+
const mockClearCheckpointFromStorage = jest.fn()
11+
12+
jest.mock("@app/screens/account-migration/utils/migration-checkpoint-storage", () => ({
13+
...jest.requireActual(
14+
"@app/screens/account-migration/utils/migration-checkpoint-storage",
15+
),
16+
loadCheckpoint: (...args: readonly unknown[]) => mockLoadCheckpoint(...args),
17+
saveCheckpointToStorage: (...args: readonly unknown[]) =>
18+
mockSaveCheckpointToStorage(...args),
19+
clearCheckpointFromStorage: (...args: readonly unknown[]) =>
20+
mockClearCheckpointFromStorage(...args),
21+
getStorageKey: (env: string) => `migrationCheckpoint_${env.toLowerCase()}`,
22+
}))
23+
24+
jest.mock("@app/hooks/use-app-config", () => ({
25+
useAppConfig: () => ({
26+
appConfig: { galoyInstance: { name: "Main" } },
27+
}),
28+
}))
29+
30+
describe("useMigrationCheckpoint", () => {
31+
beforeEach(() => {
32+
jest.clearAllMocks()
33+
mockLoadCheckpoint.mockResolvedValue(null)
34+
mockSaveCheckpointToStorage.mockResolvedValue(undefined)
35+
mockClearCheckpointFromStorage.mockResolvedValue(undefined)
36+
})
37+
38+
it("starts with null checkpoint and loading true", async () => {
39+
const { result } = renderHook(() => useMigrationCheckpoint())
40+
41+
expect(result.current.loading).toBe(true)
42+
expect(result.current.checkpoint).toBeNull()
43+
44+
await waitFor(() => expect(result.current.loading).toBe(false))
45+
})
46+
47+
it("loads existing checkpoint from storage", async () => {
48+
mockLoadCheckpoint.mockResolvedValue({
49+
step: MigrationCheckpoint.BackupAlerts,
50+
savedAt: Date.now(),
51+
})
52+
53+
const { result } = renderHook(() => useMigrationCheckpoint())
54+
55+
await waitFor(() => {
56+
expect(result.current.loading).toBe(false)
57+
})
58+
expect(result.current.checkpoint).toBe(MigrationCheckpoint.BackupAlerts)
59+
})
60+
61+
it("sets loading false when no checkpoint found", async () => {
62+
const { result } = renderHook(() => useMigrationCheckpoint())
63+
64+
await waitFor(() => {
65+
expect(result.current.loading).toBe(false)
66+
})
67+
expect(result.current.checkpoint).toBeNull()
68+
})
69+
70+
it("saves checkpoint to storage", async () => {
71+
const { result } = renderHook(() => useMigrationCheckpoint())
72+
73+
await waitFor(() => expect(result.current.loading).toBe(false))
74+
75+
act(() => {
76+
result.current.saveCheckpoint(MigrationCheckpoint.BackupMethod)
77+
})
78+
79+
expect(result.current.checkpoint).toBe(MigrationCheckpoint.BackupMethod)
80+
expect(mockSaveCheckpointToStorage).toHaveBeenCalledWith(
81+
"migrationCheckpoint_main",
82+
MigrationCheckpoint.BackupMethod,
83+
)
84+
})
85+
86+
it("clears checkpoint from storage", async () => {
87+
mockLoadCheckpoint.mockResolvedValue({
88+
step: MigrationCheckpoint.BackupMethod,
89+
savedAt: Date.now(),
90+
})
91+
92+
const { result } = renderHook(() => useMigrationCheckpoint())
93+
94+
await waitFor(() => expect(result.current.loading).toBe(false))
95+
96+
act(() => {
97+
result.current.clearCheckpoint()
98+
})
99+
100+
expect(result.current.checkpoint).toBeNull()
101+
expect(mockClearCheckpointFromStorage).toHaveBeenCalledWith(
102+
"migrationCheckpoint_main",
103+
)
104+
})
105+
106+
it("returns default route when no checkpoint", async () => {
107+
const { result } = renderHook(() => useMigrationCheckpoint())
108+
109+
await waitFor(() => expect(result.current.loading).toBe(false))
110+
111+
expect(result.current.getRouteForCheckpoint()).toBe("sparkMigrationExplainer")
112+
})
113+
114+
it("returns correct route for checkpoint", async () => {
115+
mockLoadCheckpoint.mockResolvedValue({
116+
step: MigrationCheckpoint.BackupMethod,
117+
savedAt: Date.now(),
118+
})
119+
120+
const { result } = renderHook(() => useMigrationCheckpoint())
121+
122+
await waitFor(() => expect(result.current.loading).toBe(false))
123+
124+
expect(result.current.getRouteForCheckpoint()).toBe("sparkBackupMethodScreen")
125+
})
126+
127+
it("resumes from checkpoint after unmount and remount", async () => {
128+
mockLoadCheckpoint.mockResolvedValue(null)
129+
130+
const { result, unmount } = renderHook(() => useMigrationCheckpoint())
131+
132+
await waitFor(() => expect(result.current.loading).toBe(false))
133+
134+
act(() => {
135+
result.current.saveCheckpoint(MigrationCheckpoint.BackupAlerts)
136+
})
137+
138+
expect(result.current.checkpoint).toBe(MigrationCheckpoint.BackupAlerts)
139+
expect(mockSaveCheckpointToStorage).toHaveBeenCalledWith(
140+
"migrationCheckpoint_main",
141+
MigrationCheckpoint.BackupAlerts,
142+
)
143+
144+
unmount()
145+
146+
mockLoadCheckpoint.mockResolvedValue({
147+
step: MigrationCheckpoint.BackupAlerts,
148+
savedAt: Date.now(),
149+
})
150+
151+
const { result: result2 } = renderHook(() => useMigrationCheckpoint())
152+
153+
await waitFor(() => expect(result2.current.loading).toBe(false))
154+
155+
expect(result2.current.checkpoint).toBe(MigrationCheckpoint.BackupAlerts)
156+
expect(result2.current.getRouteForCheckpoint()).toBe("sparkBackupAlertsScreen")
157+
})
158+
159+
it("does not update state after unmount", async () => {
160+
let resolveLoad: (value: null) => void
161+
mockLoadCheckpoint.mockReturnValue(
162+
new Promise((resolve) => {
163+
resolveLoad = resolve
164+
}),
165+
)
166+
167+
const { result, unmount } = renderHook(() => useMigrationCheckpoint())
168+
169+
expect(result.current.loading).toBe(true)
170+
unmount()
171+
172+
await act(async () => {
173+
resolveLoad!(null)
174+
})
175+
})
176+
})
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import React from "react"
2+
import { render, fireEvent } from "@testing-library/react-native"
3+
import { Pressable, Text } from "react-native"
4+
5+
import { MigrationExplainerLayout } from "@app/screens/account-migration/migration-explainer-layout"
6+
import { ContextForScreen } from "../helper"
7+
8+
jest.mock("@app/components/icon-hero", () => ({
9+
IconHero: ({ title }: { title: string }) => {
10+
const { Text: RNText } = jest.requireActual("react-native")
11+
return <RNText>{title}</RNText>
12+
},
13+
}))
14+
15+
jest.mock("@app/components/atomic/galoy-primary-button", () => ({
16+
GaloyPrimaryButton: ({ title, onPress }: { title: string; onPress: () => void }) => (
17+
<Pressable testID="cta-button" onPress={onPress}>
18+
<Text>{title}</Text>
19+
</Pressable>
20+
),
21+
}))
22+
23+
jest.mock("@app/components/screen", () => ({
24+
Screen: ({ children }: { children: React.ReactNode }) => <>{children}</>,
25+
}))
26+
27+
describe("MigrationExplainerLayout", () => {
28+
const mockOnCtaPress = jest.fn()
29+
30+
const defaultProps = {
31+
icon: "key-outline" as const,
32+
iconColor: "#999",
33+
title: "Test Title",
34+
steps: [
35+
<Text key="1">Step one</Text>,
36+
<Text key="2">Step two</Text>,
37+
<Text key="3">Step three</Text>,
38+
],
39+
ctaTitle: "Continue",
40+
onCtaPress: mockOnCtaPress,
41+
}
42+
43+
beforeEach(() => {
44+
jest.clearAllMocks()
45+
})
46+
47+
it("renders title", () => {
48+
const { getByText } = render(
49+
<ContextForScreen>
50+
<MigrationExplainerLayout {...defaultProps} />
51+
</ContextForScreen>,
52+
)
53+
expect(getByText("Test Title")).toBeTruthy()
54+
})
55+
56+
it("renders all steps", () => {
57+
const { getByText } = render(
58+
<ContextForScreen>
59+
<MigrationExplainerLayout {...defaultProps} />
60+
</ContextForScreen>,
61+
)
62+
expect(getByText("Step one")).toBeTruthy()
63+
expect(getByText("Step two")).toBeTruthy()
64+
expect(getByText("Step three")).toBeTruthy()
65+
})
66+
67+
it("renders step numbers", () => {
68+
const { getByText } = render(
69+
<ContextForScreen>
70+
<MigrationExplainerLayout {...defaultProps} />
71+
</ContextForScreen>,
72+
)
73+
expect(getByText("1.")).toBeTruthy()
74+
expect(getByText("2.")).toBeTruthy()
75+
expect(getByText("3.")).toBeTruthy()
76+
})
77+
78+
it("renders CTA button with title", () => {
79+
const { getByText } = render(
80+
<ContextForScreen>
81+
<MigrationExplainerLayout {...defaultProps} />
82+
</ContextForScreen>,
83+
)
84+
expect(getByText("Continue")).toBeTruthy()
85+
})
86+
87+
it("calls onCtaPress when button pressed", () => {
88+
const { getByTestId } = render(
89+
<ContextForScreen>
90+
<MigrationExplainerLayout {...defaultProps} />
91+
</ContextForScreen>,
92+
)
93+
fireEvent.press(getByTestId("cta-button"))
94+
expect(mockOnCtaPress).toHaveBeenCalledTimes(1)
95+
})
96+
})

0 commit comments

Comments
 (0)