import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ActivityIndicator, Pressable, StatusBar, StyleSheet, Text, View, } from 'react-native' import { DarkTheme, DefaultTheme, NavigationContainer, useNavigationContainerRef, type LinkingOptions, type NavigatorScreenParams, type Theme as NavigationTheme, } from '@react-navigation/native' import { createNativeStackNavigator } from '@react-navigation/native-stack' import { GestureHandlerRootView } from 'react-native-gesture-handler' import { SafeAreaProvider } from 'react-native-safe-area-context' import { I18nProvider, useI18n } from '@d3ro/i18n' import { AuthProvider, useAuth } from './src/lib/auth-context' import { MobilePreferencesProvider, useMobilePreferences, } from './src/lib/preferences-context' import TabNavigator, { type MainTabParamList } from './src/navigation/TabNavigator' import LoginScreen from './src/screens/LoginScreen' import ProPaywallScreen from './src/screens/ProPaywallScreen' import SignUpScreen from './src/screens/SignUpScreen' import ForgotPasswordScreen from './src/screens/ForgotPasswordScreen' import UpdatePasswordScreen from './src/screens/UpdatePasswordScreen' import AccountScreen from './src/screens/AccountScreen' import OnboardingScreen from './src/screens/OnboardingScreen' import HistoryScreen from './src/screens/HistoryScreen' import HistoryDetailScreen from './src/screens/HistoryDetailScreen' import DevicesScreen from './src/screens/DevicesScreen' import DictionaryScreen from './src/screens/DictionaryScreen' import CommandsScreen from './src/screens/CommandsScreen' import MeetingsScreen from './src/screens/MeetingsScreen' import MeetingDetailScreen from './src/screens/MeetingDetailScreen' import KnowledgeScreen from './src/screens/KnowledgeScreen' import ActionsScreen from './src/screens/ActionsScreen' import TeamsScreen from './src/screens/TeamsScreen' import TeamDetailScreen from './src/screens/TeamDetailScreen' import InviteAcceptScreen from './src/screens/InviteAcceptScreen' import NotificationsScreen from './src/screens/NotificationsScreen' import DataPortabilityScreen from './src/screens/DataPortabilityScreen' import TemplatesScreen from './src/screens/TemplatesScreen' import MemosScreen from './src/screens/MemosScreen' import AdminScreen from './src/screens/AdminScreen' import AdminUserDetailScreen from './src/screens/AdminUserDetailScreen' import { EntitlementProvider } from './src/lib/entitlement-context' import { fetchEntitlementSnapshot, useEntitlement } from './src/lib/entitlement-context' import { BillingProvider } from './src/lib/billing-context' import { MobileAdsProvider } from './src/lib/mobile-ads-context' import { DeviceProvider } from './src/lib/device-context' import { useNotificationRuntime } from './src/features/notifications/use-notification-runtime' import type { NotificationNavigationTarget } from './src/features/notifications/notification-contract' import { loadPendingInviteToken } from './src/features/teams/pending-invite' import { listTeams } from './src/features/teams/team-service' import { listHistoryPage } from './src/features/history/history-service' import { saveHistoryCache } from './src/features/history/history-cache' import { useRecordingProcessingQueue } from './src/features/recording/use-recording-processing-queue' import { getPendingIncomingMediaId, onIncomingMediaAvailable, } from './src/features/import/incoming-media-intent' export type RootStackParamList = { Login: undefined SignUp: undefined ForgotPassword: undefined UpdatePassword: { mode?: 'authenticated' } | undefined Onboarding: { replay?: boolean } | undefined Main: NavigatorScreenParams | undefined Account: undefined History: undefined HistoryDetail: { historyId: string } ProPaywall: undefined Devices: undefined Dictionary: undefined Commands: undefined Meetings: undefined MeetingDetail: { meetingId: string } Knowledge: undefined Actions: undefined Teams: undefined TeamDetail: { teamId: string } InviteAccept: { token?: string; url?: string } | undefined Notifications: undefined DataPortability: undefined Templates: { initialKind?: 'dictation' | 'meeting_document' } | undefined Memos: undefined Admin: undefined AdminUserDetail: { userId: string } } type AuthEntryRoute = 'Login' | 'SignUp' const Stack = createNativeStackNavigator() const linking: LinkingOptions = { prefixes: ['d3ro-voice://', 'https://d3ro.chanpaca.net'], config: { screens: { InviteAccept: { path: 'accept-invite', }, }, }, } function createNavigationTheme( effectiveTheme: 'light' | 'dark', palette: ReturnType['palette'], ): NavigationTheme { const base = effectiveTheme === 'dark' ? DarkTheme : DefaultTheme return { ...base, dark: effectiveTheme === 'dark', colors: { ...base.colors, primary: palette.accent.main, background: palette.bg.app, card: palette.bg.card, text: palette.text.primary, border: palette.border.default, notification: palette.tag.red, }, } } function BootScreen({ testID }: { testID: string }): React.ReactElement { const { palette } = useMobilePreferences() return ( ) } export function PrivacyCleanupBlockedScreen({ onRetry, }: { onRetry: () => Promise }): React.ReactElement { const { t } = useI18n() const { palette } = useMobilePreferences() return ( {t('mobile.auth.localDataCleanupTitle')} {t('mobile.auth.localDataCleanupBody')} void onRetry()} style={({ pressed }) => [ styles.privacyRetryButton, { backgroundColor: palette.accent.main }, pressed && styles.privacyRetryButtonPressed, ]} testID="auth-privacy-cleanup-retry" > {t('mobile.auth.localDataCleanupRetry')} ) } function RootNavigator(): React.ReactElement { const { user, loading: authLoading, recoveryMode, privacyCleanupState, retryPrivacyCleanup, } = useAuth() const entitlement = useEntitlement() const { loading: preferencesLoading, needsOnboarding, effectiveTheme, palette, } = useMobilePreferences() const [authEntryRoute, setAuthEntryRoute] = useState('Login') const navigationRef = useNavigationContainerRef() const pendingNotificationTarget = useRef(null) const lastRoutedIncomingMediaId = useRef(null) const previousNeedsOnboarding = useRef(null) const navigationTheme = useMemo( () => createNavigationTheme(effectiveTheme, palette), [effectiveTheme, palette], ) useRecordingProcessingQueue( user?.id ?? null, !authLoading && privacyCleanupState === 'ready', ) const navigateFromNotification = useCallback((target: NotificationNavigationTarget): void => { if (user === null && target.name !== 'InviteAccept') return if (!navigationRef.isReady()) { pendingNotificationTarget.current = target return } const navigator = navigationRef as unknown as { navigate: (name: string, params: Record) => void } navigator.navigate(target.name, target.params ?? {}) }, [navigationRef, user]) const routeIncomingMedia = useCallback(async (): Promise => { if (user === null || needsOnboarding || recoveryMode || !navigationRef.isReady()) return const incomingMediaId = await getPendingIncomingMediaId().catch(() => null) if (incomingMediaId === null || incomingMediaId === lastRoutedIncomingMediaId.current) return lastRoutedIncomingMediaId.current = incomingMediaId navigationRef.navigate('Main', { screen: 'Record', params: { incomingMediaId }, }) }, [needsOnboarding, navigationRef, recoveryMode, user]) useEffect(() => { const subscription = onIncomingMediaAvailable(() => { void routeIncomingMedia() }) return () => subscription.remove() }, [routeIncomingMedia]) useEffect(() => { void routeIncomingMedia() }, [routeIncomingMedia]) const synchronizeMissedNotifications = useCallback(async (): Promise => { const userId = user?.id if (!userId) return false try { const [history] = await Promise.all([ listHistoryPage({ userId, filter: 'all', pageSize: 50 }), listTeams(userId), fetchEntitlementSnapshot(userId), ]) await saveHistoryCache(userId, history.entries) await entitlement.refresh() return true } catch { return false } }, [entitlement, user?.id]) useNotificationRuntime({ navigate: navigateFromNotification, onFullSyncRequired: synchronizeMissedNotifications, }) useEffect(() => { if (authLoading || preferencesLoading) return const previous = previousNeedsOnboarding.current previousNeedsOnboarding.current = needsOnboarding // Completing onboarding changes the navigator branch in the same render. // React Navigation can preserve the old Onboarding route across that // remount, making the screen-level replace target stale. Reset from the // container only for the authenticated true -> false transition; replay // onboarding and unauthenticated audience selection must keep their own // exit behavior. if (previous !== true || needsOnboarding || user === null) return if (!navigationRef.isReady()) return navigationRef.resetRoot({ index: 0, routes: [{ name: 'Main' }] }) }, [authLoading, navigationRef, needsOnboarding, preferencesLoading, user]) useEffect(() => { if (!user?.id) return let active = true void loadPendingInviteToken().then((token) => { if (active && token !== null) { navigateFromNotification({ name: 'InviteAccept', params: { token } }) } }).catch(() => undefined) return () => { active = false } }, [navigateFromNotification, user?.id]) if (privacyCleanupState === 'failed') { return } if (authLoading || preferencesLoading) { return } const isRecoveringPassword = user !== null && recoveryMode const branch = isRecoveringPassword ? 'recovery' : needsOnboarding ? 'onboarding' : user === null ? `auth-${authEntryRoute}` : 'app' return ( { const pending = pendingNotificationTarget.current pendingNotificationTarget.current = null if (pending !== null) navigateFromNotification(pending) void routeIncomingMedia() }} > {isRecoveringPassword ? ( ) : needsOnboarding ? ( <> {(props) => ( { setAuthEntryRoute(audience === 'new' ? 'SignUp' : 'Login') }} /> )} ) : user === null ? ( <> ) : ( <> {({ route }) => ( )} )} ) } function LocaleSynchronizer({ children }: { children: React.ReactNode }): React.ReactElement { const { preferences } = useMobilePreferences() const { locale, setLocale } = useI18n() useEffect(() => { if (locale !== preferences.locale) setLocale(preferences.locale) }, [locale, preferences.locale, setLocale]) return <>{children} } function LocalizedApplication(): React.ReactElement { const { loading, preferences, effectiveTheme, palette, } = useMobilePreferences() if (loading) return return ( ) } export default function App(): React.ReactElement { return ( ) } const styles = StyleSheet.create({ root: { flex: 1 }, bootScreen: { flex: 1, alignItems: 'center', justifyContent: 'center', }, privacyBlockedScreen: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 28, gap: 16, }, privacyBlockedTitle: { fontSize: 21, fontWeight: '500', textAlign: 'center', }, privacyBlockedBody: { maxWidth: 460, fontSize: 15, lineHeight: 22, textAlign: 'center', }, privacyRetryButton: { minHeight: 48, minWidth: 220, alignItems: 'center', justifyContent: 'center', borderRadius: 12, paddingHorizontal: 20, paddingVertical: 12, }, privacyRetryButtonPressed: { opacity: 0.82 }, privacyRetryLabel: { fontSize: 15, fontWeight: '500' }, })