Initial project import

This commit is contained in:
2026-07-24 23:11:20 +08:00
commit 6396eabb87
372 changed files with 49682 additions and 0 deletions
@@ -0,0 +1,306 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/auth_api.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
class AccountDataPage extends StatefulWidget {
const AccountDataPage({super.key});
@override
State<AccountDataPage> createState() => _AccountDataPageState();
}
class _AccountDataPageState extends State<AccountDataPage> {
UserProfile? _profile;
bool _busy = false;
@override
void initState() {
super.initState();
_cleanupStaleExports();
_load();
}
Future<void> _cleanupStaleExports() async {
try {
final directory = await getTemporaryDirectory();
final cutoff = DateTime.now().subtract(const Duration(hours: 24));
await for (final entity in directory.list()) {
if (entity is! File ||
!entity.path.contains('jizhi-export-') ||
!entity.path.endsWith('.zip')) {
continue;
}
final modified = await entity.lastModified();
if (modified.isBefore(cutoff)) await entity.delete();
}
} catch (_) {
// Export cleanup is best-effort and must not block the settings page.
}
}
Future<void> _load() async {
try {
final profile = await AuthApi.me();
if (mounted) setState(() => _profile = profile);
} catch (error) {
if (mounted) _showError(error);
}
}
Future<String?> _ask({
required String title,
required String label,
bool obscure = false,
String? initialValue,
String? subtitle,
}) {
return showJzTextInputSheet(
context,
title: title,
label: label,
subtitle: subtitle,
initialValue: initialValue,
obscureText: obscure,
maxLength: obscure ? null : 32,
);
}
Future<void> _editNickname() async {
final nickname = await _ask(
title: '修改昵称',
label: '昵称',
initialValue: _profile?.nickname ?? '',
);
if (nickname == null) return;
try {
final profile = await AuthApi.updateProfile(nickname);
if (mounted) setState(() => _profile = profile);
} catch (error) {
if (mounted) _showError(error);
}
}
Future<void> _changePassword() async {
final current = await _ask(title: '验证当前密码', label: '当前密码', obscure: true);
if (current == null || current.isEmpty) return;
final next = await _ask(
title: '设置新密码',
label: '新密码(至少 6 位)',
obscure: true,
);
if (next == null || next.isEmpty) return;
try {
await AuthApi.changePassword(current, next);
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('密码已修改,其他设备已退出登录')));
}
} catch (error) {
if (mounted) _showError(error);
}
}
Future<void> _export() async {
File? temporaryFile;
setState(() => _busy = true);
try {
final bytes = await AuthApi.exportData();
if (bytes.isEmpty) throw StateError('导出文件为空');
final directory = await getTemporaryDirectory();
final name =
'jizhi-export-' +
DateTime.now().millisecondsSinceEpoch.toString() +
'.zip';
temporaryFile = File(directory.path + Platform.pathSeparator + name);
await temporaryFile.writeAsBytes(bytes, flush: true);
await SharePlus.instance.share(
ShareParams(
files: [XFile(temporaryFile.path, mimeType: 'application/zip')],
fileNameOverrides: [name],
title: '记之数据导出',
subject: '记之数据导出',
),
);
} catch (error) {
if (mounted) _showError(error);
} finally {
if (temporaryFile != null) {
try {
if (await temporaryFile.exists()) await temporaryFile.delete();
} catch (_) {
// The operating system can briefly retain a shared file handle.
}
}
if (mounted) setState(() => _busy = false);
}
}
Future<void> _closeAccount() async {
final understood = await showJzConfirmSheet(
context,
title: '申请注销账号',
message:
'提交后会退出所有设备,并进入 15 天后悔期。期间使用正确账号密码登录会自动取消注销;到期后账本、账单、预算和聊天记录将永久删除。',
confirmLabel: '我已了解',
destructive: true,
content: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: context.jz.warningBackground,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'建议先导出数据。后悔期结束后,数据无法恢复。',
style: TextStyle(
fontSize: 11.5,
color: context.jz.text2,
height: 1.5,
),
),
),
);
if (!understood || !mounted) return;
final password = await _ask(
title: '验证身份',
label: '输入登录密码',
obscure: true,
subtitle: '这是第二次确认,用于验证账号所有权',
);
if (password == null || password.isEmpty || !mounted) return;
final confirmation = await _ask(
title: '最后确认',
label: '输入“注销账号”',
subtitle: '提交后立即退出,15 天内重新登录可恢复账号',
);
if (confirmation == null) return;
if (confirmation.trim() != '注销账号') {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('请输入完整的“注销账号”')));
return;
}
setState(() => _busy = true);
try {
final scheduledAt = await AuthApi.requestAccountClosure(
password,
confirmation,
);
if (!mounted) return;
final notice =
'注销申请已提交,将于 ' +
scheduledAt.month.toString() +
'' +
scheduledAt.day.toString() +
'日永久删除';
context.go(
Uri(path: '/login', queryParameters: {'notice': notice}).toString(),
);
} catch (error) {
if (mounted) _showError(error);
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _showError(Object error) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('账号与数据')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Card(
child: Column(
children: [
ListTile(
title: Text('昵称'),
subtitle: Text(_profile?.nickname ?? '未设置'),
trailing: Icon(Icons.chevron_right_rounded),
onTap: _editNickname,
),
if (!SessionStore.instance.isGuest) ...[
Divider(height: 1),
ListTile(
title: Text('修改密码'),
trailing: Icon(Icons.chevron_right_rounded),
onTap: _changePassword,
),
],
],
),
),
SizedBox(height: 12),
Card(
child: ListTile(
leading: Icon(Icons.archive_outlined, color: AppTheme.primary),
title: Text('导出全部数据'),
subtitle: Text('ZIP 包含交易/预算 CSV 和完整 JSON,不含密码与密钥'),
trailing: _busy
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(Icons.ios_share_rounded),
onTap: _busy ? null : _export,
),
),
SizedBox(height: 12),
Card(
child: Column(
children: [
_legalTile('用户协议', 'terms'),
Divider(height: 1),
_legalTile('隐私政策', 'privacy'),
Divider(height: 1),
_legalTile('权限用途说明', 'permissions'),
Divider(height: 1),
_legalTile('第三方 SDK 清单', 'sdk'),
],
),
),
SizedBox(height: 12),
if (!SessionStore.instance.isGuest)
Card(
child: ListTile(
leading: Icon(Icons.person_off_outlined, color: AppTheme.red),
title: Text('注销账号', style: TextStyle(color: AppTheme.red)),
subtitle: Text('15 天后永久删除,可在后悔期内登录恢复'),
trailing: Icon(
Icons.chevron_right_rounded,
color: AppTheme.red,
),
onTap: _busy ? null : _closeAccount,
),
),
],
),
);
}
Widget _legalTile(String title, String kind) => ListTile(
leading: Icon(Icons.verified_user_outlined, color: AppTheme.primary),
title: Text(title),
trailing: Icon(Icons.chevron_right_rounded),
onTap: () => context.push('/legal/' + kind),
);
}
@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/theme/theme_store.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
class AppearancePage extends StatelessWidget {
const AppearancePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('外观设置')),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: AnimatedBuilder(
animation: ThemeStore.instance,
builder: (context, _) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'显示模式',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 5),
Text(
'跟随系统会随手机的浅色与深色设置自动切换。',
style: TextStyle(color: context.jz.text2, fontSize: 11.5),
),
const SizedBox(height: 14),
JzSegmentedControl<JzThemePreference>(
value: ThemeStore.instance.preference,
options: const [
JzOption(
value: JzThemePreference.system,
label: '跟随系统',
),
JzOption(value: JzThemePreference.light, label: '浅色'),
JzOption(value: JzThemePreference.dark, label: '深色'),
],
onChanged: ThemeStore.instance.setPreference,
),
],
),
),
),
),
],
),
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,641 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/business_api.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
class CategoryManagePage extends StatefulWidget {
const CategoryManagePage({super.key});
@override
State<CategoryManagePage> createState() => _CategoryManagePageState();
}
class _CategoryManagePageState extends State<CategoryManagePage> {
String _type = 'expense';
List<CategoryItem> _cats = const [];
bool _loading = true;
bool _reordering = false;
bool _savingOrder = false;
bool _orderDirty = false;
List<CategoryItem> get _custom =>
_cats.where((category) => category.isCustom).toList();
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final cats = await TxApi.categories(_type);
if (mounted) {
setState(() {
_cats = cats;
_orderDirty = false;
});
}
} catch (error) {
if (mounted) _showError(error);
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _add() async {
final result = await _showEditor();
if (result == null) return;
try {
await CategoryApi.create(result.name, result.icon, result.color, _type);
await _load();
} catch (error) {
if (mounted) _showError(error);
}
}
Future<void> _edit(CategoryItem category) async {
final result = await _showEditor(category: category);
if (result == null) return;
try {
await CategoryApi.update(
category.id,
name: result.name,
iconKey: result.icon,
colorKey: result.color,
sortOrder: category.sortOrder,
);
await _load();
} catch (error) {
if (mounted) _showError(error);
}
}
Future<({String name, String icon, String color})?> _showEditor({
CategoryItem? category,
}) async {
final controller = TextEditingController(text: category?.name ?? '');
var iconKey = AppIcons.keyMap.containsKey(category?.iconKey)
? category!.iconKey
: 'tag';
var colorKey = category?.colorKey ?? 'mint';
final result =
await showModalBottomSheet<({String name, String icon, String color})>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => StatefulBuilder(
builder: (context, setSheetState) => FractionallySizedBox(
heightFactor: 0.88,
child: Container(
padding: EdgeInsets.only(
left: 18,
right: 18,
bottom: MediaQuery.viewInsetsOf(context).bottom + 14,
),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: SafeArea(
top: false,
child: Column(
children: [
JzSheetHeader(
title: category == null ? '新建分类' : '编辑分类',
subtitle: category == null
? '选择一个容易辨认的名称和图标'
: '排序请返回列表后使用“调整顺序”',
),
SizedBox(height: 14),
TextField(
controller: controller,
autofocus: category == null,
maxLength: 8,
decoration: InputDecoration(
labelText: '分类名称',
hintText: '1-8 个字',
),
),
SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: Text(
'分类颜色',
style: TextStyle(
fontSize: 12,
color: context.jz.text2,
fontWeight: FontWeight.w700,
),
),
),
SizedBox(height: 9),
SizedBox(
height: 48,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: categoryColorCatalog.length,
separatorBuilder: (_, __) => SizedBox(width: 9),
itemBuilder: (_, index) {
final color = categoryColorCatalog[index];
final selected = color.key == colorKey;
return Semantics(
button: true,
selected: selected,
label: color.label,
child: InkWell(
onTap: () =>
setSheetState(() => colorKey = color.key),
borderRadius: BorderRadius.circular(15),
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: 48,
decoration: BoxDecoration(
color: color.background,
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: selected
? color.foreground
: Colors.transparent,
width: 2,
),
),
child: selected
? Icon(
Icons.check_rounded,
size: 21,
color: color.foreground,
)
: null,
),
),
);
},
),
),
SizedBox(height: 14),
Align(
alignment: Alignment.centerLeft,
child: Text(
'分类图标',
style: TextStyle(
fontSize: 12,
color: context.jz.text2,
fontWeight: FontWeight.w700,
),
),
),
SizedBox(height: 2),
Expanded(
child: ListView(
children: _iconGroups.entries.map((entry) {
return Padding(
padding: const EdgeInsets.only(
top: 12,
bottom: 6,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.key,
style: TextStyle(
fontSize: 11,
color: context.jz.text3,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 9),
GridView.builder(
shrinkWrap: true,
physics:
const NeverScrollableScrollPhysics(),
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
mainAxisSpacing: 9,
crossAxisSpacing: 9,
childAspectRatio: 0.82,
),
itemCount: entry.value.length,
itemBuilder: (_, index) {
final icon = entry.value[index];
final selected = icon.key == iconKey;
return Semantics(
selected: selected,
label: icon.label,
button: true,
child: InkWell(
onTap: () => setSheetState(
() => iconKey = icon.key,
),
borderRadius: BorderRadius.circular(
13,
),
child: AnimatedContainer(
duration: const Duration(
milliseconds: 160,
),
decoration: BoxDecoration(
color: selected
? context.jz.primaryBackground
: context.jz.background,
borderRadius:
BorderRadius.circular(13),
border: Border.all(
color: selected
? AppTheme.primary
: context.jz.line,
width: selected ? 1.3 : 1,
),
),
child: Column(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
CategoryIconBox(
iconKey: icon.key,
colorKey: colorKey,
size: 32,
),
SizedBox(height: 5),
Text(
icon.label,
maxLines: 1,
overflow:
TextOverflow.ellipsis,
style: TextStyle(
fontSize: 9.5,
color: selected
? AppTheme.primaryDeep
: context.jz.text2,
fontWeight: selected
? FontWeight.w700
: FontWeight.w500,
),
),
],
),
),
),
);
},
),
],
),
);
}).toList(),
),
),
SizedBox(height: 12),
Row(
children: [
Expanded(
child: JzActionButton(
label: '取消',
secondary: true,
onPressed: () => Navigator.pop(sheetContext),
),
),
SizedBox(width: 10),
Expanded(
child: JzActionButton(
label: category == null ? '添加' : '保存',
onPressed: () {
final name = controller.text.trim();
if (name.isEmpty) return;
Navigator.pop(sheetContext, (
name: name,
icon: iconKey,
color: colorKey,
));
},
),
),
],
),
],
),
),
),
),
),
);
controller.dispose();
return result;
}
Map<String, List<CategoryIconMeta>> get _iconGroups {
final result = <String, List<CategoryIconMeta>>{};
for (final icon in AppIcons.categoryCatalog) {
result.putIfAbsent(icon.group, () => []).add(icon);
}
return result;
}
Future<void> _showActions(CategoryItem category) async {
final action = await showJzOptionSheet<String>(
context,
title: category.name,
options: const [
JzOption(value: 'edit', label: '编辑名称和图标'),
JzOption(value: 'delete', label: '删除分类', subtitle: '历史账单仍保留原分类'),
],
);
if (!mounted) return;
if (action == 'edit') await _edit(category);
if (action == 'delete') await _delete(category);
}
Future<void> _delete(CategoryItem category) async {
final confirmed = await showJzConfirmSheet(
context,
title: '删除“' + category.name + '',
message: '删除后将不能再选择此分类,历史账单仍保留原分类。',
confirmLabel: '删除',
destructive: true,
);
if (!confirmed) return;
try {
await CategoryApi.delete(category.id);
await _load();
} catch (error) {
if (mounted) _showError(error);
}
}
void _reorder(int oldIndex, int newIndex) {
final items = _custom;
if (newIndex > oldIndex) newIndex--;
final item = items.removeAt(oldIndex);
items.insert(newIndex, item);
setState(() {
final system = _cats.where((category) => !category.isCustom).toList();
_cats = [...system, ...items];
_orderDirty = true;
});
}
Future<void> _toggleReorder() async {
if (!_reordering) {
setState(() {
_reordering = true;
_orderDirty = false;
});
return;
}
if (!_orderDirty) {
setState(() => _reordering = false);
return;
}
final ids = _custom.map((category) => category.id).toList();
setState(() => _savingOrder = true);
try {
await CategoryApi.reorder(_type, ids);
if (!mounted) return;
setState(() {
_reordering = false;
_orderDirty = false;
});
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('分类顺序已保存')));
} catch (error) {
if (mounted) _showError(error);
} finally {
if (mounted) setState(() => _savingOrder = false);
}
}
void _showError(Object error) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('分类管理'),
actions: [
if (_custom.length > 1)
TextButton(
onPressed: _savingOrder ? null : _toggleReorder,
child: Text(
_savingOrder
? '保存中'
: _reordering
? '完成'
: '调整顺序',
),
),
],
),
body: Column(
children: [
_typeSwitch(),
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 8),
child: Row(
children: [
Expanded(
child: Text(
_reordering ? '长按右侧把手调整自定义分类顺序' : '系统分类固定显示,自定义分类可编辑和排序',
style: TextStyle(fontSize: 10.5, color: context.jz.text3),
),
),
if (_savingOrder)
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
),
Expanded(
child: _loading
? Center(child: CircularProgressIndicator(strokeWidth: 2))
: _reordering
? _reorderList()
: _normalList(),
),
],
),
floatingActionButton: _reordering
? null
: FloatingActionButton.extended(
onPressed: _add,
backgroundColor: AppTheme.primary,
foregroundColor: Colors.white,
elevation: 0,
icon: AppIcons.icon(AppIcons.plus, size: 17, color: Colors.white),
label: Text('自定义分类'),
),
);
}
Widget _typeSwitch() {
return Container(
width: 216,
margin: const EdgeInsets.only(top: 6),
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
color: context.jz.line,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: ['expense', 'income'].map((type) {
final selected = _type == type;
return Expanded(
child: InkWell(
onTap: _reordering || _savingOrder
? null
: () {
if (_type == type) return;
setState(() => _type = type);
_load();
},
borderRadius: BorderRadius.circular(8),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(vertical: 7),
decoration: BoxDecoration(
color: selected ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Text(
type == 'expense' ? '支出' : '收入',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
color: selected ? context.jz.text : context.jz.text2,
),
),
),
),
);
}).toList(),
),
);
}
Widget _normalList() {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 90),
children: [
Card(child: Column(children: _cats.map(_categoryRow).toList())),
],
);
}
Widget _categoryRow(CategoryItem category) {
return InkWell(
onTap: category.isCustom ? () => _showActions(category) : null,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: Row(
children: [
CategoryIconBox(
iconKey: category.iconKey,
colorKey: category.colorKey,
size: 34,
),
SizedBox(width: 12),
Expanded(
child: Text(
category.name,
style: TextStyle(fontSize: 13.5, fontWeight: FontWeight.w600),
),
),
if (category.isCustom) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: context.jz.primaryBackground,
borderRadius: BorderRadius.circular(6),
),
child: Text(
'自定义',
style: TextStyle(
fontSize: 9,
color: AppTheme.primaryDeep,
fontWeight: FontWeight.w600,
),
),
),
SizedBox(width: 5),
Icon(
Icons.chevron_right_rounded,
size: 19,
color: context.jz.text3,
),
],
],
),
),
);
}
Widget _reorderList() {
final items = _custom;
if (items.isEmpty) {
return Center(
child: Text('还没有自定义分类', style: TextStyle(color: context.jz.text3)),
);
}
return ReorderableListView.builder(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
buildDefaultDragHandles: false,
itemCount: items.length,
onReorder: _reorder,
itemBuilder: (context, index) {
final category = items[index];
return Container(
key: ValueKey(category.id),
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.fromLTRB(13, 9, 8, 9),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: context.jz.line),
),
child: Row(
children: [
CategoryIconBox(
iconKey: category.iconKey,
colorKey: category.colorKey,
size: 34,
),
SizedBox(width: 11),
Expanded(
child: Text(
category.name,
style: TextStyle(fontSize: 13.5, fontWeight: FontWeight.w600),
),
),
ReorderableDragStartListener(
index: index,
child: Padding(
padding: const EdgeInsets.all(10),
child: AppIcons.icon(
AppIcons.drag,
size: 18,
color: context.jz.text3,
),
),
),
],
),
);
},
);
}
}
@@ -0,0 +1,283 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/auth_api.dart';
import 'package:miaoji_zhang/shared/api/config_api.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
/// AI 性格设置页(P5):形象/性格从后台 API 动态拉取
class CompanionPage extends StatefulWidget {
const CompanionPage({super.key});
@override
State<CompanionPage> createState() => _CompanionPageState();
}
class _CompanionPageState extends State<CompanionPage> {
String _avatar = 'cat', _persona = 'sassy_cat';
double _roast = 60, _sticker = 70, _proactive = 40;
bool _saving = false, _loaded = false;
List<AvatarItem> _avatars = [];
List<PersonaItem> _personas = [];
// 兜底
static const _fallbackAvatars = [
('dog', '阿福汪', AppIcons.dog),
('cat', '小账喵', AppIcons.cat),
('robot', '账小智', AppIcons.robot),
];
static const _fallbackPersonas = [
('sassy_cat', '毒舌猫娘', '乱花钱会被无情吐槽', AppTheme.ai),
('gentle', '温柔小暖', '永远鼓励,温柔提醒', AppTheme.primary),
('strict', '严格管家', '理性专业,数据说话', AppTheme.ai),
('meme', '沙雕损友', '玩梗高手,快乐记账', AppTheme.orange),
];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final p = await AuthApi.me();
if (mounted && p.aiCompanion != null)
setState(() {
_avatar = p.aiCompanion!.avatarKey;
_persona = p.aiCompanion!.personaKey;
_roast = p.aiCompanion!.roastLevel.toDouble();
_sticker = p.aiCompanion!.stickerFrequency.toDouble();
_proactive = p.aiCompanion!.proactiveLevel.toDouble();
});
} catch (_) {}
try {
final av = await PublicConfigApi.avatars();
final ps = await PublicConfigApi.personas();
if (mounted)
setState(() {
_avatars = av;
_personas = ps;
});
} catch (_) {}
if (mounted) setState(() => _loaded = true);
}
Future<void> _save() async {
setState(() => _saving = true);
try {
await AuthApi.updateCompanion(
avatarKey: _avatar,
personaKey: _persona,
roastLevel: _roast.round(),
stickerFrequency: _sticker.round(),
proactiveLevel: _proactive.round(),
);
await PublicConfigApi.refreshCompanion();
if (!mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('已保存')));
} catch (e) {
if (mounted)
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(e))));
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
if (!_loaded)
return const Scaffold(
body: Center(
child: CircularProgressIndicator(
color: AppTheme.primary,
strokeWidth: 2,
),
),
);
final avatars = _avatars.isNotEmpty
? _avatars
.map(
(a) => (
a.key,
a.defaultName,
a.key == 'dog'
? AppIcons.dog
: a.key == 'robot'
? AppIcons.robot
: AppIcons.cat,
),
)
.toList()
: _fallbackAvatars;
final personas = _personas.isNotEmpty
? _personas
.map((p) => (p.key, p.name, p.description, AppTheme.ai))
.toList()
: _fallbackPersonas;
return Scaffold(
appBar: AppBar(title: Text('AI 性格设置')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: avatars.map((a) {
final on = a.$1 == _avatar;
return GestureDetector(
onTap: () => setState(() => _avatar = a.$1),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 11),
child: Column(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: on ? 66 : 52,
height: on ? 66 : 52,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: on ? AppTheme.ai : context.jz.aiBackground,
boxShadow: on
? [
BoxShadow(
color: AppTheme.ai.withValues(alpha: 0.3),
blurRadius: 14,
),
]
: null,
),
child: Center(
child: AppIcons.icon(
a.$3,
size: on ? 30 : 24,
color: on ? Colors.white : context.jz.text3,
),
),
),
SizedBox(height: 6),
Text(
a.$2,
style: TextStyle(
fontSize: 10,
color: on ? AppTheme.ai : context.jz.text3,
fontWeight: on ? FontWeight.w600 : null,
),
),
],
),
),
);
}).toList(),
),
SizedBox(height: 16),
GridView.count(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 2,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 1.9,
children: personas.map((p) {
final on = p.$1 == _persona;
return GestureDetector(
onTap: () => setState(() => _persona = p.$1),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: on ? context.jz.aiBackground : context.jz.card,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: on ? AppTheme.ai : context.jz.line,
width: 1.5,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
p.$2,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 4),
Text(
p.$3,
style: TextStyle(fontSize: 10, color: context.jz.text2),
),
],
),
),
);
}).toList(),
),
SizedBox(height: 14),
Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
children: [
_slider('吐槽力度', _roast, (v) => setState(() => _roast = v)),
_slider(
'表情包频率',
_sticker,
(v) => setState(() => _sticker = v),
),
],
),
),
),
SizedBox(height: 16),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.ai),
onPressed: _saving ? null : _save,
child: _saving
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text('保存设置'),
),
],
),
);
}
Widget _slider(String label, double value, ValueChanged<double> onChanged) =>
Column(
children: [
Row(
children: [
Text(
label,
style: TextStyle(fontSize: 12, color: context.jz.text2),
),
Spacer(),
Text(
'${value.round()}%',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
],
),
JzSlider(
value: value,
min: 0,
max: 100,
color: AppTheme.ai,
onChanged: onChanged,
),
],
);
}
@@ -0,0 +1,192 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
enum LegalDocumentKind {
privacy('privacy', '隐私政策'),
terms('terms', '用户协议'),
permissions('permissions', '权限用途说明'),
sdk('sdk', '第三方 SDK 清单');
final String key;
final String title;
const LegalDocumentKind(this.key, this.title);
static LegalDocumentKind fromKey(String? key) =>
values.firstWhere((item) => item.key == key, orElse: () => privacy);
}
class LegalDocumentPage extends StatelessWidget {
final LegalDocumentKind kind;
const LegalDocumentPage({super.key, required this.kind});
static const _effectiveDate = '2026 年 7 月 21 日';
@override
Widget build(BuildContext context) {
final sections = _sections(kind);
return Scaffold(
appBar: AppBar(title: Text(kind.title)),
body: SelectionArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(18, 8, 18, 32),
children: [
Text(
'更新及生效日期:$_effectiveDate',
style: TextStyle(fontSize: 12, color: context.jz.text3),
),
SizedBox(height: 14),
...sections.map(
(section) => Padding(
padding: const EdgeInsets.only(bottom: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
section.title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: context.jz.text,
),
),
SizedBox(height: 8),
Text(
section.body,
style: TextStyle(
fontSize: 13.5,
height: 1.75,
color: context.jz.text2,
),
),
],
),
),
),
],
),
),
);
}
static List<_LegalSection> _sections(LegalDocumentKind kind) =>
switch (kind) {
LegalDocumentKind.privacy => _privacy,
LegalDocumentKind.terms => _terms,
LegalDocumentKind.permissions => _permissions,
LegalDocumentKind.sdk => _sdk,
};
static const _privacy = <_LegalSection>[
_LegalSection(
'一、我们如何处理信息',
'记之由个人开发者丁伊文运营。我们重视你的个人信息与财务隐私。为提供账号、记账、统计、预算、AI 对话及数据导出功能,我们会处理你主动提交的账号名称、昵称、账本、分类、账单、预算、聊天文本,以及你主动选择的图片或截屏。密码仅以不可逆哈希形式保存在服务器,不保存明文密码。',
),
_LegalSection(
'二、语音、图片与 AI 数据',
'使用语音记账时,麦克风音频由设备系统语音识别能力处理,记之接收识别后的文字;使用拍照或相册识别时,只有你主动选择的图片会用于本次识别。开启无障碍事件识别后,记之会在微信、支付宝疑似支付流程结束时按需截取当前页面,并由设备内置 OCR 在内存中识别,图片不落盘且处理后立即释放。只有你另行开启 AI 截图补全时,当前支付页图片才会发送至我们配置的火山方舟大模型服务。我们不会将完整账单历史无差别发送给模型,也不会把这些数据用于广告画像。',
),
_LegalSection(
'三、设备、网络与日志',
'为保障登录和接口安全,服务器可能记录访问时间、网络地址、请求结果、应用版本及去标识化故障信息。故障日志不得记录密码、JWT、API Key、完整图片 Base64 或完整隐私内容。',
),
_LegalSection(
'四、存储期限与安全',
'账号数据在你使用服务期间保存。你删除的账单进入 30 天回收站;截屏识别文件在完成、取消或失败后清理,遗留文件会在超过 24 小时后清理。账号注销进入 15 天后悔期,到期后永久删除账号关联数据。导出文件由你主动分享,应用会在分享完成或失败后清理临时副本。',
),
_LegalSection(
'五、你的权利',
'你可以在应用内查询、更正和删除账单,导出数据,修改昵称或密码,撤回非必要权限,并申请注销账号。关闭麦克风、通知、相册选择、无障碍或截屏授权不会影响基础手工记账,但对应功能将无法使用。',
),
_LegalSection(
'六、未成年人、变更与联系',
'未满 14 周岁的未成年人应在监护人同意和指导下使用。政策发生重要变化时,我们会通过应用内显著方式提示并重新征得必要同意。如对本政策、个人信息处理或账号注销有疑问,可发送邮件至 nanxun@nxsir.cn,或拨打 13607268374 联系运营者丁伊文。',
),
];
static const _terms = <_LegalSection>[
_LegalSection(
'一、协议范围',
'本协议是你与记之运营者丁伊文之间关于使用记之记账、统计、预算、AI 助手及相关服务的约定。注册或继续使用前,请完整阅读本协议与隐私政策。',
),
_LegalSection(
'二、账号使用',
'你应提供真实、合法且不侵害他人权益的账号信息,妥善保管登录凭据,并对账号内操作负责。发现账号异常时应及时修改密码。修改密码会使其他设备的既有登录状态失效。',
),
_LegalSection(
'三、AI 功能说明',
'AI 识别和建议可能存在误差。涉及金额、收支类型、分类、时间和预算时,请在确认页面核对。AI 不构成投资、税务、法律或其他专业建议,最终记账和资金决策由你自行确认。',
),
_LegalSection(
'四、使用规范',
'不得利用服务上传违法内容、攻击系统、窃取他人数据、绕过安全截屏限制或干扰服务运行。因系统维护、网络、上游模型或设备权限导致的暂时不可用,我们会尽力恢复并提供明确错误提示。',
),
_LegalSection(
'五、数据与注销',
'你保留对自己输入数据的权利,并可通过应用导出。申请注销后账号进入 15 天后悔期,期间重新登录可取消;等待期结束后数据将按规则永久删除且无法恢复。',
),
];
static const _permissions = <_LegalSection>[
_LegalSection('麦克风', '仅在你主动长按语音输入或发起语音记账时使用,用于系统语音识别。拒绝后仍可使用键盘记账和文字聊天。'),
_LegalSection(
'相册与照片选择',
'仅在你主动选择账单截图或图片识别时读取所选文件。Android 新版本优先使用系统照片选择器,不会扫描整套相册。',
),
_LegalSection(
'通知与振动',
'用于展示 AI 图片识别进度、智能识别候选、自动入账结果以及撤销入口。拒绝后本地识别仍可运行,但后台结果和确认提示可能不可见。',
),
_LegalSection(
'无障碍服务',
'可选权限。用于快捷磁贴静默截屏,以及在你主动开启“无障碍事件识别”后,仅处理微信、支付宝的支付流程事件、当前页面可见文字和按需本地截图 OCR。不会监听或拦截音量键,不会保存完整控件树或本地 OCR 截图;关闭后基础手工记账仍可使用。',
),
_LegalSection(
'屏幕录制 / 截屏授权',
'当无障碍截图不可用且你主动点击截屏记账时,系统会显示一次性授权。应用只截取一帧用于本次识别,完成后立即释放投屏会话;受保护页面不会尝试绕过系统限制。',
),
_LegalSection('网络', '用于登录、同步账本、调用 AI 识别和获取配置。所有生产数据应通过受信任的 HTTPS 服务传输。'),
];
static const _sdk = <_LegalSection>[
_LegalSection(
'FlutterGoogle',
'用于构建应用界面和跨平台运行。基础运行可能处理设备系统版本、界面状态及崩溃上下文;本项目未集成 Flutter 广告或行为分析 SDK。',
),
_LegalSection(
'Dio(开源网络库)',
'用于与记之后端进行 HTTPS 通信,传输登录凭据、账本数据、聊天文本和你主动提交的识别内容。',
),
_LegalSection(
'flutter_secure_storage',
'用于在设备安全存储中保存登录 Token。Android 使用系统加密存储能力,iOS 使用 Keychain。',
),
_LegalSection(
'sqlite3 / SQLCipher',
'用于在本机加密保存游客或账号的账本、分类、账单、预算及待同步操作。数据库密钥保存在系统安全存储中,本地数据库不会由该组件自行上传。',
),
_LegalSection(
'archive',
'用于在设备内存中生成 CSV 与 JSON 数据导出 ZIP,不连接网络、不收集数据;分享完成或失败后应用会清理临时 ZIP 文件。',
),
_LegalSection('image_picker', '用于调用系统相册或照片选择器,只返回你主动选中的图片。'),
_LegalSection('share_plus', '用于调用系统分享面板导出 ZIP。接收方由你选择;分享流程结束后应用清理临时导出文件。'),
_LegalSection(
'ML Kit 中文文字识别(Google',
'用于在 Android 设备本地识别微信、支付宝支付结果页中的成功状态和金额。模型随应用安装,处理过程不需要登录或联网,不会由该 SDK 自行上传截图;图片仅在内存中使用并在完成后释放。',
),
_LegalSection(
'火山方舟大模型服务(字节跳动)',
'用于 AI 对话、账单文本解析、图片识别及预算草稿调整。会处理完成对应请求所需的文本、图片和最小化财务上下文,不接收密码、JWT 或 API Key。',
),
_LegalSection(
'Android / iOS 系统能力',
'系统语音识别、照片选择器、通知、无障碍截图、MediaProjection 和系统分享属于操作系统能力,并非记之植入的广告或统计 SDK。',
),
];
}
class _LegalSection {
final String title;
final String body;
const _LegalSection(this.title, this.body);
}
+558
View File
@@ -0,0 +1,558 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/auth_api.dart';
import 'package:miaoji_zhang/shared/api/config_api.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
import 'package:miaoji_zhang/shared/services/sync_service.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/update/update_coordinator.dart';
import 'package:miaoji_zhang/shared/widgets/app_icons.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
import 'package:miaoji_zhang/shared/version.dart';
/// 我的页(P6):模式切换 + 设置入口 + 退出登录
class MePage extends StatefulWidget {
const MePage({super.key});
@override
State<MePage> createState() => _MePageState();
}
class _MePageState extends State<MePage> {
UserProfile? _profile;
@override
void initState() {
super.initState();
PublicConfigApi.companionNotifier.addListener(_refreshCompanion);
SessionStore.instance.addListener(_refreshSession);
SyncService.instance.refreshLocalStatus();
_load();
}
void _refreshCompanion() {
if (mounted) setState(() {});
}
void _refreshSession() {
if (mounted) setState(() {});
}
@override
void dispose() {
PublicConfigApi.companionNotifier.removeListener(_refreshCompanion);
SessionStore.instance.removeListener(_refreshSession);
super.dispose();
}
Future<void> _load() async {
try {
final p = await AuthApi.me();
if (mounted) setState(() => _profile = p);
} catch (_) {}
}
Future<void> _checkUpdate() =>
UpdateCoordinator.instance.checkManually(context);
Future<void> _switchMode(String mode) async {
if (_profile?.appMode == mode) return;
try {
final p = await AuthApi.switchMode(mode);
if (!mounted) return;
setState(() => _profile = p);
if (mode == 'ai') context.go('/ai-mode');
} catch (e) {
if (mounted)
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(e))));
}
}
Future<void> _logout() async {
final guest = SessionStore.instance.isGuest;
final ok = await showJzConfirmSheet(
context,
title: guest ? '退出游客模式' : '退出登录',
message: guest
? '游客数据会继续加密保存在本机,下次进入游客模式仍可使用。'
: '账号本地数据会继续加密保存在本机,切换账号不会混用。',
confirmLabel: '退出',
destructive: true,
);
if (!ok) return;
await AuthApi.logout();
if (mounted) context.go('/login');
}
@override
Widget build(BuildContext context) {
final p = _profile;
final session = SessionStore.instance;
return Scaffold(
body: SafeArea(
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
// 用户头
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Row(
children: [
CircleAvatar(
radius: 27,
backgroundColor: context.jz.card,
child: AppIcons.icon(
AppIcons.user,
size: 26,
color: context.jz.text2,
),
),
SizedBox(width: 13),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
session.nickname ??
p?.nickname ??
p?.username ??
(session.isGuest ? '游客' : ''),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 3),
Text(
'@${p?.username ?? ''}',
style: TextStyle(fontSize: 11, color: context.jz.text3),
),
],
),
],
),
),
Card(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 15,
vertical: 8,
),
child: session.isGuest
? const GuestLocalStatusCard()
: Column(
children: [
AnimatedBuilder(
animation: SyncService.instance,
builder: (context, _) {
final sync = SyncService.instance;
return JzSwitchTile(
value: session.cloudSyncEnabled,
title: '云同步',
subtitle: session.cloudSyncEnabled
? sync.statusLabel
: '本地模式:数据不会发送到服务端',
onChanged: (value) async {
await session.setCloudSyncEnabled(value);
if (value) {
await sync.run();
} else {
sync.refreshLocalStatus();
}
if (mounted) setState(() {});
},
);
},
),
if (session.needsReauth)
InkWell(
onTap: () => context.go('/login'),
borderRadius: BorderRadius.circular(10),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: context.jz.warningBackground,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'云同步登录已过期,点击重新登录;本地记账不受影响',
style: TextStyle(
fontSize: 10.5,
color: context.jz.text2,
),
),
),
),
],
),
),
),
SizedBox(height: 12),
// 模式切换
if (!session.isGuest && session.aiEnabled) ...[
Card(
child: Padding(
padding: const EdgeInsets.all(15),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'App 模式',
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w700,
),
),
SizedBox(height: 3),
Text(
'选择你喜欢的使用方式,可随时切换',
style: TextStyle(
fontSize: 10.5,
color: context.jz.text3,
),
),
SizedBox(height: 11),
Row(
children: [
_ModeOpt(
title: '普通记账模式',
desc: '经典账本界面',
iconAsset: AppIcons.wallet,
color: AppTheme.primary,
selected: session.appMode != 'ai',
onTap: () => _switchMode('normal'),
),
SizedBox(width: 10),
_ModeOpt(
title: '全 AI 模式',
desc: '对话作为主界面',
iconAsset: AppIcons.sparkle,
color: AppTheme.ai,
selected: session.appMode == 'ai',
onTap: () => _switchMode('ai'),
),
],
),
],
),
),
),
SizedBox(height: 12),
], // 设置入口
Card(
child: Column(
children: [
if (!session.isGuest && session.aiEnabled)
_row(
AppIcons.avatarAsset(PublicConfigApi.companionAvatarKey),
context.jz.aiBackground,
AppTheme.ai,
'${PublicConfigApi.companionName} 性格设置',
trailing: p?.aiCompanion?.personaKey,
onTap: () => context.push('/companion'),
),
_row(
AppIcons.target,
context.jz.primaryBackground,
AppTheme.primary,
'预算管理',
onTap: () => context.push('/budget'),
),
_row(
AppIcons.avatarAsset(PublicConfigApi.companionAvatarKey),
context.jz.aiBackground,
AppTheme.ai,
session.aiEnabled ? 'AI 报告' : '报告',
onTap: () => context.push('/report'),
),
_row(
AppIcons.tag,
context.jz.primaryBackground,
AppTheme.primary,
'分类管理',
onTap: () => context.push('/categories'),
),
if (!session.isGuest && PublicConfigApi.screenshotEnabled)
_row(
AppIcons.camera,
context.jz.primaryBackground,
AppTheme.red,
'智能识别',
trailing: '设置',
onTap: () => context.push('/screenshot-settings'),
),
_row(
AppIcons.user,
context.jz.background,
context.jz.text2,
session.isGuest ? '本地数据' : '账号与数据',
onTap: () async {
await context.push('/account-data');
_load();
},
),
_row(
AppIcons.gear,
context.jz.background,
context.jz.text2,
'外观设置',
onTap: () => context.push('/appearance'),
),
if (session.isAccount &&
SyncService.instance.conflictCount > 0)
_row(
AppIcons.offline,
context.jz.warningBackground,
AppTheme.orange,
'同步冲突',
trailing: '${SyncService.instance.conflictCount} 项待处理',
onTap: () async {
await context.push('/sync-conflicts');
if (mounted) setState(() {});
},
),
AnimatedBuilder(
animation: UpdateCoordinator.instance,
builder: (context, _) => _row(
AppIcons.cloud,
context.jz.primaryBackground,
AppTheme.primary,
'检查更新',
trailing: UpdateCoordinator.instance.checking
? '正在检查…'
: AppVersion.display,
onTap: UpdateCoordinator.instance.checking
? () {}
: _checkUpdate,
),
),
_row(
AppIcons.trash,
context.jz.expenseBackground,
AppTheme.red,
'回收站',
trailing: '保留 30 天',
onTap: () => context.push('/recycle-bin'),
),
],
),
),
SizedBox(height: 12),
Card(
child: _row(
AppIcons.exportIcon,
context.jz.expenseBackground,
AppTheme.red,
session.isGuest ? '退出游客模式' : '退出登录',
onTap: _logout,
),
),
SizedBox(height: 16),
Center(
child: Text(
AppVersion.display,
style: TextStyle(fontSize: 10, color: context.jz.text3),
),
),
SizedBox(height: 12),
],
),
),
);
}
Widget _row(
String iconAsset,
Color bg,
Color fg,
String label, {
String? trailing,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 13),
child: Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: AppIcons.icon(iconAsset, size: 15, color: fg),
),
),
SizedBox(width: 12),
Text(label, style: TextStyle(fontSize: 13.5)),
Spacer(),
if (trailing != null)
Text(
trailing,
style: TextStyle(fontSize: 11.5, color: context.jz.text3),
),
AppIcons.icon(
AppIcons.chevronRight,
size: 16,
color: context.jz.text3,
),
],
),
),
);
}
}
class GuestLocalStatusCard extends StatelessWidget {
const GuestLocalStatusCard({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: context.jz.primaryBackground,
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: AppIcons.icon(
AppIcons.check,
size: 19,
color: AppTheme.primaryDeep,
),
),
),
SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'本机保存中',
style: TextStyle(fontSize: 13.5, fontWeight: FontWeight.w700),
),
SizedBox(height: 3),
Text(
'账单、分类、预算和统计仅保存在本机,不会自动上传',
style: TextStyle(
fontSize: 10.5,
height: 1.4,
color: context.jz.text2,
),
),
],
),
),
SizedBox(width: 8),
DecoratedBox(
decoration: BoxDecoration(
color: context.jz.primaryBackground,
borderRadius: BorderRadius.all(Radius.circular(12)),
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 9, vertical: 5),
child: Text(
'游客模式',
style: TextStyle(
color: AppTheme.primaryDeep,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
);
}
}
class _ModeOpt extends StatelessWidget {
final String title, desc;
final String iconAsset;
final Color color;
final bool selected;
final VoidCallback onTap;
const _ModeOpt({
required this.title,
required this.desc,
required this.iconAsset,
required this.color,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(11),
decoration: BoxDecoration(
color: selected ? context.jz.primaryBackground : context.jz.card,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: selected ? AppTheme.primary : context.jz.line,
width: 1.5,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
AppIcons.icon(iconAsset, size: 20, color: color),
Spacer(),
Container(
width: 15,
height: 15,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: selected ? AppTheme.primary : context.jz.line,
width: 1.5,
),
color: selected ? AppTheme.primary : null,
),
child: selected
? AppIcons.icon(
AppIcons.check,
size: 10,
color: Colors.white,
)
: null,
),
],
),
SizedBox(height: 7),
Text(
title,
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700),
),
SizedBox(height: 3),
Text(
desc,
style: TextStyle(fontSize: 9, color: context.jz.text2),
),
],
),
),
),
);
}
}
@@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/api/business_api.dart';
import 'package:miaoji_zhang/shared/services/current_ledger_store.dart';
import 'package:miaoji_zhang/shared/services/transaction_events.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/category_icon.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
class RecycleBinPage extends StatefulWidget {
const RecycleBinPage({super.key});
@override
State<RecycleBinPage> createState() => _RecycleBinPageState();
}
class _RecycleBinPageState extends State<RecycleBinPage> {
List<TxItem> _items = const [];
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
await CurrentLedgerStore.instance.ensureLoaded();
final items = await TxApi.recycleBin();
if (mounted) setState(() => _items = items);
} catch (error) {
if (mounted) _showError(error);
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _restore(TxItem item) async {
try {
await TxApi.restore(item.id);
TransactionEvents.notifyChanged();
await _load();
} catch (error) {
if (mounted) _showError(error);
}
}
Future<void> _permanentDelete(TxItem item) async {
final confirmed = await _confirm('永久删除', '永久删除后无法恢复,聊天中的账单卡片会显示为已删除。');
if (!confirmed) return;
try {
await TxApi.permanentlyDelete(item.id);
await _load();
} catch (error) {
if (mounted) _showError(error);
}
}
Future<void> _clear() async {
if (_items.isEmpty) return;
final confirmed = await _confirm('清空回收站', '将永久删除当前账本回收站中的全部账单,无法恢复。');
if (!confirmed) return;
try {
await TxApi.clearRecycleBin();
await _load();
} catch (error) {
if (mounted) _showError(error);
}
}
Future<bool> _confirm(String title, String message) => showJzConfirmSheet(
context,
title: title,
message: message,
confirmLabel: '永久删除',
destructive: true,
);
Future<void> _showActions(TxItem item) async {
final action = await showJzOptionSheet<String>(
context,
title: item.note ?? item.categoryName,
options: const [
JzOption(value: 'restore', label: '恢复账单'),
JzOption(value: 'delete', label: '永久删除', subtitle: '删除后无法恢复'),
],
);
if (action == 'restore') await _restore(item);
if (action == 'delete') await _permanentDelete(item);
}
void _showError(Object error) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('${CurrentLedgerStore.instance.currentName} · 回收站'),
actions: [
TextButton(
onPressed: _items.isEmpty ? null : _clear,
child: Text('清空'),
),
],
),
body: _loading
? Center(
child: CircularProgressIndicator(
color: AppTheme.primary,
strokeWidth: 2,
),
)
: _items.isEmpty
? Center(
child: Text(
'回收站暂无账单\n删除的账单会保留 30 天',
textAlign: TextAlign.center,
style: TextStyle(color: context.jz.text3, height: 1.6),
),
)
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _items.length,
itemBuilder: (context, index) {
final item = _items[index];
return Card(
child: ListTile(
leading: CategoryIconBox(
iconKey: item.categoryIcon,
colorKey: item.categoryColor,
),
title: Text(item.note ?? item.categoryName),
subtitle: Text(
'${item.occurredAt.year}-${item.occurredAt.month.toString().padLeft(2, '0')}-${item.occurredAt.day.toString().padLeft(2, '0')}',
),
trailing: IconButton(
tooltip: '账单操作',
onPressed: () => _showActions(item),
icon: Icon(Icons.more_horiz_rounded),
),
),
);
},
),
);
}
}
@@ -0,0 +1,993 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:miaoji_zhang/shared/services/screenshot_channel.dart';
import 'package:miaoji_zhang/shared/services/session_store.dart';
import 'package:miaoji_zhang/shared/services/shanghai_time.dart';
import 'package:miaoji_zhang/shared/services/recognition_diagnostic_formatter.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
class ScreenshotSettingsPage extends StatefulWidget {
const ScreenshotSettingsPage({super.key});
@override
State<ScreenshotSettingsPage> createState() => _ScreenshotSettingsPageState();
}
class _ScreenshotSettingsPageState extends State<ScreenshotSettingsPage>
with WidgetsBindingObserver {
RecognitionStatus? _status;
bool _checking = true;
bool _authorizing = false;
String? _pendingAuthorizationKey;
String? _error;
Timer? _diagnosticRefreshTimer;
Timer? _previewExpiryTimer;
bool _diagnosticRefreshInFlight = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_check();
_diagnosticRefreshTimer = Timer.periodic(
const Duration(seconds: 2),
(_) => _refreshRunningDiagnostic(),
);
}
@override
void dispose() {
_diagnosticRefreshTimer?.cancel();
_previewExpiryTimer?.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
Future<void>.delayed(
const Duration(milliseconds: 500),
_resumeAuthorization,
);
}
}
void _refreshRunningDiagnostic() {
if (!mounted ||
_diagnosticRefreshInFlight ||
_status?.latestDiagnostic?.result != 'started') {
return;
}
_diagnosticRefreshInFlight = true;
_check().whenComplete(() => _diagnosticRefreshInFlight = false);
}
Future<void> _check() async {
try {
var status = await ScreenshotChannel.recognitionStatus();
final invalid = <String>[
if (status.accessibilityEvents &&
(!status.accessibilityAuthorized ||
!status.postNotificationsGranted))
'accessibility_events',
if (status.notificationEvents &&
(!status.notificationAuthorized ||
!status.postNotificationsGranted))
'notification_events',
if (status.aiScreenshot &&
(!status.accessibilityAuthorized ||
!status.postNotificationsGranted))
'ai_screenshot',
];
for (final key in invalid) {
await ScreenshotChannel.setRecognitionToggle(key, false);
}
if (invalid.isNotEmpty) {
status = await ScreenshotChannel.recognitionStatus();
}
if (!mounted) return;
setState(() {
_status = status;
_checking = false;
_error = null;
});
_schedulePreviewExpiry(status);
} catch (_) {
if (!mounted) return;
setState(() {
_checking = false;
_error = '状态读取失败,请重试';
});
}
}
void _schedulePreviewExpiry(RecognitionStatus status) {
_previewExpiryTimer?.cancel();
final expiresAt = status.ocrDiagnosticPreviewExpiresAt;
if (!status.ocrDiagnosticPreview || expiresAt == null) return;
final remaining = expiresAt.difference(DateTime.now());
if (remaining <= Duration.zero) {
unawaited(_check());
return;
}
_previewExpiryTimer = Timer(remaining, _check);
}
bool _hasSystemAuthorization(String key, RecognitionStatus status) {
return switch (key) {
'notification_events' => status.notificationAuthorized,
'accessibility_events' ||
'ai_screenshot' => status.accessibilityAuthorized,
_ => true,
};
}
Future<void> _toggle(String key, bool enabled) async {
if (_authorizing) return;
if (key == 'ocr_diagnostic_preview') {
final changed = await ScreenshotChannel.setRecognitionToggle(
key,
enabled,
);
if (!mounted) return;
if (!changed) {
_showMessage('设置保存失败,请重试');
} else if (enabled) {
_showMessage('脱敏预览已开启,将在 10 分钟后自动关闭');
}
await _check();
return;
}
if (enabled &&
key == 'accessibility_events' &&
!await _confirmLocalOcrConsent()) {
return;
}
if (!enabled) {
_pendingAuthorizationKey = null;
final changed = await ScreenshotChannel.setRecognitionToggle(key, false);
if (!mounted) return;
if (!changed) {
_showMessage('设置保存失败,请重试');
}
await _check();
return;
}
final status = _status ?? await ScreenshotChannel.recognitionStatus();
if (!_hasSystemAuthorization(key, status)) {
await ScreenshotChannel.setRecognitionToggle(key, false);
_pendingAuthorizationKey = key;
await _check();
if (!mounted) return;
if (key == 'notification_events') {
await ScreenshotChannel.openNotificationAccessSettings();
} else {
await ScreenshotChannel.openAccessibilitySettings();
}
return;
}
await _enableAfterAuthorization(key);
}
Future<void> _resumeAuthorization() async {
if (_authorizing) return;
await _check();
final key = _pendingAuthorizationKey;
final status = _status;
if (key == null || status == null) return;
if (!_hasSystemAuthorization(key, status)) {
_pendingAuthorizationKey = null;
await ScreenshotChannel.setRecognitionToggle(key, false);
await _check();
if (mounted) _showMessage('未完成系统授权,识别开关仍保持关闭');
return;
}
await _enableAfterAuthorization(key);
}
Future<void> _enableAfterAuthorization(String key) async {
_authorizing = true;
try {
final notificationsAllowed =
await ScreenshotChannel.requestNotificationPermission();
if (!notificationsAllowed) {
await ScreenshotChannel.setRecognitionToggle(key, false);
if (mounted) {
_showMessage('未允许结果通知,识别开关仍保持关闭');
}
return;
}
final changed = await ScreenshotChannel.setRecognitionToggle(key, true);
if (!changed && mounted) {
_showMessage('设置保存失败,请重试');
}
} finally {
_pendingAuthorizationKey = null;
_authorizing = false;
await _check();
}
}
Future<bool> _confirmLocalOcrConsent() async {
final preferences = await SharedPreferences.getInstance();
if (preferences.getBool('local_ocr_consent_v1') == true) return true;
if (!mounted) return false;
final accepted = await showModalBottomSheet<bool>(
context: context,
useSafeArea: true,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) => Container(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
decoration: BoxDecoration(
color: sheetContext.jz.card,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const JzSheetHeader(title: '启用本地视觉识别', subtitle: '请确认无障碍事件识别的截屏用途'),
const SizedBox(height: 12),
const _ConsentPoint(
icon: Icons.visibility_outlined,
text: '仅在微信、支付宝疑似支付流程结束时截取当前页面。',
),
const _ConsentPoint(
icon: Icons.memory_rounded,
text: '图片只在内存中由本地 OCR 处理,完成后立即释放。',
),
const _ConsentPoint(
icon: Icons.cloud_off_outlined,
text: '默认不会上传;只有你另行开启 AI 截图补全时才允许在线分析。',
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: JzActionButton(
label: '暂不开启',
secondary: true,
onPressed: () => Navigator.pop(sheetContext, false),
),
),
const SizedBox(width: 10),
Expanded(
child: JzActionButton(
label: '同意并继续',
onPressed: () => Navigator.pop(sheetContext, true),
),
),
],
),
],
),
),
);
if (accepted == true) {
await preferences.setBool('local_ocr_consent_v1', true);
return true;
}
return false;
}
void _showMessage(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
final palette = context.jz;
final status = _status;
final aiAvailable =
SessionStore.instance.isAccount && SessionStore.instance.aiEnabled;
return Scaffold(
appBar: AppBar(title: const Text('智能识别')),
body: RefreshIndicator(
onRefresh: _check,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 28),
children: [
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: palette.card,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: palette.line),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: palette.primaryBackground,
borderRadius: BorderRadius.circular(14),
),
child: const Icon(
Icons.auto_awesome_motion_rounded,
color: AppTheme.primary,
),
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'微信、支付宝账单自动识别',
style: TextStyle(
color: palette.text,
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 6),
Text(
'两路结果会先在本机合并去重。金额、方向和来源明确时自动入账,其他情况只通知你确认。',
style: TextStyle(
color: palette.text2,
fontSize: 12,
height: 1.55,
),
),
],
),
),
],
),
),
const SizedBox(height: 12),
if (_checking)
const Center(
child: Padding(
padding: EdgeInsets.all(30),
child: CircularProgressIndicator(),
),
)
else if (_error != null)
_ErrorCard(message: _error!, onRetry: _check)
else ...[
_RecognitionCard(
icon: Icons.accessibility_new_rounded,
title: '无障碍事件识别',
description:
'仅在微信和支付宝疑似支付流程中读取可见文字,并按需在内存中进行本地截图 OCR;不保存图片、完整控件树,也不监听按键。',
authorized: status!.accessibilityAuthorized,
connected: status.accessibilityConnected,
onOpenSettings: ScreenshotChannel.openAccessibilitySettings,
child: Column(
children: [
JzSwitchTile(
value: status.accessibilityEvents,
title: '识别开关',
subtitle: !status.accessibilityAuthorized
? '开启后需要前往系统设置授权'
: !status.postNotificationsGranted
? '识别已开启,还需允许记之显示结果通知'
: '系统权限已授权',
onChanged: (value) =>
_toggle('accessibility_events', value),
),
Divider(height: 1, color: context.jz.line),
JzSwitchTile(
value: status.ocrDiagnosticPreview,
title: 'OCR 诊断预览',
subtitle: '仅保留脱敏文字,10 分钟后自动关闭,不上传、不保存原图',
onChanged: status.accessibilityEvents
? (value) => _toggle('ocr_diagnostic_preview', value)
: null,
),
],
),
),
const SizedBox(height: 10),
_RecognitionCard(
icon: Icons.notifications_active_outlined,
title: '通知识别',
description: '只处理开启后新产生的微信、支付宝通知,不读取通知历史;关闭识别不会撤销系统授权。',
authorized: status.notificationAuthorized,
connected: status.notificationConnected,
onOpenSettings:
ScreenshotChannel.openNotificationAccessSettings,
child: JzSwitchTile(
value: status.notificationEvents,
title: '识别开关',
subtitle: !status.notificationAuthorized
? '开启后需要前往系统设置授权'
: !status.postNotificationsGranted
? '已允许读取,还需允许记之显示结果通知'
: '通知访问权限已授权',
onChanged: (value) => _toggle('notification_events', value),
),
),
const SizedBox(height: 10),
_RecognitionCard(
icon: Icons.document_scanner_outlined,
title: 'AI 截图补全',
description: '本地 OCR 已确认支付成功但字段仍不足时才在线分析。截图完成、失败或超时后立即释放。',
authorized: aiAvailable,
connected: aiAvailable && status.accessibilityConnected,
statusLabel: !aiAvailable ? 'AI 不可用' : null,
onOpenSettings: status.accessibilityAuthorized
? null
: ScreenshotChannel.openAccessibilitySettings,
child: JzSwitchTile(
value: status.aiScreenshot,
title: '补全开关',
subtitle: !aiAvailable
? '需要登录且账号具备 AI 权限'
: !status.accessibilityAuthorized
? '需要先授权无障碍截屏能力'
: '默认关闭,仅在支付应用前台运行',
onChanged: !aiAvailable
? null
: (value) => _toggle('ai_screenshot', value),
),
),
if (status.latestDiagnostic != null) ...[
const SizedBox(height: 10),
_RecognitionDiagnosticCard(
diagnostic: status.latestDiagnostic!,
onClear: () async {
await ScreenshotChannel.clearRecognitionDiagnostic();
await _check();
},
),
],
if (status.accessibilityEvents ||
status.notificationEvents ||
status.aiScreenshot) ...[
const SizedBox(height: 10),
_BackgroundKeepAliveCard(status: status),
],
const SizedBox(height: 10),
_RecognitionCard(
icon: Icons.grid_view_rounded,
title: '快捷磁贴截屏',
description:
'无障碍已连接时静默截屏;未连接时每次弹出 Android 一次性投屏授权。两种方式都不会占用音量键。',
authorized: status.accessibilityAuthorized,
connected: status.accessibilityConnected,
statusLabel: status.accessibilityConnected ? '免授权截屏' : '每次系统授权',
child: SizedBox(
width: double.infinity,
child: JzActionButton(
label: '添加截屏记账磁贴',
secondary: true,
icon: const Icon(Icons.tune_rounded, size: 18),
onPressed: ScreenshotChannel.openQuickSettings,
),
),
),
],
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: palette.warningBackground,
borderRadius: BorderRadius.circular(14),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(
Icons.shield_outlined,
color: AppTheme.orange,
size: 20,
),
const SizedBox(width: 9),
Expanded(
child: Text(
'银行密码页等安全窗口由系统禁止截屏,记之不会绕过限制。本地 OCR 图片仅在内存中处理;只有你开启 AI 截图补全时才会上传当前支付页。',
style: TextStyle(
color: palette.text2,
fontSize: 11.5,
height: 1.55,
),
),
),
],
),
),
],
),
),
);
}
}
class _ConsentPoint extends StatelessWidget {
final IconData icon;
final String text;
const _ConsentPoint({required this.icon, required this.text});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 20, color: AppTheme.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
text,
style: TextStyle(color: context.jz.text2, height: 1.5),
),
),
],
),
);
}
}
class _RecognitionDiagnosticCard extends StatelessWidget {
final RecognitionDiagnostic diagnostic;
final VoidCallback onClear;
const _RecognitionDiagnosticCard({
required this.diagnostic,
required this.onClear,
});
@override
Widget build(BuildContext context) {
final civil = ShanghaiTime.toCivil(diagnostic.at.toUtc());
final time =
'${civil.month.toString().padLeft(2, '0')}-'
'${civil.day.toString().padLeft(2, '0')} '
'${civil.hour.toString().padLeft(2, '0')}:'
'${civil.minute.toString().padLeft(2, '0')}';
final display = RecognitionDiagnosticDisplay.from(diagnostic);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: context.jz.line),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.fact_check_outlined, color: AppTheme.primary),
const SizedBox(width: 9),
Expanded(
child: Text(
'最近一次识别',
style: TextStyle(
color: context.jz.text,
fontWeight: FontWeight.w800,
),
),
),
Semantics(
button: true,
label: '清除识别诊断',
child: InkWell(
onTap: onClear,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 7,
),
child: Text(
'清除',
style: TextStyle(
color: AppTheme.primary,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
),
),
),
],
),
const SizedBox(height: 8),
Text(
'${diagnostic.appName} · ${display.stageLabel} · ${display.summaryLabel}',
style: TextStyle(
color: context.jz.text,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 5),
Text(
display.reasonLabel,
style: TextStyle(color: context.jz.text2, height: 1.45),
),
if (display.recognitionKindLabel != null ||
display.amountSourceLabel != null ||
display.statusStrengthLabel != null ||
diagnostic.expectedAmountMatched != null ||
diagnostic.resultTransitionObserved != null) ...[
const SizedBox(height: 9),
Wrap(
spacing: 7,
runSpacing: 7,
children: [
_EvidencePill(
label: display.resultLabel,
positive:
diagnostic.result == 'matched' ||
diagnostic.result == 'auto_ready',
),
if (display.recognitionKindLabel != null)
_EvidencePill(
label: display.recognitionKindLabel!,
positive: true,
),
if (display.amountSourceLabel != null)
_EvidencePill(
label: display.amountSourceLabel!,
positive: true,
),
if (diagnostic.resultFingerprint != null)
_EvidencePill(
label: '结果指纹 ${diagnostic.resultFingerprint}',
positive: true,
),
if (display.statusStrengthLabel != null)
_EvidencePill(
label: display.statusStrengthLabel!,
positive: display.statusStrengthPositive,
),
if (diagnostic.expectedAmountMatched != null)
_EvidencePill(
label: diagnostic.expectedAmountMatched! ? '金额匹配' : '金额不匹配',
positive: diagnostic.expectedAmountMatched!,
),
if (diagnostic.resultTransitionObserved != null)
_EvidencePill(
label: diagnostic.resultTransitionObserved!
? '已观察到页面跳转'
: '未观察到页面跳转',
positive: diagnostic.resultTransitionObserved!,
),
],
),
],
if (diagnostic.ocrPreview.isNotEmpty) ...[
const SizedBox(height: 10),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: context.jz.background,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: context.jz.line),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'OCR 脱敏预览',
style: TextStyle(
color: context.jz.text,
fontSize: 12,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 6),
...diagnostic.ocrPreview.map(
(line) => Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
line,
style: TextStyle(
color: context.jz.text2,
fontSize: 12,
height: 1.35,
),
),
),
),
],
),
),
],
const SizedBox(height: 7),
Text(
'$time · 节点 ${diagnostic.nodeCount}'
'${diagnostic.ocrMs == null ? '' : ' · OCR ${diagnostic.ocrMs}ms'}'
'${diagnostic.amountCandidates == 0 ? '' : ' · 金额候选 ${diagnostic.amountCandidates}'}',
style: TextStyle(color: context.jz.text3, fontSize: 11),
),
],
),
);
}
}
class _EvidencePill extends StatelessWidget {
final String label;
final bool positive;
const _EvidencePill({required this.label, required this.positive});
@override
Widget build(BuildContext context) {
final color = positive ? AppTheme.primary : context.jz.text3;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
decoration: BoxDecoration(
color: positive ? context.jz.primaryBackground : context.jz.background,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: positive ? AppTheme.primary : context.jz.line,
),
),
child: Text(
label,
style: TextStyle(
color: color,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
);
}
}
class _BackgroundKeepAliveCard extends StatelessWidget {
final RecognitionStatus status;
const _BackgroundKeepAliveCard({required this.status});
@override
Widget build(BuildContext context) {
final isVivo = status.manufacturer.toLowerCase().contains('vivo');
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: context.jz.card,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: context.jz.line),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: context.jz.warningBackground,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.battery_saver_outlined,
color: AppTheme.orange,
size: 21,
),
),
const SizedBox(width: 11),
const Expanded(
child: Text(
'后台识别保活',
style: TextStyle(fontWeight: FontWeight.w800),
),
),
Text(
status.batteryOptimizationIgnored ? '后台限制较少' : '需要设置',
style: TextStyle(
color: status.batteryOptimizationIgnored
? AppTheme.primaryDeep
: AppTheme.orange,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 10),
Text(
'无障碍和通知监听由 Android 独立轻量进程运行。请允许记之后台活动,'
'${isVivo ? '并在 OriginOS 中开启自启动,' : ''}'
'否则系统清理进程后可能暂时收不到支付事件。',
style: TextStyle(
color: context.jz.text2,
fontSize: 11.5,
height: 1.55,
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: JzActionButton(
label: '电池优化',
secondary: true,
onPressed: ScreenshotChannel.openBatteryOptimizationSettings,
),
),
const SizedBox(width: 10),
Expanded(
child: JzActionButton(
label: isVivo ? '自启动管理' : '后台设置',
secondary: true,
onPressed: ScreenshotChannel.openBackgroundStartupSettings,
),
),
],
),
],
),
);
}
}
class _RecognitionCard extends StatelessWidget {
final IconData icon;
final String title;
final String description;
final bool authorized;
final bool connected;
final String? statusLabel;
final Widget child;
final VoidCallback? onOpenSettings;
const _RecognitionCard({
required this.icon,
required this.title,
required this.description,
required this.authorized,
required this.connected,
required this.child,
this.statusLabel,
this.onOpenSettings,
});
@override
Widget build(BuildContext context) {
final palette = context.jz;
final color = connected ? AppTheme.primary : AppTheme.ai;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: palette.card,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: palette.line),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: connected
? palette.primaryBackground
: palette.aiBackground,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color, size: 21),
),
const SizedBox(width: 11),
Expanded(
child: Text(
title,
style: TextStyle(
color: palette.text,
fontWeight: FontWeight.w800,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
decoration: BoxDecoration(
color: connected
? palette.primaryBackground
: palette.aiBackground,
borderRadius: BorderRadius.circular(20),
),
child: Text(
statusLabel ??
(connected
? '已连接'
: authorized
? '等待连接'
: '未授权'),
style: TextStyle(
color: color,
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: 10),
Text(
description,
style: TextStyle(color: palette.text2, fontSize: 12, height: 1.55),
),
const SizedBox(height: 6),
child,
if (onOpenSettings != null) ...[
const SizedBox(height: 6),
Align(
alignment: Alignment.centerLeft,
child: Semantics(
button: true,
label: '打开系统权限设置',
child: InkWell(
onTap: onOpenSettings,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 8,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.settings_outlined,
size: 17,
color: AppTheme.primary,
),
const SizedBox(width: 6),
Text(
'系统权限设置',
style: TextStyle(
color: AppTheme.primary,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
),
),
],
],
),
);
}
}
class _ErrorCard extends StatelessWidget {
final String message;
final VoidCallback onRetry;
const _ErrorCard({required this.message, required this.onRetry});
@override
Widget build(BuildContext context) {
final palette = context.jz;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: palette.card,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: palette.line),
),
child: Column(
children: [
Text(message, style: TextStyle(color: palette.text2)),
const SizedBox(height: 10),
JzActionButton(label: '重新读取', secondary: true, onPressed: onRetry),
],
),
);
}
}
@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:miaoji_zhang/shared/api/api_client.dart';
import 'package:miaoji_zhang/shared/services/local_database.dart';
import 'package:miaoji_zhang/shared/services/sync_service.dart';
import 'package:miaoji_zhang/shared/theme/app_theme.dart';
import 'package:miaoji_zhang/shared/widgets/app_controls.dart';
class SyncConflictsPage extends StatefulWidget {
const SyncConflictsPage({super.key});
@override
State<SyncConflictsPage> createState() => _SyncConflictsPageState();
}
class _SyncConflictsPageState extends State<SyncConflictsPage> {
List<Map<String, dynamic>> _items = const [];
int? _resolvingId;
@override
void initState() {
super.initState();
_load();
}
void _load() {
setState(() => _items = LocalDatabase.instance.conflicts());
}
Future<void> _resolve(
Map<String, dynamic> item, {
required bool keepLocal,
}) async {
setState(() => _resolvingId = item['id'] as int);
try {
await SyncService.instance.resolveConflict(item, keepLocal: keepLocal);
if (mounted) _load();
} catch (error) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(apiErrorMessage(error))));
}
} finally {
if (mounted) setState(() => _resolvingId = null);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('同步冲突')),
body: _items.isEmpty
? Center(
child: Text(
'没有需要处理的同步冲突',
style: TextStyle(color: context.jz.text3),
),
)
: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: _items.length,
separatorBuilder: (_, __) => SizedBox(height: 10),
itemBuilder: (context, index) => _conflictCard(_items[index]),
),
);
}
Widget _conflictCard(Map<String, dynamic> item) {
final local = item['local'] as Map<String, dynamic>;
final remote = item['remote'] as Map<String, dynamic>;
final resolving = _resolvingId == item['id'];
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.sync_problem_rounded, color: AppTheme.orange),
SizedBox(width: 8),
Expanded(
child: Text(
_operationLabel(item['operation'] as String),
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w800),
),
),
],
),
SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _version('本地版本', local, context.jz.primaryBackground),
),
SizedBox(width: 9),
Expanded(
child: _version('云端版本', remote, context.jz.aiBackground),
),
],
),
SizedBox(height: 12),
Row(
children: [
Expanded(
child: JzActionButton(
label: '使用云端',
secondary: true,
loading: resolving,
onPressed: resolving
? null
: () => _resolve(item, keepLocal: false),
),
),
SizedBox(width: 9),
Expanded(
child: JzActionButton(
label: '保留本地',
loading: resolving,
onPressed: resolving
? null
: () => _resolve(item, keepLocal: true),
),
),
],
),
],
),
),
);
}
Widget _version(String title, Map<String, dynamic> value, Color background) {
final amount = value['amount'];
return Container(
constraints: const BoxConstraints(minHeight: 92),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(fontSize: 10.5, color: context.jz.text3),
),
SizedBox(height: 6),
if (amount != null)
Text(
'¥${(amount as num).toStringAsFixed(2)}',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w800),
),
SizedBox(height: 3),
Text(
(value['note'] ?? value['categoryName'] ?? '无可展示内容').toString(),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 10.5, color: context.jz.text2),
),
],
),
);
}
String _operationLabel(String operation) => switch (operation) {
'delete' => '同一账单在云端有新修改,是否仍删除?',
'restore' => '同一账单在云端有新修改,是否仍恢复?',
_ => '本地与云端都修改了同一账单',
};
}