feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
|
|
@ -1,44 +1,496 @@
|
|||
// App.tsx — D3RO Voice Mobile root
|
||||
import { NavigationContainer } from '@react-navigation/native'
|
||||
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 { StatusBar } from 'react-native'
|
||||
import { I18nProvider } from '@d3ro/i18n'
|
||||
import { d3roNativePalette } from '@d3ro/ui-native'
|
||||
import { AuthProvider } from './src/lib/auth-context'
|
||||
import TabNavigator from './src/navigation/TabNavigator'
|
||||
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'
|
||||
|
||||
const Stack = createNativeStackNavigator()
|
||||
export type RootStackParamList = {
|
||||
Login: undefined
|
||||
SignUp: undefined
|
||||
ForgotPassword: undefined
|
||||
UpdatePassword: { mode?: 'authenticated' } | undefined
|
||||
Onboarding: { replay?: boolean } | undefined
|
||||
Main: NavigatorScreenParams<MainTabParamList> | 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<RootStackParamList>()
|
||||
const linking: LinkingOptions<RootStackParamList> = {
|
||||
prefixes: ['d3ro-voice://', 'https://d3ro.chanpaca.net'],
|
||||
config: {
|
||||
screens: {
|
||||
InviteAccept: {
|
||||
path: 'accept-invite',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function createNavigationTheme(
|
||||
effectiveTheme: 'light' | 'dark',
|
||||
palette: ReturnType<typeof useMobilePreferences>['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 (
|
||||
<View
|
||||
style={[styles.bootScreen, { backgroundColor: palette.bg.app }]}
|
||||
testID={testID}
|
||||
accessibilityRole="progressbar"
|
||||
>
|
||||
<ActivityIndicator size="large" color={palette.accent.main} />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export function PrivacyCleanupBlockedScreen({
|
||||
onRetry,
|
||||
}: {
|
||||
onRetry: () => Promise<void>
|
||||
}): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const { palette } = useMobilePreferences()
|
||||
return (
|
||||
<View
|
||||
style={[styles.privacyBlockedScreen, { backgroundColor: palette.bg.app }]}
|
||||
accessibilityRole="alert"
|
||||
testID="auth-privacy-cleanup-blocked"
|
||||
>
|
||||
<Text style={[styles.privacyBlockedTitle, { color: palette.text.primary }]}>
|
||||
{t('mobile.auth.localDataCleanupTitle')}
|
||||
</Text>
|
||||
<Text style={[styles.privacyBlockedBody, { color: palette.text.muted }]}>
|
||||
{t('mobile.auth.localDataCleanupBody')}
|
||||
</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('mobile.auth.localDataCleanupRetry')}
|
||||
onPress={() => void onRetry()}
|
||||
style={({ pressed }) => [
|
||||
styles.privacyRetryButton,
|
||||
{ backgroundColor: palette.accent.main },
|
||||
pressed && styles.privacyRetryButtonPressed,
|
||||
]}
|
||||
testID="auth-privacy-cleanup-retry"
|
||||
>
|
||||
<Text style={[styles.privacyRetryLabel, { color: palette.text.onAccent }]}>
|
||||
{t('mobile.auth.localDataCleanupRetry')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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<AuthEntryRoute>('Login')
|
||||
const navigationRef = useNavigationContainerRef<RootStackParamList>()
|
||||
const pendingNotificationTarget = useRef<NotificationNavigationTarget | null>(null)
|
||||
const lastRoutedIncomingMediaId = useRef<string | null>(null)
|
||||
const previousNeedsOnboarding = useRef<boolean | null>(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<string, string>) => void
|
||||
}
|
||||
navigator.navigate(target.name, target.params ?? {})
|
||||
}, [navigationRef, user])
|
||||
|
||||
const routeIncomingMedia = useCallback(async (): Promise<void> => {
|
||||
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<boolean> => {
|
||||
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 <PrivacyCleanupBlockedScreen onRetry={retryPrivacyCleanup} />
|
||||
}
|
||||
|
||||
if (authLoading || preferencesLoading) {
|
||||
return <BootScreen testID="app-bootstrap" />
|
||||
}
|
||||
|
||||
const isRecoveringPassword = user !== null && recoveryMode
|
||||
const branch = isRecoveringPassword
|
||||
? 'recovery'
|
||||
: needsOnboarding
|
||||
? 'onboarding'
|
||||
: user === null
|
||||
? `auth-${authEntryRoute}`
|
||||
: 'app'
|
||||
|
||||
return (
|
||||
<NavigationContainer
|
||||
ref={navigationRef}
|
||||
theme={navigationTheme}
|
||||
linking={linking}
|
||||
onReady={() => {
|
||||
const pending = pendingNotificationTarget.current
|
||||
pendingNotificationTarget.current = null
|
||||
if (pending !== null) navigateFromNotification(pending)
|
||||
void routeIncomingMedia()
|
||||
}}
|
||||
>
|
||||
<Stack.Navigator
|
||||
key={branch}
|
||||
initialRouteName={user === null && !needsOnboarding ? authEntryRoute : undefined}
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: palette.bg.app },
|
||||
animation: 'fade',
|
||||
}}
|
||||
>
|
||||
{isRecoveringPassword ? (
|
||||
<Stack.Screen name="UpdatePassword" component={UpdatePasswordScreen} />
|
||||
) : needsOnboarding ? (
|
||||
<>
|
||||
<Stack.Screen name="Onboarding">
|
||||
{(props) => (
|
||||
<OnboardingScreen
|
||||
{...props}
|
||||
onFreshFinish={(audience) => {
|
||||
setAuthEntryRoute(audience === 'new' ? 'SignUp' : 'Login')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack.Screen>
|
||||
<Stack.Screen name="InviteAccept" component={InviteAcceptScreen} />
|
||||
<Stack.Screen name="Login" component={LoginScreen} />
|
||||
</>
|
||||
) : user === null ? (
|
||||
<>
|
||||
<Stack.Screen name="Login" component={LoginScreen} />
|
||||
<Stack.Screen name="SignUp" component={SignUpScreen} />
|
||||
<Stack.Screen name="ForgotPassword" component={ForgotPasswordScreen} />
|
||||
<Stack.Screen name="InviteAccept" component={InviteAcceptScreen} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Stack.Screen name="Main" component={TabNavigator} />
|
||||
<Stack.Screen name="Account" component={AccountScreen} />
|
||||
<Stack.Screen name="History" component={HistoryScreen} />
|
||||
<Stack.Screen name="HistoryDetail" component={HistoryDetailScreen} />
|
||||
<Stack.Screen name="Devices" component={DevicesScreen} />
|
||||
<Stack.Screen name="Dictionary" component={DictionaryScreen} />
|
||||
<Stack.Screen name="Commands" component={CommandsScreen} />
|
||||
<Stack.Screen name="Meetings" component={MeetingsScreen} />
|
||||
<Stack.Screen name="MeetingDetail" component={MeetingDetailScreen} />
|
||||
<Stack.Screen name="Knowledge" component={KnowledgeScreen} />
|
||||
<Stack.Screen name="Actions" component={ActionsScreen} />
|
||||
<Stack.Screen name="Teams" component={TeamsScreen} />
|
||||
<Stack.Screen name="TeamDetail" component={TeamDetailScreen} />
|
||||
<Stack.Screen name="InviteAccept" component={InviteAcceptScreen} />
|
||||
<Stack.Screen name="Notifications" component={NotificationsScreen} />
|
||||
<Stack.Screen name="DataPortability" component={DataPortabilityScreen} />
|
||||
<Stack.Screen name="Templates">
|
||||
{({ route }) => (
|
||||
<TemplatesScreen initialKind={route.params?.initialKind} />
|
||||
)}
|
||||
</Stack.Screen>
|
||||
<Stack.Screen name="Memos" component={MemosScreen} />
|
||||
<Stack.Screen name="Admin" component={AdminScreen} />
|
||||
<Stack.Screen name="AdminUserDetail" component={AdminUserDetailScreen} />
|
||||
<Stack.Screen name="UpdatePassword" component={UpdatePasswordScreen} />
|
||||
<Stack.Screen
|
||||
name="Onboarding"
|
||||
component={OnboardingScreen}
|
||||
options={{ presentation: 'fullScreenModal' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="ProPaywall"
|
||||
component={ProPaywallScreen}
|
||||
options={{ presentation: 'modal' }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
)
|
||||
}
|
||||
|
||||
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 <BootScreen testID="preferences-bootstrap" />
|
||||
|
||||
return (
|
||||
<I18nProvider initialLocale={preferences.locale}>
|
||||
<LocaleSynchronizer>
|
||||
<StatusBar
|
||||
barStyle={effectiveTheme === 'dark' ? 'light-content' : 'dark-content'}
|
||||
backgroundColor={palette.bg.app}
|
||||
/>
|
||||
<RootNavigator />
|
||||
</LocaleSynchronizer>
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App(): React.ReactElement {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<SafeAreaProvider>
|
||||
<I18nProvider initialLocale="ko">
|
||||
<AuthProvider>
|
||||
<StatusBar barStyle="light-content" backgroundColor={d3roNativePalette.bg.app} />
|
||||
<NavigationContainer>
|
||||
<Stack.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: d3roNativePalette.bg.app },
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="Login" component={LoginScreen} />
|
||||
<Stack.Screen name="Main" component={TabNavigator} />
|
||||
<Stack.Screen
|
||||
name="ProPaywall"
|
||||
component={ProPaywallScreen}
|
||||
options={{ presentation: 'modal' }}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
</AuthProvider>
|
||||
</I18nProvider>
|
||||
<AuthProvider>
|
||||
<EntitlementProvider>
|
||||
<BillingProvider>
|
||||
<MobileAdsProvider>
|
||||
<MobilePreferencesProvider>
|
||||
<DeviceProvider>
|
||||
<LocalizedApplication />
|
||||
</DeviceProvider>
|
||||
</MobilePreferencesProvider>
|
||||
</MobileAdsProvider>
|
||||
</BillingProvider>
|
||||
</EntitlementProvider>
|
||||
</AuthProvider>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
)
|
||||
}
|
||||
|
||||
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' },
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue