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),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user