Files
jizhi/frontend/lib/features/settings/account_data_page.dart
T
2026-07-24 23:11:20 +08:00

307 lines
9.5 KiB
Dart

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