Files
carry-your-live/carry-your-live/src/components/SyncModal.tsx
T
2026-08-23 11:36:17 +02:00

404 lines
12 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import {
Modal,
View,
Text,
StyleSheet,
TextInput,
TouchableOpacity,
KeyboardAvoidingView,
ActivityIndicator,
} from 'react-native';
import { useSettings } from '@/theme';
import { AuthUser, getAuthToken, getAuthUser, login, register, signOutAuth } from '@/services/auth';
import { runSyncGuarded, startAutoSync, stopAutoSync, getLastSyncTime, SyncResult } from '@/database/sync';
import { ForgotPasswordModal } from './ForgotPasswordModal';
import { registerForPushNotificationsAsync, unregisterForPushNotificationsAsync } from '@/services/pushNotifications';
interface SyncModalProps {
visible: boolean;
onClose: () => void;
}
type Status = 'idle' | 'syncing' | 'success' | 'error';
export function SyncModal({ visible, onClose }: SyncModalProps) {
const { theme } = useSettings();
const [user, setUser] = useState<AuthUser | null>(null);
const [checking, setChecking] = useState(true);
const [mode, setMode] = useState<'login' | 'register'>('login');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<Status>('idle');
const [lastSync, setLastSync] = useState<number | null>(null);
const [result, setResult] = useState<SyncResult | null>(null);
const [prevVisible, setPrevVisible] = useState(visible);
const [showForgotPassword, setShowForgotPassword] = useState(false);
if (prevVisible !== visible) {
setPrevVisible(visible);
if (visible) {
setChecking(true);
setError(null);
setStatus('idle');
setResult(null);
}
}
useEffect(() => {
if (!visible) return;
let mounted = true;
(async () => {
const token = await getAuthToken();
const storedUser = await getAuthUser();
const lastSyncedAt = await getLastSyncTime();
if (mounted) {
setUser(token && storedUser ? storedUser : null);
setLastSync(lastSyncedAt);
setChecking(false);
}
})();
return () => {
mounted = false;
};
}, [visible]);
const handleAuth = async () => {
if (busy) return;
setError(null);
if (!username.trim()) {
setError('Username is required');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setBusy(true);
try {
const authedUser = mode === 'register'
? await register(username.trim(), password)
: await login(username.trim(), password);
setUser(authedUser);
startAutoSync();
registerForPushNotificationsAsync().catch(() => {});
} catch (err: any) {
setError(err?.message ?? 'Sign in failed');
} finally {
setBusy(false);
}
};
const handleSync = async () => {
if (busy) return;
setError(null);
setStatus('syncing');
try {
const syncResult = await runSyncGuarded();
setResult(syncResult);
setStatus('success');
setLastSync(Date.now());
} catch (err: any) {
setStatus('idle');
if (err?.message === 'SYNC_IN_FLIGHT') {
setError('Sync already in progress');
} else if (err?.message === 'NOT_SIGNED_IN') {
setUser(null);
setError('Not signed in. Sign in to sync.');
} else {
setError(err?.message ?? 'Sync failed. Is the server running?');
}
}
};
const handleSignOut = async () => {
await signOutAuth();
stopAutoSync();
unregisterForPushNotificationsAsync().catch(() => {});
setUser(null);
setError(null);
setStatus('idle');
setResult(null);
};
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
<KeyboardAvoidingView
style={styles.overlay}
behavior="padding"
>
<TouchableOpacity style={styles.backdrop} activeOpacity={1} onPress={onClose} />
<View style={[styles.sheet, { backgroundColor: theme.sheetBg }]}>
<View style={[styles.handle, { backgroundColor: theme.borderStrong }]} />
<Text style={[styles.title, { color: theme.text }]}>Backup & Sync</Text>
{checking ? (
<View style={styles.centerBox}>
<ActivityIndicator color={theme.accent} />
</View>
) : user ? (
<View style={styles.body}>
<Text style={[styles.subtitle, { color: theme.textSecondary }]} numberOfLines={1}>
Signed in as {user.username}
</Text>
<Text style={[styles.lastSync, { color: theme.textFaint }]}>
{lastSync ? `Last synced ${formatRelativeTime(lastSync)}` : 'Never synced'}
</Text>
<TouchableOpacity
style={[styles.primaryButton, { backgroundColor: theme.accent }]}
onPress={handleSync}
disabled={status === 'syncing'}
activeOpacity={0.8}
>
{status === 'syncing' ? (
<ActivityIndicator color={theme.accentText} />
) : (
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>Sync Now</Text>
)}
</TouchableOpacity>
{status === 'success' && result && (
<Text style={[styles.successText, { color: theme.successStrong }]}>
Synced! {result.pushed.tasks + result.pulled.tasks} tasks,{' '}
{result.pushed.categories + result.pulled.categories} categories,{' '}
{result.pushed.subtasks + result.pulled.subtasks} subtasks,{' '}
{result.pushed.repeatProfiles + result.pulled.repeatProfiles} repeat profiles.
{result.conflicts > 0 ? ` ${result.conflicts} conflict(s) resolved.` : ''}
</Text>
)}
{status === 'error' && error && (
<Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>
)}
<TouchableOpacity
style={[styles.secondaryButton, { borderColor: theme.borderStrong }]}
onPress={handleSignOut}
activeOpacity={0.7}
>
<Text style={[styles.secondaryButtonText, { color: theme.dangerStrong }]}>Sign Out</Text>
</TouchableOpacity>
</View>
) : (
<View style={styles.body}>
<Text style={[styles.subtitle, { color: theme.textSecondary }]}>
Sync your tasks with the server. Create an account or sign in.
</Text>
<View style={[styles.segmentRow, { backgroundColor: theme.inputBg }]}>
{(['login', 'register'] as const).map((m) => (
<TouchableOpacity
key={m}
style={[
styles.segment,
mode === m && { backgroundColor: theme.accent },
]}
onPress={() => {
setMode(m);
setError(null);
}}
activeOpacity={0.8}
>
<Text
style={[
styles.segmentText,
{ color: mode === m ? theme.accentText : theme.textSecondary },
]}
>
{m === 'login' ? 'Sign In' : 'Create Account'}
</Text>
</TouchableOpacity>
))}
</View>
{mode === 'register' && (
<Text style={[styles.registerHint, { color: theme.textFaint }]}>
3-30 chars, letters, numbers, dots, dashes, underscores
</Text>
)}
<TextInput
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
placeholder="Username"
placeholderTextColor={theme.textMuted}
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
/>
<TextInput
style={[styles.input, { backgroundColor: theme.inputBg, borderColor: theme.borderStrong, color: theme.text }]}
placeholder="Password (min 8 chars)"
placeholderTextColor={theme.textMuted}
value={password}
onChangeText={setPassword}
secureTextEntry
/>
{mode === 'login' && (
<TouchableOpacity
style={styles.forgotPasswordLink}
onPress={() => setShowForgotPassword(true)}
activeOpacity={0.7}
>
<Text style={[styles.forgotPasswordText, { color: theme.accent }]}>Forgot Password?</Text>
</TouchableOpacity>
)}
{error && <Text style={[styles.errorText, { color: theme.dangerStrong }]}>{error}</Text>}
<TouchableOpacity
style={[styles.primaryButton, { backgroundColor: theme.accent }]}
onPress={handleAuth}
disabled={busy}
activeOpacity={0.8}
>
{busy ? (
<ActivityIndicator color={theme.accentText} />
) : (
<Text style={[styles.primaryButtonText, { color: theme.accentText }]}>
{mode === 'login' ? 'Sign In' : 'Create Account'}
</Text>
)}
</TouchableOpacity>
</View>
)}
<ForgotPasswordModal visible={showForgotPassword} onClose={() => setShowForgotPassword(false)} />
<TouchableOpacity
style={[styles.secondaryButton, { borderColor: theme.borderStrong }]}
onPress={onClose}
activeOpacity={0.7}
>
<Text style={[styles.secondaryButtonText, { color: theme.textSecondary }]}>Close</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</Modal>
);
}
function formatRelativeTime(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return `${Math.floor(seconds / 60)} min ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)} h ago`;
return new Date(timestamp).toLocaleDateString();
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
justifyContent: 'flex-end',
},
backdrop: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.4)',
},
sheet: {
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
paddingTop: 8,
paddingHorizontal: 20,
paddingBottom: 32,
},
handle: {
width: 40,
height: 4,
borderRadius: 2,
alignSelf: 'center',
marginBottom: 12,
},
title: {
fontSize: 18,
fontWeight: '700',
textAlign: 'center',
marginBottom: 16,
},
centerBox: {
paddingVertical: 32,
alignItems: 'center',
},
body: {
gap: 12,
marginBottom: 16,
},
subtitle: {
fontSize: 14,
},
lastSync: {
fontSize: 13,
marginTop: -6,
},
segmentRow: {
flexDirection: 'row',
borderRadius: 12,
padding: 4,
gap: 4,
},
segment: {
flex: 1,
paddingVertical: 10,
borderRadius: 9,
alignItems: 'center',
},
segmentText: {
fontSize: 14,
fontWeight: '600',
},
input: {
borderRadius: 12,
borderWidth: 1,
paddingHorizontal: 14,
paddingVertical: 12,
fontSize: 15,
},
primaryButton: {
paddingVertical: 14,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
minHeight: 50,
},
primaryButtonText: {
fontSize: 15,
fontWeight: '700',
},
secondaryButton: {
paddingVertical: 12,
borderRadius: 12,
borderWidth: 1,
alignItems: 'center',
},
secondaryButtonText: {
fontSize: 15,
fontWeight: '600',
},
errorText: {
fontSize: 13,
fontWeight: '500',
},
registerHint: {
fontSize: 12,
marginTop: -4,
},
successText: {
fontSize: 13,
fontWeight: '500',
},
forgotPasswordLink: {
alignSelf: 'flex-end',
marginTop: -4,
marginBottom: 8,
},
forgotPasswordText: {
fontSize: 13,
fontWeight: '500',
},
});