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();
}
});