- lib/pinService.ts: PIN storage/verification via expo-secure-store - lib/syncService.ts: sync abstraction with full merge logic (mDNS/TCP stubbed) - app/lock.tsx: full-screen PIN lock with biometric support - components/ui/PinPad.tsx: reusable 6-digit PIN pad component - components/sync/QRScanner.tsx: QR code scanner via expo-camera - app/(tabs)/sync.tsx: Sync tab with QR display, partner input, history - app/sync-progress.tsx: animated sync progress screen with summary - app/(tabs)/settings.tsx: full settings with PIN setup, security, data sections - app/(tabs)/_layout.tsx: added Sync tab (RefreshCw icon) - app/_layout.tsx: AppState listener locks app after 60s background - package.json: added expo-secure-store, react-native-qrcode-svg
334 lines
8.5 KiB
TypeScript
334 lines
8.5 KiB
TypeScript
// app/sync-progress.tsx — Full-screen active sync progress
|
|
import React, { useEffect, useState } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
TouchableOpacity,
|
|
SafeAreaView,
|
|
Animated,
|
|
Easing,
|
|
} from 'react-native';
|
|
import { router, useLocalSearchParams } from 'expo-router';
|
|
import { CheckCircle, RefreshCw } from 'lucide-react-native';
|
|
|
|
type SyncPhase =
|
|
| 'connecting'
|
|
| 'sending'
|
|
| 'receiving'
|
|
| 'done'
|
|
| 'error';
|
|
|
|
export default function SyncProgressScreen() {
|
|
const params = useLocalSearchParams<{
|
|
partnerName?: string;
|
|
sentCount?: string;
|
|
receivedCount?: string;
|
|
conflictCount?: string;
|
|
error?: string;
|
|
}>();
|
|
|
|
const [phase, setPhase] = useState<SyncPhase>('connecting');
|
|
const [sendProgress] = useState(new Animated.Value(0));
|
|
const [receiveProgress] = useState(new Animated.Value(0));
|
|
const spinAnim = useState(new Animated.Value(0))[0];
|
|
|
|
const sentCount = parseInt(params.sentCount ?? '0', 10);
|
|
const receivedCount = parseInt(params.receivedCount ?? '0', 10);
|
|
const conflictCount = parseInt(params.conflictCount ?? '0', 10);
|
|
const partnerName = params.partnerName ?? 'Partner';
|
|
const errorMsg = params.error;
|
|
|
|
useEffect(() => {
|
|
if (errorMsg) {
|
|
setPhase('error');
|
|
return;
|
|
}
|
|
|
|
// Simulate sync animation sequence
|
|
const connectTimer = setTimeout(() => {
|
|
setPhase('sending');
|
|
Animated.timing(sendProgress, {
|
|
toValue: 1,
|
|
duration: 1200,
|
|
useNativeDriver: false,
|
|
easing: Easing.out(Easing.cubic),
|
|
}).start(() => {
|
|
setPhase('receiving');
|
|
Animated.timing(receiveProgress, {
|
|
toValue: 1,
|
|
duration: 1200,
|
|
useNativeDriver: false,
|
|
easing: Easing.out(Easing.cubic),
|
|
}).start(() => {
|
|
setPhase('done');
|
|
});
|
|
});
|
|
}, 1000);
|
|
|
|
return () => clearTimeout(connectTimer);
|
|
}, []);
|
|
|
|
// Spinning icon animation
|
|
useEffect(() => {
|
|
if (phase === 'connecting' || phase === 'sending' || phase === 'receiving') {
|
|
Animated.loop(
|
|
Animated.timing(spinAnim, {
|
|
toValue: 1,
|
|
duration: 1000,
|
|
useNativeDriver: true,
|
|
easing: Easing.linear,
|
|
})
|
|
).start();
|
|
} else {
|
|
spinAnim.stopAnimation();
|
|
}
|
|
}, [phase]);
|
|
|
|
const spin = spinAnim.interpolate({
|
|
inputRange: [0, 1],
|
|
outputRange: ['0deg', '360deg'],
|
|
});
|
|
|
|
const sendWidth = sendProgress.interpolate({
|
|
inputRange: [0, 1],
|
|
outputRange: ['0%', '100%'],
|
|
});
|
|
|
|
const receiveWidth = receiveProgress.interpolate({
|
|
inputRange: [0, 1],
|
|
outputRange: ['0%', '100%'],
|
|
});
|
|
|
|
const phaseLabel = {
|
|
connecting: `Connecting to ${partnerName}…`,
|
|
sending: `Sending records…`,
|
|
receiving: `Receiving records…`,
|
|
done: 'Sync Complete!',
|
|
error: 'Sync Failed',
|
|
}[phase];
|
|
|
|
return (
|
|
<SafeAreaView style={styles.container}>
|
|
<View style={styles.content}>
|
|
{/* Icon */}
|
|
<View style={styles.iconArea}>
|
|
{phase === 'done' ? (
|
|
<CheckCircle size={64} color="#22C55E" />
|
|
) : phase === 'error' ? (
|
|
<View style={styles.errorCircle}>
|
|
<Text style={styles.errorX}>✕</Text>
|
|
</View>
|
|
) : (
|
|
<Animated.View style={{ transform: [{ rotate: spin }] }}>
|
|
<RefreshCw size={56} color="#1A6B72" />
|
|
</Animated.View>
|
|
)}
|
|
</View>
|
|
|
|
{/* Phase label */}
|
|
<Text style={styles.phaseLabel}>{phaseLabel}</Text>
|
|
|
|
{errorMsg ? (
|
|
<Text style={styles.errorDetail}>{errorMsg}</Text>
|
|
) : null}
|
|
|
|
{/* Progress bars */}
|
|
{phase !== 'done' && phase !== 'error' && (
|
|
<View style={styles.progressArea}>
|
|
<View style={styles.progressRow}>
|
|
<Text style={styles.progressLabel}>Sending</Text>
|
|
<View style={styles.progressBar}>
|
|
<Animated.View
|
|
style={[styles.progressFill, styles.sendFill, { width: sendWidth }]}
|
|
/>
|
|
</View>
|
|
<Text style={styles.progressCount}>{sentCount}</Text>
|
|
</View>
|
|
|
|
<View style={styles.progressRow}>
|
|
<Text style={styles.progressLabel}>Receiving</Text>
|
|
<View style={styles.progressBar}>
|
|
<Animated.View
|
|
style={[styles.progressFill, styles.receiveFill, { width: receiveWidth }]}
|
|
/>
|
|
</View>
|
|
<Text style={styles.progressCount}>{receivedCount}</Text>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* Summary card */}
|
|
{phase === 'done' && (
|
|
<View style={styles.summaryCard}>
|
|
<Text style={styles.summaryTitle}>Sync Summary</Text>
|
|
<View style={styles.summaryRow}>
|
|
<Text style={styles.summaryKey}>Partner</Text>
|
|
<Text style={styles.summaryValue}>{partnerName}</Text>
|
|
</View>
|
|
<View style={styles.summaryRow}>
|
|
<Text style={styles.summaryKey}>Sent</Text>
|
|
<Text style={[styles.summaryValue, styles.sentColor]}>{sentCount} records</Text>
|
|
</View>
|
|
<View style={styles.summaryRow}>
|
|
<Text style={styles.summaryKey}>Received</Text>
|
|
<Text style={[styles.summaryValue, styles.receivedColor]}>{receivedCount} records</Text>
|
|
</View>
|
|
{conflictCount > 0 && (
|
|
<View style={styles.summaryRow}>
|
|
<Text style={styles.summaryKey}>Conflicts</Text>
|
|
<Text style={[styles.summaryValue, styles.conflictColor]}>
|
|
{conflictCount} resolved
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
)}
|
|
|
|
{/* Done button */}
|
|
{(phase === 'done' || phase === 'error') && (
|
|
<TouchableOpacity
|
|
style={[styles.doneBtn, phase === 'error' && styles.errorBtn]}
|
|
onPress={() => router.back()}
|
|
>
|
|
<Text style={styles.doneBtnText}>
|
|
{phase === 'done' ? 'Done' : 'Go Back'}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: '#0F172A',
|
|
},
|
|
content: {
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
paddingHorizontal: 24,
|
|
gap: 24,
|
|
},
|
|
iconArea: {
|
|
width: 100,
|
|
height: 100,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
errorCircle: {
|
|
width: 64,
|
|
height: 64,
|
|
borderRadius: 32,
|
|
backgroundColor: '#FEE2E2',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
errorX: {
|
|
fontSize: 28,
|
|
color: '#EF4444',
|
|
fontWeight: '700',
|
|
},
|
|
phaseLabel: {
|
|
fontSize: 22,
|
|
fontWeight: '700',
|
|
color: '#F9FAFB',
|
|
textAlign: 'center',
|
|
},
|
|
errorDetail: {
|
|
fontSize: 14,
|
|
color: '#F87171',
|
|
textAlign: 'center',
|
|
paddingHorizontal: 16,
|
|
},
|
|
progressArea: {
|
|
width: '100%',
|
|
gap: 16,
|
|
backgroundColor: 'rgba(255,255,255,0.05)',
|
|
borderRadius: 16,
|
|
padding: 20,
|
|
},
|
|
progressRow: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 10,
|
|
},
|
|
progressLabel: {
|
|
color: '#9CA3AF',
|
|
fontSize: 13,
|
|
width: 72,
|
|
},
|
|
progressBar: {
|
|
flex: 1,
|
|
height: 8,
|
|
backgroundColor: 'rgba(255,255,255,0.1)',
|
|
borderRadius: 4,
|
|
overflow: 'hidden',
|
|
},
|
|
progressFill: {
|
|
height: '100%',
|
|
borderRadius: 4,
|
|
},
|
|
sendFill: {
|
|
backgroundColor: '#1A6B72',
|
|
},
|
|
receiveFill: {
|
|
backgroundColor: '#10B981',
|
|
},
|
|
progressCount: {
|
|
color: '#D1D5DB',
|
|
fontSize: 13,
|
|
width: 32,
|
|
textAlign: 'right',
|
|
},
|
|
summaryCard: {
|
|
width: '100%',
|
|
backgroundColor: 'rgba(255,255,255,0.06)',
|
|
borderRadius: 16,
|
|
padding: 20,
|
|
gap: 12,
|
|
borderWidth: 1,
|
|
borderColor: 'rgba(255,255,255,0.1)',
|
|
},
|
|
summaryTitle: {
|
|
color: '#F9FAFB',
|
|
fontSize: 16,
|
|
fontWeight: '700',
|
|
marginBottom: 4,
|
|
},
|
|
summaryRow: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
},
|
|
summaryKey: {
|
|
color: '#9CA3AF',
|
|
fontSize: 14,
|
|
},
|
|
summaryValue: {
|
|
color: '#F9FAFB',
|
|
fontSize: 14,
|
|
fontWeight: '600',
|
|
},
|
|
sentColor: { color: '#1A6B72' },
|
|
receivedColor: { color: '#10B981' },
|
|
conflictColor: { color: '#F59E0B' },
|
|
doneBtn: {
|
|
paddingVertical: 14,
|
|
paddingHorizontal: 48,
|
|
backgroundColor: '#1A6B72',
|
|
borderRadius: 14,
|
|
marginTop: 8,
|
|
},
|
|
errorBtn: {
|
|
backgroundColor: '#6B7280',
|
|
},
|
|
doneBtnText: {
|
|
color: '#fff',
|
|
fontSize: 16,
|
|
fontWeight: '700',
|
|
},
|
|
});
|