Files
jizhi/frontend/lib/features/settings/me_page.dart
T

560 lines
20 KiB
Dart

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.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)
_row(
AppIcons.bell,
context.jz.primaryBackground,
AppTheme.primary,
'通知设置',
onTap: () => context.push('/notification-settings'),
),
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),
),
],
),
),
),
);
}
}