apps/web was never deployed, so /billing on the public domain returned the landing page and d3ro.dev (desktop "upgrade") did not resolve. - apps/web runs with basePath /app and output standalone; /download and /releases redirect to the site's #download. A Dockerfile and a d3ro-web compose service (port 3002) deploy it to the NAS with the other images. - The site bridge worker forwards /app/* to WEB_APP_ORIGIN (the tunnel host) and rewrites upstream redirects; everything else still goes to Pages. With no origin configured /app answers 503 instead of the landing page. - Desktop upgrade, desktop Stripe return, mobile subscription management, the web checkout/portal returns and the site all use billingUrl(); the return query is success=1 / canceled=1, which the billing page reads. The billing page highlights ?tier=pro|pro_plus, and signing in from a billing link returns to the same plan. - auth/callback pins the redirect origin in production and rejects protocol-relative next= values (open redirect). - Mobile legal links use SITE_URLS (fixes the missing slash on /terms). - Compose drops the unused NEXT_PUBLIC_API_URL and the dead wwwroot legal mounts; deploy scripts add the web image and the SUPABASE_* values the NAS compose already required; .dockerignore keeps app .env files out of images. - Supabase auth redirects allow /app/** (remote dashboard must match). Policy: docs/REFACTOR_POLICY.md Wave 3, W3-3 and W3-4.
818 lines
25 KiB
TypeScript
818 lines
25 KiB
TypeScript
import { useState } from 'react'
|
|
import {
|
|
Alert,
|
|
Linking,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Switch,
|
|
View,
|
|
} from 'react-native'
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
import { useNavigation } from '@react-navigation/native'
|
|
import { useI18n, type TranslationKey } from '@d3ro/i18n'
|
|
import { SITE_URLS } from '@d3ro/core/web-urls'
|
|
import { useAuth } from '../lib/auth-context'
|
|
import { useEntitlement } from '../lib/entitlement-context'
|
|
import {
|
|
useMobilePreferences,
|
|
type MobilePreferencesPatch,
|
|
type PreferencesErrorCode,
|
|
type PreferencesSyncStatus,
|
|
type SupportedMobileLocale,
|
|
} from '../lib/preferences-context'
|
|
import {
|
|
MOBILE_THEME_MODES,
|
|
type MobileThemeMode,
|
|
} from '../theme/mobile-theme'
|
|
import {
|
|
ThemeButton,
|
|
ThemeCard,
|
|
ThemeText,
|
|
} from '../theme/themed-components'
|
|
import { useMobileAds } from '../lib/mobile-ads-context'
|
|
import { useDevice } from '../lib/device-context'
|
|
import { currentAdminRole } from '../features/admin/admin-service'
|
|
import { performAccountLogout } from '../lib/account-exit'
|
|
|
|
type SavingPreference = 'theme' | 'locale' | 'autoPolish' | 'haptic' | null
|
|
|
|
const THEME_LABEL_KEYS: Record<MobileThemeMode, TranslationKey> = {
|
|
system: 'mobile.theme.system',
|
|
light: 'mobile.theme.light',
|
|
dark: 'mobile.theme.dark',
|
|
}
|
|
|
|
const SYNC_STATUS_KEYS: Record<PreferencesSyncStatus, TranslationKey> = {
|
|
loading: 'mobile.preferences.statusLoading',
|
|
local: 'mobile.preferences.statusLocal',
|
|
saving: 'mobile.preferences.statusSaving',
|
|
synced: 'mobile.preferences.statusSynced',
|
|
offline: 'mobile.preferences.statusOffline',
|
|
error: 'mobile.preferences.statusError',
|
|
}
|
|
|
|
const ERROR_KEYS: Record<PreferencesErrorCode, TranslationKey> = {
|
|
CACHE_READ_FAILED: 'mobile.preferences.cacheReadError',
|
|
CACHE_WRITE_FAILED: 'mobile.preferences.cacheWriteError',
|
|
SYNC_FAILED: 'mobile.preferences.syncError',
|
|
SYNC_CONFLICT: 'mobile.preferences.conflictError',
|
|
}
|
|
|
|
export default function SettingsScreen(): React.ReactElement {
|
|
const insets = useSafeAreaInsets()
|
|
const { t, locale, setLocale, formatTime } = useI18n()
|
|
const { user, purgeLocalSession } = useAuth()
|
|
const navigation = useNavigation<any>()
|
|
const {
|
|
preferences,
|
|
palette,
|
|
syncStatus,
|
|
errorCode,
|
|
lastSyncedAt,
|
|
updatePreferences,
|
|
retrySync,
|
|
} = useMobilePreferences()
|
|
const [savingPreference, setSavingPreference] = useState<SavingPreference>(null)
|
|
const [loggingOut, setLoggingOut] = useState(false)
|
|
const [retrying, setRetrying] = useState(false)
|
|
const { snapshot: entitlement } = useEntitlement()
|
|
const mobileAds = useMobileAds()
|
|
const { currentDevice } = useDevice()
|
|
const adminRole = currentAdminRole(user?.app_metadata)
|
|
|
|
const initials = user?.email
|
|
? user.email.substring(0, 2).toUpperCase()
|
|
: 'US'
|
|
const syncLabel = t(SYNC_STATUS_KEYS[syncStatus])
|
|
const syncError = errorCode === null ? null : t(ERROR_KEYS[errorCode])
|
|
const syncedTime = lastSyncedAt === null
|
|
? null
|
|
: formatTime(Date.parse(lastSyncedAt))
|
|
|
|
async function persistPreference(
|
|
key: Exclude<SavingPreference, null>,
|
|
patch: MobilePreferencesPatch,
|
|
): Promise<void> {
|
|
if (savingPreference !== null) return
|
|
setSavingPreference(key)
|
|
const result = await updatePreferences(patch)
|
|
setSavingPreference(null)
|
|
if (!result.localSaved) {
|
|
Alert.alert(
|
|
t('mobile.preferences.saveFailedTitle'),
|
|
t('mobile.preferences.cacheWriteError'),
|
|
)
|
|
}
|
|
}
|
|
|
|
function selectLocale(nextLocale: SupportedMobileLocale): void {
|
|
if (locale === nextLocale || savingPreference !== null) return
|
|
setLocale(nextLocale)
|
|
void persistPreference('locale', { locale: nextLocale })
|
|
}
|
|
|
|
async function performLogout(): Promise<void> {
|
|
if (loggingOut) return
|
|
setLoggingOut(true)
|
|
try {
|
|
await performAccountLogout({
|
|
userId: user?.id ?? null,
|
|
deviceId: currentDevice?.id ?? null,
|
|
purgeLocalSession,
|
|
})
|
|
} catch {
|
|
Alert.alert(t('mobile.auth.errorTitle'), t('mobile.auth.logoutFailed'))
|
|
} finally {
|
|
setLoggingOut(false)
|
|
}
|
|
}
|
|
|
|
function confirmLogout(): void {
|
|
Alert.alert(t('mobile.set.logout'), t('mobile.set.logoutConfirm'), [
|
|
{ text: t('common.cancel'), style: 'cancel' },
|
|
{
|
|
text: t('mobile.set.logout'),
|
|
style: 'destructive',
|
|
onPress: () => void performLogout(),
|
|
},
|
|
])
|
|
}
|
|
|
|
async function handleRetry(): Promise<void> {
|
|
if (retrying) return
|
|
setRetrying(true)
|
|
await retrySync()
|
|
setRetrying(false)
|
|
}
|
|
|
|
async function openAdPrivacyOptions(): Promise<void> {
|
|
const opened = await mobileAds.showPrivacyOptions()
|
|
if (!opened) {
|
|
Alert.alert(
|
|
t('mobile.set.adPrivacy'),
|
|
t('mobile.set.adPrivacyUnavailable'),
|
|
)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<ScrollView
|
|
style={[styles.container, { backgroundColor: palette.bg.app }]}
|
|
contentContainerStyle={styles.content}
|
|
testID="settings-screen"
|
|
>
|
|
<ThemeText
|
|
variant="title"
|
|
color="primary"
|
|
accessibilityRole="header"
|
|
style={[
|
|
styles.title,
|
|
{
|
|
paddingTop: insets.top + 8,
|
|
borderBottomColor: palette.border.subtle,
|
|
},
|
|
]}
|
|
>
|
|
{t('mobile.set.title')}
|
|
</ThemeText>
|
|
|
|
<SectionLabel label={t('mobile.set.accountPlan')} />
|
|
<ThemeCard style={styles.section}>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('mobile.set.openAccount')}
|
|
accessibilityHint={t('mobile.set.openAccountHint')}
|
|
testID="open-account"
|
|
onPress={() => navigation.navigate('Account')}
|
|
style={({ pressed }) => [
|
|
styles.profileRow,
|
|
{ borderBottomColor: palette.border.subtle },
|
|
pressed && { backgroundColor: palette.bg.cardHover },
|
|
]}
|
|
>
|
|
<View
|
|
style={[
|
|
styles.avatar,
|
|
{
|
|
backgroundColor: palette.bg.inset,
|
|
borderColor: palette.border.default,
|
|
},
|
|
]}
|
|
>
|
|
<ThemeText variant="body" color="accent">{initials}</ThemeText>
|
|
</View>
|
|
<View style={styles.profileInfo}>
|
|
<ThemeText variant="body" color="primary" numberOfLines={1}>
|
|
{user?.email?.split('@')[0] ?? t('mobile.set.userFallback')}
|
|
</ThemeText>
|
|
<ThemeText variant="small" color="muted" numberOfLines={1}>
|
|
{user?.email ?? '—'}
|
|
</ThemeText>
|
|
</View>
|
|
<ThemeText variant="label" color="accent">
|
|
{t('mobile.set.edit')}
|
|
</ThemeText>
|
|
</Pressable>
|
|
|
|
<View style={[styles.row, { backgroundColor: palette.bg.inset }]}>
|
|
<ThemeText variant="small" color="muted">
|
|
{t('mobile.dash.tier')}
|
|
</ThemeText>
|
|
<View style={styles.rowRight}>
|
|
<View style={[styles.statusDot, { backgroundColor: palette.accent.main }]} />
|
|
<ThemeText variant="body" color="accent" style={styles.rowValue}>
|
|
{entitlement.tier === 'pro_plus'
|
|
? 'PRO+'
|
|
: entitlement.tier === 'pro'
|
|
? 'PRO'
|
|
: t('mobile.dash.free')}
|
|
</ThemeText>
|
|
</View>
|
|
</View>
|
|
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('mobile.set.openPlans')}
|
|
accessibilityHint={t('mobile.set.openPlansHint')}
|
|
testID="open-paywall"
|
|
onPress={() => navigation.navigate('ProPaywall')}
|
|
style={({ pressed }) => [
|
|
styles.upgradeSettingRow,
|
|
{
|
|
borderTopColor: palette.accent.dim,
|
|
backgroundColor: pressed ? palette.bg.cardHover : palette.accent.dim,
|
|
},
|
|
]}
|
|
>
|
|
<ThemeText variant="body" color="accent" style={styles.upgradeText}>
|
|
{t('mobile.set.planAndRewards')}
|
|
</ThemeText>
|
|
<ThemeText variant="heading" color="accent">→</ThemeText>
|
|
</Pressable>
|
|
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('mobile.devices.title')}
|
|
accessibilityHint={t('mobile.devices.description')}
|
|
testID="open-devices"
|
|
onPress={() => navigation.navigate('Devices')}
|
|
style={({ pressed }) => [
|
|
styles.upgradeSettingRow,
|
|
{
|
|
borderTopColor: palette.border.subtle,
|
|
backgroundColor: pressed ? palette.bg.cardHover : palette.bg.card,
|
|
},
|
|
]}
|
|
>
|
|
<View style={styles.rowText}>
|
|
<ThemeText variant="body" color="primary">
|
|
{t('mobile.devices.title')}
|
|
</ThemeText>
|
|
<ThemeText variant="small" color="muted">
|
|
{t('mobile.devices.description')}
|
|
</ThemeText>
|
|
</View>
|
|
<ThemeText variant="heading" color="accent">→</ThemeText>
|
|
</Pressable>
|
|
{adminRole !== null && (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('mobile.admin.title')}
|
|
accessibilityHint={t('mobile.admin.description', {
|
|
role: t(`mobile.admin.role.${adminRole}`),
|
|
})}
|
|
testID="open-admin"
|
|
onPress={() => navigation.navigate('Admin')}
|
|
style={({ pressed }) => [
|
|
styles.upgradeSettingRow,
|
|
{
|
|
borderTopColor: palette.border.subtle,
|
|
backgroundColor: pressed ? palette.bg.cardHover : palette.bg.card,
|
|
},
|
|
]}
|
|
>
|
|
<View style={styles.rowText}>
|
|
<ThemeText variant="body" color="primary">{t('mobile.admin.title')}</ThemeText>
|
|
<ThemeText variant="small" color="muted">
|
|
{t('mobile.admin.description', { role: t(`mobile.admin.role.${adminRole}`) })}
|
|
</ThemeText>
|
|
</View>
|
|
<ThemeText variant="heading" color="accent">→</ThemeText>
|
|
</Pressable>
|
|
)}
|
|
</ThemeCard>
|
|
|
|
<SectionLabel label={t('mobile.set.appearance')} />
|
|
<ThemeCard style={styles.section}>
|
|
<SettingHeader
|
|
title={t('mobile.set.theme')}
|
|
description={t('mobile.set.themeDesc')}
|
|
/>
|
|
<View
|
|
style={styles.segmentRow}
|
|
accessibilityRole="radiogroup"
|
|
accessibilityLabel={t('mobile.set.theme')}
|
|
>
|
|
{MOBILE_THEME_MODES.map((mode) => (
|
|
<SegmentOption
|
|
key={mode}
|
|
label={t(THEME_LABEL_KEYS[mode])}
|
|
selected={preferences.themeMode === mode}
|
|
disabled={savingPreference !== null}
|
|
testID={`settings-theme-${mode}`}
|
|
onPress={() => void persistPreference('theme', { themeMode: mode })}
|
|
/>
|
|
))}
|
|
</View>
|
|
|
|
<View
|
|
style={[
|
|
styles.settingBlock,
|
|
{ borderTopColor: palette.border.subtle },
|
|
]}
|
|
>
|
|
<SettingHeader
|
|
title={t('mobile.set.language')}
|
|
description={t('mobile.set.languageDesc')}
|
|
/>
|
|
<View
|
|
style={styles.segmentRow}
|
|
accessibilityRole="radiogroup"
|
|
accessibilityLabel={t('mobile.set.language')}
|
|
>
|
|
<SegmentOption
|
|
label="한국어"
|
|
selected={locale === 'ko'}
|
|
disabled={savingPreference !== null}
|
|
testID="settings-language-ko"
|
|
onPress={() => selectLocale('ko')}
|
|
/>
|
|
<SegmentOption
|
|
label="English"
|
|
selected={locale === 'en'}
|
|
disabled={savingPreference !== null}
|
|
testID="settings-language-en"
|
|
onPress={() => selectLocale('en')}
|
|
/>
|
|
</View>
|
|
</View>
|
|
</ThemeCard>
|
|
|
|
<SectionLabel label={t('mobile.set.backendConfig')} />
|
|
<ThemeCard style={styles.section}>
|
|
<View style={styles.row}>
|
|
<View style={styles.rowText}>
|
|
<ThemeText variant="body" color="primary">
|
|
{t('mobile.set.llmModel')}
|
|
</ThemeText>
|
|
<ThemeText variant="small" color="muted">
|
|
{t('mobile.set.llmDesc')}
|
|
</ThemeText>
|
|
</View>
|
|
<ThemeText variant="small" color="accent" numberOfLines={1}>
|
|
{(preferences.preferredLlmModel ?? t('mobile.set.automatic')).toUpperCase()}
|
|
</ThemeText>
|
|
</View>
|
|
<View
|
|
style={[
|
|
styles.row,
|
|
styles.rowBorder,
|
|
{ borderTopColor: palette.border.subtle },
|
|
]}
|
|
>
|
|
<View style={styles.rowText}>
|
|
<ThemeText variant="body" color="primary">
|
|
{t('mobile.set.cloudStt')}
|
|
</ThemeText>
|
|
<ThemeText variant="small" color="muted">
|
|
{t('mobile.set.cloudSttManagedDesc')}
|
|
</ThemeText>
|
|
</View>
|
|
<ThemeText variant="small" color="success">
|
|
{t('mobile.set.serverManaged')}
|
|
</ThemeText>
|
|
</View>
|
|
</ThemeCard>
|
|
|
|
<SectionLabel label={t('mobile.set.preferences')} />
|
|
<ThemeCard style={styles.section}>
|
|
<PreferenceSwitch
|
|
label={t('mobile.set.autoPolish')}
|
|
description={t('mobile.set.autoPolishDesc')}
|
|
value={preferences.autoPolishEnabled}
|
|
disabled={savingPreference !== null}
|
|
testID="settings-auto-polish"
|
|
onValueChange={(value) => void persistPreference(
|
|
'autoPolish',
|
|
{ autoPolishEnabled: value },
|
|
)}
|
|
/>
|
|
<PreferenceSwitch
|
|
label={t('mobile.set.haptic')}
|
|
description={t('mobile.set.hapticDesc')}
|
|
value={preferences.hapticEnabled}
|
|
disabled={savingPreference !== null}
|
|
testID="settings-haptic"
|
|
bordered
|
|
onValueChange={(value) => void persistPreference(
|
|
'haptic',
|
|
{ hapticEnabled: value },
|
|
)}
|
|
/>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t('mobile.set.replayTutorial')}
|
|
accessibilityHint={t('mobile.set.replayTutorialHint')}
|
|
testID="settings-replay-onboarding"
|
|
onPress={() => navigation.navigate('Onboarding', { replay: true })}
|
|
style={({ pressed }) => [
|
|
styles.replayRow,
|
|
{
|
|
borderTopColor: palette.border.subtle,
|
|
backgroundColor: pressed ? palette.bg.cardHover : 'transparent',
|
|
},
|
|
]}
|
|
>
|
|
<View style={styles.rowText}>
|
|
<ThemeText variant="body" color="primary">
|
|
{t('mobile.set.replayTutorial')}
|
|
</ThemeText>
|
|
<ThemeText variant="small" color="muted">
|
|
{t('mobile.set.replayTutorialDesc')}
|
|
</ThemeText>
|
|
</View>
|
|
<ThemeText variant="heading" color="accent">→</ThemeText>
|
|
</Pressable>
|
|
</ThemeCard>
|
|
|
|
{mobileAds.privacyOptionsRequired ? (
|
|
<>
|
|
<SectionLabel label={t('mobile.set.adPrivacy')} />
|
|
<ThemeCard style={styles.section}>
|
|
<View style={styles.privacyBlock}>
|
|
<View style={styles.rowText}>
|
|
<ThemeText variant="body" color="primary">
|
|
{t('mobile.set.adPrivacyRequired')}
|
|
</ThemeText>
|
|
<ThemeText variant="small" color="muted">
|
|
{t('mobile.set.adPrivacyDesc')}
|
|
</ThemeText>
|
|
</View>
|
|
<ThemeButton
|
|
label={t('mobile.set.adPrivacyOpen')}
|
|
variant="secondary"
|
|
onPress={() => void openAdPrivacyOptions()}
|
|
testID="settings-ad-privacy"
|
|
/>
|
|
</View>
|
|
</ThemeCard>
|
|
</>
|
|
) : null}
|
|
|
|
<SectionLabel label={t('mobile.set.legal')} />
|
|
<ThemeCard style={styles.section}>
|
|
<LegalLinkRow
|
|
label={t('mobile.set.privacyPolicy')}
|
|
hint={t('mobile.set.privacyPolicyHint')}
|
|
testID="settings-privacy-policy"
|
|
onPress={() => void Linking.openURL(SITE_URLS.privacy)}
|
|
/>
|
|
<LegalLinkRow
|
|
label={t('mobile.set.termsOfService')}
|
|
hint={t('mobile.set.termsOfServiceHint')}
|
|
testID="settings-terms-of-service"
|
|
bordered
|
|
onPress={() => void Linking.openURL(SITE_URLS.terms)}
|
|
/>
|
|
<LegalLinkRow
|
|
label={t('mobile.set.accountDeletion')}
|
|
hint={t('mobile.set.accountDeletionHint')}
|
|
testID="settings-account-deletion-web"
|
|
bordered
|
|
onPress={() => void Linking.openURL(SITE_URLS.deleteAccount)}
|
|
/>
|
|
</ThemeCard>
|
|
|
|
<SectionLabel label={t('mobile.preferences.syncTitle')} />
|
|
<ThemeCard
|
|
style={[
|
|
styles.section,
|
|
errorCode !== null && { borderColor: palette.tag.red },
|
|
]}
|
|
accessibilityRole={errorCode !== null ? 'alert' : undefined}
|
|
testID="settings-sync-status"
|
|
>
|
|
<View style={styles.syncHeader}>
|
|
<View
|
|
style={[
|
|
styles.syncDot,
|
|
{
|
|
backgroundColor: syncStatus === 'synced'
|
|
? palette.tag.green
|
|
: syncStatus === 'offline' || syncStatus === 'error'
|
|
? palette.tag.red
|
|
: palette.tag.orange,
|
|
},
|
|
]}
|
|
/>
|
|
<View style={styles.rowText}>
|
|
<ThemeText variant="body" color="primary">
|
|
{syncLabel}
|
|
</ThemeText>
|
|
<ThemeText variant="small" color={errorCode === null ? 'muted' : 'danger'}>
|
|
{syncError
|
|
?? (syncedTime === null
|
|
? t('mobile.preferences.notSyncedYet')
|
|
: t('mobile.preferences.lastSynced', { time: syncedTime }))}
|
|
</ThemeText>
|
|
</View>
|
|
</View>
|
|
{(syncStatus === 'offline' || syncStatus === 'error') && (
|
|
<ThemeButton
|
|
label={retrying
|
|
? t('mobile.preferences.retrying')
|
|
: t('mobile.preferences.retry')}
|
|
variant="secondary"
|
|
disabled={retrying}
|
|
onPress={() => void handleRetry()}
|
|
testID="settings-sync-retry"
|
|
style={styles.retryButton}
|
|
/>
|
|
)}
|
|
</ThemeCard>
|
|
|
|
<View style={styles.logoutWrap}>
|
|
<ThemeButton
|
|
label={loggingOut ? t('mobile.set.loggingOut') : t('mobile.set.logout')}
|
|
variant="secondary"
|
|
disabled={loggingOut}
|
|
onPress={confirmLogout}
|
|
testID="settings-logout"
|
|
/>
|
|
</View>
|
|
|
|
<View style={styles.footer}>
|
|
<View style={[styles.statusDot, { backgroundColor: palette.tag.green }]} />
|
|
<ThemeText variant="label" color="muted" style={styles.footerText}>
|
|
{t('mobile.set.statusFooter')}
|
|
</ThemeText>
|
|
</View>
|
|
</ScrollView>
|
|
)
|
|
}
|
|
|
|
function SectionLabel({ label }: { label: string }): React.ReactElement {
|
|
return (
|
|
<ThemeText variant="label" color="muted" style={styles.sectionLabel}>
|
|
{label}
|
|
</ThemeText>
|
|
)
|
|
}
|
|
|
|
function SettingHeader({
|
|
title,
|
|
description,
|
|
}: {
|
|
title: string
|
|
description: string
|
|
}): React.ReactElement {
|
|
return (
|
|
<View style={styles.settingHeader}>
|
|
<ThemeText variant="body" color="primary">{title}</ThemeText>
|
|
<ThemeText variant="small" color="muted">{description}</ThemeText>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
function SegmentOption({
|
|
label,
|
|
selected,
|
|
disabled,
|
|
testID,
|
|
onPress,
|
|
}: {
|
|
label: string
|
|
selected: boolean
|
|
disabled: boolean
|
|
testID: string
|
|
onPress: () => void
|
|
}): React.ReactElement {
|
|
const { palette } = useMobilePreferences()
|
|
return (
|
|
<Pressable
|
|
accessibilityRole="radio"
|
|
accessibilityState={{ selected, disabled }}
|
|
accessibilityLabel={label}
|
|
testID={testID}
|
|
disabled={disabled}
|
|
onPress={onPress}
|
|
style={({ pressed }) => [
|
|
styles.segmentOption,
|
|
{
|
|
borderColor: selected ? palette.accent.main : palette.border.default,
|
|
backgroundColor: selected ? palette.accent.dim : palette.bg.inset,
|
|
opacity: disabled ? 0.5 : pressed ? 0.72 : 1,
|
|
},
|
|
]}
|
|
>
|
|
<ThemeText variant="small" color={selected ? 'accent' : 'primary'}>
|
|
{label}
|
|
</ThemeText>
|
|
</Pressable>
|
|
)
|
|
}
|
|
|
|
function PreferenceSwitch({
|
|
label,
|
|
description,
|
|
value,
|
|
disabled,
|
|
bordered = false,
|
|
testID,
|
|
onValueChange,
|
|
}: {
|
|
label: string
|
|
description: string
|
|
value: boolean
|
|
disabled: boolean
|
|
bordered?: boolean
|
|
testID: string
|
|
onValueChange: (value: boolean) => void
|
|
}): React.ReactElement {
|
|
const { palette } = useMobilePreferences()
|
|
return (
|
|
<View
|
|
style={[
|
|
styles.preferenceRow,
|
|
bordered && {
|
|
borderTopColor: palette.border.subtle,
|
|
borderTopWidth: 1,
|
|
},
|
|
]}
|
|
>
|
|
<View style={styles.rowText}>
|
|
<ThemeText variant="body" color="primary">{label}</ThemeText>
|
|
<ThemeText variant="small" color="muted">{description}</ThemeText>
|
|
</View>
|
|
<Switch
|
|
accessibilityLabel={label}
|
|
accessibilityState={{ checked: value, disabled }}
|
|
testID={testID}
|
|
value={value}
|
|
disabled={disabled}
|
|
onValueChange={onValueChange}
|
|
trackColor={{
|
|
false: palette.bg.inset,
|
|
true: palette.accent.main,
|
|
}}
|
|
ios_backgroundColor={palette.bg.inset}
|
|
thumbColor={palette.text.white}
|
|
/>
|
|
</View>
|
|
)
|
|
}
|
|
|
|
function LegalLinkRow({
|
|
label,
|
|
hint,
|
|
testID,
|
|
bordered = false,
|
|
onPress,
|
|
}: {
|
|
label: string
|
|
hint: string
|
|
testID: string
|
|
bordered?: boolean
|
|
onPress: () => void
|
|
}): React.ReactElement {
|
|
const { palette } = useMobilePreferences()
|
|
return (
|
|
<Pressable
|
|
accessibilityRole="link"
|
|
accessibilityLabel={label}
|
|
accessibilityHint={hint}
|
|
testID={testID}
|
|
onPress={onPress}
|
|
style={({ pressed }) => [
|
|
styles.legalRow,
|
|
bordered && {
|
|
borderTopColor: palette.border.subtle,
|
|
borderTopWidth: 1,
|
|
},
|
|
pressed && { backgroundColor: palette.bg.cardHover },
|
|
]}
|
|
>
|
|
<View style={styles.rowText}>
|
|
<ThemeText variant="body" color="primary">{label}</ThemeText>
|
|
<ThemeText variant="small" color="muted">{hint}</ThemeText>
|
|
</View>
|
|
<ThemeText variant="heading" color="accent">↗</ThemeText>
|
|
</Pressable>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: { flex: 1 },
|
|
content: { paddingBottom: 120 },
|
|
title: {
|
|
paddingHorizontal: 20,
|
|
paddingBottom: 12,
|
|
borderBottomWidth: 1,
|
|
letterSpacing: 1.2,
|
|
},
|
|
sectionLabel: {
|
|
paddingHorizontal: 24,
|
|
paddingTop: 22,
|
|
paddingBottom: 8,
|
|
letterSpacing: 0.4,
|
|
},
|
|
section: { marginHorizontal: 20, padding: 0, overflow: 'hidden' },
|
|
profileRow: {
|
|
minHeight: 74,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
padding: 15,
|
|
gap: 12,
|
|
borderBottomWidth: 1,
|
|
},
|
|
avatar: {
|
|
width: 44,
|
|
height: 44,
|
|
borderRadius: 22,
|
|
borderWidth: 1,
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
},
|
|
profileInfo: { flex: 1, gap: 3 },
|
|
row: {
|
|
minHeight: 64,
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
gap: 14,
|
|
padding: 15,
|
|
},
|
|
rowBorder: { borderTopWidth: 1 },
|
|
rowRight: { flexDirection: 'row', alignItems: 'center' },
|
|
rowValue: { marginLeft: 8 },
|
|
rowText: { flex: 1, gap: 4 },
|
|
statusDot: { width: 7, height: 7, borderRadius: 4 },
|
|
upgradeSettingRow: {
|
|
minHeight: 58,
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
gap: 12,
|
|
padding: 15,
|
|
borderTopWidth: 1,
|
|
},
|
|
upgradeText: { flex: 1 },
|
|
settingHeader: { gap: 4, padding: 15, paddingBottom: 10 },
|
|
settingBlock: { borderTopWidth: 1, marginTop: 15, paddingTop: 2 },
|
|
segmentRow: { flexDirection: 'row', gap: 8, paddingHorizontal: 15, paddingBottom: 15 },
|
|
segmentOption: {
|
|
minHeight: 48,
|
|
flex: 1,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
borderWidth: 1,
|
|
borderRadius: 9,
|
|
paddingHorizontal: 8,
|
|
},
|
|
preferenceRow: {
|
|
minHeight: 72,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 14,
|
|
padding: 15,
|
|
},
|
|
replayRow: {
|
|
minHeight: 72,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 12,
|
|
padding: 15,
|
|
borderTopWidth: 1,
|
|
},
|
|
legalRow: {
|
|
minHeight: 68,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
gap: 12,
|
|
padding: 15,
|
|
},
|
|
syncHeader: { flexDirection: 'row', alignItems: 'center', gap: 12, padding: 15 },
|
|
syncDot: { width: 10, height: 10, borderRadius: 5 },
|
|
retryButton: { marginHorizontal: 15, marginBottom: 15 },
|
|
privacyBlock: { gap: 14, padding: 15 },
|
|
logoutWrap: { margin: 20 },
|
|
footer: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
paddingVertical: 8,
|
|
},
|
|
footerText: { fontSize: 8, letterSpacing: 0.4 },
|
|
})
|