196 lines
5.8 KiB
TypeScript
196 lines
5.8 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
|
import { useSettings } from '@/theme';
|
|
import Svg, { Path, Circle } from 'react-native-svg';
|
|
import { runSyncGuarded, getLastSyncTime } from '@/database/sync';
|
|
|
|
interface SyncStatusProps {
|
|
compact?: boolean;
|
|
}
|
|
|
|
export default function SyncStatus({ compact = false }: SyncStatusProps) {
|
|
const { theme } = useSettings();
|
|
const [status, setStatus] = useState<'idle' | 'syncing' | 'success' | 'error'>('idle');
|
|
const [lastSync, setLastSync] = useState<number | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [now] = useState(() => Date.now());
|
|
|
|
const loadLastSync = async () => {
|
|
const time = await getLastSyncTime();
|
|
setLastSync(time);
|
|
};
|
|
|
|
useEffect(() => {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
loadLastSync();
|
|
}, []);
|
|
|
|
const handleSync = async () => {
|
|
setStatus('syncing');
|
|
setError(null);
|
|
try {
|
|
await runSyncGuarded();
|
|
setStatus('success');
|
|
await loadLastSync();
|
|
setTimeout(() => setStatus('idle'), 3000);
|
|
} catch (err) {
|
|
setStatus('error');
|
|
setError(err instanceof Error ? err.message : 'Sync failed');
|
|
setTimeout(() => setStatus('idle'), 5000);
|
|
}
|
|
};
|
|
|
|
const formatTime = (timestamp: number | null): string => {
|
|
if (!timestamp) return 'Never';
|
|
const diff = now - timestamp;
|
|
if (diff < 60000) return 'Just now';
|
|
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
|
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
|
return new Date(timestamp).toLocaleDateString();
|
|
};
|
|
|
|
const getStatusIcon = () => {
|
|
switch (status) {
|
|
case 'syncing':
|
|
return (
|
|
<Svg width={16} height={16} viewBox="0 0 24 24">
|
|
<Path
|
|
d="M12 2a10 10 0 1 0 10 10"
|
|
stroke={theme.accent}
|
|
strokeWidth={2}
|
|
strokeLinecap="round"
|
|
fill="none"
|
|
/>
|
|
</Svg>
|
|
);
|
|
case 'success':
|
|
return (
|
|
<Svg width={16} height={16} viewBox="0 0 24 24">
|
|
<Path d="M5 12l5 5 9-10" stroke={theme.success} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none" />
|
|
</Svg>
|
|
);
|
|
case 'error':
|
|
return (
|
|
<Svg width={16} height={16} viewBox="0 0 24 24">
|
|
<Circle cx={12} cy={12} r={10} stroke={theme.danger} strokeWidth={2} fill="none" />
|
|
<Path d="M12 8v4M12 16h.01" stroke={theme.danger} strokeWidth={2} strokeLinecap="round" />
|
|
</Svg>
|
|
);
|
|
default:
|
|
return (
|
|
<Svg width={16} height={16} viewBox="0 0 24 24">
|
|
<Path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" stroke={theme.textFaint} strokeWidth={1.8} fill="none" />
|
|
<Path d="M12 22V12" stroke={theme.textFaint} strokeWidth={1.8} strokeLinecap="round" />
|
|
</Svg>
|
|
);
|
|
}
|
|
};
|
|
|
|
const getStatusText = () => {
|
|
switch (status) {
|
|
case 'syncing': return 'Syncing...';
|
|
case 'success': return 'Synced';
|
|
case 'error': return 'Sync failed';
|
|
default: return lastSync ? `Last sync: ${formatTime(lastSync)}` : 'Not synced';
|
|
}
|
|
};
|
|
|
|
if (compact) {
|
|
return (
|
|
<TouchableOpacity style={[styles.compact, { backgroundColor: theme.sheetBg }]} onPress={handleSync} disabled={status === 'syncing'} activeOpacity={0.7}>
|
|
<View style={styles.compactRow}>
|
|
{getStatusIcon()}
|
|
<Text style={[styles.compactText, { color: theme.textMuted }]}>
|
|
{status === 'syncing' ? 'Syncing...' : formatTime(lastSync)}
|
|
</Text>
|
|
</View>
|
|
</TouchableOpacity>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={[styles.container, { backgroundColor: theme.sheetBg }]}>
|
|
<View style={styles.header}>
|
|
<View style={styles.statusRow}>
|
|
{getStatusIcon()}
|
|
<Text style={[styles.statusText, { color: theme.text }]}>{getStatusText()}</Text>
|
|
</View>
|
|
<TouchableOpacity
|
|
style={[styles.syncButton, { backgroundColor: status === 'syncing' ? theme.accentSoft : theme.accent }]}
|
|
onPress={handleSync}
|
|
disabled={status === 'syncing'}
|
|
activeOpacity={0.8}
|
|
>
|
|
<Text style={[styles.syncButtonText, { color: status === 'syncing' ? theme.accent : theme.accentText }]}>
|
|
{status === 'syncing' ? 'Syncing...' : 'Sync Now'}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
{error && (
|
|
<View style={styles.error}>
|
|
<Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>
|
|
</View>
|
|
)}
|
|
<Text style={[styles.lastSync, { color: theme.textFaint }]}>
|
|
Last sync: {formatTime(lastSync)}
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
padding: 16,
|
|
borderRadius: 12,
|
|
marginVertical: 8,
|
|
},
|
|
header: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
marginBottom: 8,
|
|
},
|
|
statusRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
},
|
|
statusText: {
|
|
fontSize: 14,
|
|
fontWeight: '500',
|
|
},
|
|
syncButton: {
|
|
paddingVertical: 8,
|
|
paddingHorizontal: 16,
|
|
borderRadius: 8,
|
|
},
|
|
syncButtonText: {
|
|
fontSize: 13,
|
|
fontWeight: '600',
|
|
},
|
|
error: {
|
|
paddingTop: 8,
|
|
borderTopWidth: 1,
|
|
},
|
|
errorText: {
|
|
fontSize: 12,
|
|
},
|
|
lastSync: {
|
|
fontSize: 11,
|
|
marginTop: 8,
|
|
},
|
|
compact: {
|
|
paddingVertical: 6,
|
|
paddingHorizontal: 10,
|
|
borderRadius: 8,
|
|
borderWidth: 1,
|
|
},
|
|
compactRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 6,
|
|
},
|
|
compactText: {
|
|
fontSize: 12,
|
|
},
|
|
}); |