DriftLedger/src/app/settings/preferences.tsx
fengmengqi f6437b83fe feat: 初始化完整应用框架
- 切换到 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+ 单元测试覆盖核心逻辑
2026-07-15 10:01:31 +08:00

219 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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' },
});