Initial project import
This commit is contained in:
@@ -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(
|
||||
'Flutter(Google)',
|
||||
'用于构建应用界面和跨平台运行。基础运行可能处理设备系统版本、界面状态及崩溃上下文;本项目未集成 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);
|
||||
}
|
||||
@@ -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' => '同一账单在云端有新修改,是否仍恢复?',
|
||||
_ => '本地与云端都修改了同一账单',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user