use auto sync
This commit is contained in:
+2
-2
@@ -28,11 +28,11 @@ import { onboardingStorage } from './services/onboardingStorage';
|
|||||||
import { initDB } from './services/fileStore';
|
import { initDB } from './services/fileStore';
|
||||||
import { migrateFromLegacy } from './services/fileStore/migrate';
|
import { migrateFromLegacy } from './services/fileStore/migrate';
|
||||||
import { createMMKVPersister } from './services/mmkvPersister';
|
import { createMMKVPersister } from './services/mmkvPersister';
|
||||||
import { setIsSyncing } from './hooks/useSyncQueue';
|
import { resetSyncState } from './hooks/useSyncQueue';
|
||||||
|
|
||||||
initDB();
|
initDB();
|
||||||
migrateFromLegacy();
|
migrateFromLegacy();
|
||||||
setIsSyncing(false);
|
resetSyncState();
|
||||||
|
|
||||||
const Stack = createNativeStackNavigator();
|
const Stack = createNativeStackNavigator();
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
|
|||||||
+170
-73
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo, useCallback, useState, useEffect } from 'react';
|
import React, { useMemo, useCallback, useState, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -7,12 +7,12 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Alert,
|
Alert,
|
||||||
|
Animated,
|
||||||
|
Easing,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import { fileStore, FileRecord } from '../services/fileStore';
|
import { fileStore, FileRecord } from '../services/fileStore';
|
||||||
import { safDirectory } from '../services/safDirectory';
|
import { useSyncQueue, useSyncProgress } from '../hooks/useSyncQueue';
|
||||||
import { useSyncQueue } from '../hooks/useSyncQueue';
|
|
||||||
import { useAutoSync } from '../hooks/useAutoSync';
|
import { useAutoSync } from '../hooks/useAutoSync';
|
||||||
import { useSyncPush } from '../hooks/useSyncPush';
|
import { useSyncPush } from '../hooks/useSyncPush';
|
||||||
import { useUploadQueue } from '../hooks/useUploadQueue';
|
import { useUploadQueue } from '../hooks/useUploadQueue';
|
||||||
@@ -31,10 +31,117 @@ function uploadStatusIcon(task: UploadTask) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SpinningSyncIcon({ size, color }: { size: number; color: string }) {
|
||||||
|
const spin = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const animation = Animated.loop(
|
||||||
|
Animated.timing(spin, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 1200,
|
||||||
|
easing: Easing.linear,
|
||||||
|
useNativeDriver: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
animation.start();
|
||||||
|
return () => animation.stop();
|
||||||
|
}, [spin]);
|
||||||
|
|
||||||
|
const rotate = spin.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: ['0deg', '360deg'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View style={{ transform: [{ rotate }] }}>
|
||||||
|
<MaterialIcons name="sync" size={size} color={color} />
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type PendingFile = { id: string; name: string };
|
||||||
|
|
||||||
|
const PendingSyncCard = React.memo(function PendingSyncCard({
|
||||||
|
syncing,
|
||||||
|
pendingCount,
|
||||||
|
shortList,
|
||||||
|
onSyncPress,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
syncing: boolean;
|
||||||
|
pendingCount: number;
|
||||||
|
shortList: PendingFile[] | null;
|
||||||
|
onSyncPress: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={styles.pendingCard}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.pendingCardTop}
|
||||||
|
onPress={syncing ? undefined : onSyncPress}
|
||||||
|
disabled={syncing}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<View style={styles.pendingCardIcon}>
|
||||||
|
{syncing ? (
|
||||||
|
<SpinningSyncIcon size={28} color="#1976D2" />
|
||||||
|
) : (
|
||||||
|
<MaterialIcons name="cloud-upload" size={28} color="#1976D2" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.pendingCardInfo}>
|
||||||
|
<Text style={styles.pendingCardTitle}>
|
||||||
|
{syncing
|
||||||
|
? 'Synchronisation en cours...'
|
||||||
|
: pendingCount > 1
|
||||||
|
? `Vous avez ${pendingCount} fichiers locaux pouvant être synchronisés`
|
||||||
|
: 'Vous avez 1 fichier local pouvant être synchronisé'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.pendingCardSubtitle}>
|
||||||
|
{syncing
|
||||||
|
? shortList && shortList[0]
|
||||||
|
? `En cours : ${shortList[0].name}`
|
||||||
|
: 'Synchronisation en cours...'
|
||||||
|
: 'Appuyer maintenant pour les synchroniser'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{!syncing && <MaterialIcons name="chevron-right" size={24} color="#999" />}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{syncing && shortList && shortList.length > 0 && (
|
||||||
|
<View style={styles.pendingList}>
|
||||||
|
{shortList.map((f, i) => (
|
||||||
|
<View key={f.id} style={styles.pendingRow}>
|
||||||
|
{i === 0 ? (
|
||||||
|
<SpinningSyncIcon size={16} color="#1976D2" />
|
||||||
|
) : (
|
||||||
|
<MaterialIcons name="schedule" size={16} color="#FFA000" />
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
style={[styles.pendingRowText, i === 0 && styles.pendingRowActive]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{f.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{syncing && (
|
||||||
|
<TouchableOpacity style={styles.stopBtn} onPress={onCancel} activeOpacity={0.8}>
|
||||||
|
<MaterialIcons name="stop-circle" size={18} color="#fff" />
|
||||||
|
<Text style={styles.stopBtnText}>Arrêter la synchronisation</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
export function SyncDetailScreen() {
|
export function SyncDetailScreen() {
|
||||||
const insets = useSafeAreaInsets();
|
|
||||||
const { pendingCount, isSyncing, refresh } = useSyncQueue();
|
const { pendingCount, isSyncing, refresh } = useSyncQueue();
|
||||||
const { syncManually } = useAutoSync();
|
const { syncProgress } = useSyncProgress();
|
||||||
|
const { syncManually, cancelSync } = useAutoSync();
|
||||||
const { push } = useSyncPush();
|
const { push } = useSyncPush();
|
||||||
const { tasks: uploadTasks, retry, retryAll } = useUploadQueue();
|
const { tasks: uploadTasks, retry, retryAll } = useUploadQueue();
|
||||||
|
|
||||||
@@ -107,37 +214,21 @@ export function SyncDetailScreen() {
|
|||||||
);
|
);
|
||||||
}, [retry]);
|
}, [retry]);
|
||||||
|
|
||||||
|
const syncing = !!syncProgress;
|
||||||
const hasUploads = uploadTasks.length > 0;
|
const hasUploads = uploadTasks.length > 0;
|
||||||
const hasPending = pendingCount > 0;
|
const hasPending = pendingCount > 0 || syncing;
|
||||||
const hasErrors = errorFiles.length > 0;
|
const hasErrors = errorFiles.length > 0;
|
||||||
const hasContent = hasUploads || hasPending || hasErrors;
|
const hasContent = hasUploads || hasPending || hasErrors;
|
||||||
|
|
||||||
|
const shortList = useMemo(() => {
|
||||||
|
if (!syncProgress) return null;
|
||||||
|
const { files, currentIndex } = syncProgress;
|
||||||
|
const start = Math.max(0, currentIndex);
|
||||||
|
return files.slice(start, start + 5);
|
||||||
|
}, [syncProgress]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{(hasPending || hasErrors || uploadErrors.length > 0) && (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.syncBtn, isSyncing && styles.syncBtnDisabled]}
|
|
||||||
onPress={handleSyncButtonPress}
|
|
||||||
disabled={isSyncing}
|
|
||||||
>
|
|
||||||
{isSyncing ? (
|
|
||||||
<ActivityIndicator size="small" color="#fff" />
|
|
||||||
) : (
|
|
||||||
<MaterialIcons
|
|
||||||
name={uploadErrors.length > 0 ? "refresh" : "cloud-upload"}
|
|
||||||
size={20}
|
|
||||||
color="#fff"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Text style={styles.syncBtnText}>
|
|
||||||
{isSyncing ? 'Synchronisation...'
|
|
||||||
: uploadErrors.length > 0
|
|
||||||
? `Tout réessayer (${uploadErrors.length + (hasPending ? pendingCount : 0)})`
|
|
||||||
: `Synchroniser (${pendingCount})`}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!hasContent ? (
|
{!hasContent ? (
|
||||||
<View style={styles.empty}>
|
<View style={styles.empty}>
|
||||||
<MaterialIcons name="cloud-done" size={48} color="#4CAF50" />
|
<MaterialIcons name="cloud-done" size={48} color="#4CAF50" />
|
||||||
@@ -162,33 +253,20 @@ export function SyncDetailScreen() {
|
|||||||
: item.label
|
: item.label
|
||||||
: item.data.id
|
: item.data.id
|
||||||
}
|
}
|
||||||
|
extraData={[isSyncing, syncProgress]}
|
||||||
renderItem={({ item }) => {
|
renderItem={({ item }) => {
|
||||||
if (item.type === 'section') {
|
if (item.type === 'section') {
|
||||||
return <Text style={styles.headerText}>{item.label}</Text>;
|
return <Text style={styles.headerText}>{item.label}</Text>;
|
||||||
}
|
}
|
||||||
if (item.type === 'pendingCard') {
|
if (item.type === 'pendingCard') {
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<PendingSyncCard
|
||||||
style={styles.pendingCard}
|
syncing={syncing}
|
||||||
onPress={handleSyncButtonPress}
|
pendingCount={pendingCount}
|
||||||
disabled={isSyncing}
|
shortList={shortList}
|
||||||
activeOpacity={0.8}
|
onSyncPress={handleSyncButtonPress}
|
||||||
>
|
onCancel={cancelSync}
|
||||||
<View style={styles.pendingCardIcon}>
|
/>
|
||||||
<MaterialIcons name="cloud-upload" size={28} color="#1976D2" />
|
|
||||||
</View>
|
|
||||||
<View style={styles.pendingCardInfo}>
|
|
||||||
<Text style={styles.pendingCardTitle}>
|
|
||||||
{pendingCount > 1
|
|
||||||
? `Vous avez ${pendingCount} fichiers locaux pouvant être synchronisés`
|
|
||||||
: 'Vous avez 1 fichier local pouvant être synchronisé'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.pendingCardSubtitle}>
|
|
||||||
Appuyer maintenant pour les synchroniser
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<MaterialIcons name="chevron-right" size={24} color="#999" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (item.type === 'upload') {
|
if (item.type === 'upload') {
|
||||||
@@ -253,25 +331,6 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#f5f5f5',
|
backgroundColor: '#f5f5f5',
|
||||||
},
|
},
|
||||||
syncBtn: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
backgroundColor: '#1976D2',
|
|
||||||
marginHorizontal: 16,
|
|
||||||
marginTop: 16,
|
|
||||||
borderRadius: 10,
|
|
||||||
paddingVertical: 14,
|
|
||||||
gap: 10,
|
|
||||||
},
|
|
||||||
syncBtnDisabled: {
|
|
||||||
backgroundColor: '#90CAF9',
|
|
||||||
},
|
|
||||||
syncBtnText: {
|
|
||||||
fontSize: 16,
|
|
||||||
color: '#fff',
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
list: {
|
list: {
|
||||||
padding: 16,
|
padding: 16,
|
||||||
},
|
},
|
||||||
@@ -290,8 +349,6 @@ const styles = StyleSheet.create({
|
|||||||
gap: 12,
|
gap: 12,
|
||||||
},
|
},
|
||||||
pendingCard: {
|
pendingCard: {
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: '#fff',
|
backgroundColor: '#fff',
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
padding: 16,
|
padding: 16,
|
||||||
@@ -303,6 +360,11 @@ const styles = StyleSheet.create({
|
|||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 2 },
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
},
|
},
|
||||||
|
pendingCardTop: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 14,
|
||||||
|
},
|
||||||
pendingCardIcon: {
|
pendingCardIcon: {
|
||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
@@ -324,6 +386,41 @@ const styles = StyleSheet.create({
|
|||||||
color: '#1976D2',
|
color: '#1976D2',
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
},
|
},
|
||||||
|
pendingList: {
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#f0f0f0',
|
||||||
|
paddingTop: 12,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
pendingRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
pendingRowText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 13,
|
||||||
|
color: '#999',
|
||||||
|
},
|
||||||
|
pendingRowActive: {
|
||||||
|
color: '#1976D2',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
stopBtn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
backgroundColor: '#E53935',
|
||||||
|
borderRadius: 8,
|
||||||
|
paddingVertical: 10,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
stopBtnText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
fileInfo: {
|
fileInfo: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,7 +9,15 @@ import { activeUploadUris } from '../services/uploadQueue';
|
|||||||
import { apiClient } from '../api/client';
|
import { apiClient } from '../api/client';
|
||||||
import { API_BASE_URL, ENDPOINTS } from '../constants/api';
|
import { API_BASE_URL, ENDPOINTS } from '../constants/api';
|
||||||
import { ApiError } from '../types';
|
import { ApiError } from '../types';
|
||||||
import { setIsSyncing } from './useSyncQueue';
|
import {
|
||||||
|
setIsSyncing,
|
||||||
|
setSyncProgress,
|
||||||
|
isSyncLoopRunning,
|
||||||
|
setSyncLoopRunning,
|
||||||
|
requestSyncCancel,
|
||||||
|
isSyncCancelRequested,
|
||||||
|
consumeSyncCancel,
|
||||||
|
} from './useSyncQueue';
|
||||||
|
|
||||||
const MAX_RETRIES = 5;
|
const MAX_RETRIES = 5;
|
||||||
const RETRY_MMKV_ID = 'vaultdrop-sync-retries';
|
const RETRY_MMKV_ID = 'vaultdrop-sync-retries';
|
||||||
@@ -73,6 +81,8 @@ function canSyncBasedOnNetwork(netInfo: NetInfoState, cellularAllowed: boolean):
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let autoSyncLoopStarted = false;
|
||||||
|
|
||||||
export function useAutoSync() {
|
export function useAutoSync() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isRunning = useRef(false);
|
const isRunning = useRef(false);
|
||||||
@@ -83,8 +93,13 @@ export function useAutoSync() {
|
|||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const cancelSync = useCallback(() => {
|
||||||
|
requestSyncCancel();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const runPendingSync = useCallback(async (pendingFiles: Array<ReturnType<typeof fileStore.getAllLocal>[number]>) => {
|
const runPendingSync = useCallback(async (pendingFiles: Array<ReturnType<typeof fileStore.getAllLocal>[number]>) => {
|
||||||
if (isRunning.current) return;
|
if (isRunning.current) return;
|
||||||
|
if (isSyncLoopRunning()) return;
|
||||||
|
|
||||||
const globalCellular = safDirectory.getGlobalSyncCellular();
|
const globalCellular = safDirectory.getGlobalSyncCellular();
|
||||||
const netInfo = await NetInfo.fetch();
|
const netInfo = await NetInfo.fetch();
|
||||||
@@ -92,11 +107,18 @@ export function useAutoSync() {
|
|||||||
if (!canSyncBasedOnNetwork(netInfo, globalCellular)) return;
|
if (!canSyncBasedOnNetwork(netInfo, globalCellular)) return;
|
||||||
|
|
||||||
isRunning.current = true;
|
isRunning.current = true;
|
||||||
|
setSyncLoopRunning(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsSyncing(true);
|
setIsSyncing(true);
|
||||||
|
consumeSyncCancel();
|
||||||
|
|
||||||
for (const entry of pendingFiles) {
|
const progressFiles = pendingFiles.map((f) => ({ id: f.id, name: f.name }));
|
||||||
|
|
||||||
|
for (let index = 0; index < pendingFiles.length; index++) {
|
||||||
|
if (isSyncCancelRequested()) break;
|
||||||
|
const entry = pendingFiles[index];
|
||||||
|
setSyncProgress({ files: progressFiles, currentIndex: index });
|
||||||
const uri = entry.localUri;
|
const uri = entry.localUri;
|
||||||
if (!uri || activeUploadUris.has(uri)) continue;
|
if (!uri || activeUploadUris.has(uri)) continue;
|
||||||
activeUploadUris.add(uri);
|
activeUploadUris.add(uri);
|
||||||
@@ -126,10 +148,16 @@ export function useAutoSync() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (consumeSyncCancel()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['resources'] });
|
queryClient.invalidateQueries({ queryKey: ['resources'] });
|
||||||
} finally {
|
} finally {
|
||||||
|
setSyncProgress(null);
|
||||||
setIsSyncing(false);
|
setIsSyncing(false);
|
||||||
isRunning.current = false;
|
isRunning.current = false;
|
||||||
|
setSyncLoopRunning(false);
|
||||||
}
|
}
|
||||||
}, [queryClient]);
|
}, [queryClient]);
|
||||||
|
|
||||||
@@ -161,12 +189,15 @@ export function useAutoSync() {
|
|||||||
}, [getPendingFiles, runPendingSync]);
|
}, [getPendingFiles, runPendingSync]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (autoSyncLoopStarted) return;
|
||||||
|
autoSyncLoopStarted = true;
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
checkAndSync();
|
checkAndSync();
|
||||||
}, 5_000);
|
}, 5_000);
|
||||||
const interval = setInterval(checkAndSync, 30_000);
|
const interval = setInterval(checkAndSync, 30_000);
|
||||||
return () => { clearTimeout(timeout); clearInterval(interval); };
|
return () => { clearTimeout(timeout); clearInterval(interval); autoSyncLoopStarted = false; };
|
||||||
}, [checkAndSync]);
|
}, [checkAndSync]);
|
||||||
|
|
||||||
return { triggerSync: checkAndSync, syncManually };
|
return { triggerSync: checkAndSync, syncManually, cancelSync };
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-13
@@ -1,8 +1,22 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback, useSyncExternalStore } from 'react';
|
||||||
import { createMMKV } from 'react-native-mmkv';
|
import { createMMKV } from 'react-native-mmkv';
|
||||||
import { fileStore } from '../services/fileStore';
|
import { fileStore } from '../services/fileStore';
|
||||||
|
|
||||||
|
export type SyncProgress = {
|
||||||
|
files: Array<{ id: string; name: string }>;
|
||||||
|
currentIndex: number;
|
||||||
|
} | null;
|
||||||
|
|
||||||
const syncStateStorage = createMMKV({ id: 'vaultdrop-sync-state' });
|
const syncStateStorage = createMMKV({ id: 'vaultdrop-sync-state' });
|
||||||
|
const PROGRESS_KEY = 'sync_progress';
|
||||||
|
|
||||||
|
let syncRunning = false;
|
||||||
|
let cancelRequested = false;
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function notify() {
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
export function getIsSyncing(): boolean {
|
export function getIsSyncing(): boolean {
|
||||||
return syncStateStorage.getString('is_syncing') === 'true';
|
return syncStateStorage.getString('is_syncing') === 'true';
|
||||||
@@ -10,24 +24,98 @@ export function getIsSyncing(): boolean {
|
|||||||
|
|
||||||
export function setIsSyncing(value: boolean) {
|
export function setIsSyncing(value: boolean) {
|
||||||
syncStateStorage.set('is_syncing', value ? 'true' : 'false');
|
syncStateStorage.set('is_syncing', value ? 'true' : 'false');
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSyncProgress(): SyncProgress {
|
||||||
|
return parseProgress(syncStateStorage.getString(PROGRESS_KEY));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProgress(raw: string | undefined): SyncProgress {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { files: Array<{ id: string; name: string }>; currentIndex: number };
|
||||||
|
if (!parsed || !Array.isArray(parsed.files)) return null;
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSyncProgress(value: SyncProgress) {
|
||||||
|
if (value === null) {
|
||||||
|
syncStateStorage.remove(PROGRESS_KEY);
|
||||||
|
} else {
|
||||||
|
syncStateStorage.set(PROGRESS_KEY, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSyncLoopRunning(): boolean {
|
||||||
|
return syncRunning;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSyncLoopRunning(value: boolean) {
|
||||||
|
syncRunning = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestSyncCancel() {
|
||||||
|
cancelRequested = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSyncCancelRequested(): boolean {
|
||||||
|
return cancelRequested;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function consumeSyncCancel(): boolean {
|
||||||
|
const value = cancelRequested;
|
||||||
|
cancelRequested = false;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetSyncState() {
|
||||||
|
syncStateStorage.remove('is_syncing');
|
||||||
|
syncStateStorage.remove(PROGRESS_KEY);
|
||||||
|
syncRunning = false;
|
||||||
|
cancelRequested = false;
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
function subscribe(listener: () => void): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedIsSyncing = getIsSyncing();
|
||||||
|
let lastIsSyncingRaw: string | undefined;
|
||||||
|
|
||||||
|
function getIsSyncingSnapshot(): boolean {
|
||||||
|
const raw = syncStateStorage.getString('is_syncing');
|
||||||
|
if (raw !== lastIsSyncingRaw) {
|
||||||
|
lastIsSyncingRaw = raw;
|
||||||
|
cachedIsSyncing = raw === 'true';
|
||||||
|
}
|
||||||
|
return cachedIsSyncing;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedProgress = getSyncProgress();
|
||||||
|
let lastProgressRaw: string | undefined;
|
||||||
|
|
||||||
|
function getSyncProgressSnapshot(): SyncProgress {
|
||||||
|
const raw = syncStateStorage.getString(PROGRESS_KEY);
|
||||||
|
if (raw !== lastProgressRaw) {
|
||||||
|
lastProgressRaw = raw;
|
||||||
|
cachedProgress = parseProgress(raw);
|
||||||
|
}
|
||||||
|
return cachedProgress;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSyncQueue() {
|
export function useSyncQueue() {
|
||||||
|
const isSyncing = useSyncExternalStore(subscribe, getIsSyncingSnapshot, getIsSyncingSnapshot);
|
||||||
const [pendingCount, setPendingCount] = useState(0);
|
const [pendingCount, setPendingCount] = useState(0);
|
||||||
const [isSyncing, setIsSyncingState] = useState(() => getIsSyncing());
|
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
const registry = fileStore.getAllLocal();
|
setPendingCount(fileStore.countPendingSync());
|
||||||
let count = 0;
|
|
||||||
|
|
||||||
for (const entry of registry) {
|
|
||||||
if (entry.backendId || !entry.localUri) continue;
|
|
||||||
if (entry.syncStatus !== 'local' && entry.syncStatus !== 'error') continue;
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPendingCount(count);
|
|
||||||
setIsSyncingState(getIsSyncing());
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -38,3 +126,8 @@ export function useSyncQueue() {
|
|||||||
|
|
||||||
return { pendingCount, isSyncing, refresh };
|
return { pendingCount, isSyncing, refresh };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useSyncProgress() {
|
||||||
|
const syncProgress = useSyncExternalStore(subscribe, getSyncProgressSnapshot, getSyncProgressSnapshot);
|
||||||
|
return { syncProgress };
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { drizzle } from 'drizzle-orm/expo-sqlite';
|
import { drizzle } from 'drizzle-orm/expo-sqlite';
|
||||||
import * as SQLite from 'expo-sqlite';
|
import * as SQLite from 'expo-sqlite';
|
||||||
import { eq, like, or, and, desc, asc, sql, isNull } from 'drizzle-orm';
|
import { eq, like, or, and, desc, asc, sql, isNull, inArray, isNotNull } from 'drizzle-orm';
|
||||||
import { files, fileTags, deletedFiles, pendingActions } from './schema';
|
import { files, fileTags, deletedFiles, pendingActions } from './schema';
|
||||||
import type { Tag, PendingAction, PendingActionType, PendingActionStatus } from '../../types';
|
import type { Tag, PendingAction, PendingActionType, PendingActionStatus } from '../../types';
|
||||||
|
|
||||||
@@ -590,6 +590,19 @@ export const fileStore = {
|
|||||||
return row?.count ?? 0;
|
return row?.count ?? 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
countPendingSync(): number {
|
||||||
|
const d = getDb();
|
||||||
|
const row = d.select({ count: sql<number>`count(*)` }).from(files).where(
|
||||||
|
and(
|
||||||
|
or(eq(files.source, 'local'), eq(files.source, 'synced')),
|
||||||
|
isNull(files.backendId),
|
||||||
|
isNotNull(files.localUri),
|
||||||
|
inArray(files.syncStatus, ['local', 'error']),
|
||||||
|
)
|
||||||
|
).get();
|
||||||
|
return row?.count ?? 0;
|
||||||
|
},
|
||||||
|
|
||||||
getAllLocal(): FileRecord[] {
|
getAllLocal(): FileRecord[] {
|
||||||
const d = getDb();
|
const d = getDb();
|
||||||
const rows = d.select().from(files)
|
const rows = d.select().from(files)
|
||||||
|
|||||||
Reference in New Issue
Block a user