307 lines
9.5 KiB
Dart
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),
|
|
);
|
|
}
|