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,225 +1,817 @@
|
|||
// apps/mobile-rn/src/screens/SettingsScreen.tsx
|
||||
// Settings tab — account, backend, preferences
|
||||
// Converted from Expo → RN CLI
|
||||
|
||||
import React from 'react'
|
||||
import { View, ScrollView, StyleSheet, Alert, Switch, Pressable } from 'react-native'
|
||||
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 {
|
||||
MetalCard,
|
||||
PhosphorText,
|
||||
PhysicalButton,
|
||||
Led,
|
||||
AppStatusBar,
|
||||
d3roNativePalette
|
||||
} from '@d3ro/ui-native'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import { useI18n, type TranslationKey } from '@d3ro/i18n'
|
||||
import { useAuth } from '../lib/auth-context'
|
||||
import { supabase } from '../lib/supabase'
|
||||
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 } = useI18n()
|
||||
const { user } = useAuth()
|
||||
const { t, locale, setLocale, formatTime } = useI18n()
|
||||
const { user, purgeLocalSession } = useAuth()
|
||||
const navigation = useNavigation<any>()
|
||||
|
||||
async function handleLogout(): Promise<void> {
|
||||
Alert.alert(t('mobile.set.logout'), t('mobile.set.logoutConfirm'), [
|
||||
{ text: t('mobile.rec.cancel'), style: 'cancel' },
|
||||
{
|
||||
text: t('mobile.set.logout'),
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await supabase.auth.signOut()
|
||||
}
|
||||
}
|
||||
])
|
||||
}
|
||||
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} contentContainerStyle={styles.content}>
|
||||
<PhosphorText
|
||||
<ScrollView
|
||||
style={[styles.container, { backgroundColor: palette.bg.app }]}
|
||||
contentContainerStyle={styles.content}
|
||||
testID="settings-screen"
|
||||
>
|
||||
<ThemeText
|
||||
variant="title"
|
||||
color="primary"
|
||||
style={[styles.title, { paddingTop: insets.top + 8 }]}
|
||||
accessibilityRole="header"
|
||||
style={[
|
||||
styles.title,
|
||||
{
|
||||
paddingTop: insets.top + 8,
|
||||
borderBottomColor: palette.border.subtle,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{t('mobile.set.title')}
|
||||
</PhosphorText>
|
||||
</ThemeText>
|
||||
|
||||
{/* Account Section */}
|
||||
<PhosphorText variant="label" color="muted" style={styles.sectionLabel}>
|
||||
{t('mobile.set.accountPlan')}
|
||||
</PhosphorText>
|
||||
<MetalCard style={styles.section}>
|
||||
<View style={styles.profileRow}>
|
||||
<View style={styles.avatar}>
|
||||
<PhosphorText variant="body" color="amber">{initials}</PhosphorText>
|
||||
<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}>
|
||||
<PhosphorText variant="body" color="primary">
|
||||
{user?.email?.split('@')[0] ?? 'User'}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="label" color="muted">
|
||||
{user?.email ?? '\u2014'}
|
||||
</PhosphorText>
|
||||
<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>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowInset]}>
|
||||
<PhosphorText variant="small" color="muted">{t('mobile.dash.tier')}</PhosphorText>
|
||||
<View style={styles.rowRight}>
|
||||
<Led color="amber" size={6} />
|
||||
<PhosphorText variant="body" color="amber" style={styles.rowValue}>
|
||||
{t('mobile.dash.free')}
|
||||
</PhosphorText>
|
||||
</View>
|
||||
</View>
|
||||
<Pressable onPress={() => navigation.navigate('ProPaywall')} style={styles.upgradeSettingRow}>
|
||||
<PhosphorText variant="body" color="amber">💎 PRO 요금제 & 리워드 토큰 충전</PhosphorText>
|
||||
<PhosphorText variant="label" color="amber">→</PhosphorText>
|
||||
<ThemeText variant="label" color="accent">
|
||||
{t('mobile.set.edit')}
|
||||
</ThemeText>
|
||||
</Pressable>
|
||||
</MetalCard>
|
||||
|
||||
{/* Backend Section */}
|
||||
<PhosphorText variant="label" color="muted" style={styles.sectionLabel}>
|
||||
{t('mobile.set.backendConfig')}
|
||||
</PhosphorText>
|
||||
<MetalCard style={styles.section}>
|
||||
<View style={styles.row}>
|
||||
<View>
|
||||
<PhosphorText variant="body" color="primary">{t('mobile.set.llmModel')}</PhosphorText>
|
||||
<PhosphorText variant="label" color="muted">{t('mobile.set.llmDesc')}</PhosphorText>
|
||||
<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>
|
||||
<PhosphorText variant="body" color="amber">CLAUDE</PhosphorText>
|
||||
</View>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<View>
|
||||
<PhosphorText variant="body" color="primary">{t('mobile.set.cloudStt')}</PhosphorText>
|
||||
<PhosphorText variant="label" color="muted">{t('mobile.set.cloudSttDesc')}</PhosphorText>
|
||||
|
||||
<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>
|
||||
<Switch
|
||||
value={true}
|
||||
trackColor={{
|
||||
false: d3roNativePalette.bg.inset,
|
||||
true: d3roNativePalette.accent.main
|
||||
}}
|
||||
thumbColor="#ffffff"
|
||||
/>
|
||||
</View>
|
||||
</MetalCard>
|
||||
<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>
|
||||
|
||||
{/* Preferences Section */}
|
||||
<PhosphorText variant="label" color="muted" style={styles.sectionLabel}>
|
||||
{t('mobile.set.preferences')}
|
||||
</PhosphorText>
|
||||
<MetalCard style={styles.section}>
|
||||
<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}>
|
||||
<PhosphorText variant="body" color="primary">{t('mobile.set.language')}</PhosphorText>
|
||||
<PhosphorText variant="body" color="muted">Korean</PhosphorText>
|
||||
<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]}>
|
||||
<PhosphorText variant="body" color="primary">{t('mobile.set.autoPolish')}</PhosphorText>
|
||||
<Switch
|
||||
value={true}
|
||||
trackColor={{
|
||||
false: d3roNativePalette.bg.inset,
|
||||
true: d3roNativePalette.accent.main
|
||||
}}
|
||||
thumbColor="#ffffff"
|
||||
/>
|
||||
<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>
|
||||
<View style={[styles.row, styles.rowBorder]}>
|
||||
<PhosphorText variant="body" color="primary">{t('mobile.set.haptic')}</PhosphorText>
|
||||
<Switch
|
||||
value={true}
|
||||
trackColor={{
|
||||
false: d3roNativePalette.bg.inset,
|
||||
true: d3roNativePalette.accent.main
|
||||
}}
|
||||
thumbColor="#ffffff"
|
||||
/>
|
||||
</View>
|
||||
</MetalCard>
|
||||
</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('https://d3ro.chanpaca.net/privacy/')}
|
||||
/>
|
||||
<LegalLinkRow
|
||||
label={t('mobile.set.termsOfService')}
|
||||
hint={t('mobile.set.termsOfServiceHint')}
|
||||
testID="settings-terms-of-service"
|
||||
bordered
|
||||
onPress={() => void Linking.openURL('https://d3ro.chanpaca.net/terms/')}
|
||||
/>
|
||||
<LegalLinkRow
|
||||
label={t('mobile.set.accountDeletion')}
|
||||
hint={t('mobile.set.accountDeletionHint')}
|
||||
testID="settings-account-deletion-web"
|
||||
bordered
|
||||
onPress={() => void Linking.openURL('https://d3ro.chanpaca.net/delete-account/')}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* Logout */}
|
||||
<View style={styles.logoutWrap}>
|
||||
<PhysicalButton
|
||||
label={t('mobile.set.logout')}
|
||||
<ThemeButton
|
||||
label={loggingOut ? t('mobile.set.loggingOut') : t('mobile.set.logout')}
|
||||
variant="secondary"
|
||||
onPress={() => void handleLogout()}
|
||||
disabled={loggingOut}
|
||||
onPress={confirmLogout}
|
||||
testID="settings-logout"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Status Footer */}
|
||||
<AppStatusBar />
|
||||
<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, backgroundColor: d3roNativePalette.bg.app },
|
||||
container: { flex: 1 },
|
||||
content: { paddingBottom: 120 },
|
||||
title: {
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: d3roNativePalette.border.subtle
|
||||
letterSpacing: 1.2,
|
||||
},
|
||||
sectionLabel: {
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 20,
|
||||
paddingTop: 22,
|
||||
paddingBottom: 8,
|
||||
letterSpacing: 3
|
||||
},
|
||||
section: {
|
||||
marginHorizontal: 20,
|
||||
padding: 0,
|
||||
overflow: 'hidden'
|
||||
letterSpacing: 0.4,
|
||||
},
|
||||
section: { marginHorizontal: 20, padding: 0, overflow: 'hidden' },
|
||||
profileRow: {
|
||||
minHeight: 74,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
padding: 15,
|
||||
gap: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: d3roNativePalette.border.subtle
|
||||
},
|
||||
avatar: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: d3roNativePalette.bg.inset,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
borderWidth: 1,
|
||||
borderColor: d3roNativePalette.border.default,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
alignItems: 'center',
|
||||
},
|
||||
profileInfo: { gap: 2 },
|
||||
profileInfo: { flex: 1, gap: 3 },
|
||||
row: {
|
||||
minHeight: 64,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: 16
|
||||
},
|
||||
rowInset: { backgroundColor: d3roNativePalette.bg.inset },
|
||||
rowBorder: {
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: d3roNativePalette.border.subtle
|
||||
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',
|
||||
padding: 16,
|
||||
gap: 12,
|
||||
padding: 15,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: 'rgba(242, 91, 41, 0.25)',
|
||||
backgroundColor: 'rgba(242, 91, 41, 0.08)'
|
||||
},
|
||||
logoutWrap: { margin: 20 }
|
||||
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 },
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue