Files
life-companion/App.tsx
T

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;