diff --git a/app/index.tsx b/app/index.tsx index 6e80a9f..cde2f65 100644 --- a/app/index.tsx +++ b/app/index.tsx @@ -90,8 +90,13 @@ export default function LibraryScreen() { () => setAction({ kind: 'community' }), [], ); - const { backupSupported, backupEnabled, backupSubtitle, toggleBackup } = - useBackup(refresh); + const { + backupSupported, + backupEnabled, + backupSubtitle, + toggleBackup, + setBackupPreference, + } = useBackup(refresh, onboarding.ready && !onboarding.visible); const { dismissCommunity, joinCommunity, rateOpenNotes } = useLibrarySupport({ canShowAutomaticPrompt: onboarding.ready && !onboarding.visible && action === null, @@ -330,6 +335,8 @@ export default function LibraryScreen() { void | Promise; + /** Shows the iCloud backup question as the final slide (iOS only). */ + showBackupSlide?: boolean; + /** Called with the user's explicit choice on the backup slide. */ + onChooseBackup?: (enabled: boolean) => void; } export function OnboardingExperience({ visible, onComplete, + showBackupSlide = false, + onChooseBackup, }: OnboardingExperienceProps) { const theme = useTheme(); const insets = useSafeAreaInsets(); const { width, height, fontScale } = useWindowDimensions(); - const listRef = useRef>(null); + const listRef = useRef>(null); const [page, setPage] = useState(0); + const slides = useMemo( + () => (showBackupSlide ? [...SLIDES, BACKUP_SLIDE] : SLIDES), + [showBackupSlide], + ); + const isLastPage = page === slides.length - 1; + const isBackupPage = showBackupSlide && isLastPage; + useEffect(() => { if (!visible) return; setPage(0); @@ -70,7 +99,8 @@ export function OnboardingExperience({ }, [onComplete]); const next = useCallback(() => { - if (page === SLIDES.length - 1) { + if (isLastPage) { + if (isBackupPage) onChooseBackup?.(true); finish(); return; } @@ -78,10 +108,15 @@ export function OnboardingExperience({ void Haptics.selectionAsync(); setPage(nextPage); listRef.current?.scrollToIndex({ index: nextPage, animated: true }); - }, [finish, page]); + }, [finish, isBackupPage, isLastPage, onChooseBackup, page]); + + const declineBackup = useCallback(() => { + onChooseBackup?.(false); + finish(); + }, [finish, onChooseBackup]); const renderSlide = useCallback( - ({ item, index }: ListRenderItemInfo<(typeof SLIDES)[number]>) => ( + ({ item, index }: ListRenderItemInfo) => ( - {SLIDES.map((slide, index) => ( + {slides.map((slide, index) => ( item.key} horizontal @@ -187,14 +222,39 @@ export function OnboardingExperience({ numberOfLines={1} style={styles.primaryButtonText} > - {page === SLIDES.length - 1 ? 'Start writing' : 'Continue'} + {isBackupPage + ? 'Enable iCloud backup' + : isLastPage + ? 'Start writing' + : 'Continue'} + {isBackupPage ? ( + [styles.secondaryButton, pressed && styles.pressed]} + > + + Continue without backup + + + ) : null} @@ -210,7 +270,7 @@ function OnboardingSlide({ }: { active: boolean; fontScale: number; - item: (typeof SLIDES)[number]; + item: OnboardingSlideItem; viewportHeight: number; width: number; }) { @@ -389,4 +449,14 @@ const styles = StyleSheet.create({ fontWeight: '600', letterSpacing: -0.2, }, + secondaryButton: { + alignItems: 'center', + justifyContent: 'center', + marginTop: spacing.sm, + minHeight: 44, + }, + secondaryButtonText: { + fontSize: 15, + fontWeight: '500', + }, }); diff --git a/src/hooks/useBackup.ts b/src/hooks/useBackup.ts index 05f3647..7588381 100644 --- a/src/hooks/useBackup.ts +++ b/src/hooks/useBackup.ts @@ -17,6 +17,11 @@ export interface UseBackupResult { backupEnabled: boolean | null; backupSubtitle: string; toggleBackup: (enabled: boolean) => void; + /** + * Sets the preference directly, without the confirmation dialog. For flows + * where the user is already answering an explicit question (onboarding). + */ + setBackupPreference: (enabled: boolean) => void; } const BACKUP_SUPPORTED = Platform.OS === 'ios'; @@ -25,8 +30,15 @@ const BACKUP_SUPPORTED = Platform.OS === 'ios'; * Backup state for the library screen: the enable switch, a status line, and * a one-time restore offer when the library is empty but a backup exists * (fresh install after the app was deleted). + * + * `canAutoSync` gates the automatic restore-check/startup-sync until + * onboarding has finished, so nothing is mirrored before a fresh user has + * answered the backup question. An explicit choice always syncs immediately. */ -export function useBackup(refreshLibrary: () => Promise): UseBackupResult { +export function useBackup( + refreshLibrary: () => Promise, + canAutoSync = true, +): UseBackupResult { const [enabled, setEnabled] = useState(null); const [available, setAvailable] = useState(null); const restorePromptShownRef = useRef(false); @@ -102,14 +114,18 @@ export function useBackup(refreshLibrary: () => Promise): UseBackupResult }, [refreshLibrary]); useEffect(() => { - if (!BACKUP_SUPPORTED) return; + if (!BACKUP_SUPPORTED || !canAutoSync) return; void offerRestore(); - }, [offerRestore]); + }, [canAutoSync, offerRestore]); + + const setBackupPreference = useCallback((next: boolean) => { + setEnabled(next); + void setBackupEnabled(next); + }, []); const toggleBackup = useCallback((next: boolean) => { if (next) { - setEnabled(true); - void setBackupEnabled(true); + setBackupPreference(true); return; } Alert.alert( @@ -120,14 +136,11 @@ export function useBackup(refreshLibrary: () => Promise): UseBackupResult { text: 'Turn off', style: 'destructive', - onPress: () => { - setEnabled(false); - void setBackupEnabled(false); - }, + onPress: () => setBackupPreference(false), }, ], ); - }, []); + }, [setBackupPreference]); const backupSubtitle = enabled === false @@ -141,5 +154,6 @@ export function useBackup(refreshLibrary: () => Promise): UseBackupResult backupEnabled: enabled, backupSubtitle, toggleBackup, + setBackupPreference, }; } diff --git a/src/services/backupService.ts b/src/services/backupService.ts index bc9dae8..0b94701 100644 --- a/src/services/backupService.ts +++ b/src/services/backupService.ts @@ -14,6 +14,7 @@ import { type RestoreResult, } from './backupEngine'; import { CATALOG_FILENAME } from './catalogStore'; +import { hasCompletedOnboarding } from './onboardingService'; const ENABLED_KEY = '@opennotes:backup:enabled'; const SYNC_DEBOUNCE_MS = 8000; @@ -261,12 +262,30 @@ function startSync(): Promise { return promise; } +/** + * True once the user has been asked the backup question. Automatic syncs + * (debounced pushes, background flushes) must not mirror anything before + * onboarding presents the choice; explicit user actions bypass this via + * flushBackupSync. + */ +async function autoSyncAllowed(): Promise { + try { + return await hasCompletedOnboarding(); + } catch { + return false; + } +} + +async function startSyncIfConsented(): Promise { + if (await autoSyncAllowed()) await startSync(); +} + /** Debounced backup push; called after every successful catalog persist. */ export function scheduleBackupSync(): void { if (syncTimer) clearTimeout(syncTimer); syncTimer = setTimeout(() => { syncTimer = null; - void startSync(); + void startSyncIfConsented(); }, SYNC_DEBOUNCE_MS); } @@ -309,6 +328,8 @@ export function performBackupRestore(): Promise { // app is suspended, mirroring the autosave hook's behavior. AppState.addEventListener('change', (state) => { if ((state === 'background' || state === 'inactive') && syncTimer) { - void flushBackupSync(); + clearTimeout(syncTimer); + syncTimer = null; + void startSyncIfConsented(); } });