404 lines
12 KiB
TypeScript
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',
|
|
},
|
|
});
|