diff --git a/__tests__/components/status-screen-layout.spec.tsx b/__tests__/components/status-screen-layout.spec.tsx new file mode 100644 index 0000000000..6cc50ea0f7 --- /dev/null +++ b/__tests__/components/status-screen-layout.spec.tsx @@ -0,0 +1,56 @@ +import React from "react" +import { Text } from "react-native" +import { render, screen } from "@testing-library/react-native" + +import { StatusScreenLayout } from "@app/components/status-screen-layout" +import { ContextForScreen } from "../screens/helper" + +jest.mock("@app/components/atomic/galoy-icon", () => ({ + GaloyIcon: ({ name }: { name: string }) => { + const { Text } = jest.requireActual("react-native") + return {name} + }, +})) + +describe("StatusScreenLayout", () => { + it("renders icon and children", () => { + render( + + + Loading message + + , + ) + + expect(screen.getByText("Loading message")).toBeTruthy() + expect(screen.getByTestId("galoy-icon")).toBeTruthy() + }) + + it("renders without icon background when not provided", () => { + render( + + + Success + + , + ) + + expect(screen.getByText("Success")).toBeTruthy() + }) + + it("renders footer when provided", () => { + render( + + Footer} + > + Content + + , + ) + + expect(screen.getByText("Footer")).toBeTruthy() + }) +}) diff --git a/__tests__/screens/account-migration/hooks/use-migration-checkpoint.spec.ts b/__tests__/screens/account-migration/hooks/use-migration-checkpoint.spec.ts new file mode 100644 index 0000000000..d3b4b1ed49 --- /dev/null +++ b/__tests__/screens/account-migration/hooks/use-migration-checkpoint.spec.ts @@ -0,0 +1,176 @@ +import { renderHook, act, waitFor } from "@testing-library/react-native" + +import { + useMigrationCheckpoint, + MigrationCheckpoint, +} from "@app/screens/account-migration/hooks" + +const mockLoadCheckpoint = jest.fn() +const mockSaveCheckpointToStorage = jest.fn() +const mockClearCheckpointFromStorage = jest.fn() + +jest.mock("@app/screens/account-migration/utils/migration-checkpoint-storage", () => ({ + ...jest.requireActual( + "@app/screens/account-migration/utils/migration-checkpoint-storage", + ), + loadCheckpoint: (...args: readonly unknown[]) => mockLoadCheckpoint(...args), + saveCheckpointToStorage: (...args: readonly unknown[]) => + mockSaveCheckpointToStorage(...args), + clearCheckpointFromStorage: (...args: readonly unknown[]) => + mockClearCheckpointFromStorage(...args), + getStorageKey: (env: string) => `migrationCheckpoint_${env.toLowerCase()}`, +})) + +jest.mock("@app/hooks/use-app-config", () => ({ + useAppConfig: () => ({ + appConfig: { galoyInstance: { name: "Main" } }, + }), +})) + +describe("useMigrationCheckpoint", () => { + beforeEach(() => { + jest.clearAllMocks() + mockLoadCheckpoint.mockResolvedValue(null) + mockSaveCheckpointToStorage.mockResolvedValue(undefined) + mockClearCheckpointFromStorage.mockResolvedValue(undefined) + }) + + it("starts with null checkpoint and loading true", async () => { + const { result } = renderHook(() => useMigrationCheckpoint()) + + expect(result.current.loading).toBe(true) + expect(result.current.checkpoint).toBeNull() + + await waitFor(() => expect(result.current.loading).toBe(false)) + }) + + it("loads existing checkpoint from storage", async () => { + mockLoadCheckpoint.mockResolvedValue({ + step: MigrationCheckpoint.BackupAlerts, + savedAt: Date.now(), + }) + + const { result } = renderHook(() => useMigrationCheckpoint()) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + expect(result.current.checkpoint).toBe(MigrationCheckpoint.BackupAlerts) + }) + + it("sets loading false when no checkpoint found", async () => { + const { result } = renderHook(() => useMigrationCheckpoint()) + + await waitFor(() => { + expect(result.current.loading).toBe(false) + }) + expect(result.current.checkpoint).toBeNull() + }) + + it("saves checkpoint to storage", async () => { + const { result } = renderHook(() => useMigrationCheckpoint()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + act(() => { + result.current.saveCheckpoint(MigrationCheckpoint.BackupMethod) + }) + + expect(result.current.checkpoint).toBe(MigrationCheckpoint.BackupMethod) + expect(mockSaveCheckpointToStorage).toHaveBeenCalledWith( + "migrationCheckpoint_main", + MigrationCheckpoint.BackupMethod, + ) + }) + + it("clears checkpoint from storage", async () => { + mockLoadCheckpoint.mockResolvedValue({ + step: MigrationCheckpoint.BackupMethod, + savedAt: Date.now(), + }) + + const { result } = renderHook(() => useMigrationCheckpoint()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + act(() => { + result.current.clearCheckpoint() + }) + + expect(result.current.checkpoint).toBeNull() + expect(mockClearCheckpointFromStorage).toHaveBeenCalledWith( + "migrationCheckpoint_main", + ) + }) + + it("returns default route when no checkpoint", async () => { + const { result } = renderHook(() => useMigrationCheckpoint()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + expect(result.current.getRouteForCheckpoint()).toBe("sparkMigrationExplainer") + }) + + it("returns correct route for checkpoint", async () => { + mockLoadCheckpoint.mockResolvedValue({ + step: MigrationCheckpoint.BackupMethod, + savedAt: Date.now(), + }) + + const { result } = renderHook(() => useMigrationCheckpoint()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + expect(result.current.getRouteForCheckpoint()).toBe("sparkBackupMethodScreen") + }) + + it("resumes from checkpoint after unmount and remount", async () => { + mockLoadCheckpoint.mockResolvedValue(null) + + const { result, unmount } = renderHook(() => useMigrationCheckpoint()) + + await waitFor(() => expect(result.current.loading).toBe(false)) + + act(() => { + result.current.saveCheckpoint(MigrationCheckpoint.BackupAlerts) + }) + + expect(result.current.checkpoint).toBe(MigrationCheckpoint.BackupAlerts) + expect(mockSaveCheckpointToStorage).toHaveBeenCalledWith( + "migrationCheckpoint_main", + MigrationCheckpoint.BackupAlerts, + ) + + unmount() + + mockLoadCheckpoint.mockResolvedValue({ + step: MigrationCheckpoint.BackupAlerts, + savedAt: Date.now(), + }) + + const { result: result2 } = renderHook(() => useMigrationCheckpoint()) + + await waitFor(() => expect(result2.current.loading).toBe(false)) + + expect(result2.current.checkpoint).toBe(MigrationCheckpoint.BackupAlerts) + expect(result2.current.getRouteForCheckpoint()).toBe("sparkBackupAlertsScreen") + }) + + it("does not update state after unmount", async () => { + let resolveLoad: (value: null) => void + mockLoadCheckpoint.mockReturnValue( + new Promise((resolve) => { + resolveLoad = resolve + }), + ) + + const { result, unmount } = renderHook(() => useMigrationCheckpoint()) + + expect(result.current.loading).toBe(true) + unmount() + + await act(async () => { + resolveLoad!(null) + }) + }) +}) diff --git a/__tests__/screens/account-migration/migration-explainer-layout.spec.tsx b/__tests__/screens/account-migration/migration-explainer-layout.spec.tsx new file mode 100644 index 0000000000..6b6ff3825e --- /dev/null +++ b/__tests__/screens/account-migration/migration-explainer-layout.spec.tsx @@ -0,0 +1,96 @@ +import React from "react" +import { render, fireEvent } from "@testing-library/react-native" +import { Pressable, Text } from "react-native" + +import { MigrationExplainerLayout } from "@app/screens/account-migration/migration-explainer-layout" +import { ContextForScreen } from "../helper" + +jest.mock("@app/components/icon-hero", () => ({ + IconHero: ({ title }: { title: string }) => { + const { Text: RNText } = jest.requireActual("react-native") + return {title} + }, +})) + +jest.mock("@app/components/atomic/galoy-primary-button", () => ({ + GaloyPrimaryButton: ({ title, onPress }: { title: string; onPress: () => void }) => ( + + {title} + + ), +})) + +jest.mock("@app/components/screen", () => ({ + Screen: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +describe("MigrationExplainerLayout", () => { + const mockOnCtaPress = jest.fn() + + const defaultProps = { + icon: "key-outline" as const, + iconColor: "#999", + title: "Test Title", + steps: [ + Step one, + Step two, + Step three, + ], + ctaTitle: "Continue", + onCtaPress: mockOnCtaPress, + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("renders title", () => { + const { getByText } = render( + + + , + ) + expect(getByText("Test Title")).toBeTruthy() + }) + + it("renders all steps", () => { + const { getByText } = render( + + + , + ) + expect(getByText("Step one")).toBeTruthy() + expect(getByText("Step two")).toBeTruthy() + expect(getByText("Step three")).toBeTruthy() + }) + + it("renders step numbers", () => { + const { getByText } = render( + + + , + ) + expect(getByText("1.")).toBeTruthy() + expect(getByText("2.")).toBeTruthy() + expect(getByText("3.")).toBeTruthy() + }) + + it("renders CTA button with title", () => { + const { getByText } = render( + + + , + ) + expect(getByText("Continue")).toBeTruthy() + }) + + it("calls onCtaPress when button pressed", () => { + const { getByTestId } = render( + + + , + ) + fireEvent.press(getByTestId("cta-button")) + expect(mockOnCtaPress).toHaveBeenCalledTimes(1) + }) +}) diff --git a/__tests__/screens/account-migration/to-non-custodial/explainer-screen.spec.tsx b/__tests__/screens/account-migration/to-non-custodial/explainer-screen.spec.tsx new file mode 100644 index 0000000000..291d51942a --- /dev/null +++ b/__tests__/screens/account-migration/to-non-custodial/explainer-screen.spec.tsx @@ -0,0 +1,109 @@ +import React from "react" +import { render, screen, fireEvent } from "@testing-library/react-native" +import { loadLocale } from "@app/i18n/i18n-util.sync" + +import { SparkMigrationExplainerScreen } from "@app/screens/account-migration/to-non-custodial/explainer-screen" +import { ContextForScreen } from "../../helper" + +loadLocale("en") + +const mockNavigate = jest.fn() + +jest.mock("@react-navigation/native", () => ({ + ...jest.requireActual("@react-navigation/native"), + useNavigation: () => ({ + navigate: mockNavigate, + }), +})) + +jest.mock("@app/utils/external", () => ({ + openExternalUrl: jest.fn(), +})) + +jest.mock("@app/config/feature-flags-context", () => ({ + useRemoteConfig: () => ({ + sparkCompatibleWalletsUrl: "https://docs.spark.money/wallets/overview", + }), +})) + +jest.mock("@app/components/icon-hero", () => ({ + IconHero: ({ title }: { title: string }) => { + const { Text } = jest.requireActual("react-native") + return {title} + }, +})) + +describe("SparkMigrationExplainerScreen", () => { + beforeEach(() => { + jest.clearAllMocks() + loadLocale("en") + }) + + it("renders the explainer title", () => { + render( + + + , + ) + + expect(screen.getByText("What does it mean to move to non-custodial?")).toBeTruthy() + }) + + it("renders all three steps", () => { + render( + + + , + ) + + expect(screen.getByText("1.")).toBeTruthy() + expect(screen.getByText("2.")).toBeTruthy() + expect(screen.getByText("3.")).toBeTruthy() + }) + + it("renders learn more link", () => { + render( + + + , + ) + + expect(screen.getByText("learn more here")).toBeTruthy() + }) + + it("opens external URL when learn more is pressed", () => { + const { openExternalUrl } = jest.requireMock("@app/utils/external") + + render( + + + , + ) + + fireEvent.press(screen.getByText("learn more here")) + expect(openExternalUrl).toHaveBeenCalledWith( + "https://docs.spark.money/wallets/overview", + ) + }) + + it("renders Let's move button", () => { + render( + + + , + ) + + expect(screen.getByText("Let's move")).toBeTruthy() + }) + + it("navigates to backup method on button press", () => { + render( + + + , + ) + + fireEvent.press(screen.getByText("Let's move")) + expect(mockNavigate).toHaveBeenCalledWith("sparkBackupMethodScreen") + }) +}) diff --git a/__tests__/screens/account-migration/to-non-custodial/transferring-funds-screen.spec.tsx b/__tests__/screens/account-migration/to-non-custodial/transferring-funds-screen.spec.tsx new file mode 100644 index 0000000000..ce6053b149 --- /dev/null +++ b/__tests__/screens/account-migration/to-non-custodial/transferring-funds-screen.spec.tsx @@ -0,0 +1,78 @@ +import React from "react" +import { render, screen, act } from "@testing-library/react-native" +import { loadLocale } from "@app/i18n/i18n-util.sync" + +import { TransferringFundsScreen } from "@app/screens/account-migration/to-non-custodial/transferring-funds-screen" +import { ContextForScreen } from "../../helper" + +loadLocale("en") + +const mockReplace = jest.fn() + +jest.mock("@react-navigation/native", () => ({ + ...jest.requireActual("@react-navigation/native"), + useNavigation: () => ({ + replace: mockReplace, + }), +})) + +jest.mock("@app/components/status-screen-layout", () => ({ + StatusScreenLayout: ({ children }: { children: React.ReactNode }) => { + const { View } = jest.requireActual("react-native") + return {children} + }, +})) + +describe("TransferringFundsScreen", () => { + beforeEach(() => { + jest.clearAllMocks() + jest.useFakeTimers() + loadLocale("en") + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it("renders transferring funds message", () => { + render( + + + , + ) + + expect( + screen.getByText("Transferring your funds. It should be done in a few seconds."), + ).toBeTruthy() + }) + + it("navigates to success screen after timeout", async () => { + render( + + + , + ) + + expect(mockReplace).not.toHaveBeenCalled() + + await act(() => { + jest.advanceTimersByTime(2000) + }) + + expect(mockReplace).toHaveBeenCalledWith("sparkBackupSuccessScreen") + }) + + it("does not navigate before timeout", async () => { + render( + + + , + ) + + await act(() => { + jest.advanceTimersByTime(1000) + }) + + expect(mockReplace).not.toHaveBeenCalled() + }) +}) diff --git a/__tests__/screens/account-migration/utils/migration-checkpoint-storage.spec.ts b/__tests__/screens/account-migration/utils/migration-checkpoint-storage.spec.ts new file mode 100644 index 0000000000..ad3f987012 --- /dev/null +++ b/__tests__/screens/account-migration/utils/migration-checkpoint-storage.spec.ts @@ -0,0 +1,211 @@ +import { Platform } from "react-native" + +import { + MigrationCheckpoint, + clearCheckpointFromStorage, + getStorageKey, + isExpired, + loadCheckpoint, + resolveCheckpointRoute, + saveCheckpointToStorage, + validateStoredCheckpoint, +} from "@app/screens/account-migration/utils/migration-checkpoint-storage" + +const mockLoadJson = jest.fn() +const mockSaveJson = jest.fn() +const mockRemove = jest.fn() + +jest.mock("@app/utils/storage", () => ({ + loadJson: (...args: readonly unknown[]) => mockLoadJson(...args), + saveJson: (...args: readonly unknown[]) => mockSaveJson(...args), + remove: (...args: readonly unknown[]) => mockRemove(...args), +})) + +describe("migration-checkpoint-storage", () => { + beforeEach(() => { + jest.clearAllMocks() + mockRemove.mockResolvedValue(undefined) + }) + + describe("getStorageKey", () => { + it("namespaces by environment", () => { + expect(getStorageKey("Main")).toBe("migrationCheckpoint_main") + expect(getStorageKey("Staging")).toBe("migrationCheckpoint_staging") + }) + }) + + describe("validateStoredCheckpoint", () => { + it("returns null for null input", () => { + expect(validateStoredCheckpoint(null)).toBeNull() + }) + + it("returns null for non-object input", () => { + expect(validateStoredCheckpoint("string")).toBeNull() + }) + + it("returns null for invalid step", () => { + expect(validateStoredCheckpoint({ step: "invalid", savedAt: 123 })).toBeNull() + }) + + it("returns null for missing savedAt", () => { + expect(validateStoredCheckpoint({ step: "backupMethod" })).toBeNull() + }) + + it("returns null for non-number savedAt", () => { + expect( + validateStoredCheckpoint({ step: "backupMethod", savedAt: "not-a-number" }), + ).toBeNull() + }) + + it("returns valid checkpoint", () => { + const result = validateStoredCheckpoint({ step: "backupMethod", savedAt: 1000 }) + expect(result).toEqual({ step: "backupMethod", savedAt: 1000 }) + }) + }) + + describe("isExpired (48h uniform)", () => { + const now = 1000000000 + const h = 60 * 60 * 1000 + + it("not expired at 24h", () => { + const cp = { step: MigrationCheckpoint.BackupMethod, savedAt: now - 24 * h } + expect(isExpired(cp, now)).toBe(false) + }) + + it("not expired at 47h", () => { + const cp = { step: MigrationCheckpoint.CloudBackup, savedAt: now - 47 * h } + expect(isExpired(cp, now)).toBe(false) + }) + + it("not expired at 1h", () => { + const cp = { step: MigrationCheckpoint.BackupAlerts, savedAt: now - Number(h) } + expect(isExpired(cp, now)).toBe(false) + }) + + it("expired at 49h for BackupMethod", () => { + const cp = { step: MigrationCheckpoint.BackupMethod, savedAt: now - 49 * h } + expect(isExpired(cp, now)).toBe(true) + }) + + it("expired at 49h for CloudBackup", () => { + const cp = { step: MigrationCheckpoint.CloudBackup, savedAt: now - 49 * h } + expect(isExpired(cp, now)).toBe(true) + }) + + it("expired at 49h for BackupAlerts", () => { + const cp = { step: MigrationCheckpoint.BackupAlerts, savedAt: now - 49 * h } + expect(isExpired(cp, now)).toBe(true) + }) + }) + + describe("resolveCheckpointRoute", () => { + it("returns default for null checkpoint", () => { + expect(resolveCheckpointRoute(null)).toBe("sparkMigrationExplainer") + }) + + it("returns correct route for BackupMethod", () => { + expect(resolveCheckpointRoute(MigrationCheckpoint.BackupMethod)).toBe( + "sparkBackupMethodScreen", + ) + }) + + it("returns correct route for BackupAlerts", () => { + expect(resolveCheckpointRoute(MigrationCheckpoint.BackupAlerts)).toBe( + "sparkBackupAlertsScreen", + ) + }) + + it("returns correct route for CloudBackup on Android", () => { + const original = Platform.OS + Object.defineProperty(Platform, "OS", { value: "android" }) + + expect(resolveCheckpointRoute(MigrationCheckpoint.CloudBackup)).toBe( + "sparkCloudBackupScreen", + ) + + Object.defineProperty(Platform, "OS", { value: original }) + }) + + it("returns default route for CloudBackup on iOS", () => { + const original = Platform.OS + Object.defineProperty(Platform, "OS", { value: "ios" }) + + expect(resolveCheckpointRoute(MigrationCheckpoint.CloudBackup)).toBe( + "sparkMigrationExplainer", + ) + + Object.defineProperty(Platform, "OS", { value: original }) + }) + }) + + describe("loadCheckpoint", () => { + it("returns valid non-expired checkpoint", async () => { + mockLoadJson.mockResolvedValue({ + step: "backupAlerts", + savedAt: Date.now() - 1000, + }) + + const result = await loadCheckpoint("test-key") + expect(result).toEqual({ + step: "backupAlerts", + savedAt: expect.any(Number), + }) + }) + + it("returns null and removes expired checkpoint", async () => { + mockLoadJson.mockResolvedValue({ + step: "backupMethod", + savedAt: Date.now() - 49 * 60 * 60 * 1000, + }) + + const result = await loadCheckpoint("test-key") + expect(result).toBeNull() + expect(mockRemove).toHaveBeenCalledWith("test-key") + }) + + it("returns null for invalid data", async () => { + mockLoadJson.mockResolvedValue({ step: "invalid" }) + + const result = await loadCheckpoint("test-key") + expect(result).toBeNull() + }) + + it("returns null and clears on storage error", async () => { + mockLoadJson.mockRejectedValue(new Error("corrupt")) + + const result = await loadCheckpoint("test-key") + expect(result).toBeNull() + expect(mockRemove).toHaveBeenCalledWith("test-key") + }) + + it("returns null for null storage", async () => { + mockLoadJson.mockResolvedValue(null) + + const result = await loadCheckpoint("test-key") + expect(result).toBeNull() + }) + }) + + describe("saveCheckpointToStorage", () => { + it("persists step and timestamp", async () => { + const before = Date.now() + await saveCheckpointToStorage("test-key", MigrationCheckpoint.BackupAlerts) + + expect(mockSaveJson).toHaveBeenCalledWith("test-key", { + step: MigrationCheckpoint.BackupAlerts, + savedAt: expect.any(Number), + }) + + const savedAt = mockSaveJson.mock.calls[0][1].savedAt + expect(savedAt).toBeGreaterThanOrEqual(before) + expect(savedAt).toBeLessThanOrEqual(Date.now()) + }) + }) + + describe("clearCheckpointFromStorage", () => { + it("removes key from storage", async () => { + await clearCheckpointFromStorage("test-key") + expect(mockRemove).toHaveBeenCalledWith("test-key") + }) + }) +}) diff --git a/__tests__/screens/settings-screen/settings-screen.spec.tsx b/__tests__/screens/settings-screen/settings-screen.spec.tsx index 710130be39..f4e022446f 100644 --- a/__tests__/screens/settings-screen/settings-screen.spec.tsx +++ b/__tests__/screens/settings-screen/settings-screen.spec.tsx @@ -511,4 +511,21 @@ describe("Settings Screen", () => { expect(subtitleNode.props.numberOfLines).toBe(1) expect(subtitleNode.props.ellipsizeMode).toBe("tail") }) + + it("renders Move to non-custodial option in Account section", async () => { + render( + + + , + ) + + await act( + () => + new Promise((resolve) => { + setTimeout(resolve, 10) + }), + ) + + expect(screen.getByText("Move to non-custodial")).toBeTruthy() + }) }) diff --git a/__tests__/screens/spark-onboarding/backup-alerts-screen.spec.tsx b/__tests__/screens/spark-onboarding/backup-alerts-screen.spec.tsx index 8dca0f757b..509662d8ac 100644 --- a/__tests__/screens/spark-onboarding/backup-alerts-screen.spec.tsx +++ b/__tests__/screens/spark-onboarding/backup-alerts-screen.spec.tsx @@ -12,6 +12,15 @@ jest.mock("@react-navigation/native", () => ({ useNavigation: () => ({ navigate: mockNavigate }), })) +const mockSaveCheckpoint = jest.fn() + +jest.mock("@app/screens/account-migration/hooks", () => ({ + useMigrationCheckpoint: () => ({ + saveCheckpoint: mockSaveCheckpoint, + }), + MigrationCheckpoint: { BackupAlerts: "backupAlerts" }, +})) + jest.mock("@app/components/icon-hero", () => { const { Text } = jest.requireActual("react-native") return { @@ -105,4 +114,14 @@ describe("SparkBackupAlertsScreen", () => { expect(getByText(LL.SparkOnboarding.ManualBackup.Alerts.check2())).toBeTruthy() expect(getByText(LL.SparkOnboarding.ManualBackup.Alerts.check3())).toBeTruthy() }) + + it("saves BackupAlerts checkpoint on mount", () => { + render( + + + , + ) + + expect(mockSaveCheckpoint).toHaveBeenCalledWith("backupAlerts") + }) }) diff --git a/__tests__/screens/spark-onboarding/backup-confirm-screen.spec.tsx b/__tests__/screens/spark-onboarding/backup-confirm-screen.spec.tsx index 1adf20dde8..0df5edbab3 100644 --- a/__tests__/screens/spark-onboarding/backup-confirm-screen.spec.tsx +++ b/__tests__/screens/spark-onboarding/backup-confirm-screen.spec.tsx @@ -11,6 +11,28 @@ jest.mock("react-native-inappbrowser-reborn", () => ({ default: { open: jest.fn(() => Promise.resolve()) }, })) +jest.mock("@app/screens/account-migration/hooks", () => ({ + useMigrationCheckpoint: () => ({ saveCheckpoint: jest.fn() }), + MigrationCheckpoint: { + BackupMethod: "backupMethod", + CloudBackup: "cloudBackup", + BackupAlerts: "backupAlerts", + }, +})) + +jest.mock("@app/graphql/generated", () => ({ + ...jest.requireActual("@app/graphql/generated"), + useHomeAuthedQuery: () => ({ + data: { + me: { + defaultAccount: { + wallets: [{ balance: 1000, walletCurrency: "BTC" }], + }, + }, + }, + }), +})) + const mockNavigate = jest.fn() jest.mock("@react-navigation/native", () => ({ ...jest.requireActual("@react-navigation/native"), diff --git a/__tests__/screens/spark-onboarding/backup-method-screen.spec.tsx b/__tests__/screens/spark-onboarding/backup-method-screen.spec.tsx index 309e1355b6..390cd84db7 100644 --- a/__tests__/screens/spark-onboarding/backup-method-screen.spec.tsx +++ b/__tests__/screens/spark-onboarding/backup-method-screen.spec.tsx @@ -23,6 +23,15 @@ jest.mock("@app/screens/spark-onboarding/hooks", () => ({ }), })) +const mockSaveCheckpoint = jest.fn() + +jest.mock("@app/screens/account-migration/hooks", () => ({ + useMigrationCheckpoint: () => ({ + saveCheckpoint: mockSaveCheckpoint, + }), + MigrationCheckpoint: { BackupMethod: "backupMethod" }, +})) + jest.mock("@app/components/atomic/galoy-primary-button", () => ({ GaloyPrimaryButton: ({ title, @@ -152,4 +161,14 @@ describe("SparkBackupMethodScreen", () => { ).toEqual({ disabled: true }) expect(getByText(LL.SparkOnboarding.BackupMethod.iOSComingSoon())).toBeTruthy() }) + + it("saves BackupMethod checkpoint on mount", () => { + render( + + + , + ) + + expect(mockSaveCheckpoint).toHaveBeenCalledWith("backupMethod") + }) }) diff --git a/__tests__/screens/spark-onboarding/backup-success-screen.spec.tsx b/__tests__/screens/spark-onboarding/backup-success-screen.spec.tsx index 54299c564c..47eb5fab99 100644 --- a/__tests__/screens/spark-onboarding/backup-success-screen.spec.tsx +++ b/__tests__/screens/spark-onboarding/backup-success-screen.spec.tsx @@ -31,6 +31,13 @@ jest.mock("@app/components/success-animation/success-text-animation", () => { } }) +const mockClearCheckpoint = jest.fn() +jest.mock("@app/screens/account-migration/hooks", () => ({ + useMigrationCheckpoint: () => ({ + clearCheckpoint: mockClearCheckpoint, + }), +})) + const mockDispatch = jest.fn() jest.mock("@react-navigation/native", () => ({ ...jest.requireActual("@react-navigation/native"), @@ -89,4 +96,18 @@ describe("SparkBackupSuccessScreen", () => { }), ) }) + + it("clears migration checkpoint on navigation", async () => { + render( + + + , + ) + + await new Promise((resolve) => { + setTimeout(resolve, 50) + }) + + expect(mockClearCheckpoint).toHaveBeenCalled() + }) }) diff --git a/__tests__/utils/external.spec.ts b/__tests__/utils/external.spec.ts new file mode 100644 index 0000000000..5a391c58ba --- /dev/null +++ b/__tests__/utils/external.spec.ts @@ -0,0 +1,48 @@ +import { Linking } from "react-native" + +import { openExternalUrl, openWhatsApp } from "@app/utils/external" + +const mockOpen = jest.fn() + +jest.mock("react-native-inappbrowser-reborn", () => ({ + default: { open: (...args: readonly unknown[]) => mockOpen(...args) }, + __esModule: true, +})) + +jest.spyOn(Linking, "openURL").mockResolvedValue(undefined) + +describe("openExternalUrl", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("opens URL in InAppBrowser", async () => { + mockOpen.mockResolvedValue(undefined) + await openExternalUrl("https://example.com") + + expect(mockOpen).toHaveBeenCalledWith("https://example.com") + expect(Linking.openURL).not.toHaveBeenCalled() + }) + + it("falls back to Linking when InAppBrowser fails", async () => { + mockOpen.mockRejectedValue(new Error("unavailable")) + await openExternalUrl("https://example.com") + + expect(mockOpen).toHaveBeenCalledWith("https://example.com") + expect(Linking.openURL).toHaveBeenCalledWith("https://example.com") + }) +}) + +describe("openWhatsApp", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it("opens whatsapp URL with encoded phone and message", async () => { + await openWhatsApp("+1234567890", "Hello World") + + expect(Linking.openURL).toHaveBeenCalledWith( + "whatsapp://send?phone=%2B1234567890&text=Hello%20World", + ) + }) +}) diff --git a/app/components/atomic/galoy-icon/galoy-icon.tsx b/app/components/atomic/galoy-icon/galoy-icon.tsx index ee7383ccda..5413e9398c 100644 --- a/app/components/atomic/galoy-icon/galoy-icon.tsx +++ b/app/components/atomic/galoy-icon/galoy-icon.tsx @@ -15,6 +15,7 @@ import { BookOpenIcon, CalculatorIcon, CalendarIcon, + ClockIcon, CaretDownIcon, CaretLeftIcon, CaretRightIcon, @@ -132,6 +133,7 @@ const phosphorIconMap = { "bank": BankIcon, "bell": BellIcon, "book": BookIcon, + "clock": ClockIcon, "brush": PaintBrushIcon, "book-open": BookOpenIcon, "calculator": CalculatorIcon, diff --git a/app/components/icon-hero/icon-hero.tsx b/app/components/icon-hero/icon-hero.tsx index 6ad9bdd7be..8f8a1b7503 100644 --- a/app/components/icon-hero/icon-hero.tsx +++ b/app/components/icon-hero/icon-hero.tsx @@ -50,7 +50,6 @@ const useStyles = makeStyles(({ colors }) => ({ textContainer: { alignItems: "center", gap: 8, - width: 264, }, title: { fontSize: 20, diff --git a/app/components/numbered-steps-list/index.ts b/app/components/numbered-steps-list/index.ts new file mode 100644 index 0000000000..250c92e6d2 --- /dev/null +++ b/app/components/numbered-steps-list/index.ts @@ -0,0 +1 @@ +export { NumberedStepsList } from "./numbered-steps-list" diff --git a/app/components/numbered-steps-list/numbered-steps-list.tsx b/app/components/numbered-steps-list/numbered-steps-list.tsx new file mode 100644 index 0000000000..7e9c696d7d --- /dev/null +++ b/app/components/numbered-steps-list/numbered-steps-list.tsx @@ -0,0 +1,45 @@ +import React from "react" +import { Text, View } from "react-native" + +import { makeStyles } from "@rn-vui/themed" + +type NumberedStepsListProps = { + steps: ReadonlyArray +} + +export const NumberedStepsList: React.FC = ({ steps }) => { + const styles = useStyles() + + return ( + + {steps.map((content, index) => ( + + {`${index + 1}.`} + {content} + + ))} + + ) +} + +const useStyles = makeStyles(({ colors }) => ({ + container: { + gap: 4, + paddingTop: 14, + }, + row: { + flexDirection: "row", + }, + base: { + fontSize: 16, + fontWeight: "400", + lineHeight: 22, + color: colors.black, + }, + number: { + width: 20, + }, + text: { + flex: 1, + }, +})) diff --git a/app/components/rich-text/rich-text.tsx b/app/components/rich-text/rich-text.tsx index 968a9851a8..b5f909353e 100644 --- a/app/components/rich-text/rich-text.tsx +++ b/app/components/rich-text/rich-text.tsx @@ -1,28 +1,45 @@ import React from "react" +import { StyleProp, TextStyle } from "react-native" import { makeStyles, Text } from "@rn-vui/themed" +type TagHandler = { + style?: StyleProp + onPress?: () => void +} + type RichTextProps = { text: string - bold: string + tags?: Record + style?: StyleProp } -export const RichText: React.FC = ({ text, bold }) => { +const SPLIT_PATTERN = /(<\w+>.*?<\/\w+>)/g +const TAG_PATTERN = /^<(\w+)>(.*)<\/\1>$/ + +export const RichText: React.FC = ({ text, tags, style }) => { const styles = useStyles() - if (!text.includes(bold)) { - return {text} + const allTags: Record = { + bold: { style: styles.bold }, + link: { style: styles.link }, + ...tags, } - const [before, after] = text.split(bold) + const parts = text.split(SPLIT_PATTERN).map((part, i) => { + const match = part.match(TAG_PATTERN) + if (!match) return part - return ( - - {before} - {bold} - {after} - - ) + const [, tag, inner] = match + const handler = allTags[tag] + return ( + + {inner} + + ) + }) + + return {parts} } const useStyles = makeStyles(({ colors }) => ({ @@ -35,4 +52,8 @@ const useStyles = makeStyles(({ colors }) => ({ fontWeight: "700", color: colors.black, }, + link: { + textDecorationLine: "underline", + color: colors.black, + }, })) diff --git a/app/components/status-screen-layout/index.ts b/app/components/status-screen-layout/index.ts new file mode 100644 index 0000000000..5378a0ed43 --- /dev/null +++ b/app/components/status-screen-layout/index.ts @@ -0,0 +1 @@ +export { StatusScreenLayout } from "./status-screen-layout" diff --git a/app/components/status-screen-layout/status-screen-layout.tsx b/app/components/status-screen-layout/status-screen-layout.tsx new file mode 100644 index 0000000000..0d28f26650 --- /dev/null +++ b/app/components/status-screen-layout/status-screen-layout.tsx @@ -0,0 +1,64 @@ +import React from "react" +import { View } from "react-native" + +import { makeStyles } from "@rn-vui/themed" + +import { GaloyIcon, IconNamesType } from "@app/components/atomic/galoy-icon" + +type StatusScreenLayoutProps = { + icon: IconNamesType + iconSize?: number + iconBackgroundColor?: string + children: React.ReactNode + footer?: React.ReactNode +} + +export const StatusScreenLayout: React.FC = ({ + icon, + iconSize = 72, + iconBackgroundColor = "transparent", + children, + footer, +}) => { + const styles = useStyles({ iconBackgroundColor }) + + return ( + + + + + + {children} + + {footer ? {footer} : null} + + ) +} + +type StyleProps = { + iconBackgroundColor: string +} + +const useStyles = makeStyles((_theme, { iconBackgroundColor }: StyleProps) => ({ + container: { + flex: 1, + justifyContent: "space-between", + }, + content: { + flex: 1, + justifyContent: "center", + alignItems: "center", + paddingHorizontal: 40, + gap: 26, + }, + iconCircle: { + padding: 10, + borderRadius: 59, + backgroundColor: iconBackgroundColor, + }, + footer: { + paddingHorizontal: 20, + paddingBottom: 20, + paddingTop: 10, + }, +})) diff --git a/app/i18n/en/index.ts b/app/i18n/en/index.ts index 571abf743d..8be0decbe2 100644 --- a/app/i18n/en/index.ts +++ b/app/i18n/en/index.ts @@ -3632,6 +3632,18 @@ const en: BaseTranslation = { }, }, }, + AccountMigration: { + moveToNonCustodial: "Move to non-custodial", + explainerTitle: "What does it mean to move to non-custodial?", + explainerStep1: + "You will create a non-custodial account on the Spark protocol, learn more here", + explainerStep2: + "We transfer your funds into your new non-custodial account, and your current account will be deleted", + explainerStep3: "Continue using Blink as usual", + letsMove: "Let's move", + transferringFunds: + "Transferring your funds. It should be done in a few seconds.", + }, } export default en diff --git a/app/i18n/i18n-types.ts b/app/i18n/i18n-types.ts index a58c43fa0a..ccce87ccb3 100644 --- a/app/i18n/i18n-types.ts +++ b/app/i18n/i18n-types.ts @@ -11498,6 +11498,36 @@ type RootTranslation = { } } } + AccountMigration: { + /** + * M​o​v​e​ ​t​o​ ​n​o​n​-​c​u​s​t​o​d​i​a​l + */ + moveToNonCustodial: string + /** + * W​h​a​t​ ​d​o​e​s​ ​i​t​ ​m​e​a​n​ ​t​o​ ​m​o​v​e​ ​t​o​ ​n​o​n​-​c​u​s​t​o​d​i​a​l​? + */ + explainerTitle: string + /** + * Y​o​u​ ​w​i​l​l​ ​c​r​e​a​t​e​ ​a​ ​n​o​n​-​c​u​s​t​o​d​i​a​l​ ​a​c​c​o​u​n​t​ ​o​n​ ​t​h​e​ ​S​p​a​r​k​ ​p​r​o​t​o​c​o​l​,​ ​<​l​i​n​k​>​l​e​a​r​n​ ​m​o​r​e​ ​h​e​r​e​<​/​l​i​n​k​> + */ + explainerStep1: string + /** + * W​e​ ​t​r​a​n​s​f​e​r​ ​y​o​u​r​ ​f​u​n​d​s​ ​i​n​t​o​ ​y​o​u​r​ ​n​e​w​ ​n​o​n​-​c​u​s​t​o​d​i​a​l​ ​a​c​c​o​u​n​t​,​ ​a​n​d​ ​y​o​u​r​ ​c​u​r​r​e​n​t​ ​a​c​c​o​u​n​t​ ​w​i​l​l​ ​b​e​ ​d​e​l​e​t​e​d + */ + explainerStep2: string + /** + * C​o​n​t​i​n​u​e​ ​u​s​i​n​g​ ​B​l​i​n​k​ ​a​s​ ​u​s​u​a​l + */ + explainerStep3: string + /** + * L​e​t​'​s​ ​m​o​v​e + */ + letsMove: string + /** + * T​r​a​n​s​f​e​r​r​i​n​g​ ​y​o​u​r​ ​f​u​n​d​s​.​ ​I​t​ ​s​h​o​u​l​d​ ​b​e​ ​d​o​n​e​ ​i​n​ ​a​ ​f​e​w​ ​s​e​c​o​n​d​s​. + */ + transferringFunds: string + } } export type TranslationFunctions = { @@ -22846,6 +22876,36 @@ export type TranslationFunctions = { } } } + AccountMigration: { + /** + * Move to non-custodial + */ + moveToNonCustodial: () => LocalizedString + /** + * What does it mean to move to non-custodial? + */ + explainerTitle: () => LocalizedString + /** + * You will create a non-custodial account on the Spark protocol, learn more here + */ + explainerStep1: () => LocalizedString + /** + * We transfer your funds into your new non-custodial account, and your current account will be deleted + */ + explainerStep2: () => LocalizedString + /** + * Continue using Blink as usual + */ + explainerStep3: () => LocalizedString + /** + * Let's move + */ + letsMove: () => LocalizedString + /** + * Transferring your funds. It should be done in a few seconds. + */ + transferringFunds: () => LocalizedString + } } export type Formatters = { diff --git a/app/i18n/raw-i18n/source/en.json b/app/i18n/raw-i18n/source/en.json index 39c66967be..4e4b7a4202 100644 --- a/app/i18n/raw-i18n/source/en.json +++ b/app/i18n/raw-i18n/source/en.json @@ -3483,5 +3483,14 @@ "title": "Welcome to non-custodial Blink" } } + }, + "AccountMigration": { + "moveToNonCustodial": "Move to non-custodial", + "explainerTitle": "What does it mean to move to non-custodial?", + "explainerStep1": "You will create a non-custodial account on the Spark protocol, learn more here", + "explainerStep2": "We transfer your funds into your new non-custodial account, and your current account will be deleted", + "explainerStep3": "Continue using Blink as usual", + "letsMove": "Let's move", + "transferringFunds": "Transferring your funds. It should be done in a few seconds." } } diff --git a/app/i18n/raw-i18n/translations/af.json b/app/i18n/raw-i18n/translations/af.json index 2236ae2806..02f8dc0de4 100644 --- a/app/i18n/raw-i18n/translations/af.json +++ b/app/i18n/raw-i18n/translations/af.json @@ -3534,5 +3534,14 @@ "existingBackupMessage": "'n Rugsteun bestaan reeds in jou {provider}. Wil jy dit oorskryf?", "overwrite": "Oorskryf" } + }, + "AccountMigration": { + "moveToNonCustodial": "Skuif na nie-bewaarde", + "explainerTitle": "Wat beteken dit om na nie-bewaarde te skuif?", + "explainerStep1": "Jy sal 'n nie-bewaarde rekening op die Spark-protokol skep, leer meer hier", + "explainerStep2": "Ons dra jou fondse oor na jou nuwe nie-bewaarde rekening en jou huidige rekening sal verwyder word", + "explainerStep3": "Gebruik Blink voort soos gewoonlik", + "letsMove": "Kom ons gaan", + "transferringFunds": "Jou fondse word oorgedra. Dit behoort binne 'n paar sekondes klaar te wees." } } diff --git a/app/i18n/raw-i18n/translations/ar.json b/app/i18n/raw-i18n/translations/ar.json index ff60aaee85..8eb2881c69 100644 --- a/app/i18n/raw-i18n/translations/ar.json +++ b/app/i18n/raw-i18n/translations/ar.json @@ -3531,5 +3531,14 @@ "existingBackupMessage": "توجد نسخة احتياطية بالفعل في {provider} الخاص بك. هل تريد استبدالها؟", "overwrite": "استبدال" } + }, + "AccountMigration": { + "moveToNonCustodial": "الانتقال إلى غير حفظي", + "explainerTitle": "ماذا يعني الانتقال إلى غير حفظي؟", + "explainerStep1": "ستقوم بإنشاء حساب غير حفظي على بروتوكول Spark، اعرف المزيد هنا", + "explainerStep2": "سننقل أموالك إلى حسابك الجديد غير الحفظي وسيتم حذف حسابك الحالي", + "explainerStep3": "استمر في استخدام Blink كالمعتاد", + "letsMove": "هيا بنا", + "transferringFunds": "جارٍ تحويل أموالك. سيتم الانتهاء في غضون ثوانٍ قليلة." } } diff --git a/app/i18n/raw-i18n/translations/ca.json b/app/i18n/raw-i18n/translations/ca.json index e1342f9a93..e82abae987 100644 --- a/app/i18n/raw-i18n/translations/ca.json +++ b/app/i18n/raw-i18n/translations/ca.json @@ -3493,5 +3493,14 @@ "existingBackupMessage": "Ja existeix una còpia de seguretat al teu {provider}. Vols sobreescriure-la?", "overwrite": "Sobreescriure" } + }, + "AccountMigration": { + "moveToNonCustodial": "Passa a no custodial", + "explainerTitle": "Què significa passar a no custodial?", + "explainerStep1": "Crearàs un compte no custodial al protocol Spark, més informació aquí", + "explainerStep2": "Transferirem els teus fons al teu nou compte no custodial i el teu compte actual serà eliminat", + "explainerStep3": "Continua fent servir Blink com sempre", + "letsMove": "Anem-hi", + "transferringFunds": "Transferint els teus fons. Hauria d'estar llest en uns segons." } } diff --git a/app/i18n/raw-i18n/translations/cs.json b/app/i18n/raw-i18n/translations/cs.json index c154ea3fbb..1aa0a7185d 100644 --- a/app/i18n/raw-i18n/translations/cs.json +++ b/app/i18n/raw-i18n/translations/cs.json @@ -3534,5 +3534,14 @@ "existingBackupMessage": "Ve vašem {provider} již existuje záloha. Chcete ji přepsat?", "overwrite": "Přepsat" } + }, + "AccountMigration": { + "moveToNonCustodial": "Přejít na non-custodial", + "explainerTitle": "Co znamená přejít na non-custodial?", + "explainerStep1": "Vytvoříte non-custodial účet na protokolu Spark, zjistit více zde", + "explainerStep2": "Převedeme vaše prostředky na váš nový non-custodial účet a váš stávající účet bude smazán", + "explainerStep3": "Pokračujte v používání Blink jako obvykle", + "letsMove": "Pojďme na to", + "transferringFunds": "Převádíme vaše prostředky. Mělo by to být hotové za několik sekund." } } diff --git a/app/i18n/raw-i18n/translations/da.json b/app/i18n/raw-i18n/translations/da.json index d47b11ab46..b742429a76 100644 --- a/app/i18n/raw-i18n/translations/da.json +++ b/app/i18n/raw-i18n/translations/da.json @@ -3511,5 +3511,14 @@ "existingBackupMessage": "Der findes allerede en sikkerhedskopi i din {provider}. Vil du overskrive den?", "overwrite": "Overskriv" } + }, + "AccountMigration": { + "moveToNonCustodial": "Flyt til non-custodial", + "explainerTitle": "Hvad betyder det at flytte til non-custodial?", + "explainerStep1": "Du opretter en non-custodial konto på Spark-protokollen, lær mere her", + "explainerStep2": "Vi overfører dine midler til din nye non-custodial konto, og din nuværende konto vil blive slettet", + "explainerStep3": "Fortsæt med at bruge Blink som normalt", + "letsMove": "Lad os gå", + "transferringFunds": "Overfører dine midler. Det skulle være færdigt om få sekunder." } } diff --git a/app/i18n/raw-i18n/translations/de.json b/app/i18n/raw-i18n/translations/de.json index 87a013e7b8..cd37a08cb4 100644 --- a/app/i18n/raw-i18n/translations/de.json +++ b/app/i18n/raw-i18n/translations/de.json @@ -3481,5 +3481,14 @@ "existingBackupMessage": "Es existiert bereits eine Sicherung in Ihrem {provider}. Möchten Sie sie überschreiben?", "overwrite": "Überschreiben" } + }, + "AccountMigration": { + "moveToNonCustodial": "Zu Non-Custodial wechseln", + "explainerTitle": "Was bedeutet es, zu Non-Custodial zu wechseln?", + "explainerStep1": "Du erstellst ein Non-Custodial-Konto auf dem Spark-Protokoll, hier mehr erfahren", + "explainerStep2": "Wir übertragen deine Guthaben auf dein neues Non-Custodial-Konto und dein aktuelles Konto wird gelöscht", + "explainerStep3": "Nutze Blink weiterhin wie gewohnt", + "letsMove": "Los geht's", + "transferringFunds": "Deine Guthaben werden übertragen. Es sollte in wenigen Sekunden abgeschlossen sein." } } diff --git a/app/i18n/raw-i18n/translations/el.json b/app/i18n/raw-i18n/translations/el.json index 0d86175cc5..e3782514c5 100644 --- a/app/i18n/raw-i18n/translations/el.json +++ b/app/i18n/raw-i18n/translations/el.json @@ -3493,5 +3493,14 @@ "existingBackupMessage": "Υπάρχει ήδη αντίγραφο ασφαλείας στο {provider} σας. Θέλετε να το αντικαταστήσετε;", "overwrite": "Αντικατάσταση" } + }, + "AccountMigration": { + "moveToNonCustodial": "Μετάβαση σε non-custodial", + "explainerTitle": "Τι σημαίνει η μετάβαση σε non-custodial;", + "explainerStep1": "Θα δημιουργήσετε έναν non-custodial λογαριασμό στο πρωτόκολλο Spark, μάθετε περισσότερα εδώ", + "explainerStep2": "Θα μεταφέρουμε τα κεφάλαιά σας στον νέο σας non-custodial λογαριασμό και ο τρέχων λογαριασμός σας θα διαγραφεί", + "explainerStep3": "Συνεχίστε να χρησιμοποιείτε το Blink κανονικά", + "letsMove": "Πάμε", + "transferringFunds": "Μεταφορά των κεφαλαίων σας. Θα ολοκληρωθεί σε λίγα δευτερόλεπτα." } } diff --git a/app/i18n/raw-i18n/translations/es.json b/app/i18n/raw-i18n/translations/es.json index de4d87a9f3..45331bcd4c 100644 --- a/app/i18n/raw-i18n/translations/es.json +++ b/app/i18n/raw-i18n/translations/es.json @@ -3481,5 +3481,14 @@ "existingBackupMessage": "Ya existe un respaldo en tu {provider}. ¿Deseas sobrescribirlo?", "overwrite": "Sobrescribir" } + }, + "AccountMigration": { + "moveToNonCustodial": "Migrar a no custodia", + "explainerTitle": "¿Qué significa migrar a no custodia?", + "explainerStep1": "Crearás una cuenta no custodial en el protocolo Spark, aprende más aquí", + "explainerStep2": "Transferiremos tus fondos a tu nueva cuenta no custodial y tu cuenta actual será eliminada", + "explainerStep3": "Continúa usando Blink como siempre", + "letsMove": "¡Vamos!", + "transferringFunds": "Transfiriendo tus fondos. Debería completarse en unos segundos." } } diff --git a/app/i18n/raw-i18n/translations/fr.json b/app/i18n/raw-i18n/translations/fr.json index efc7c1b1fb..aaf74b44d5 100644 --- a/app/i18n/raw-i18n/translations/fr.json +++ b/app/i18n/raw-i18n/translations/fr.json @@ -3522,5 +3522,14 @@ "existingBackupMessage": "Une sauvegarde existe déjà dans votre {provider}. Voulez-vous la remplacer ?", "overwrite": "Remplacer" } + }, + "AccountMigration": { + "moveToNonCustodial": "Passer au non-custodial", + "explainerTitle": "Que signifie passer au non-custodial ?", + "explainerStep1": "Vous allez créer un compte non-custodial sur le protocole Spark, en savoir plus ici", + "explainerStep2": "Nous transférons vos fonds vers votre nouveau compte non-custodial et votre compte actuel sera supprimé", + "explainerStep3": "Continuez à utiliser Blink comme d'habitude", + "letsMove": "Allons-y", + "transferringFunds": "Transfert de vos fonds en cours. Cela devrait être terminé en quelques secondes." } } diff --git a/app/i18n/raw-i18n/translations/hr.json b/app/i18n/raw-i18n/translations/hr.json index bc7d7a4202..a1a4cbb3b4 100644 --- a/app/i18n/raw-i18n/translations/hr.json +++ b/app/i18n/raw-i18n/translations/hr.json @@ -3534,5 +3534,14 @@ "existingBackupMessage": "Sigurnosna kopija već postoji u vašem {provider}. Želite li je prebrisati?", "overwrite": "Prebriši" } + }, + "AccountMigration": { + "moveToNonCustodial": "Prebaci na ne-skrbnički", + "explainerTitle": "Što znači prebaciti na ne-skrbnički?", + "explainerStep1": "Stvorit ćete ne-skrbnički račun na Spark protokolu, saznajte više ovdje", + "explainerStep2": "Prebacujemo vaša sredstva na vaš novi ne-skrbnički račun, a vaš trenutni račun bit će izbrisan", + "explainerStep3": "Nastavite koristiti Blink kao i obično", + "letsMove": "Idemo", + "transferringFunds": "Prebacujemo vaša sredstva. Trebalo bi biti gotovo za nekoliko sekundi." } } diff --git a/app/i18n/raw-i18n/translations/hu.json b/app/i18n/raw-i18n/translations/hu.json index cc00467ffa..0fcb73036e 100644 --- a/app/i18n/raw-i18n/translations/hu.json +++ b/app/i18n/raw-i18n/translations/hu.json @@ -3493,5 +3493,14 @@ "existingBackupMessage": "Már létezik biztonsági mentés a {provider} tárhelyeden. Felül szeretnéd írni?", "overwrite": "Felülírás" } + }, + "AccountMigration": { + "moveToNonCustodial": "Áttérés non-custodial-ra", + "explainerTitle": "Mit jelent a non-custodial-ra való áttérés?", + "explainerStep1": "Létrehozol egy non-custodial fiókot a Spark protokollon, tudj meg többet itt", + "explainerStep2": "Átutaljuk az egyenlegedet az új non-custodial fiókodba, és a jelenlegi fiókod törlésre kerül", + "explainerStep3": "Használd tovább a Blinket a megszokott módon", + "letsMove": "Rajta", + "transferringFunds": "Átutalás folyamatban. Néhány másodperc alatt befejeződik." } } diff --git a/app/i18n/raw-i18n/translations/hy.json b/app/i18n/raw-i18n/translations/hy.json index 02c3f83d64..ceb4185969 100644 --- a/app/i18n/raw-i18n/translations/hy.json +++ b/app/i18n/raw-i18n/translations/hy.json @@ -3534,5 +3534,14 @@ "existingBackupMessage": "Ձեր {provider}-ում արդեն պահուստային պատճեն կա: Ուզում եք վերագրե՞լ:", "overwrite": "Վերագրել" } + }, + "AccountMigration": { + "moveToNonCustodial": "Տեղափոխվել ոչ պահառուի", + "explainerTitle": "Ի՞նչ է նշանակում տեղափոխվել ոչ պահառուի:", + "explainerStep1": "Դուք կստեղծեք ոչ պահառու հաշիվ Spark արձանագրության վրա, իմացեք ավելին այստեղ", + "explainerStep2": "Մենք կփոխանցենք ձեր միջոցները ձեր նոր ոչ պահառու հաշվին, իսկ ձեր ընթացիկ հաշիվը կհեռացվի", + "explainerStep3": "Շարունակեք օգտագործել Blink-ը ինչպես միշտ", + "letsMove": "Եկեք տեղափոխվենք", + "transferringFunds": "Ձեր միջոցները փոխանցվում են։ Դա պետք է ավարտվի մի քանի վայրկյանի ընթացքում։" } } diff --git a/app/i18n/raw-i18n/translations/id.json b/app/i18n/raw-i18n/translations/id.json index 7e2677133a..c2a89505a2 100644 --- a/app/i18n/raw-i18n/translations/id.json +++ b/app/i18n/raw-i18n/translations/id.json @@ -3493,5 +3493,14 @@ "existingBackupMessage": "Cadangan sudah ada di {provider} Anda. Apakah Anda ingin menimpanya?", "overwrite": "Timpa" } + }, + "AccountMigration": { + "moveToNonCustodial": "Pindah ke non-custodial", + "explainerTitle": "Apa artinya pindah ke non-custodial?", + "explainerStep1": "Anda akan membuat akun non-custodial di protokol Spark, pelajari lebih lanjut di sini", + "explainerStep2": "Kami mentransfer dana Anda ke akun non-custodial baru dan akun Anda saat ini akan dihapus", + "explainerStep3": "Terus gunakan Blink seperti biasa", + "letsMove": "Ayo mulai", + "transferringFunds": "Mentransfer dana Anda. Seharusnya selesai dalam beberapa detik." } } diff --git a/app/i18n/raw-i18n/translations/it.json b/app/i18n/raw-i18n/translations/it.json index 328609d084..17656648ec 100644 --- a/app/i18n/raw-i18n/translations/it.json +++ b/app/i18n/raw-i18n/translations/it.json @@ -3481,5 +3481,14 @@ "existingBackupMessage": "Esiste già un backup nel tuo {provider}. Vuoi sovrascriverlo?", "overwrite": "Sovrascrivi" } + }, + "AccountMigration": { + "moveToNonCustodial": "Passa a non-custodial", + "explainerTitle": "Cosa significa passare a non-custodial?", + "explainerStep1": "Creerai un account non-custodial sul protocollo Spark, scopri di più qui", + "explainerStep2": "Trasferiamo i tuoi fondi nel tuo nuovo account non-custodial e il tuo account attuale verrà eliminato", + "explainerStep3": "Continua a usare Blink come al solito", + "letsMove": "Andiamo", + "transferringFunds": "Trasferimento dei tuoi fondi in corso. Dovrebbe completarsi in pochi secondi." } } diff --git a/app/i18n/raw-i18n/translations/ja.json b/app/i18n/raw-i18n/translations/ja.json index 7aa2925e37..f8db5fc516 100644 --- a/app/i18n/raw-i18n/translations/ja.json +++ b/app/i18n/raw-i18n/translations/ja.json @@ -3522,5 +3522,14 @@ "existingBackupMessage": "{provider}にバックアップが既に存在します。上書きしますか?", "overwrite": "上書き" } + }, + "AccountMigration": { + "moveToNonCustodial": "ノンカストディアルに移行", + "explainerTitle": "ノンカストディアルへの移行とは?", + "explainerStep1": "Sparkプロトコル上にノンカストディアルアカウントを作成します、詳細はこちら", + "explainerStep2": "お客様の資金を新しいノンカストディアルアカウントに転送し、現在のアカウントは削除されます", + "explainerStep3": "これまで通りBlinkをご利用ください", + "letsMove": "移行する", + "transferringFunds": "資金を転送中です。数秒で完了するはずです。" } } diff --git a/app/i18n/raw-i18n/translations/lg.json b/app/i18n/raw-i18n/translations/lg.json index 009b46f1fa..f51d9c0c42 100644 --- a/app/i18n/raw-i18n/translations/lg.json +++ b/app/i18n/raw-i18n/translations/lg.json @@ -3493,5 +3493,14 @@ "existingBackupMessage": "Waliwo kopi mu {provider} yo. Oyagala okugisimbulira?", "overwrite": "Simbulira" } + }, + "AccountMigration": { + "moveToNonCustodial": "Tuuka ku non-custodial", + "explainerTitle": "Kitegeeza ki okutuuka ku non-custodial?", + "explainerStep1": "Ojja okukola akawunti ya non-custodial ku Spark protocol, yiga ebisingawo wano", + "explainerStep2": "Tutwala ssente zo ku akawunti yo empya eya non-custodial n'akawunti yo eriwo ejja kusazibwamu", + "explainerStep3": "Weyongere okukozesa Blink nga bulijjo", + "letsMove": "Ka tugende", + "transferringFunds": "Tutwala ssente zo. Kijja kuggwa mu sikonda ntono." } } diff --git a/app/i18n/raw-i18n/translations/ms.json b/app/i18n/raw-i18n/translations/ms.json index 11a49f554c..162a4c484f 100644 --- a/app/i18n/raw-i18n/translations/ms.json +++ b/app/i18n/raw-i18n/translations/ms.json @@ -3534,5 +3534,14 @@ "existingBackupMessage": "Sandaran sudah wujud di {provider} anda. Adakah anda mahu menimpanya?", "overwrite": "Tulis ganti" } + }, + "AccountMigration": { + "moveToNonCustodial": "Beralih ke non-custodial", + "explainerTitle": "Apakah maksud beralih ke non-custodial?", + "explainerStep1": "Anda akan membuat akaun non-custodial di protokol Spark, ketahui lebih lanjut di sini", + "explainerStep2": "Kami memindahkan dana anda ke akaun non-custodial baharu dan akaun semasa anda akan dipadamkan", + "explainerStep3": "Teruskan menggunakan Blink seperti biasa", + "letsMove": "Jom", + "transferringFunds": "Memindahkan dana anda. Ia sepatutnya siap dalam beberapa saat." } } diff --git a/app/i18n/raw-i18n/translations/nl.json b/app/i18n/raw-i18n/translations/nl.json index cdb3f7ab5e..a01e213186 100644 --- a/app/i18n/raw-i18n/translations/nl.json +++ b/app/i18n/raw-i18n/translations/nl.json @@ -3534,5 +3534,14 @@ "existingBackupMessage": "Er bestaat al een back-up in je {provider}. Wil je deze overschrijven?", "overwrite": "Overschrijven" } + }, + "AccountMigration": { + "moveToNonCustodial": "Overstappen naar non-custodial", + "explainerTitle": "Wat betekent overstappen naar non-custodial?", + "explainerStep1": "Je maakt een non-custodial account aan op het Spark-protocol, lees hier meer", + "explainerStep2": "We dragen je saldo over naar je nieuwe non-custodial account en je huidige account wordt verwijderd", + "explainerStep3": "Blijf Blink gebruiken zoals gewoonlijk", + "letsMove": "Laten we gaan", + "transferringFunds": "Je saldo wordt overgedragen. Het zou binnen enkele seconden klaar moeten zijn." } } diff --git a/app/i18n/raw-i18n/translations/pt.json b/app/i18n/raw-i18n/translations/pt.json index 8886cc72f0..3c08dadb9f 100644 --- a/app/i18n/raw-i18n/translations/pt.json +++ b/app/i18n/raw-i18n/translations/pt.json @@ -3481,5 +3481,14 @@ "existingBackupMessage": "Já existe um backup no seu {provider}. Deseja sobrescrevê-lo?", "overwrite": "Sobrescrever" } + }, + "AccountMigration": { + "moveToNonCustodial": "Migrar para não custodial", + "explainerTitle": "O que significa migrar para não custodial?", + "explainerStep1": "Você criará uma conta não custodial no protocolo Spark, saiba mais aqui", + "explainerStep2": "Transferiremos seus fundos para sua nova conta não custodial e sua conta atual será excluída", + "explainerStep3": "Continue usando o Blink como de costume", + "letsMove": "Vamos lá", + "transferringFunds": "Transferindo seus fundos. Deve ser concluído em poucos segundos." } } diff --git a/app/i18n/raw-i18n/translations/qu.json b/app/i18n/raw-i18n/translations/qu.json index 2aa206b123..1d671a7dda 100644 --- a/app/i18n/raw-i18n/translations/qu.json +++ b/app/i18n/raw-i18n/translations/qu.json @@ -3531,5 +3531,14 @@ "existingBackupMessage": "{provider} nisqaykipi huknin waqaychay kachkanña. Qawariyta munankichu?", "overwrite": "Hawamanta qillqay" } + }, + "AccountMigration": { + "moveToNonCustodial": "Non-custodial nisqaman kutiy", + "explainerTitle": "Imataq niyta munan non-custodial nisqaman kutiy?", + "explainerStep1": "Spark protocol nisqapi non-custodial akawuntita ruwanki, astawanta yachay kaypi", + "explainerStep2": "Qullqiykita musuq non-custodial akawuntiykiman apasun, kunan akawuntiykitaq puchukanqa", + "explainerStep3": "Blink nisqata llamkachiy imaynallapas", + "letsMove": "Haku", + "transferringFunds": "Qullqiykita apachkaniku. Iskay kinsa sikundullapi tukurunqa." } } diff --git a/app/i18n/raw-i18n/translations/ro.json b/app/i18n/raw-i18n/translations/ro.json index b3c9fcd328..205fa3ada6 100644 --- a/app/i18n/raw-i18n/translations/ro.json +++ b/app/i18n/raw-i18n/translations/ro.json @@ -3493,5 +3493,14 @@ "existingBackupMessage": "Există deja o copie de siguranță în {provider} tău. Vrei să o suprascrii?", "overwrite": "Suprascrie" } + }, + "AccountMigration": { + "moveToNonCustodial": "Treci la non-custodial", + "explainerTitle": "Ce înseamnă să treci la non-custodial?", + "explainerStep1": "Vei crea un cont non-custodial pe protocolul Spark, află mai multe aici", + "explainerStep2": "Transferăm fondurile tale în noul tău cont non-custodial, iar contul actual va fi șters", + "explainerStep3": "Continuă să folosești Blink ca de obicei", + "letsMove": "Hai să mergem", + "transferringFunds": "Se transferă fondurile tale. Ar trebui să fie gata în câteva secunde." } } diff --git a/app/i18n/raw-i18n/translations/sk.json b/app/i18n/raw-i18n/translations/sk.json index 7e457af957..85f8c02f97 100644 --- a/app/i18n/raw-i18n/translations/sk.json +++ b/app/i18n/raw-i18n/translations/sk.json @@ -3493,5 +3493,14 @@ "existingBackupMessage": "Vo vašom {provider} už existuje záloha. Chcete ju prepísať?", "overwrite": "Prepísať" } + }, + "AccountMigration": { + "moveToNonCustodial": "Prejsť na non-custodial", + "explainerTitle": "Čo znamená prejsť na non-custodial?", + "explainerStep1": "Vytvoríte non-custodial účet na protokole Spark, zistiť viac tu", + "explainerStep2": "Prevedieme vaše prostriedky na váš nový non-custodial účet a váš súčasný účet bude vymazaný", + "explainerStep3": "Pokračujte v používaní Blink ako zvyčajne", + "letsMove": "Poďme na to", + "transferringFunds": "Prevádzame vaše prostriedky. Malo by to byť hotové za niekoľko sekúnd." } } diff --git a/app/i18n/raw-i18n/translations/sr.json b/app/i18n/raw-i18n/translations/sr.json index 0ac10c9c25..740e8b316d 100644 --- a/app/i18n/raw-i18n/translations/sr.json +++ b/app/i18n/raw-i18n/translations/sr.json @@ -3531,5 +3531,14 @@ "existingBackupMessage": "Резервна копија већ постоји у вашем {provider}. Желите ли да је препишете?", "overwrite": "Препиши" } + }, + "AccountMigration": { + "moveToNonCustodial": "Пребаци на non-custodial", + "explainerTitle": "Шта значи пребацити на non-custodial?", + "explainerStep1": "Креираћете non-custodial налог на Spark протоколу, сазнајте више овде", + "explainerStep2": "Пребацујемо ваша средства на ваш нови non-custodial налог, а ваш тренутни налог ће бити обрисан", + "explainerStep3": "Наставите да користите Blink као и обично", + "letsMove": "Идемо", + "transferringFunds": "Пребацујемо ваша средства. Требало би да буде готово за неколико секунди." } } diff --git a/app/i18n/raw-i18n/translations/sw.json b/app/i18n/raw-i18n/translations/sw.json index 5cb562877e..9d4a222355 100644 --- a/app/i18n/raw-i18n/translations/sw.json +++ b/app/i18n/raw-i18n/translations/sw.json @@ -3493,5 +3493,14 @@ "existingBackupMessage": "Tayari kuna nakala kwenye {provider} yako. Je, ungependa kuibadilisha?", "overwrite": "Badilisha" } + }, + "AccountMigration": { + "moveToNonCustodial": "Hamia kwa non-custodial", + "explainerTitle": "Inamaanisha nini kuhamia kwa non-custodial?", + "explainerStep1": "Utaunda akaunti ya non-custodial kwenye itifaki ya Spark, jifunze zaidi hapa", + "explainerStep2": "Tunahamisha fedha zako kwenye akaunti yako mpya ya non-custodial na akaunti yako ya sasa itafutwa", + "explainerStep3": "Endelea kutumia Blink kama kawaida", + "letsMove": "Twende", + "transferringFunds": "Tunahamisha fedha zako. Inapaswa kukamilika ndani ya sekunde chache." } } diff --git a/app/i18n/raw-i18n/translations/th.json b/app/i18n/raw-i18n/translations/th.json index 439b74156f..49a56fb9a0 100644 --- a/app/i18n/raw-i18n/translations/th.json +++ b/app/i18n/raw-i18n/translations/th.json @@ -3531,5 +3531,14 @@ "existingBackupMessage": "มีข้อมูลสำรองอยู่แล้วใน {provider} ของคุณ คุณต้องการเขียนทับหรือไม่?", "overwrite": "เขียนทับ" } + }, + "AccountMigration": { + "moveToNonCustodial": "ย้ายไปแบบ non-custodial", + "explainerTitle": "การย้ายไปแบบ non-custodial หมายความว่าอย่างไร?", + "explainerStep1": "คุณจะสร้างบัญชีแบบ non-custodial บนโปรโตคอล Spark เรียนรู้เพิ่มเติมที่นี่", + "explainerStep2": "เราจะโอนเงินของคุณไปยังบัญชี non-custodial ใหม่และบัญชีปัจจุบันของคุณจะถูกลบ", + "explainerStep3": "ใช้ Blink ต่อไปได้ตามปกติ", + "letsMove": "ไปกันเลย", + "transferringFunds": "กำลังโอนเงินของคุณ จะเสร็จสิ้นภายในไม่กี่วินาที" } } diff --git a/app/i18n/raw-i18n/translations/tr.json b/app/i18n/raw-i18n/translations/tr.json index fd6b87c3d2..b72e64f3cc 100644 --- a/app/i18n/raw-i18n/translations/tr.json +++ b/app/i18n/raw-i18n/translations/tr.json @@ -3493,5 +3493,14 @@ "existingBackupMessage": "{provider} hesabınızda zaten bir yedek var. Üzerine yazmak ister misiniz?", "overwrite": "Üzerine yaz" } + }, + "AccountMigration": { + "moveToNonCustodial": "Non-custodial'a geç", + "explainerTitle": "Non-custodial'a geçmek ne anlama gelir?", + "explainerStep1": "Spark protokolünde bir non-custodial hesap oluşturacaksınız, buradan daha fazla bilgi edinin", + "explainerStep2": "Bakiyenizi yeni non-custodial hesabınıza aktarıyoruz ve mevcut hesabınız silinecek", + "explainerStep3": "Blink'i her zamanki gibi kullanmaya devam edin", + "letsMove": "Hadi başlayalım", + "transferringFunds": "Bakiyeniz aktarılıyor. Birkaç saniye içinde tamamlanacaktır." } } diff --git a/app/i18n/raw-i18n/translations/vi.json b/app/i18n/raw-i18n/translations/vi.json index a452fecd51..aae5a9a0c2 100644 --- a/app/i18n/raw-i18n/translations/vi.json +++ b/app/i18n/raw-i18n/translations/vi.json @@ -3531,5 +3531,14 @@ "existingBackupMessage": "Đã có bản sao lưu trong {provider} của bạn. Bạn có muốn ghi đè không?", "overwrite": "Ghi đè" } + }, + "AccountMigration": { + "moveToNonCustodial": "Chuyển sang non-custodial", + "explainerTitle": "Chuyển sang non-custodial nghĩa là gì?", + "explainerStep1": "Bạn sẽ tạo một tài khoản non-custodial trên giao thức Spark, tìm hiểu thêm tại đây", + "explainerStep2": "Chúng tôi chuyển tiền của bạn vào tài khoản non-custodial mới và tài khoản hiện tại của bạn sẽ bị xóa", + "explainerStep3": "Tiếp tục sử dụng Blink như bình thường", + "letsMove": "Bắt đầu nào", + "transferringFunds": "Đang chuyển tiền của bạn. Sẽ hoàn tất trong vài giây." } } diff --git a/app/i18n/raw-i18n/translations/xh.json b/app/i18n/raw-i18n/translations/xh.json index 8e772a0b38..ddfd6249ba 100644 --- a/app/i18n/raw-i18n/translations/xh.json +++ b/app/i18n/raw-i18n/translations/xh.json @@ -3540,5 +3540,14 @@ "existingBackupMessage": "Sele kukho ikopi kwi-{provider} yakho. Ungathanda ukuyibhala phezu kwayo?", "overwrite": "Bhala phezu kwayo" } + }, + "AccountMigration": { + "moveToNonCustodial": "Fudukela kwi-non-custodial", + "explainerTitle": "Kuthetha ntoni ukufudukela kwi-non-custodial?", + "explainerStep1": "Uza kudala i-akhawunti ye-non-custodial kwi-Spark protocol, funda ngakumbi apha", + "explainerStep2": "Sidlulisela imali yakho kwi-akhawunti yakho entsha ye-non-custodial kwaye i-akhawunti yakho yangoku iya kucinywa", + "explainerStep3": "Qhubeka usebenzise i-Blink njengoko uqhele", + "letsMove": "Masiqale", + "transferringFunds": "Sidlulisela imali yakho. Kufanele kugqitywe ngomzuzwana nje." } } diff --git a/app/navigation/root-navigator.tsx b/app/navigation/root-navigator.tsx index 6ed2ff020d..6873438f01 100644 --- a/app/navigation/root-navigator.tsx +++ b/app/navigation/root-navigator.tsx @@ -130,6 +130,10 @@ import { SparkBackupConfirmScreen, SparkBackupSuccessScreen, } from "@app/screens/spark-onboarding" +import { + SparkMigrationExplainerScreen, + TransferringFundsScreen, +} from "@app/screens/account-migration" import { OnboardingStackParamList, PeopleStackParamList, @@ -732,6 +736,16 @@ export const RootStack = () => { component={SparkBackupSuccessScreen} options={{ headerShown: false }} /> + + ) } diff --git a/app/navigation/stack-param-lists.ts b/app/navigation/stack-param-lists.ts index b12c9419fa..3821e39562 100644 --- a/app/navigation/stack-param-lists.ts +++ b/app/navigation/stack-param-lists.ts @@ -187,6 +187,8 @@ export type RootStackParamList = { challenges: Array<{ index: number; word: string }> } sparkBackupSuccessScreen: undefined + sparkMigrationExplainer: undefined + sparkMigrationTransferringFunds: undefined } export type OnboardingStackParamList = { diff --git a/app/rne-theme/colors.ts b/app/rne-theme/colors.ts index d19f007d7a..57da1a6598 100644 --- a/app/rne-theme/colors.ts +++ b/app/rne-theme/colors.ts @@ -13,6 +13,7 @@ const light = { _green: "#00A700", _primary1: "#FFBE0B", _primary2: "#FB5607", + _warningLight: "#FFF9E5", // adjusted white: "#FFFFFF", @@ -64,6 +65,7 @@ const dark = { _green: "#00A700", _primary1: "#FFBE0B", _primary2: "#FB5607", + _warningLight: "#FFF9E5", // adjusted white: "#000000", diff --git a/app/rne-theme/themed.d.ts b/app/rne-theme/themed.d.ts index 953042af47..73af85cd4f 100644 --- a/app/rne-theme/themed.d.ts +++ b/app/rne-theme/themed.d.ts @@ -22,6 +22,7 @@ declare module "@rn-vui/themed" { _green: string _primary1: string _primary2: string + _warningLight: string primary3: string primary4: string diff --git a/app/screens/account-migration/hooks/index.ts b/app/screens/account-migration/hooks/index.ts new file mode 100644 index 0000000000..9696993ad8 --- /dev/null +++ b/app/screens/account-migration/hooks/index.ts @@ -0,0 +1 @@ +export { useMigrationCheckpoint, MigrationCheckpoint } from "./use-migration-checkpoint" diff --git a/app/screens/account-migration/hooks/use-migration-checkpoint.ts b/app/screens/account-migration/hooks/use-migration-checkpoint.ts new file mode 100644 index 0000000000..56733df224 --- /dev/null +++ b/app/screens/account-migration/hooks/use-migration-checkpoint.ts @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useRef, useState } from "react" + +import { useAppConfig } from "@app/hooks/use-app-config" + +import { + MigrationCheckpoint, + clearCheckpointFromStorage, + getStorageKey, + loadCheckpoint, + resolveCheckpointRoute, + saveCheckpointToStorage, +} from "../utils/migration-checkpoint-storage" + +export { MigrationCheckpoint } + +export const useMigrationCheckpoint = () => { + const [checkpoint, setCheckpoint] = useState(null) + const [loading, setLoading] = useState(true) + const mountedRef = useRef(true) + + const { + appConfig: { + galoyInstance: { name: environment }, + }, + } = useAppConfig() + + const storageKey = getStorageKey(environment) + + useEffect(() => { + mountedRef.current = true + + loadCheckpoint(storageKey).then((stored) => { + if (!mountedRef.current) return + if (stored) setCheckpoint(stored.step) + setLoading(false) + }) + + return () => { + mountedRef.current = false + } + }, [storageKey]) + + const saveCheckpoint = useCallback( + (step: MigrationCheckpoint) => { + setCheckpoint(step) + saveCheckpointToStorage(storageKey, step) + }, + [storageKey], + ) + + const clearCheckpoint = useCallback(() => { + setCheckpoint(null) + clearCheckpointFromStorage(storageKey) + }, [storageKey]) + + const getRouteForCheckpoint = useCallback( + () => resolveCheckpointRoute(checkpoint), + [checkpoint], + ) + + return { checkpoint, loading, saveCheckpoint, clearCheckpoint, getRouteForCheckpoint } +} diff --git a/app/screens/account-migration/index.ts b/app/screens/account-migration/index.ts new file mode 100644 index 0000000000..54821267af --- /dev/null +++ b/app/screens/account-migration/index.ts @@ -0,0 +1,3 @@ +export { MigrationExplainerLayout } from "./migration-explainer-layout" +export { SparkMigrationExplainerScreen } from "./to-non-custodial/explainer-screen" +export { TransferringFundsScreen } from "./to-non-custodial/transferring-funds-screen" diff --git a/app/screens/account-migration/migration-explainer-layout.tsx b/app/screens/account-migration/migration-explainer-layout.tsx new file mode 100644 index 0000000000..67e590631b --- /dev/null +++ b/app/screens/account-migration/migration-explainer-layout.tsx @@ -0,0 +1,53 @@ +import React from "react" +import { ScrollView, View } from "react-native" +import { makeStyles } from "@rn-vui/themed" + +import { GaloyPrimaryButton } from "@app/components/atomic/galoy-primary-button" +import { IconNamesType } from "@app/components/atomic/galoy-icon" +import { IconHero } from "@app/components/icon-hero" +import { NumberedStepsList } from "@app/components/numbered-steps-list" +import { Screen } from "@app/components/screen" + +type MigrationExplainerLayoutProps = { + icon: IconNamesType + iconColor: string + title: string + steps: ReadonlyArray + ctaTitle: string + onCtaPress: () => void +} + +export const MigrationExplainerLayout: React.FC = ({ + icon, + iconColor, + title, + steps, + ctaTitle, + onCtaPress, +}) => { + const styles = useStyles() + + return ( + + + + + + + + + + + ) +} + +const useStyles = makeStyles(() => ({ + scrollContent: { + paddingHorizontal: 20, + }, + buttonsContainer: { + paddingHorizontal: 20, + paddingBottom: 20, + paddingTop: 10, + }, +})) diff --git a/app/screens/account-migration/to-non-custodial/explainer-screen.tsx b/app/screens/account-migration/to-non-custodial/explainer-screen.tsx new file mode 100644 index 0000000000..7097090fb3 --- /dev/null +++ b/app/screens/account-migration/to-non-custodial/explainer-screen.tsx @@ -0,0 +1,47 @@ +import React, { useMemo } from "react" +import { useNavigation } from "@react-navigation/native" +import { StackNavigationProp } from "@react-navigation/stack" +import { useTheme } from "@rn-vui/themed" + +import { RichText } from "@app/components/rich-text" +import { useRemoteConfig } from "@app/config/feature-flags-context" +import { useI18nContext } from "@app/i18n/i18n-react" +import { RootStackParamList } from "@app/navigation/stack-param-lists" +import { openExternalUrl } from "@app/utils/external" + +import { MigrationExplainerLayout } from "../migration-explainer-layout" + +export const SparkMigrationExplainerScreen: React.FC = () => { + const { LL } = useI18nContext() + const { + theme: { colors }, + } = useTheme() + const navigation = useNavigation>() + const { sparkCompatibleWalletsUrl } = useRemoteConfig() + + const steps: ReadonlyArray = useMemo( + () => [ + openExternalUrl(sparkCompatibleWalletsUrl) }, + }} + />, + LL.AccountMigration.explainerStep2(), + LL.AccountMigration.explainerStep3(), + ], + [LL, sparkCompatibleWalletsUrl], + ) + + return ( + navigation.navigate("sparkBackupMethodScreen")} + /> + ) +} diff --git a/app/screens/account-migration/to-non-custodial/transferring-funds-screen.tsx b/app/screens/account-migration/to-non-custodial/transferring-funds-screen.tsx new file mode 100644 index 0000000000..ad2d0320f3 --- /dev/null +++ b/app/screens/account-migration/to-non-custodial/transferring-funds-screen.tsx @@ -0,0 +1,46 @@ +import React, { useEffect } from "react" +import { Text } from "react-native" + +import { useNavigation } from "@react-navigation/native" +import { StackNavigationProp } from "@react-navigation/stack" +import { makeStyles, useTheme } from "@rn-vui/themed" + +import { Screen } from "@app/components/screen" +import { StatusScreenLayout } from "@app/components/status-screen-layout" +import { useI18nContext } from "@app/i18n/i18n-react" +import { RootStackParamList } from "@app/navigation/stack-param-lists" + +export const TransferringFundsScreen: React.FC = () => { + const { LL } = useI18nContext() + const styles = useStyles() + const { + theme: { colors }, + } = useTheme() + const navigation = useNavigation>() + + // TODO: replace with real funds transfer logic + useEffect(() => { + const timeout = setTimeout(() => { + navigation.replace("sparkBackupSuccessScreen") + }, 2000) + return () => clearTimeout(timeout) + }, [navigation]) + + return ( + + + {LL.AccountMigration.transferringFunds()} + + + ) +} + +const useStyles = makeStyles(({ colors }) => ({ + message: { + fontSize: 18, + lineHeight: 24, + fontWeight: "400", + color: colors.black, + textAlign: "center", + }, +})) diff --git a/app/screens/account-migration/utils/migration-checkpoint-storage.ts b/app/screens/account-migration/utils/migration-checkpoint-storage.ts new file mode 100644 index 0000000000..c24877a362 --- /dev/null +++ b/app/screens/account-migration/utils/migration-checkpoint-storage.ts @@ -0,0 +1,96 @@ +import { Platform } from "react-native" + +import { loadJson, remove, saveJson } from "@app/utils/storage" + +// Values are persisted to AsyncStorage — do not rename +export enum MigrationCheckpoint { + BackupMethod = "backupMethod", + CloudBackup = "cloudBackup", + BackupAlerts = "backupAlerts", +} + +type StoredCheckpoint = { + step: MigrationCheckpoint + savedAt: number +} + +type ResumeRoute = + | "sparkMigrationExplainer" + | "sparkBackupMethodScreen" + | "sparkCloudBackupScreen" + | "sparkBackupAlertsScreen" + +const STORAGE_KEY_PREFIX = "migrationCheckpoint" + +const CHECKPOINT_EXPIRATION_MS = 48 * 60 * 60 * 1000 // 48h + +const CHECKPOINT_ROUTE_MAP: Record = { + [MigrationCheckpoint.BackupMethod]: "sparkBackupMethodScreen", + [MigrationCheckpoint.CloudBackup]: "sparkCloudBackupScreen", + [MigrationCheckpoint.BackupAlerts]: "sparkBackupAlertsScreen", +} + +const DEFAULT_ROUTE: ResumeRoute = "sparkMigrationExplainer" + +export const getStorageKey = (environment: string): string => + `${STORAGE_KEY_PREFIX}_${environment.toLowerCase()}` + +export const isExpired = ( + checkpoint: StoredCheckpoint, + now: number = Date.now(), +): boolean => now - checkpoint.savedAt > CHECKPOINT_EXPIRATION_MS + +export const validateStoredCheckpoint = (raw: unknown): StoredCheckpoint | null => { + if (!raw || typeof raw !== "object") return null + + const { step, savedAt } = raw as StoredCheckpoint + + if (!Object.values(MigrationCheckpoint).includes(step)) return null + if (typeof savedAt !== "number") return null + + return { step, savedAt } +} + +export const resolveCheckpointRoute = ( + checkpoint: MigrationCheckpoint | null, +): ResumeRoute => { + if (!checkpoint) return DEFAULT_ROUTE + + if (checkpoint === MigrationCheckpoint.CloudBackup && Platform.OS === "ios") { + return DEFAULT_ROUTE + } + + return CHECKPOINT_ROUTE_MAP[checkpoint] +} + +export const loadCheckpoint = async ( + storageKey: string, +): Promise => { + try { + const raw = await loadJson(storageKey) + const parsed = validateStoredCheckpoint(raw) + + if (!parsed) return null + + if (isExpired(parsed)) { + await remove(storageKey) + return null + } + + return parsed + } catch { + await remove(storageKey).catch(() => {}) + return null + } +} + +export const saveCheckpointToStorage = async ( + storageKey: string, + step: MigrationCheckpoint, +): Promise => { + await saveJson(storageKey, { step, savedAt: Date.now() }) +} + +export const clearCheckpointFromStorage = async (storageKey: string): Promise => { + await remove(storageKey) +} diff --git a/app/screens/settings-screen/settings-screen.tsx b/app/screens/settings-screen/settings-screen.tsx index badc5d1a8e..1289e7adbe 100644 --- a/app/screens/settings-screen/settings-screen.tsx +++ b/app/screens/settings-screen/settings-screen.tsx @@ -36,6 +36,7 @@ import { NotificationSetting } from "./settings/sp-notifications" import { OnDeviceSecuritySetting } from "./settings/sp-security" import { TotpSetting } from "./totp" import { AccountStaticQR } from "./settings/account-static-qr" +import { MoveToNonCustodialSetting } from "./settings/account-move-to-noncustodial" import { SwitchAccountSetting } from "./settings/multi-account" // All queries in settings have to be set here so that the server is not hit with @@ -84,7 +85,12 @@ export const SettingsScreen: React.FC = () => { }) const items = { - account: [AccountLevelSetting, TxLimits, SwitchAccountSetting], + account: [ + AccountLevelSetting, + TxLimits, + SwitchAccountSetting, + MoveToNonCustodialSetting, + ], waysToGetPaid: [AccountLNAddress, PhoneLnAddress, AccountPOS, AccountStaticQR], loginMethods: [EmailSetting, PhoneSetting], preferences: [ diff --git a/app/screens/settings-screen/settings/account-move-to-noncustodial.tsx b/app/screens/settings-screen/settings/account-move-to-noncustodial.tsx new file mode 100644 index 0000000000..452ef73bc3 --- /dev/null +++ b/app/screens/settings-screen/settings/account-move-to-noncustodial.tsx @@ -0,0 +1,25 @@ +import React from "react" + +import { useNavigation } from "@react-navigation/native" +import { StackNavigationProp } from "@react-navigation/stack" + +import { useI18nContext } from "@app/i18n/i18n-react" +import { RootStackParamList } from "@app/navigation/stack-param-lists" +import { useMigrationCheckpoint } from "@app/screens/account-migration/hooks" + +import { SettingsRow } from "../row" + +export const MoveToNonCustodialSetting: React.FC = () => { + const { LL } = useI18nContext() + const { navigate } = useNavigation>() + const { loading, getRouteForCheckpoint } = useMigrationCheckpoint() + + return ( + navigate(getRouteForCheckpoint())} + /> + ) +} diff --git a/app/screens/spark-onboarding/backup-method-screen.tsx b/app/screens/spark-onboarding/backup-method-screen.tsx index eed972dab4..8bf40dc70d 100644 --- a/app/screens/spark-onboarding/backup-method-screen.tsx +++ b/app/screens/spark-onboarding/backup-method-screen.tsx @@ -1,4 +1,4 @@ -import React from "react" +import React, { useEffect } from "react" import { View } from "react-native" import { Text, makeStyles, useTheme } from "@rn-vui/themed" @@ -8,6 +8,10 @@ import { GaloySecondaryButton } from "@app/components/atomic/galoy-secondary-but import { IconHero } from "@app/components/icon-hero" import { Screen } from "@app/components/screen" import { useI18nContext } from "@app/i18n/i18n-react" +import { + MigrationCheckpoint, + useMigrationCheckpoint, +} from "@app/screens/account-migration/hooks" import { testProps } from "@app/utils/testProps" import { useBackupMethods } from "./hooks" @@ -20,6 +24,7 @@ export const SparkBackupMethodScreen: React.FC = () => { theme: { colors }, } = useTheme() + const { saveCheckpoint } = useMigrationCheckpoint() const { isCloudBackupAvailable, keychainLoading, @@ -28,6 +33,10 @@ export const SparkBackupMethodScreen: React.FC = () => { handleManualBackup, } = useBackupMethods() + useEffect(() => { + saveCheckpoint(MigrationCheckpoint.BackupMethod) + }, [saveCheckpoint]) + const cloudProvider = getCloudProviderName(LL) return ( diff --git a/app/screens/spark-onboarding/backup-success-screen.tsx b/app/screens/spark-onboarding/backup-success-screen.tsx index 3529665b0d..628af45251 100644 --- a/app/screens/spark-onboarding/backup-success-screen.tsx +++ b/app/screens/spark-onboarding/backup-success-screen.tsx @@ -6,15 +6,18 @@ import { CommonActions, useNavigation } from "@react-navigation/native" import { Screen } from "@app/components/screen" import { SuccessScreenLayout } from "@app/components/success-screen-layout" import { useI18nContext } from "@app/i18n/i18n-react" +import { useMigrationCheckpoint } from "@app/screens/account-migration/hooks" export const SparkBackupSuccessScreen: React.FC = () => { const { LL } = useI18nContext() const styles = useStyles() const navigation = useNavigation() + const { clearCheckpoint } = useMigrationCheckpoint() const navigateToHome = useCallback(() => { + clearCheckpoint() navigation.dispatch(CommonActions.reset({ index: 0, routes: [{ name: "Primary" }] })) - }, [navigation]) + }, [navigation, clearCheckpoint]) return ( diff --git a/app/screens/spark-onboarding/cloud-backup-screen.tsx b/app/screens/spark-onboarding/cloud-backup-screen.tsx index e2f410fd68..d6294d7643 100644 --- a/app/screens/spark-onboarding/cloud-backup-screen.tsx +++ b/app/screens/spark-onboarding/cloud-backup-screen.tsx @@ -89,9 +89,8 @@ export const SparkCloudBackupScreen: React.FC = () => { > ${LL.SparkOnboarding.CloudBackup.importantMessageBold()}`, })} - bold={LL.SparkOnboarding.CloudBackup.importantMessageBold()} /> diff --git a/app/screens/spark-onboarding/hooks/use-backup-phrase.ts b/app/screens/spark-onboarding/hooks/use-backup-phrase.ts index de99dd0132..4560e1dc27 100644 --- a/app/screens/spark-onboarding/hooks/use-backup-phrase.ts +++ b/app/screens/spark-onboarding/hooks/use-backup-phrase.ts @@ -1,9 +1,7 @@ import { useCallback, useMemo, useRef } from "react" -import { Linking } from "react-native" import { useNavigation } from "@react-navigation/native" import { StackNavigationProp } from "@react-navigation/stack" -import InAppBrowser from "react-native-inappbrowser-reborn" import { useRemoteConfig } from "@app/config/feature-flags-context" import { useClipboard, useCountdown } from "@app/hooks" @@ -11,6 +9,7 @@ import { useWalletMnemonicWords } from "@app/hooks/use-wallet-mnemonic" import { useI18nContext } from "@app/i18n/i18n-react" import { PhraseStep, RootStackParamList } from "@app/navigation/stack-param-lists" import { formatDuration } from "@app/utils/date" +import { openExternalUrl } from "@app/utils/external" import { buildConfirmChallenges } from "../utils" @@ -49,10 +48,7 @@ export const useBackupPhrase = (step: PhraseStep) => { }, [copyToClipboard, LL, words]) const handleOpenLink = useCallback( - () => - InAppBrowser.open(sparkCompatibleWalletsUrl).catch(() => - Linking.openURL(sparkCompatibleWalletsUrl), - ), + () => openExternalUrl(sparkCompatibleWalletsUrl), [sparkCompatibleWalletsUrl], ) diff --git a/app/screens/spark-onboarding/manual-backup/backup-alerts-screen.tsx b/app/screens/spark-onboarding/manual-backup/backup-alerts-screen.tsx index 774f3d87fb..8365571a22 100644 --- a/app/screens/spark-onboarding/manual-backup/backup-alerts-screen.tsx +++ b/app/screens/spark-onboarding/manual-backup/backup-alerts-screen.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState } from "react" +import React, { useEffect, useRef, useState } from "react" import { Animated, View } from "react-native" import { makeStyles, useTheme } from "@rn-vui/themed" @@ -11,6 +11,10 @@ import { IconHero } from "@app/components/icon-hero" import { Screen } from "@app/components/screen" import { useI18nContext } from "@app/i18n/i18n-react" import { PhraseStep, RootStackParamList } from "@app/navigation/stack-param-lists" +import { + MigrationCheckpoint, + useMigrationCheckpoint, +} from "@app/screens/account-migration/hooks" const ANIM_DURATION = 300 @@ -21,6 +25,11 @@ export const SparkBackupAlertsScreen: React.FC = () => { theme: { colors }, } = useTheme() const navigation = useNavigation>() + const { saveCheckpoint } = useMigrationCheckpoint() + + useEffect(() => { + saveCheckpoint(MigrationCheckpoint.BackupAlerts) + }, [saveCheckpoint]) const [checks, setChecks] = useState([false, false, false]) const [visibleCount, setVisibleCount] = useState(1) diff --git a/app/screens/spark-onboarding/manual-backup/backup-confirm-screen.tsx b/app/screens/spark-onboarding/manual-backup/backup-confirm-screen.tsx index 226b5a0e6a..420cf02eda 100644 --- a/app/screens/spark-onboarding/manual-backup/backup-confirm-screen.tsx +++ b/app/screens/spark-onboarding/manual-backup/backup-confirm-screen.tsx @@ -9,6 +9,7 @@ import { GaloyIcon } from "@app/components/atomic/galoy-icon" import { GaloyPrimaryButton } from "@app/components/atomic/galoy-primary-button" import { Screen } from "@app/components/screen" import { SuggestionBar } from "@app/components/suggestion-bar" +import { useHomeAuthedQuery } from "@app/graphql/generated" import { useI18nContext } from "@app/i18n/i18n-react" import { RootStackParamList } from "@app/navigation/stack-param-lists" @@ -25,9 +26,17 @@ export const SparkBackupConfirmScreen: React.FC = () => { const navigation = useNavigation>() const { challenges } = useRoute().params + const { data: { me } = {} } = useHomeAuthedQuery({ fetchPolicy: "cache-first" }) + const hasFunds = + me?.defaultAccount?.wallets?.some((wallet) => wallet.balance > 0) ?? false + const onComplete = useCallback(() => { + if (hasFunds) { + navigation.navigate("sparkMigrationTransferringFunds") + return + } navigation.navigate("sparkBackupSuccessScreen") - }, [navigation]) + }, [navigation, hasFunds]) const { inputs, diff --git a/app/utils/external.ts b/app/utils/external.ts index 0ef5c37de9..b4fe88d261 100644 --- a/app/utils/external.ts +++ b/app/utils/external.ts @@ -1,4 +1,5 @@ import { Linking } from "react-native" +import InAppBrowser from "react-native-inappbrowser-reborn" export const openWhatsApp: (number: string, message: string) => Promise = async ( number: string, @@ -9,3 +10,11 @@ export const openWhatsApp: (number: string, message: string) => Promise = message, )}`, ) + +export const openExternalUrl = async (url: string): Promise => { + try { + await InAppBrowser.open(url) + } catch { + await Linking.openURL(url) + } +} diff --git a/jest.config.js b/jest.config.js index 24a67d6194..327997f492 100644 --- a/jest.config.js +++ b/jest.config.js @@ -54,6 +54,7 @@ module.exports = { "|react-native-nfc-manager" + "|uuid" + "|@formatjs" + + "|react-native-inappbrowser-reborn" + ")/)", ], }