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 createState() => _AccountDataPageState(); } class _AccountDataPageState extends State { UserProfile? _profile; bool _busy = false; @override void initState() { super.initState(); _cleanupStaleExports(); _load(); } Future _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 _load() async { try { final profile = await AuthApi.me(); if (mounted) setState(() => _profile = profile); } catch (error) { if (mounted) _showError(error); } } Future _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 _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 _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 _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 _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), ); }