diff --git a/apps/mobile-app/app/(tabs)/items/add-edit.tsx b/apps/mobile-app/app/(tabs)/items/add-edit.tsx index bd2552e14..fa5b4a3bc 100644 --- a/apps/mobile-app/app/(tabs)/items/add-edit.tsx +++ b/apps/mobile-app/app/(tabs)/items/add-edit.tsx @@ -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]); /** diff --git a/apps/mobile-app/app/initialize.tsx b/apps/mobile-app/app/initialize.tsx index f6da98cd8..11aba1b4b 100644 --- a/apps/mobile-app/app/initialize.tsx +++ b/apps/mobile-app/app/initialize.tsx @@ -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(null); - const lastStatusRef = useRef(''); - const canShowSkipButtonRef = useRef(false); // Only allow skip button after vault unlock - const abortControllerRef = useRef(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 => { - 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 => { + 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 { - {showSkipButton && ( - - - - )} ); } \ No newline at end of file diff --git a/apps/mobile-app/app/reinitialize.tsx b/apps/mobile-app/app/reinitialize.tsx index c35771c43..793930a9d 100644 --- a/apps/mobile-app/app/reinitialize.tsx +++ b/apps/mobile-app/app/reinitialize.tsx @@ -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(null); - const lastStatusRef = useRef(''); - 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 => { - 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 => { + 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 ? : null} {t('app.reinitialize.vaultAutoLockedMessage')} {t('app.reinitialize.attemptingToUnlockMessage')} - {showSkipButton && ( - - - - )} diff --git a/apps/mobile-app/components/ServerSyncIndicator.tsx b/apps/mobile-app/components/ServerSyncIndicator.tsx index d7edc7cb6..5468eeb38 100644 --- a/apps/mobile-app/components/ServerSyncIndicator.tsx +++ b/apps/mobile-app/components/ServerSyncIndicator.tsx @@ -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(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 ( diff --git a/apps/mobile-app/context/DbContext.tsx b/apps/mobile-app/context/DbContext.tsx index 0389066ce..08259df40 100644 --- a/apps/mobile-app/context/DbContext.tsx +++ b/apps/mobile-app/context/DbContext.tsx @@ -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. */