diff --git a/formulus/src/components/AutoSyncSettings.tsx b/formulus/src/components/AutoSyncSettings.tsx new file mode 100644 index 000000000..97946bc1d --- /dev/null +++ b/formulus/src/components/AutoSyncSettings.tsx @@ -0,0 +1,214 @@ +import React, { useCallback, useState } from 'react'; +import { StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-native'; +import { useFocusEffect } from '@react-navigation/native'; +import { useTranslation } from 'react-i18next'; +import { useAppTheme } from '../contexts/AppThemeContext'; +import { + AUTO_SYNC_INTERVALS, + type AutoSyncInterval, + loadAutoSyncSettings, + saveAutoSyncSettings, +} from '../services/AutoSyncSettingsService'; +import { autoSyncService } from '../services/AutoSyncService'; +import { ToastService } from '../services/ToastService'; +import { odeSpacing, odeTypography } from '../theme/odeDesign'; +import { withAlpha } from '../theme/colors'; + +const intervalLabelKey = (interval: AutoSyncInterval): string => { + switch (interval) { + case 30_000: + return 'profiles.autoSync30Seconds'; + case 2 * 60_000: + return 'profiles.autoSync2Minutes'; + case 5 * 60_000: + return 'profiles.autoSync5Minutes'; + case 15 * 60_000: + return 'profiles.autoSync15Minutes'; + case 60 * 60_000: + return 'profiles.autoSync1Hour'; + case 24 * 60 * 60_000: + return 'profiles.autoSync24Hours'; + } + return 'profiles.autoSync5Minutes'; +}; + +const AutoSyncSettings = () => { + const { t } = useTranslation(); + const { themeColors } = useAppTheme(); + const [enabled, setEnabled] = useState(false); + const [intervalMs, setIntervalMs] = useState(5 * 60_000); + const [loading, setLoading] = useState(true); + + useFocusEffect( + useCallback(() => { + let cancelled = false; + + setLoading(true); + + void loadAutoSyncSettings() + .then(settings => { + if (cancelled) return; + + setEnabled(settings.enabled); + setIntervalMs(settings.intervalMs); + }) + .catch(() => { + if (!cancelled) { + ToastService.showLong(t('profiles.autoSyncLoadFailed')); + } + }) + .finally(() => { + if (!cancelled) { + setLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [t]), + ); + + const save = async ( + nextEnabled: boolean, + nextInterval: AutoSyncInterval, + ): Promise => { + try { + await saveAutoSyncSettings({ + enabled: nextEnabled, + intervalMs: nextInterval, + }); + + autoSyncService.refresh(); + } catch { + ToastService.showLong(t('profiles.autoSyncSaveFailed')); + } + }; + + const handleEnabledChange = (value: boolean) => { + setEnabled(value); + void save(value, intervalMs); + }; + + const handleIntervalChange = (value: AutoSyncInterval) => { + setIntervalMs(value); + void save(enabled, value); + }; + + return ( + + + + + {t('profiles.autoSyncTitle')} + + + {t('profiles.autoSyncHint')} + + + + + + + {enabled && ( + + + {t('profiles.autoSyncInterval')} + + + + {AUTO_SYNC_INTERVALS.map(interval => { + const selected = intervalMs === interval; + + return ( + handleIntervalChange(interval)} + style={[ + styles.intervalOption, + { + backgroundColor: withAlpha( + themeColors.primary as string, + selected ? 0.12 : 0, + ), + }, + ]}> + + {t(intervalLabelKey(interval))} + + + ); + })} + + + )} + + ); +}; + +const styles = StyleSheet.create({ + section: { + marginTop: odeSpacing.md, + gap: odeSpacing.sm, + }, + toggleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: odeSpacing.md, + }, + toggleText: { + flex: 1, + }, + heading: { + fontSize: odeTypography.sectionTitle, + fontWeight: '600', + }, + hint: { + fontSize: odeTypography.bodySm, + marginTop: odeSpacing.xs, + }, + intervalSection: { + gap: odeSpacing.sm, + }, + intervalTitle: { + fontSize: odeTypography.body, + fontWeight: '600', + }, + intervalOptions: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: odeSpacing.xs, + }, + intervalOption: { + borderWidth: 1, + borderRadius: odeSpacing.xs, + paddingHorizontal: odeSpacing.sm, + paddingVertical: odeSpacing.xs, + minHeight: 44, + justifyContent: 'center', + }, + intervalText: { + fontSize: odeTypography.bodySm, + fontWeight: '500', + }, +}); + +export default AutoSyncSettings; diff --git a/formulus/src/locales/en.json b/formulus/src/locales/en.json index d12013c7c..049d3be19 100644 --- a/formulus/src/locales/en.json +++ b/formulus/src/locales/en.json @@ -55,6 +55,17 @@ "profiles.showPassword": "Show password", "profiles.hidePassword": "Hide password", "profiles.working": "Working…", + "profiles.autoSyncTitle": "Automatic sync", + "profiles.autoSyncHint": "Automatically sync observations while Formulus is open.", + "profiles.autoSyncInterval": "Sync interval", + "profiles.autoSync30Seconds": "Every 30 seconds", + "profiles.autoSync2Minutes": "Every 2 minutes", + "profiles.autoSync5Minutes": "Every 5 minutes", + "profiles.autoSync15Minutes": "Every 15 minutes", + "profiles.autoSync1Hour": "Every hour", + "profiles.autoSync24Hours": "Every 24 hours", + "profiles.autoSyncLoadFailed": "Could not load automatic sync settings.", + "profiles.autoSyncSaveFailed": "Could not save automatic sync settings.", "welcome.title": "Welcome to:", "welcome.profilesSubtitle": "Set up a profile to connect to your server.", "welcome.getStarted": "Get started", diff --git a/formulus/src/locales/fr.json b/formulus/src/locales/fr.json index 6a3e1f37b..7c9572eaa 100644 --- a/formulus/src/locales/fr.json +++ b/formulus/src/locales/fr.json @@ -55,6 +55,17 @@ "profiles.showPassword": "Afficher le mot de passe", "profiles.hidePassword": "Masquer le mot de passe", "profiles.working": "Traitement…", + "profiles.autoSyncTitle": "Synchronisation automatique", + "profiles.autoSyncHint": "Synchronise automatiquement les observations lorsque Formulus est ouvert.", + "profiles.autoSyncInterval": "Intervalle de synchronisation", + "profiles.autoSync30Seconds": "Toutes les 30 secondes", + "profiles.autoSync2Minutes": "Toutes les 2 minutes", + "profiles.autoSync5Minutes": "Toutes les 5 minutes", + "profiles.autoSync15Minutes": "Toutes les 15 minutes", + "profiles.autoSync1Hour": "Toutes les heures", + "profiles.autoSync24Hours": "Toutes les 24 heures", + "profiles.autoSyncLoadFailed": "Impossible de charger les paramètres de synchronisation automatique.", + "profiles.autoSyncSaveFailed": "Impossible d’enregistrer les paramètres de synchronisation automatique.", "welcome.title": "Bienvenue dans :", "welcome.profilesSubtitle": "Configurez un profil pour vous connecter à votre serveur.", "welcome.getStarted": "Commencer", diff --git a/formulus/src/locales/pt.json b/formulus/src/locales/pt.json index a64ca54cb..5903cfb7e 100644 --- a/formulus/src/locales/pt.json +++ b/formulus/src/locales/pt.json @@ -55,6 +55,17 @@ "profiles.showPassword": "Mostrar palavra-passe", "profiles.hidePassword": "Ocultar palavra-passe", "profiles.working": "A processar…", + "profiles.autoSyncTitle": "Sincronização automática", + "profiles.autoSyncHint": "Sincroniza automaticamente as observações enquanto o Formulus está aberto.", + "profiles.autoSyncInterval": "Intervalo de sincronização", + "profiles.autoSync30Seconds": "A cada 30 segundos", + "profiles.autoSync2Minutes": "A cada 2 minutos", + "profiles.autoSync5Minutes": "A cada 5 minutos", + "profiles.autoSync15Minutes": "A cada 15 minutos", + "profiles.autoSync1Hour": "A cada hora", + "profiles.autoSync24Hours": "A cada 24 horas", + "profiles.autoSyncLoadFailed": "Não foi possível carregar as definições de sincronização automática.", + "profiles.autoSyncSaveFailed": "Não foi possível guardar as definições de sincronização automática.", "welcome.title": "Bem-vindo ao:", "welcome.profilesSubtitle": "Configure um perfil para se ligar ao seu servidor.", "welcome.getStarted": "Começar", diff --git a/formulus/src/profiles/ProfileRoot.tsx b/formulus/src/profiles/ProfileRoot.tsx index 3db642335..87132e9ee 100644 --- a/formulus/src/profiles/ProfileRoot.tsx +++ b/formulus/src/profiles/ProfileRoot.tsx @@ -7,6 +7,7 @@ import React, { import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; import { bootstrapProfileApp } from './ProfileBootstrap'; import { i18n } from '../i18n'; +import { autoSyncService } from '../services/AutoSyncService'; import { getProfileTransitionState, registerProfileTransitionHost, @@ -49,6 +50,19 @@ export default function ProfileRoot(): React.JSX.Element { }; }, []); + useEffect(() => { + if (!AppComponent || unmounted || transition !== 'idle' || error) { + autoSyncService.stop(); + return; + } + + const stopAutoSync = autoSyncService.start(); + + return () => { + stopAutoSync(); + }; + }, [AppComponent, unmounted, transition, error]); + useEffect( () => registerProfileTransitionHost({ diff --git a/formulus/src/screens/ProfileConnection.tsx b/formulus/src/screens/ProfileConnection.tsx index 6ab41d304..41bd1c121 100644 --- a/formulus/src/screens/ProfileConnection.tsx +++ b/formulus/src/screens/ProfileConnection.tsx @@ -5,6 +5,7 @@ import type { BottomTabNavigationProp } from '@react-navigation/bottom-tabs'; import Icon from '@react-native-vector-icons/material-design-icons'; import { useTranslation } from 'react-i18next'; import { Button, Input } from '../components/common'; +import AutoSyncSettings from '../components/AutoSyncSettings'; import QRScannerModal, { ScannerModalResults, } from '../components/QRScannerModal'; @@ -423,6 +424,9 @@ const ProfileConnection = ({ } /> + + {!creation && } + {creation ? (