import AsyncStorage from '@react-native-async-storage/async-storage' import { AppState, useColorScheme, } from 'react-native' import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react' import type { ReactNode } from 'react' import { useAuth } from './auth-context' import { supabase } from './supabase' import { getMobileThemePalette, isMobileThemeMode, resolveEffectiveTheme, type EffectiveMobileTheme, type MobileThemeMode, type MobileThemePalette, } from '../theme/mobile-theme' export const CURRENT_ONBOARDING_VERSION = 1 const CACHE_SCHEMA_VERSION = 1 const INSTALLATION_CACHE_KEY = '@d3ro/mobile/preferences/installation-v1' const USER_CACHE_PREFIX = '@d3ro/mobile/preferences/user-v1/' const USER_SETTINGS_COLUMNS = [ 'user_id', 'theme_mode', 'locale', 'haptic_enabled', 'auto_polish_enabled', 'preferred_stt_model', 'preferred_llm_model', 'onboarding_version', 'tutorial_completed_at', 'revision', 'updated_at', ].join(',') export type SupportedMobileLocale = 'ko' | 'en' export type PreferencesSyncStatus = | 'loading' | 'local' | 'saving' | 'synced' | 'offline' | 'error' export type PreferencesErrorCode = | 'CACHE_READ_FAILED' | 'CACHE_WRITE_FAILED' | 'SYNC_FAILED' | 'SYNC_CONFLICT' export interface MobilePreferences { themeMode: MobileThemeMode locale: SupportedMobileLocale hapticEnabled: boolean autoPolishEnabled: boolean preferredSttModel: string | null preferredLlmModel: string | null onboardingVersion: number tutorialCompletedAt: string | null revision: number updatedAt: string | null } export type MobilePreferencesPatch = Partial> export interface PreferenceMutationResult { localSaved: boolean serverSynced: boolean } interface PreferencesContextValue { preferences: MobilePreferences loading: boolean syncStatus: PreferencesSyncStatus errorCode: PreferencesErrorCode | null lastSyncedAt: string | null effectiveTheme: EffectiveMobileTheme palette: MobileThemePalette needsOnboarding: boolean updatePreferences: (patch: MobilePreferencesPatch) => Promise completeOnboarding: ( outcome: 'completed' | 'skipped', ) => Promise retrySync: () => Promise } interface CacheEnvelope { schemaVersion: number preferences: MobilePreferences pendingPatch: MobilePreferencesPatch lastSyncedAt: string | null } interface SettingsRow { user_id: string theme_mode: unknown locale: unknown haptic_enabled: unknown auto_polish_enabled: unknown preferred_stt_model: unknown preferred_llm_model: unknown onboarding_version: unknown tutorial_completed_at: unknown revision: unknown updated_at: unknown } interface ProviderSnapshot { ownerKey: string preferences: MobilePreferences loading: boolean syncStatus: PreferencesSyncStatus errorCode: PreferencesErrorCode | null lastSyncedAt: string | null } const DEFAULT_PREFERENCES: MobilePreferences = Object.freeze({ themeMode: 'system', locale: 'ko', hapticEnabled: true, autoPolishEnabled: true, preferredSttModel: null, preferredLlmModel: null, onboardingVersion: 0, tutorialCompletedAt: null, revision: 1, updatedAt: null, }) const PreferencesContext = createContext(null) export async function clearAllUserPreferenceCaches(): Promise { const keys = await AsyncStorage.getAllKeys() const userKeys = keys.filter((key) => key.startsWith(USER_CACHE_PREFIX)) if (userKeys.length > 0) await AsyncStorage.multiRemove(userKeys) } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } function normalizeLocale(value: unknown): SupportedMobileLocale { return value === 'en' ? 'en' : 'ko' } function normalizeNullableString(value: unknown): string | null { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null } function normalizeNonNegativeInteger(value: unknown, fallback: number): number { const numeric = typeof value === 'number' ? value : Number(value) return Number.isSafeInteger(numeric) && numeric >= 0 ? numeric : fallback } function normalizePositiveInteger(value: unknown, fallback: number): number { const numeric = typeof value === 'number' ? value : Number(value) return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : fallback } function normalizeIsoDate(value: unknown): string | null { if (typeof value !== 'string') return null const timestamp = Date.parse(value) return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null } export function normalizePreferences( value: unknown, fallback: MobilePreferences = DEFAULT_PREFERENCES, ): MobilePreferences { if (!isRecord(value)) return { ...fallback } return { themeMode: isMobileThemeMode(value.themeMode) ? value.themeMode : fallback.themeMode, locale: normalizeLocale(value.locale ?? fallback.locale), hapticEnabled: typeof value.hapticEnabled === 'boolean' ? value.hapticEnabled : fallback.hapticEnabled, autoPolishEnabled: typeof value.autoPolishEnabled === 'boolean' ? value.autoPolishEnabled : fallback.autoPolishEnabled, preferredSttModel: value.preferredSttModel === undefined ? fallback.preferredSttModel : normalizeNullableString(value.preferredSttModel), preferredLlmModel: value.preferredLlmModel === undefined ? fallback.preferredLlmModel : normalizeNullableString(value.preferredLlmModel), onboardingVersion: normalizeNonNegativeInteger( value.onboardingVersion, fallback.onboardingVersion, ), tutorialCompletedAt: value.tutorialCompletedAt === undefined ? fallback.tutorialCompletedAt : normalizeIsoDate(value.tutorialCompletedAt), revision: normalizePositiveInteger(value.revision, fallback.revision), updatedAt: value.updatedAt === undefined ? fallback.updatedAt : normalizeIsoDate(value.updatedAt), } } export function normalizePreferencesPatch(value: unknown): MobilePreferencesPatch { if (!isRecord(value)) return {} const patch: MobilePreferencesPatch = {} if (isMobileThemeMode(value.themeMode)) patch.themeMode = value.themeMode if (value.locale === 'ko' || value.locale === 'en') patch.locale = value.locale if (typeof value.hapticEnabled === 'boolean') patch.hapticEnabled = value.hapticEnabled if (typeof value.autoPolishEnabled === 'boolean') { patch.autoPolishEnabled = value.autoPolishEnabled } if (value.preferredSttModel === null || typeof value.preferredSttModel === 'string') { patch.preferredSttModel = normalizeNullableString(value.preferredSttModel) } if (value.preferredLlmModel === null || typeof value.preferredLlmModel === 'string') { patch.preferredLlmModel = normalizeNullableString(value.preferredLlmModel) } if (value.onboardingVersion !== undefined) { patch.onboardingVersion = normalizeNonNegativeInteger(value.onboardingVersion, 0) } if (value.tutorialCompletedAt === null || typeof value.tutorialCompletedAt === 'string') { patch.tutorialCompletedAt = normalizeIsoDate(value.tutorialCompletedAt) } return patch } export function applyPreferencesPatch( preferences: MobilePreferences, patch: MobilePreferencesPatch, ): MobilePreferences { return normalizePreferences({ ...preferences, ...normalizePreferencesPatch(patch) }, preferences) } function parseSettingsRow(value: unknown): MobilePreferences | null { if (!isRecord(value) || typeof value.user_id !== 'string') return null const row = value as unknown as SettingsRow return normalizePreferences({ themeMode: row.theme_mode, locale: row.locale, hapticEnabled: row.haptic_enabled, autoPolishEnabled: row.auto_polish_enabled, preferredSttModel: row.preferred_stt_model, preferredLlmModel: row.preferred_llm_model, onboardingVersion: row.onboarding_version, tutorialCompletedAt: row.tutorial_completed_at, revision: row.revision, updatedAt: row.updated_at, }) } function parseCacheEnvelope(raw: string | null): CacheEnvelope | null { if (raw === null) return null const parsed: unknown = JSON.parse(raw) if (!isRecord(parsed) || parsed.schemaVersion !== CACHE_SCHEMA_VERSION) { throw new Error('Unsupported mobile preferences cache schema') } return { schemaVersion: CACHE_SCHEMA_VERSION, preferences: normalizePreferences(parsed.preferences), pendingPatch: normalizePreferencesPatch(parsed.pendingPatch), lastSyncedAt: normalizeIsoDate(parsed.lastSyncedAt), } } function createEnvelope( preferences: MobilePreferences, pendingPatch: MobilePreferencesPatch, lastSyncedAt: string | null, ): CacheEnvelope { return { schemaVersion: CACHE_SCHEMA_VERSION, preferences, pendingPatch: normalizePreferencesPatch(pendingPatch), lastSyncedAt, } } function toInstallationPreferences( preferences: MobilePreferences, ): MobilePreferences { return { ...DEFAULT_PREFERENCES, themeMode: preferences.themeMode, locale: preferences.locale, hapticEnabled: preferences.hapticEnabled, onboardingVersion: preferences.onboardingVersion, tutorialCompletedAt: preferences.tutorialCompletedAt, } } function getUserCacheKey(userId: string): string { return `${USER_CACHE_PREFIX}${userId}` } function serializeEnvelope(envelope: CacheEnvelope): string { return JSON.stringify(envelope) } async function writeCaches( userId: string | null, preferences: MobilePreferences, userPendingPatch: MobilePreferencesPatch, lastSyncedAt: string | null, installationPendingPatch: MobilePreferencesPatch = {}, ): Promise { const installationEnvelope = createEnvelope( toInstallationPreferences(preferences), installationPendingPatch, lastSyncedAt, ) if (userId === null) { await AsyncStorage.setItem( INSTALLATION_CACHE_KEY, serializeEnvelope(installationEnvelope), ) return } const userEnvelope = createEnvelope( preferences, userPendingPatch, lastSyncedAt, ) await AsyncStorage.multiSet([ [INSTALLATION_CACHE_KEY, serializeEnvelope(installationEnvelope)], [getUserCacheKey(userId), serializeEnvelope(userEnvelope)], ]) } function toRowMutation( userId: string, preferences: MobilePreferences, ): Record { return { user_id: userId, theme_mode: preferences.themeMode, locale: preferences.locale, haptic_enabled: preferences.hapticEnabled, auto_polish_enabled: preferences.autoPolishEnabled, preferred_stt_model: preferences.preferredSttModel, preferred_llm_model: preferences.preferredLlmModel, onboarding_version: preferences.onboardingVersion, tutorial_completed_at: preferences.tutorialCompletedAt, revision: preferences.revision, } } async function fetchServerPreferences(userId: string): Promise { const { data, error } = await supabase .from('user_settings') .select(USER_SETTINGS_COLUMNS) .eq('user_id', userId) .maybeSingle() if (error) throw error return parseSettingsRow(data) } async function writeServerPreferences( userId: string, fallback: MobilePreferences, patch: MobilePreferencesPatch, ): Promise { for (let attempt = 0; attempt < 3; attempt += 1) { const remote = await fetchServerPreferences(userId) if (remote === null) { const desired = applyPreferencesPatch(fallback, patch) const insertValue: MobilePreferences = { ...desired, revision: Math.max(1, desired.revision), } const { data, error } = await supabase .from('user_settings') .insert(toRowMutation(userId, insertValue)) .select(USER_SETTINGS_COLUMNS) .single() if (error?.code === '23505') continue if (error) throw error const inserted = parseSettingsRow(data) if (inserted === null) throw new Error('Invalid user settings insert response') return inserted } const desired: MobilePreferences = { ...applyPreferencesPatch(remote, patch), revision: remote.revision + 1, } const { data, error } = await supabase .from('user_settings') .update(toRowMutation(userId, desired)) .eq('user_id', userId) .eq('revision', remote.revision) .select(USER_SETTINGS_COLUMNS) .maybeSingle() if (error) throw error const updated = parseSettingsRow(data) if (updated !== null) return updated } const conflict = new Error('User settings changed repeatedly on another device') conflict.name = 'PreferencesSyncConflictError' throw conflict } function isPatchEmpty(patch: MobilePreferencesPatch): boolean { return Object.keys(patch).length === 0 } function removeCommittedPatch( current: MobilePreferencesPatch, committed: MobilePreferencesPatch, ): MobilePreferencesPatch { const remaining: MobilePreferencesPatch = { ...current } for (const key of Object.keys(committed) as Array) { if (remaining[key] === committed[key]) { delete remaining[key] } } return remaining } function errorCodeForSync(error: unknown): PreferencesErrorCode { return error instanceof Error && error.name === 'PreferencesSyncConflictError' ? 'SYNC_CONFLICT' : 'SYNC_FAILED' } export function MobilePreferencesProvider({ children, }: { children: ReactNode }): React.ReactElement { const { user } = useAuth() const systemScheme = useColorScheme() const userId = user?.id ?? null const ownerKey = userId === null ? 'installation' : `user:${userId}` const [snapshot, setSnapshot] = useState({ ownerKey: 'uninitialized', preferences: { ...DEFAULT_PREFERENCES }, loading: true, syncStatus: 'loading', errorCode: null, lastSyncedAt: null, }) const ownerKeyRef = useRef(ownerKey) const userIdRef = useRef(userId) const preferencesRef = useRef({ ...DEFAULT_PREFERENCES }) const pendingPatchRef = useRef({}) const lastSyncedAtRef = useRef(null) const loadGenerationRef = useRef(0) const serverQueueRef = useRef>(Promise.resolve()) const localQueueRef = useRef>(Promise.resolve()) ownerKeyRef.current = ownerKey userIdRef.current = userId const flushPendingPatch = useCallback(async (targetUserId: string): Promise => { if (userIdRef.current !== targetUserId) return false const committedPatch = { ...pendingPatchRef.current } if (isPatchEmpty(committedPatch)) return true setSnapshot((current) => current.ownerKey === `user:${targetUserId}` ? { ...current, syncStatus: 'saving', errorCode: null } : current) try { const saved = await writeServerPreferences( targetUserId, preferencesRef.current, committedPatch, ) if (userIdRef.current !== targetUserId) return false const remainingPatch = removeCommittedPatch( pendingPatchRef.current, committedPatch, ) const nextPreferences = applyPreferencesPatch(saved, remainingPatch) const syncedAt = new Date().toISOString() preferencesRef.current = nextPreferences pendingPatchRef.current = remainingPatch lastSyncedAtRef.current = syncedAt try { await writeCaches( targetUserId, nextPreferences, remainingPatch, syncedAt, ) setSnapshot({ ownerKey: `user:${targetUserId}`, preferences: nextPreferences, loading: false, syncStatus: isPatchEmpty(remainingPatch) ? 'synced' : 'saving', errorCode: null, lastSyncedAt: syncedAt, }) } catch { setSnapshot({ ownerKey: `user:${targetUserId}`, preferences: nextPreferences, loading: false, syncStatus: 'error', errorCode: 'CACHE_WRITE_FAILED', lastSyncedAt: syncedAt, }) } return isPatchEmpty(remainingPatch) } catch (error) { if (userIdRef.current !== targetUserId) return false setSnapshot((current) => current.ownerKey === `user:${targetUserId}` ? { ...current, loading: false, syncStatus: 'offline', errorCode: errorCodeForSync(error), } : current) return false } }, []) const queueServerFlush = useCallback((targetUserId: string): Promise => { const run = serverQueueRef.current.then( () => flushPendingPatch(targetUserId), () => flushPendingPatch(targetUserId), ) serverQueueRef.current = run.then(() => undefined, () => undefined) return run }, [flushPendingPatch]) const loadPreferences = useCallback(async ( targetOwnerKey: string, targetUserId: string | null, ): Promise => { const generation = ++loadGenerationRef.current setSnapshot((current) => ({ ...current, ownerKey: targetOwnerKey, loading: true, syncStatus: 'loading', errorCode: null, })) let installationEnvelope: CacheEnvelope | null = null let userEnvelope: CacheEnvelope | null = null let cacheReadFailed = false let cacheWriteFailed = false try { const keys = targetUserId === null ? [INSTALLATION_CACHE_KEY] : [INSTALLATION_CACHE_KEY, getUserCacheKey(targetUserId)] const entries = await AsyncStorage.multiGet(keys) installationEnvelope = parseCacheEnvelope(entries[0]?.[1] ?? null) if (targetUserId !== null) { userEnvelope = parseCacheEnvelope(entries[1]?.[1] ?? null) } } catch { cacheReadFailed = true } if (generation !== loadGenerationRef.current || ownerKeyRef.current !== targetOwnerKey) { return } if (targetUserId === null) { const localPreferences = installationEnvelope?.preferences ?? { ...DEFAULT_PREFERENCES } const localPending = installationEnvelope?.pendingPatch ?? {} preferencesRef.current = localPreferences pendingPatchRef.current = localPending lastSyncedAtRef.current = installationEnvelope?.lastSyncedAt ?? null setSnapshot({ ownerKey: targetOwnerKey, preferences: localPreferences, loading: false, syncStatus: cacheReadFailed ? 'error' : 'local', errorCode: cacheReadFailed ? 'CACHE_READ_FAILED' : null, lastSyncedAt: installationEnvelope?.lastSyncedAt ?? null, }) return } const cachedPreferences = userEnvelope?.preferences ?? installationEnvelope?.preferences ?? { ...DEFAULT_PREFERENCES } const stagedPatch: MobilePreferencesPatch = { ...(userEnvelope?.pendingPatch ?? {}), ...(installationEnvelope?.pendingPatch ?? {}), } const cachedWithPending = applyPreferencesPatch(cachedPreferences, stagedPatch) preferencesRef.current = cachedWithPending pendingPatchRef.current = stagedPatch lastSyncedAtRef.current = userEnvelope?.lastSyncedAt ?? installationEnvelope?.lastSyncedAt ?? null setSnapshot({ ownerKey: targetOwnerKey, preferences: cachedWithPending, loading: false, syncStatus: 'loading', errorCode: cacheReadFailed ? 'CACHE_READ_FAILED' : null, lastSyncedAt: userEnvelope?.lastSyncedAt ?? installationEnvelope?.lastSyncedAt ?? null, }) try { await writeCaches( targetUserId, cachedWithPending, stagedPatch, userEnvelope?.lastSyncedAt ?? null, ) } catch { cacheWriteFailed = true } let resolved: MobilePreferences try { const remote = await fetchServerPreferences(targetUserId) if (remote === null || !isPatchEmpty(stagedPatch)) { resolved = await writeServerPreferences( targetUserId, remote ?? cachedWithPending, stagedPatch, ) } else { resolved = remote } if (generation !== loadGenerationRef.current || ownerKeyRef.current !== targetOwnerKey) { return } } catch (error) { if (generation !== loadGenerationRef.current || ownerKeyRef.current !== targetOwnerKey) { return } setSnapshot({ ownerKey: targetOwnerKey, preferences: cachedWithPending, loading: false, syncStatus: 'offline', errorCode: cacheReadFailed ? 'CACHE_READ_FAILED' : cacheWriteFailed ? 'CACHE_WRITE_FAILED' : errorCodeForSync(error), lastSyncedAt: userEnvelope?.lastSyncedAt ?? installationEnvelope?.lastSyncedAt ?? null, }) return } if (generation !== loadGenerationRef.current || ownerKeyRef.current !== targetOwnerKey) { return } const syncedAt = new Date().toISOString() try { await writeCaches(targetUserId, resolved, {}, syncedAt) } catch { cacheWriteFailed = true } preferencesRef.current = resolved pendingPatchRef.current = {} lastSyncedAtRef.current = syncedAt setSnapshot({ ownerKey: targetOwnerKey, preferences: resolved, loading: false, syncStatus: cacheReadFailed || cacheWriteFailed ? 'error' : 'synced', errorCode: cacheReadFailed ? 'CACHE_READ_FAILED' : cacheWriteFailed ? 'CACHE_WRITE_FAILED' : null, lastSyncedAt: syncedAt, }) }, []) useEffect(() => { void loadPreferences(ownerKey, userId) }, [loadPreferences, ownerKey, userId]) useEffect(() => { if (userId === null) return undefined const channel = supabase .channel(`mobile-user-settings-${userId}`) .on( 'postgres_changes', { event: 'UPDATE', schema: 'public', table: 'user_settings', filter: `user_id=eq.${userId}`, }, (payload) => { if (userIdRef.current !== userId || !isPatchEmpty(pendingPatchRef.current)) return const remote = parseSettingsRow(payload.new) if (remote === null) return const syncedAt = new Date().toISOString() preferencesRef.current = remote lastSyncedAtRef.current = syncedAt setSnapshot({ ownerKey: `user:${userId}`, preferences: remote, loading: false, syncStatus: 'synced', errorCode: null, lastSyncedAt: syncedAt, }) void writeCaches(userId, remote, {}, syncedAt).catch(() => { if (userIdRef.current !== userId) return setSnapshot((current) => ({ ...current, syncStatus: 'error', errorCode: 'CACHE_WRITE_FAILED', })) }) }, ) .subscribe() return () => { void supabase.removeChannel(channel) } }, [userId]) const commitPatch = useCallback(async ( requestedPatch: MobilePreferencesPatch, ): Promise => { const targetOwnerKey = ownerKeyRef.current const targetUserId = userIdRef.current const patch = normalizePreferencesPatch(requestedPatch) if (isPatchEmpty(patch)) { return { localSaved: true, serverSynced: targetUserId === null || isPatchEmpty(pendingPatchRef.current), } } const nextPreferences = applyPreferencesPatch(preferencesRef.current, patch) const nextPendingPatch = { ...pendingPatchRef.current, ...patch, } try { await writeCaches( targetUserId, nextPreferences, nextPendingPatch, lastSyncedAtRef.current, targetUserId === null ? nextPendingPatch : {}, ) } catch { if (ownerKeyRef.current === targetOwnerKey) { setSnapshot((current) => ({ ...current, syncStatus: 'error', errorCode: 'CACHE_WRITE_FAILED', })) } return { localSaved: false, serverSynced: false } } if (ownerKeyRef.current !== targetOwnerKey) { return { localSaved: true, serverSynced: false } } preferencesRef.current = nextPreferences pendingPatchRef.current = nextPendingPatch setSnapshot((current) => ({ ...current, ownerKey: targetOwnerKey, preferences: nextPreferences, loading: false, syncStatus: targetUserId === null ? 'local' : 'saving', errorCode: null, })) if (targetUserId === null) { return { localSaved: true, serverSynced: false } } const serverSynced = await queueServerFlush(targetUserId) return { localSaved: true, serverSynced } }, [queueServerFlush]) const updatePreferences = useCallback(( patch: MobilePreferencesPatch, ): Promise => { const run = localQueueRef.current.then( () => commitPatch(patch), () => commitPatch(patch), ) localQueueRef.current = run.then(() => undefined, () => undefined) return run }, [commitPatch]) const completeOnboarding = useCallback(( outcome: 'completed' | 'skipped', ): Promise => updatePreferences({ onboardingVersion: CURRENT_ONBOARDING_VERSION, tutorialCompletedAt: outcome === 'completed' ? new Date().toISOString() : null, }), [updatePreferences]) const retrySync = useCallback(async (): Promise => { const targetOwnerKey = ownerKeyRef.current const targetUserId = userIdRef.current if (targetUserId !== null && !isPatchEmpty(pendingPatchRef.current)) { await queueServerFlush(targetUserId) return } await loadPreferences(targetOwnerKey, targetUserId) }, [loadPreferences, queueServerFlush]) useEffect(() => { const subscription = AppState.addEventListener('change', (state) => { if (state !== 'active' || userIdRef.current === null) return if (isPatchEmpty(pendingPatchRef.current) && snapshot.syncStatus !== 'offline') return void retrySync() }) return () => subscription.remove() }, [retrySync, snapshot.syncStatus]) const effectiveTheme = resolveEffectiveTheme( snapshot.preferences.themeMode, systemScheme, ) const palette = useMemo( () => getMobileThemePalette(effectiveTheme), [effectiveTheme], ) const loading = snapshot.loading || snapshot.ownerKey !== ownerKey const contextValue = useMemo(() => ({ preferences: snapshot.preferences, loading, syncStatus: snapshot.syncStatus, errorCode: snapshot.errorCode, lastSyncedAt: snapshot.lastSyncedAt, effectiveTheme, palette, needsOnboarding: snapshot.preferences.onboardingVersion < CURRENT_ONBOARDING_VERSION, updatePreferences, completeOnboarding, retrySync, }), [ completeOnboarding, effectiveTheme, loading, palette, retrySync, snapshot.errorCode, snapshot.lastSyncedAt, snapshot.preferences, snapshot.syncStatus, updatePreferences, ]) return ( {children} ) } export function useMobilePreferences(): PreferencesContextValue { const context = useContext(PreferencesContext) if (context === null) { throw new Error('useMobilePreferences must be used inside MobilePreferencesProvider') } return context }