Refresh sync state on app boot to try and push local changes if pending (#1404)

This commit is contained in:
Leendert de Borst
2026-01-03 21:57:52 +01:00
parent 5c37217ce7
commit 82ecee793a
5 changed files with 207 additions and 528 deletions

View File

@@ -792,74 +792,66 @@ export default function AddEditItemScreen(): React.ReactNode {
}
}
await executeVaultMutation(async () => {
if (isEditMode) {
await dbContext.sqliteClient!.items.update(itemToSave, originalAttachmentIds, attachments, originalTotpCodeIds, totpCodes);
/*
* Execute mutation - local save + background sync (non-blocking).
* Navigate immediately after local save; sync happens in background via ServerSyncIndicator.
*/
try {
await executeVaultMutation(async () => {
if (isEditMode) {
await dbContext.sqliteClient!.items.update(itemToSave, originalAttachmentIds, attachments, originalTotpCodeIds, totpCodes);
// Delete passkeys if marked for deletion
if (passkeyIdsMarkedForDeletion.length > 0) {
for (const passkeyId of passkeyIdsMarkedForDeletion) {
await dbContext.sqliteClient!.passkeys.delete(passkeyId);
}
}
} else {
await dbContext.sqliteClient!.items.create(itemToSave, attachments, totpCodes);
}
},
{
/**
* Handle successful save
*/
onSuccess: () => {
/*
* Emit event to notify list and detail views to refresh
* Must be after sync completes so merged data is available
*/
emitter.emit('credentialChanged', itemToSave.Id);
setHasUnsavedChanges(false);
if (serviceUrl && !isEditMode) {
router.replace('/items/autofill-item-created');
} else {
setIsSaving(false);
setIsSaveDisabled(false);
router.dismiss();
setTimeout(() => {
if (isEditMode) {
Toast.show({
type: 'success',
text1: t('items.toasts.itemUpdated'),
position: 'bottom'
});
} else {
Toast.show({
type: 'success',
text1: t('items.toasts.itemCreated'),
position: 'bottom'
});
router.push(`/items/${itemToSave.Id}`);
// Delete passkeys if marked for deletion
if (passkeyIdsMarkedForDeletion.length > 0) {
for (const passkeyId of passkeyIdsMarkedForDeletion) {
await dbContext.sqliteClient!.passkeys.delete(passkeyId);
}
}, 100);
}
} else {
await dbContext.sqliteClient!.items.create(itemToSave, attachments, totpCodes);
}
},
/**
* Handle error saving item
*/
onError: (error) => {
Toast.show({
type: 'error',
text1: t('items.errors.saveFailed'),
text2: error.message,
position: 'bottom'
});
console.error('Error saving item:', error.message);
setIsSaving(false);
setIsSaveDisabled(false);
});
// Emit event to notify list and detail views to refresh
emitter.emit('credentialChanged', itemToSave.Id);
setHasUnsavedChanges(false);
setIsSaving(false);
setIsSaveDisabled(false);
// Navigate immediately - sync continues in background
if (serviceUrl && !isEditMode) {
router.replace('/items/autofill-item-created');
} else {
router.dismiss();
setTimeout(() => {
if (isEditMode) {
Toast.show({
type: 'success',
text1: t('items.toasts.itemUpdated'),
position: 'bottom'
});
} else {
Toast.show({
type: 'success',
text1: t('items.toasts.itemCreated'),
position: 'bottom'
});
router.push(`/items/${itemToSave.Id}`);
}
}, 100);
}
});
} catch (error) {
Toast.show({
type: 'error',
text1: t('items.errors.saveFailed'),
text2: error instanceof Error ? error.message : t('common.errors.unknownError'),
position: 'bottom'
});
console.error('Error saving item:', error);
setIsSaving(false);
setIsSaveDisabled(false);
}
}, [isEditMode, id, serviceUrl, router, executeVaultMutation, dbContext.sqliteClient, webApi, isSaveDisabled, item, fieldValues, applicableSystemFields, customFields, t, originalAttachmentIds, attachments, originalTotpCodeIds, totpCodes, passkeyIdsMarkedForDeletion]);
/**

View File

@@ -1,12 +1,10 @@
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, StyleSheet, TouchableOpacity, View } from 'react-native';
import { StyleSheet, View } from 'react-native';
import { VaultUnlockHelper } from '@/utils/VaultUnlockHelper';
import { useColors } from '@/hooks/useColorScheme';
import { useVaultSync } from '@/hooks/useVaultSync';
import LoadingIndicator from '@/components/LoadingIndicator';
@@ -22,159 +20,20 @@ import NativeVaultManager from '@/specs/NativeVaultManager';
export default function Initialize() : React.ReactNode {
const router = useRouter();
const [status, setStatus] = useState('');
const [showSkipButton, setShowSkipButton] = useState(false);
const hasInitialized = useRef(false);
const skipButtonTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastStatusRef = useRef<string>('');
const canShowSkipButtonRef = useRef(false); // Only allow skip button after vault unlock
const abortControllerRef = useRef<AbortController | null>(null);
const { t } = useTranslation();
const app = useApp();
const navigation = useNavigation();
const { syncVault } = useVaultSync();
const dbContext = useDb();
const colors = useColors();
/**
* Update status with smart skip button logic.
* Normalizes status by removing animation dots and manages skip button visibility.
* Update status message.
*/
const updateStatus = useCallback((message: string): void => {
setStatus(message);
// Normalize status by removing animation dots for comparison
const normalizedMessage = message.replace(/\.+$/, '');
const normalizedLastStatus = lastStatusRef.current.replace(/\.+$/, '');
// Clear any existing timeout
if (skipButtonTimeoutRef.current) {
clearTimeout(skipButtonTimeoutRef.current);
skipButtonTimeoutRef.current = null;
}
// If status changed (excluding dots), hide skip button and reset timer
if (normalizedMessage !== normalizedLastStatus) {
setShowSkipButton(false);
lastStatusRef.current = message;
// Start new timer for the new status (only if skip button is allowed)
if (message && canShowSkipButtonRef.current) {
skipButtonTimeoutRef.current = setTimeout(() => {
setShowSkipButton(true);
}, 5000) as unknown as NodeJS.Timeout;
}
} else {
// Same status (excluding dots) - update ref but keep timer running
lastStatusRef.current = message;
}
}, []);
/**
* Handle offline scenario - show alert with options to open local vault or retry sync.
*/
const handleOfflineFlow = useCallback((): void => {
// Don't show the alert if we're already in offline mode
if (app.isOffline) {
console.debug('Already in offline mode, skipping offline flow alert');
navigation.navigateAfterUnlock();
return;
}
Alert.alert(
t('app.alerts.syncIssue'),
t('app.alerts.syncIssueMessage'),
[
{
text: t('app.alerts.openLocalVault'),
/**
* Handle opening vault in read-only mode.
*/
onPress: async () : Promise<void> => {
updateStatus(t('app.status.openingVaultReadOnly'));
const { enabledAuthMethods } = await app.initializeAuth();
try {
const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase();
// No encrypted database
if (!hasEncryptedDatabase) {
router.replace('/unlock');
return;
}
// Set offline mode
app.setOfflineMode(true);
// FaceID not enabled
const isFaceIDEnabled = enabledAuthMethods.includes('faceid');
if (!isFaceIDEnabled) {
router.replace('/unlock');
return;
}
// Attempt to unlock vault
updateStatus(t('app.status.unlockingVault'));
const isUnlocked = await dbContext.unlockVault();
// Vault couldn't be unlocked
if (!isUnlocked) {
router.replace('/unlock');
return;
}
// Vault successfully unlocked - proceed with decryption
await new Promise(resolve => setTimeout(resolve, 500));
// Migrations pending
if (await dbContext.hasPendingMigrations()) {
router.replace('/upgrade');
return;
}
// Success - use centralized navigation logic
navigation.navigateAfterUnlock();
} catch (err) {
console.error('Error during offline vault unlock:', err);
router.replace('/unlock');
}
}
},
{
text: t('app.alerts.retrySync'),
/**
* Handle retrying the connection.
*/
onPress: () : void => {
updateStatus(t('app.status.retryingConnection'));
setShowSkipButton(false);
// Abort any pending sync operation
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
// Clear any existing timeout
if (skipButtonTimeoutRef.current) {
clearTimeout(skipButtonTimeoutRef.current);
skipButtonTimeoutRef.current = null;
}
// Reset status tracking
lastStatusRef.current = '';
/**
* Reset the hasInitialized flag and navigate to the same route
* to force a re-render and trigger the useEffect again
*/
hasInitialized.current = false;
router.replace('/initialize');
}
}
]
);
}, [dbContext, router, app, navigation, t, updateStatus]);
useEffect(() => {
// Ensure this only runs once.
if (hasInitialized.current) {
@@ -231,9 +90,6 @@ export default function Initialize() : React.ReactNode {
router.replace('/upgrade');
return;
}
// Vault unlocked successfully - now allow skip button for network operations
canShowSkipButtonRef.current = true;
}
} catch (err) {
console.error('Error during initial vault unlock:', err);
@@ -249,98 +105,61 @@ export default function Initialize() : React.ReactNode {
router.replace('/upgrade');
return;
}
/**
* Allow skip button for sync operations since vault is already unlocked.
*/
canShowSkipButtonRef.current = true;
}
// Create abort controller for sync operations
abortControllerRef.current = new AbortController();
/*
* Perform vault sync in background - don't block app access.
* The ServerSyncIndicator will show sync progress/offline status.
* This also handles uploading pending local changes (isDirty) from previous sessions.
*/
dbContext.setIsSyncing(true);
void (async (): Promise<void> => {
try {
await syncVault({
/**
* Handle successful vault sync.
*/
onSuccess: async () => {
// Sync completed - ServerSyncIndicator will update
await dbContext.refreshSyncState();
},
/**
* Handle offline state - just set offline mode and continue.
* The ServerSyncIndicator will show offline status.
*/
onOffline: async () => {
await dbContext.setIsOffline(true);
await dbContext.refreshSyncState();
},
/**
* Handle error during vault sync.
*/
onError: async (error: string) => {
console.error('Vault sync error during initialize:', error);
await dbContext.refreshSyncState();
},
/**
* On upgrade required.
*/
onUpgradeRequired: () : void => {
router.replace('/upgrade');
},
});
} finally {
dbContext.setIsSyncing(false);
await dbContext.refreshSyncState();
}
})();
// Now perform vault sync (network operations - these are skippable)
await syncVault({
abortSignal: abortControllerRef.current.signal,
/**
* Handle the status update.
*/
onStatus: (message) => {
updateStatus(message);
},
/**
* Handle successful vault sync.
*/
onSuccess: async () => {
// Use centralized navigation logic
navigation.navigateAfterUnlock();
},
/**
* Handle offline state and prompt user for action.
*/
onOffline: () => {
handleOfflineFlow();
},
/**
* Handle error during vault sync.
*/
onError: async (error: string) => {
/**
* Authentication errors are already handled in useVaultSync
* Show modal with error message for other errors
*/
Alert.alert(
t('common.error'),
error,
[{ text: t('common.ok'), style: 'default' }]
);
router.replace('/unlock');
return;
},
/**
* On upgrade required.
*/
onUpgradeRequired: () : void => {
router.replace('/upgrade');
},
});
// Navigate immediately - don't wait for sync
navigation.navigateAfterUnlock();
};
initialize();
};
initializeApp();
// Cleanup timeout on unmount
return (): void => {
if (skipButtonTimeoutRef.current) {
clearTimeout(skipButtonTimeoutRef.current);
}
};
}, [dbContext, syncVault, app, router, navigation, t, handleOfflineFlow, updateStatus]);
/**
* Handle skip button press by calling the offline handler.
*/
const handleSkipPress = (): void => {
// Abort any pending sync operation
if (abortControllerRef.current) {
console.debug('Aborting pending sync operation');
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
// Clear any existing timeout
if (skipButtonTimeoutRef.current) {
clearTimeout(skipButtonTimeoutRef.current);
skipButtonTimeoutRef.current = null;
}
setShowSkipButton(false);
lastStatusRef.current = '';
handleOfflineFlow();
};
}, [dbContext, syncVault, app, router, navigation, t, updateStatus]);
const styles = StyleSheet.create({
container: {
@@ -350,23 +169,6 @@ export default function Initialize() : React.ReactNode {
paddingHorizontal: 20,
paddingTop: '40%', // Position above center to avoid Face ID prompt obstruction
},
skipButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.accentBackground,
paddingVertical: 8,
paddingHorizontal: 20,
borderRadius: 8,
width: 200,
borderWidth: 1,
borderColor: colors.accentBorder,
},
skipButtonText: {
marginLeft: 8,
fontSize: 16,
color: colors.textMuted,
},
});
return (
@@ -374,11 +176,6 @@ export default function Initialize() : React.ReactNode {
<View>
<LoadingIndicator status={status || ''} />
</View>
{showSkipButton && (
<TouchableOpacity style={styles.skipButton} onPress={handleSkipPress}>
<Ionicons name="play-forward-outline" size={20} color={colors.textMuted} />
</TouchableOpacity>
)}
</ThemedView>
);
}

View File

@@ -1,8 +1,7 @@
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { StyleSheet, View, Alert, TouchableOpacity } from 'react-native';
import { StyleSheet, View } from 'react-native';
import { VaultUnlockHelper } from '@/utils/VaultUnlockHelper';
@@ -27,141 +26,17 @@ export default function ReinitializeScreen() : React.ReactNode {
const navigation = useNavigation();
const { syncVault } = useVaultSync();
const [status, setStatus] = useState('');
const [showSkipButton, setShowSkipButton] = useState(false);
const hasInitialized = useRef(false);
const skipButtonTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastStatusRef = useRef<string>('');
const canShowSkipButtonRef = useRef(false); // Only allow skip button after vault unlock
const colors = useColors();
const { t } = useTranslation();
/**
* Update status with smart skip button logic.
* Normalizes status by removing animation dots and manages skip button visibility.
* Update status message.
*/
const updateStatus = useCallback((message: string): void => {
setStatus(message);
// Normalize status by removing animation dots for comparison
const normalizedMessage = message.replace(/\.+$/, '');
const normalizedLastStatus = lastStatusRef.current.replace(/\.+$/, '');
// Clear any existing timeout
if (skipButtonTimeoutRef.current) {
clearTimeout(skipButtonTimeoutRef.current);
skipButtonTimeoutRef.current = null;
}
// If status changed (excluding dots), hide skip button and reset timer
if (normalizedMessage !== normalizedLastStatus) {
setShowSkipButton(false);
lastStatusRef.current = message;
// Start new timer for the new status (only if skip button is allowed)
if (message && canShowSkipButtonRef.current) {
skipButtonTimeoutRef.current = setTimeout(() => {
setShowSkipButton(true);
}, 5000) as unknown as NodeJS.Timeout;
}
} else {
// Same status (excluding dots) - update ref but keep timer running
lastStatusRef.current = message;
}
}, []);
/**
* Handle offline scenario - show alert with options to open local vault or retry sync.
*/
const handleOfflineFlow = useCallback((): void => {
Alert.alert(
t('app.alerts.syncIssue'),
t('app.alerts.syncIssueMessage'),
[
{
text: t('app.alerts.openLocalVault'),
/**
* Handle opening vault in read-only mode.
*/
onPress: async () : Promise<void> => {
updateStatus(t('app.status.openingVaultReadOnly'));
const { enabledAuthMethods } = await app.initializeAuth();
try {
const hasEncryptedDatabase = await NativeVaultManager.hasEncryptedDatabase();
// No encrypted database
if (!hasEncryptedDatabase) {
router.replace('/unlock');
return;
}
// Set offline mode
app.setOfflineMode(true);
// FaceID not enabled
const isFaceIDEnabled = enabledAuthMethods.includes('faceid');
if (!isFaceIDEnabled) {
router.replace('/unlock');
return;
}
// Attempt to unlock vault
updateStatus(t('app.status.unlockingVault'));
const isUnlocked = await dbContext.unlockVault();
// Vault couldn't be unlocked
if (!isUnlocked) {
router.replace('/unlock');
return;
}
// Vault successfully unlocked - proceed with decryption
await new Promise(resolve => setTimeout(resolve, 500));
// Migrations pending
if (await dbContext.hasPendingMigrations()) {
router.replace('/upgrade');
return;
}
// Use centralized navigation logic
navigation.navigateAfterUnlock();
} catch (err) {
console.error('Error during offline vault unlock:', err);
router.replace('/unlock');
}
}
},
{
text: t('app.alerts.retrySync'),
/**
* Handle retrying the connection.
*/
onPress: () : void => {
updateStatus(t('app.status.retryingConnection'));
setShowSkipButton(false);
// Clear any existing timeout
if (skipButtonTimeoutRef.current) {
clearTimeout(skipButtonTimeoutRef.current);
skipButtonTimeoutRef.current = null;
}
// Reset status tracking
lastStatusRef.current = '';
/**
* Reset the hasInitialized flag and navigate to reinitialize route
* to force a re-render and trigger the useEffect again
*/
hasInitialized.current = false;
router.replace('/reinitialize');
}
}
]
);
}, [app, dbContext, navigation, t, updateStatus]);
useEffect(() => {
if (hasInitialized.current) {
return;
@@ -214,9 +89,6 @@ export default function ReinitializeScreen() : React.ReactNode {
router.replace('/upgrade');
return;
}
// Vault unlocked successfully - now allow skip button for network operations
canShowSkipButtonRef.current = true;
} else {
// No encrypted database, redirect to unlock screen
router.replace('/unlock');
@@ -236,69 +108,59 @@ export default function ReinitializeScreen() : React.ReactNode {
router.replace('/upgrade');
return;
}
/**
* Allow skip button for sync operations since vault is already unlocked.
*/
canShowSkipButtonRef.current = true;
}
// Now perform vault sync (network operations - these are skippable)
await syncVault({
/**
* Handle the status update.
*/
onStatus: (message) => {
updateStatus(message);
},
/**
* Handle successful vault sync.
*/
onSuccess: async () => {
navigation.navigateAfterUnlock();
},
/**
* Handle error during vault sync.
* Authentication errors are already handled in useVaultSync.
*/
onError: (error: string) => {
console.error('Vault sync error during reinitialize:', error);
// Even if sync fails, vault is already unlocked, use centralized navigation
navigation.navigateAfterUnlock();
},
/**
* Handle offline state and prompt user for action.
*/
onOffline: () => {
handleOfflineFlow();
},
/**
* On upgrade required.
*/
onUpgradeRequired: () : void => {
router.replace('/upgrade');
},
});
/*
* Perform vault sync in background - don't block app access.
* The ServerSyncIndicator will show sync progress/offline status.
* This also handles uploading pending local changes (isDirty) from previous sessions.
*/
dbContext.setIsSyncing(true);
void (async (): Promise<void> => {
try {
await syncVault({
/**
* Handle successful vault sync.
*/
onSuccess: async () => {
// Sync completed - ServerSyncIndicator will update
await dbContext.refreshSyncState();
},
/**
* Handle error during vault sync.
* Authentication errors are already handled in useVaultSync.
*/
onError: async (error: string) => {
console.error('Vault sync error during reinitialize:', error);
await dbContext.refreshSyncState();
},
/**
* Handle offline state - just set offline mode and continue.
* The ServerSyncIndicator will show offline status.
*/
onOffline: async () => {
await dbContext.setIsOffline(true);
await dbContext.refreshSyncState();
},
/**
* On upgrade required.
*/
onUpgradeRequired: () : void => {
router.replace('/upgrade');
},
});
} finally {
dbContext.setIsSyncing(false);
await dbContext.refreshSyncState();
}
})();
// Navigate immediately - don't wait for sync
navigation.navigateAfterUnlock();
};
initialize();
}, [syncVault, app, dbContext, navigation, t, handleOfflineFlow, updateStatus]);
/**
* Handle skip button press by calling the offline handler.
*/
const handleSkipPress = (): void => {
// Clear any existing timeout
if (skipButtonTimeoutRef.current) {
clearTimeout(skipButtonTimeoutRef.current);
skipButtonTimeoutRef.current = null;
}
setShowSkipButton(false);
lastStatusRef.current = '';
handleOfflineFlow();
};
}, [syncVault, app, dbContext, navigation, t, updateStatus]);
const styles = StyleSheet.create({
container: {
@@ -326,23 +188,6 @@ export default function ReinitializeScreen() : React.ReactNode {
alignItems: 'center',
width: '100%',
},
skipButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.accentBackground,
paddingVertical: 8,
paddingHorizontal: 20,
borderRadius: 8,
width: 200,
borderWidth: 1,
borderColor: colors.accentBorder,
},
skipButtonText: {
marginLeft: 8,
fontSize: 16,
color: colors.textMuted,
},
});
return (
@@ -352,11 +197,6 @@ export default function ReinitializeScreen() : React.ReactNode {
{status ? <LoadingIndicator status={status} /> : null}
<ThemedText style={styles.message1}>{t('app.reinitialize.vaultAutoLockedMessage')}</ThemedText>
<ThemedText style={styles.message2}>{t('app.reinitialize.attemptingToUnlockMessage')}</ThemedText>
{showSkipButton && (
<TouchableOpacity style={styles.skipButton} onPress={handleSkipPress}>
<Ionicons name="play-forward-outline" size={20} color={colors.textMuted} />
</TouchableOpacity>
)}
</View>
</View>
</ThemedView>

View File

@@ -1,5 +1,5 @@
import { Ionicons } from '@expo/vector-icons';
import React, { useState } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { ActivityIndicator, Platform, StyleSheet, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Toast from 'react-native-toast-message';
@@ -13,13 +13,19 @@ import { RobustPressable } from '@/components/ui/RobustPressable';
import { useApp } from '@/context/AppContext';
import { useDb } from '@/context/DbContext';
/**
* Minimum time (ms) to show the syncing indicator.
* Prevents flickering when sync completes quickly.
*/
const MIN_SYNC_DISPLAY_TIME = 1500;
/**
* Floating sync status indicator component.
* Displays sync state badges for offline mode, syncing, and pending sync.
*
* Priority order (highest to lowest):
* 1. Offline (amber) - network unavailable
* 2. Syncing (green spinner) - sync in progress
* 2. Syncing (green spinner) - sync in progress (minimum 1.5s display)
* 3. Pending (blue spinner) - local changes waiting to be uploaded
* 4. Hidden - when synced
*/
@@ -32,6 +38,38 @@ export function ServerSyncIndicator(): React.ReactNode {
const { syncVault } = useVaultSync();
const [isRetrying, setIsRetrying] = useState(false);
// Track syncing state with minimum display time
const [showSyncing, setShowSyncing] = useState(false);
const syncStartTimeRef = useRef<number | null>(null);
/**
* Handle syncing state changes with minimum display time.
* When syncing starts, show indicator immediately.
* When syncing ends, wait until minimum time has passed.
*/
useEffect(() => {
if (dbContext.isSyncing) {
// Sync started - show immediately and record start time
setShowSyncing(true);
syncStartTimeRef.current = Date.now();
} else if (syncStartTimeRef.current !== null) {
// Sync ended - wait for minimum display time
const elapsed = Date.now() - syncStartTimeRef.current;
const remaining = MIN_SYNC_DISPLAY_TIME - elapsed;
if (remaining > 0) {
const timer = setTimeout(() => {
setShowSyncing(false);
syncStartTimeRef.current = null;
}, remaining);
return () => clearTimeout(timer);
} else {
setShowSyncing(false);
syncStartTimeRef.current = null;
}
}
}, [dbContext.isSyncing]);
// Only show when logged in AND vault is unlocked (dbAvailable)
if (!app.isLoggedIn || !dbContext.dbAvailable) {
return null;
@@ -152,7 +190,8 @@ export function ServerSyncIndicator(): React.ReactNode {
}
// Priority 2: Syncing indicator (not tappable, shows progress)
if (dbContext.isSyncing) {
// Uses showSyncing which has minimum display time to prevent flickering
if (showSyncing) {
return (
<View style={[styles.container, styles.syncing]}>
<ActivityIndicator size="small" color={colors.success ?? '#16a34a'} />

View File

@@ -179,6 +179,17 @@ export const DbProvider: React.FC<{ children: React.ReactNode }> = ({ children }
}
}, []);
/**
* Refresh sync state when database becomes available.
* This ensures isDirty is populated from native storage on app boot,
* so ServerSyncIndicator shows pending changes from previous sessions.
*/
useEffect(() : void => {
if (dbAvailable) {
void refreshSyncState();
}
}, [dbAvailable, refreshSyncState]);
/**
* Set syncing state - exposed for use by sync hooks.
*/