Files
territory-log/app/sync-progress.tsx
Kibin 6fe6ebef13 feat: Sprint 5 — PIN lock, biometric auth, sync screen, QR code, merge logic
- 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
2026-02-18 19:57:35 +08:00

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',
},
});