d3ro-voice/apps/mobile-rn/src/lib/preferences-context.tsx
2026-08-29 18:33:45 +09:00

909 lines
27 KiB
TypeScript

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<Pick<
MobilePreferences,
| 'themeMode'
| 'locale'
| 'hapticEnabled'
| 'autoPolishEnabled'
| 'preferredSttModel'
| 'preferredLlmModel'
| 'onboardingVersion'
| 'tutorialCompletedAt'
>>
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<PreferenceMutationResult>
completeOnboarding: (
outcome: 'completed' | 'skipped',
) => Promise<PreferenceMutationResult>
retrySync: () => Promise<void>
}
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<PreferencesContextValue | null>(null)
export async function clearAllUserPreferenceCaches(): Promise<void> {
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<string, unknown> {
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<void> {
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<string, unknown> {
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<MobilePreferences | null> {
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<MobilePreferences> {
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<keyof MobilePreferencesPatch>) {
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<ProviderSnapshot>({
ownerKey: 'uninitialized',
preferences: { ...DEFAULT_PREFERENCES },
loading: true,
syncStatus: 'loading',
errorCode: null,
lastSyncedAt: null,
})
const ownerKeyRef = useRef(ownerKey)
const userIdRef = useRef<string | null>(userId)
const preferencesRef = useRef<MobilePreferences>({ ...DEFAULT_PREFERENCES })
const pendingPatchRef = useRef<MobilePreferencesPatch>({})
const lastSyncedAtRef = useRef<string | null>(null)
const loadGenerationRef = useRef(0)
const serverQueueRef = useRef<Promise<void>>(Promise.resolve())
const localQueueRef = useRef<Promise<void>>(Promise.resolve())
ownerKeyRef.current = ownerKey
userIdRef.current = userId
const flushPendingPatch = useCallback(async (targetUserId: string): Promise<boolean> => {
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<boolean> => {
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<void> => {
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<PreferenceMutationResult> => {
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<PreferenceMutationResult> => {
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<PreferenceMutationResult> => updatePreferences({
onboardingVersion: CURRENT_ONBOARDING_VERSION,
tutorialCompletedAt: outcome === 'completed' ? new Date().toISOString() : null,
}), [updatePreferences])
const retrySync = useCallback(async (): Promise<void> => {
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<PreferencesContextValue>(() => ({
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 (
<PreferencesContext.Provider value={contextValue}>
{children}
</PreferencesContext.Provider>
)
}
export function useMobilePreferences(): PreferencesContextValue {
const context = useContext(PreferencesContext)
if (context === null) {
throw new Error('useMobilePreferences must be used inside MobilePreferencesProvider')
}
return context
}