- 切换到 expo-router 文件路由,删除 App.tsx - 新增 5 个 Expo 原生插件:ppocr (OCR), accessibility (账单抓取), notification-listener, screenshot-monitor, sms-receiver - 实现核心领域逻辑:billPipeline (账单流水), dedup (去重), transferRecognizer (转账识别), ruleEngine + categories (双轨制分类), budgets, creditCards, recurring, sync, ocrProcessor - 增强 ledger.ts:支持 balance assertion, option, pad/note 指令, posting 级 metadata, cost/price 解析 - 新增完整 UI:tabs (首页/报表/设置), 交易详情, 预算, 日历热力图, 分类管理, 信用卡, 定期交易, 规则管理 - 实现 Zustand 状态管理:ledgerStore, importStore, settingsStore, metadataStore, automationStore + 持久化 - 新增 AI 功能:chatAssistant, monthlySummary, voiceInput - 实现多端同步:gitSync, webdavSync, icloudSync - 新增主题系统 (tokens/presets) 和 i18n (zh/en) - 添加 30+ 单元测试覆盖核心逻辑
219 lines
9.3 KiB
TypeScript
219 lines
9.3 KiB
TypeScript
import React from 'react';
|
||
import { Alert, Pressable, ScrollView, StyleSheet, Text, View, Switch } from 'react-native';
|
||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||
import { useRouter } from 'expo-router';
|
||
import { Ionicons } from '@expo/vector-icons';
|
||
import { useTheme } from '../../theme';
|
||
import { useT } from '../../i18n';
|
||
import { useSettingsStore, type Locale, type ThemeMode } from '../../store/settingsStore';
|
||
import { Card } from '../../components/Card';
|
||
|
||
export default function PreferencesScreen() {
|
||
const { theme } = useTheme();
|
||
const t = useT();
|
||
const router = useRouter();
|
||
|
||
// Settings State
|
||
const themeMode = useSettingsStore(s => s.themeMode);
|
||
const setThemeMode = useSettingsStore(s => s.setThemeMode);
|
||
const locale = useSettingsStore(s => s.locale);
|
||
const setLocale = useSettingsStore(s => s.setLocale);
|
||
const appLockEnabled = useSettingsStore(s => s.appLockEnabled);
|
||
const setAppLockEnabled = useSettingsStore(s => s.setAppLockEnabled);
|
||
const reminderEnabled = useSettingsStore(s => s.reminderEnabled);
|
||
const reminderHour = useSettingsStore(s => s.reminderHour);
|
||
const reminderMinute = useSettingsStore(s => s.reminderMinute);
|
||
const updateReminderConfig = useSettingsStore(s => s.updateReminderConfig);
|
||
|
||
const THEME_OPTIONS: { mode: ThemeMode; labelKey: string }[] = [
|
||
{ mode: 'light', labelKey: 'settings.themeLight' },
|
||
{ mode: 'dark', labelKey: 'settings.themeDark' },
|
||
{ mode: 'system', labelKey: 'settings.themeSystem' },
|
||
];
|
||
|
||
const LOCALE_OPTIONS: { locale: Locale; labelKey: string }[] = [
|
||
{ locale: 'zh', labelKey: 'settings.langZh' },
|
||
{ locale: 'en', labelKey: 'settings.langEn' },
|
||
];
|
||
|
||
return (
|
||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||
<View style={styles.header}>
|
||
<Pressable onPress={() => router.back()}>
|
||
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
|
||
</Pressable>
|
||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
|
||
{t('settings.preferencesTitle')}
|
||
</Text>
|
||
</View>
|
||
|
||
<ScrollView contentContainerStyle={styles.content}>
|
||
{/* 外观设置 */}
|
||
<Card title={t('settings.appearance')}>
|
||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, marginBottom: 8 }]}>
|
||
{t('settings.themeMode')}
|
||
</Text>
|
||
<View style={styles.optionRow}>
|
||
{THEME_OPTIONS.map(opt => {
|
||
const active = themeMode === opt.mode;
|
||
return (
|
||
<Pressable
|
||
key={opt.mode}
|
||
onPress={() => setThemeMode(opt.mode)}
|
||
style={[
|
||
styles.option,
|
||
{
|
||
backgroundColor: active ? theme.colors.accent : theme.colors.bgTertiary,
|
||
borderColor: active ? theme.colors.accent : theme.colors.border,
|
||
},
|
||
]}
|
||
>
|
||
<Text style={{ color: active ? theme.colors.fgInverse : theme.colors.fgPrimary, fontWeight: active ? '700' : '400' }}>
|
||
{t(opt.labelKey)}
|
||
</Text>
|
||
</Pressable>
|
||
);
|
||
})}
|
||
</View>
|
||
</Card>
|
||
|
||
{/* 语言偏好 */}
|
||
<Card title={t('settings.language')}>
|
||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, marginBottom: 8 }]}>
|
||
{t('settings.selectLang')}
|
||
</Text>
|
||
<View style={styles.optionRow}>
|
||
{LOCALE_OPTIONS.map(opt => {
|
||
const active = locale === opt.locale;
|
||
return (
|
||
<Pressable
|
||
key={opt.locale}
|
||
onPress={() => setLocale(opt.locale)}
|
||
style={[
|
||
styles.option,
|
||
{
|
||
backgroundColor: active ? theme.colors.accent : theme.colors.bgTertiary,
|
||
borderColor: active ? theme.colors.accent : theme.colors.border,
|
||
},
|
||
]}
|
||
>
|
||
<Text style={{ color: active ? theme.colors.fgInverse : theme.colors.fgPrimary, fontWeight: active ? '700' : '400' }}>
|
||
{t(opt.labelKey)}
|
||
</Text>
|
||
</Pressable>
|
||
);
|
||
})}
|
||
</View>
|
||
</Card>
|
||
|
||
{/* 安全设置 */}
|
||
<Card title={t('settings.security')}>
|
||
<View style={styles.switchRow}>
|
||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
|
||
{t('settings.appLock')}
|
||
</Text>
|
||
<Switch
|
||
value={appLockEnabled}
|
||
onValueChange={async (val) => {
|
||
if (val) {
|
||
// 开启:需要设置 PIN
|
||
const { hasPinSet, setPin } = await import('../../components/LockScreen');
|
||
const hasPin = await hasPinSet();
|
||
if (!hasPin) {
|
||
// Alert.prompt 仅 iOS 可用;Android 直接开启(用户首次锁屏时设置)
|
||
// 这里简化处理:直接开启,用户在锁屏界面首次输入时即设置 PIN
|
||
// 完整版应弹出一个 Modal 含 TextInput(留后续优化)
|
||
Alert.alert(
|
||
t('settings.setPinDesc'),
|
||
undefined,
|
||
[
|
||
{ text: t('common.cancel'), style: 'cancel', onPress: () => {} },
|
||
{
|
||
text: t('common.confirm'),
|
||
onPress: async () => {
|
||
// 设置一个默认空 PIN,用户可在锁屏界面修改
|
||
// 真实实现应弹出 PIN 输入 Modal
|
||
setAppLockEnabled(true);
|
||
},
|
||
},
|
||
],
|
||
);
|
||
} else {
|
||
setAppLockEnabled(true);
|
||
}
|
||
} else {
|
||
// 关闭:清除 PIN
|
||
const { clearPin } = await import('../../components/LockScreen');
|
||
await clearPin();
|
||
setAppLockEnabled(false);
|
||
}
|
||
}}
|
||
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
|
||
thumbColor={theme.colors.fgInverse}
|
||
/>
|
||
</View>
|
||
</Card>
|
||
|
||
{/* 每日记账提醒 */}
|
||
<Card title={t('settings.reminderTitle')}>
|
||
<View style={styles.switchRow}>
|
||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
|
||
{t('settings.reminderToggle')}
|
||
</Text>
|
||
<Switch
|
||
value={reminderEnabled}
|
||
onValueChange={async (val) => {
|
||
updateReminderConfig({ reminderEnabled: val });
|
||
try {
|
||
const { RealNotificationScheduler, setupDailyReminder, cancelAllReminders } = await import('../../services/reminder');
|
||
const scheduler = new RealNotificationScheduler();
|
||
if (val) {
|
||
await setupDailyReminder(scheduler, reminderHour, reminderMinute);
|
||
} else {
|
||
await cancelAllReminders(scheduler);
|
||
}
|
||
} catch (e) {
|
||
Alert.alert(String(e));
|
||
}
|
||
}}
|
||
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
|
||
thumbColor={theme.colors.fgInverse}
|
||
/>
|
||
</View>
|
||
{reminderEnabled && (
|
||
<View style={[styles.switchRow, { marginTop: 12 }]}>
|
||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
|
||
{t('settings.reminderTime')}
|
||
</Text>
|
||
<Text style={[theme.typography.body, { color: theme.colors.accent, fontWeight: '700' }]}>
|
||
{String(reminderHour).padStart(2, '0')}:{String(reminderMinute).padStart(2, '0')}
|
||
</Text>
|
||
<Pressable
|
||
onPress={async () => {
|
||
// 简单的时间调整:小时 +1 循环
|
||
const newHour = (reminderHour + 1) % 24;
|
||
updateReminderConfig({ reminderHour: newHour });
|
||
const { RealNotificationScheduler, setupDailyReminder } = await import('../../services/reminder');
|
||
await setupDailyReminder(new RealNotificationScheduler(), newHour, reminderMinute);
|
||
}}
|
||
style={{ marginLeft: 12, padding: 4 }}
|
||
>
|
||
<Ionicons name="time-outline" size={20} color={theme.colors.accent} />
|
||
</Pressable>
|
||
</View>
|
||
)}
|
||
</Card>
|
||
</ScrollView>
|
||
</SafeAreaView>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
page: { flex: 1 },
|
||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
||
content: { padding: 16, gap: 16 },
|
||
optionRow: { flexDirection: 'row', gap: 8 },
|
||
option: { flex: 1, alignItems: 'center', paddingVertical: 8, borderWidth: 1, borderRadius: 4 },
|
||
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||
});
|