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
This commit is contained in:
333
app/sync-progress.tsx
Normal file
333
app/sync-progress.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
// 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',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user