137 lines
4.8 KiB
TypeScript
137 lines
4.8 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { AccessibilityInfo, ActivityIndicator, AppState, StatusBar, StyleSheet, Text, View } from 'react-native';
|
|
import NetInfo, { type NetInfoState } from '@react-native-community/netinfo';
|
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
import { NavigationContainer } from '@react-navigation/native';
|
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import '@/i18n';
|
|
import { AppNavigator } from '@/navigation/AppNavigator';
|
|
import {
|
|
configureHealthBackgroundDelivery,
|
|
refreshHealthFromCursor,
|
|
} from '@/services/health/healthSync';
|
|
import { useAppStore } from '@/store/appStore';
|
|
import { colors, space, type } from '@/theme/tokens';
|
|
|
|
function App() {
|
|
return (
|
|
<GestureHandlerRootView style={styles.root}>
|
|
<SafeAreaProvider>
|
|
<StatusBar barStyle="dark-content" backgroundColor="transparent" translucent />
|
|
<AppBootstrap />
|
|
</SafeAreaProvider>
|
|
</GestureHandlerRootView>
|
|
);
|
|
}
|
|
|
|
function AppBootstrap() {
|
|
const { t, i18n } = useTranslation();
|
|
const hydrated = useAppStore(state => state.hydrated);
|
|
const phase = useAppStore(state => state.onboardingPhase);
|
|
const language = useAppStore(state => state.language);
|
|
const setMotionPreferences = useAppStore(state => state.setMotionPreferences);
|
|
const automaticSignalKey = useAppStore(state => state.signals
|
|
.filter(signal => signal.enabled && signal.source === 'automatic')
|
|
.map(signal => signal.action)
|
|
.sort()
|
|
.join('|'));
|
|
const hasAutomaticSignal = automaticSignalKey.length > 0;
|
|
const healthStatus = useAppStore(state => state.healthConnection.status);
|
|
const [offline, setOffline] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const applyNetworkState = (state: NetInfoState) => {
|
|
setOffline(state.isConnected === false || state.isInternetReachable === false);
|
|
};
|
|
const unsubscribe = NetInfo.addEventListener(applyNetworkState);
|
|
NetInfo.fetch().then(applyNetworkState).catch(() => undefined);
|
|
return unsubscribe;
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (hydrated && i18n.language !== language) {
|
|
i18n.changeLanguage(language).catch(() => undefined);
|
|
}
|
|
}, [hydrated, i18n, language]);
|
|
|
|
useEffect(() => {
|
|
const syncSystemPreference = (reduceMotion: boolean) => {
|
|
if (reduceMotion) setMotionPreferences({ reduceMotion: true });
|
|
};
|
|
AccessibilityInfo.isReduceMotionEnabled()
|
|
.then(syncSystemPreference)
|
|
.catch(() => undefined);
|
|
const subscription = AccessibilityInfo.addEventListener('reduceMotionChanged', syncSystemPreference);
|
|
return () => subscription.remove();
|
|
}, [setMotionPreferences]);
|
|
|
|
useEffect(() => {
|
|
if (!hydrated || phase !== 'complete') return;
|
|
configureHealthBackgroundDelivery().catch(() => undefined);
|
|
}, [automaticSignalKey, healthStatus, hydrated, phase]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!hydrated ||
|
|
phase !== 'complete' ||
|
|
!hasAutomaticSignal ||
|
|
(healthStatus !== 'authorized' && healthStatus !== 'partial')
|
|
) {
|
|
return;
|
|
}
|
|
const refresh = () => {
|
|
refreshHealthFromCursor().catch(() => undefined);
|
|
};
|
|
refresh();
|
|
const subscription = AppState.addEventListener('change', state => {
|
|
if (state === 'active') refresh();
|
|
});
|
|
return () => subscription.remove();
|
|
}, [hasAutomaticSignal, healthStatus, hydrated, phase]);
|
|
|
|
if (!hydrated) {
|
|
return (
|
|
<View style={styles.loading}>
|
|
<ActivityIndicator size="large" color={colors.mossDark} />
|
|
<Text style={styles.loadingText}>{t('app.loading')}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={styles.appShell}>
|
|
{offline ? <OfflineBanner /> : null}
|
|
<View style={styles.navigationRoot}>
|
|
<NavigationContainer>
|
|
<AppNavigator phase={phase} />
|
|
</NavigationContainer>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export function OfflineBanner() {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<View accessibilityLiveRegion="polite" style={styles.offlineBanner}>
|
|
<Text accessibilityRole="alert" style={styles.offlineTitle}>{t('offline.title')}</Text>
|
|
<Text style={styles.offlineBody}>{t('offline.body')}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
root: { flex: 1 },
|
|
appShell: { flex: 1, backgroundColor: colors.canvas },
|
|
navigationRoot: { flex: 1 },
|
|
offlineBanner: { paddingHorizontal: space.lg, paddingVertical: space.sm, backgroundColor: colors.coralSoft, borderBottomWidth: 1, borderBottomColor: colors.coral },
|
|
offlineTitle: { ...type.bodyStrong, color: colors.danger },
|
|
offlineBody: { ...type.caption, color: colors.inkMuted, marginTop: 2 },
|
|
loading: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: space.md, backgroundColor: colors.canvas },
|
|
loadingText: { ...type.body, color: colors.inkMuted },
|
|
});
|
|
|
|
export default App;
|