985 lines
34 KiB
Dart
985 lines
34 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import 'package:package_info_plus/package_info_plus.dart';
|
||
import '../../../../core/theme/app_colors.dart';
|
||
import '../../../../core/updater/update_service.dart';
|
||
import '../../../auth/data/providers/auth_provider.dart';
|
||
import '../../../agent_call/presentation/pages/voice_test_page.dart';
|
||
import '../../../settings/presentation/providers/settings_providers.dart';
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Profile page — "我" Tab
|
||
// A slimmer version of SettingsPage, always accessible as a Tab.
|
||
// Billing is linked as a prominent row at the top.
|
||
// ---------------------------------------------------------------------------
|
||
|
||
class ProfilePage extends ConsumerStatefulWidget {
|
||
const ProfilePage({super.key});
|
||
|
||
@override
|
||
ConsumerState<ProfilePage> createState() => _ProfilePageState();
|
||
}
|
||
|
||
class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||
String _appVersion = '';
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
Future.microtask(
|
||
() => ref.read(accountProfileProvider.notifier).loadProfile());
|
||
_loadVersion();
|
||
}
|
||
|
||
Future<void> _loadVersion() async {
|
||
final info = await PackageInfo.fromPlatform();
|
||
if (mounted) {
|
||
setState(() => _appVersion = 'v${info.version}+${info.buildNumber}');
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final settings = ref.watch(settingsProvider);
|
||
final profile = ref.watch(accountProfileProvider);
|
||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||
final cardColor = isDark ? AppColors.surface : Colors.white;
|
||
final subtitleColor =
|
||
isDark ? AppColors.textSecondary : Colors.grey[600];
|
||
|
||
return Scaffold(
|
||
backgroundColor: AppColors.background,
|
||
appBar: AppBar(
|
||
backgroundColor: AppColors.background,
|
||
title: const Text('我'),
|
||
),
|
||
body: ListView(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
children: [
|
||
// ── Profile card ────────────────────────────────────────────
|
||
_buildProfileCard(profile, cardColor, subtitleColor),
|
||
const SizedBox(height: 20),
|
||
|
||
// ── Billing (prominent) ─────────────────────────────────────
|
||
_SettingsGroup(
|
||
cardColor: cardColor,
|
||
children: [
|
||
_SettingsRow(
|
||
icon: Icons.workspace_premium_outlined,
|
||
iconBg: const Color(0xFF10B981),
|
||
title: '订阅套餐与用量',
|
||
trailing: Text(
|
||
'Free',
|
||
style: TextStyle(color: subtitleColor, fontSize: 14),
|
||
),
|
||
onTap: () => context.push('/billing'),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// ── General ─────────────────────────────────────────────────
|
||
_SettingsGroup(
|
||
cardColor: cardColor,
|
||
children: [
|
||
_SettingsRow(
|
||
icon: Icons.palette_outlined,
|
||
iconBg: const Color(0xFF6366F1),
|
||
title: '外观主题',
|
||
trailing: _ThemeLabel(mode: settings.themeMode),
|
||
onTap: () => _showThemePicker(settings.themeMode),
|
||
),
|
||
_SettingsRow(
|
||
icon: Icons.language,
|
||
iconBg: const Color(0xFF3B82F6),
|
||
title: '语言',
|
||
trailing: Text('简体中文',
|
||
style: TextStyle(color: subtitleColor, fontSize: 14)),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// ── Voice / Engine ───────────────────────────────────────────
|
||
_SettingsGroup(
|
||
cardColor: cardColor,
|
||
children: [
|
||
// [HIDDEN] 对话引擎选择(Claude API vs Agent SDK)
|
||
// 产品决策:引擎切换属于平台级配置,普通用户无需感知,暂不在"我"页面暴露。
|
||
// 如需恢复显示,删除 `if (false)` 即可。
|
||
if (false) _SettingsRow(
|
||
icon: Icons.psychology,
|
||
iconBg: const Color(0xFF7C3AED),
|
||
title: '对话引擎',
|
||
trailing: Text(
|
||
settings.engineType == 'claude_agent_sdk'
|
||
? 'Agent SDK'
|
||
: 'Claude API',
|
||
style: TextStyle(color: subtitleColor, fontSize: 14),
|
||
),
|
||
onTap: () => _showEngineTypePicker(settings.engineType),
|
||
),
|
||
_SettingsRow(
|
||
icon: Icons.record_voice_over,
|
||
iconBg: const Color(0xFF0EA5E9),
|
||
title: '语音音色',
|
||
trailing: Text(
|
||
_voiceDisplayLabel(settings.ttsVoice),
|
||
style: TextStyle(color: subtitleColor, fontSize: 14),
|
||
),
|
||
onTap: () => _showVoicePicker(settings.ttsVoice),
|
||
),
|
||
_SettingsRow(
|
||
icon: Icons.tune,
|
||
iconBg: const Color(0xFFF97316),
|
||
title: '语音风格',
|
||
trailing: Text(
|
||
_styleDisplayName(settings.ttsStyle),
|
||
style: TextStyle(color: subtitleColor, fontSize: 14),
|
||
),
|
||
onTap: () => _showStylePicker(settings.ttsStyle),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// ── Notifications ────────────────────────────────────────────
|
||
_SettingsGroup(
|
||
cardColor: cardColor,
|
||
children: [
|
||
_SettingsToggleRow(
|
||
icon: Icons.notifications_outlined,
|
||
iconBg: const Color(0xFFEF4444),
|
||
title: '推送通知',
|
||
value: settings.notificationsEnabled,
|
||
onChanged: (v) => ref
|
||
.read(settingsProvider.notifier)
|
||
.setNotificationsEnabled(v),
|
||
),
|
||
_SettingsToggleRow(
|
||
icon: Icons.volume_up_outlined,
|
||
iconBg: const Color(0xFFF59E0B),
|
||
title: '提示音',
|
||
value: settings.soundEnabled,
|
||
onChanged: settings.notificationsEnabled
|
||
? (v) =>
|
||
ref.read(settingsProvider.notifier).setSoundEnabled(v)
|
||
: null,
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// ── Security ────────────────────────────────────────────────
|
||
_SettingsGroup(
|
||
cardColor: cardColor,
|
||
children: [
|
||
_SettingsRow(
|
||
icon: Icons.lock_outline,
|
||
iconBg: const Color(0xFF8B5CF6),
|
||
title: '修改密码',
|
||
onTap: _showChangePasswordSheet,
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// ── About / Dev ──────────────────────────────────────────────
|
||
_SettingsGroup(
|
||
cardColor: cardColor,
|
||
children: [
|
||
_SettingsRow(
|
||
icon: Icons.info_outline,
|
||
iconBg: const Color(0xFF64748B),
|
||
title: '版本',
|
||
trailing: Text(
|
||
_appVersion.isNotEmpty ? _appVersion : 'v1.0.0',
|
||
style: TextStyle(color: subtitleColor, fontSize: 14),
|
||
),
|
||
),
|
||
// [HIDDEN] 检查更新
|
||
// 产品决策:更新检查在 App 启动时已后台静默运行(UpdateService.silentCheck),
|
||
// 无需在"我"页面额外入口。如需恢复,删除 `if (false)` 即可。
|
||
if (false) _SettingsRow(
|
||
icon: Icons.system_update_outlined,
|
||
iconBg: const Color(0xFF22C55E),
|
||
title: '检查更新',
|
||
onTap: () => UpdateService().manualCheckUpdate(context),
|
||
),
|
||
// [HIDDEN] 语音 I/O 测试(TTS / STT)
|
||
// 产品决策:仅供开发调试使用,正式版不对普通用户展示。
|
||
// 调试时可删除 `if (false)` 临时开启;VoiceTestPage 代码及路由均保留。
|
||
if (false) _SettingsRow(
|
||
icon: Icons.record_voice_over,
|
||
iconBg: const Color(0xFF10B981),
|
||
title: '语音 I/O 测试',
|
||
trailing: Text('TTS / STT',
|
||
style: TextStyle(color: subtitleColor, fontSize: 14)),
|
||
onTap: () => Navigator.push(
|
||
context,
|
||
MaterialPageRoute(builder: (_) => const VoiceTestPage()),
|
||
),
|
||
),
|
||
if (settings.selectedTenantName != null)
|
||
_SettingsRow(
|
||
icon: Icons.business_outlined,
|
||
iconBg: const Color(0xFF0EA5E9),
|
||
title: '租户',
|
||
trailing: Text(settings.selectedTenantName!,
|
||
style: TextStyle(color: subtitleColor, fontSize: 14)),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 24),
|
||
|
||
// ── Logout ──────────────────────────────────────────────────
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: OutlinedButton(
|
||
onPressed: _confirmLogout,
|
||
style: OutlinedButton.styleFrom(
|
||
foregroundColor: AppColors.error,
|
||
side: const BorderSide(color: AppColors.error),
|
||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
child: const Text('退出登录',
|
||
style: TextStyle(fontSize: 16)),
|
||
),
|
||
),
|
||
const SizedBox(height: 48),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── Profile card ──────────────────────────────────────────────────────────
|
||
|
||
Widget _buildProfileCard(
|
||
AccountProfile profile, Color cardColor, Color? subtitleColor) {
|
||
final initial = profile.displayName.isNotEmpty
|
||
? profile.displayName[0].toUpperCase()
|
||
: '?';
|
||
|
||
return Card(
|
||
color: cardColor,
|
||
elevation: 0,
|
||
shape:
|
||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||
child: InkWell(
|
||
borderRadius: BorderRadius.circular(16),
|
||
onTap: () => _showEditNameDialog(profile.displayName),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(20),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 60,
|
||
height: 60,
|
||
decoration: BoxDecoration(
|
||
gradient: const LinearGradient(
|
||
colors: [Color(0xFF6366F1), Color(0xFF818CF8)],
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
),
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
child: Center(
|
||
child: Text(
|
||
initial,
|
||
style: const TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 24,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 16),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
profile.displayName.isNotEmpty
|
||
? profile.displayName
|
||
: '加载中...',
|
||
style: const TextStyle(
|
||
fontWeight: FontWeight.w600,
|
||
fontSize: 18,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
profile.email.isNotEmpty ? profile.email : ' ',
|
||
style: TextStyle(
|
||
color: subtitleColor, fontSize: 14),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Icon(Icons.chevron_right, color: subtitleColor),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── Pickers ───────────────────────────────────────────────────────────────
|
||
|
||
void _showThemePicker(ThemeMode current) {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
builder: (ctx) => SafeArea(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const SizedBox(height: 12),
|
||
_handle(),
|
||
const SizedBox(height: 12),
|
||
Text('选择主题',
|
||
style: Theme.of(ctx).textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
)),
|
||
const SizedBox(height: 8),
|
||
_ThemeOption(
|
||
icon: Icons.dark_mode,
|
||
label: '深色模式',
|
||
selected: current == ThemeMode.dark,
|
||
onTap: () {
|
||
ref
|
||
.read(settingsProvider.notifier)
|
||
.setThemeMode(ThemeMode.dark);
|
||
Navigator.pop(ctx);
|
||
},
|
||
),
|
||
_ThemeOption(
|
||
icon: Icons.light_mode,
|
||
label: '浅色模式',
|
||
selected: current == ThemeMode.light,
|
||
onTap: () {
|
||
ref
|
||
.read(settingsProvider.notifier)
|
||
.setThemeMode(ThemeMode.light);
|
||
Navigator.pop(ctx);
|
||
},
|
||
),
|
||
_ThemeOption(
|
||
icon: Icons.settings_brightness,
|
||
label: '跟随系统',
|
||
selected: current == ThemeMode.system,
|
||
onTap: () {
|
||
ref
|
||
.read(settingsProvider.notifier)
|
||
.setThemeMode(ThemeMode.system);
|
||
Navigator.pop(ctx);
|
||
},
|
||
),
|
||
const SizedBox(height: 16),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
static const _voices = [
|
||
('coral', 'Coral', '女 · 温暖'),
|
||
('nova', 'Nova', '女 · 活泼'),
|
||
('sage', 'Sage', '女 · 知性'),
|
||
('shimmer', 'Shimmer', '女 · 柔和'),
|
||
('ash', 'Ash', '男 · 沉稳'),
|
||
('echo', 'Echo', '男 · 清朗'),
|
||
('onyx', 'Onyx', '男 · 低沉'),
|
||
('alloy', 'Alloy', '中性'),
|
||
];
|
||
|
||
String _voiceDisplayLabel(String voice) {
|
||
for (final v in _voices) {
|
||
if (v.$1 == voice) return '${v.$2} · ${v.$3}';
|
||
}
|
||
return voice.isNotEmpty
|
||
? voice[0].toUpperCase() + voice.substring(1)
|
||
: 'Coral';
|
||
}
|
||
|
||
void _showVoicePicker(String current) {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
builder: (ctx) => SafeArea(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const SizedBox(height: 12),
|
||
_handle(),
|
||
const SizedBox(height: 12),
|
||
Text('选择语音音色',
|
||
style: Theme.of(ctx).textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
)),
|
||
const SizedBox(height: 8),
|
||
Flexible(
|
||
child: ListView(
|
||
shrinkWrap: true,
|
||
children: _voices
|
||
.map((v) => ListTile(
|
||
leading: Icon(Icons.record_voice_over,
|
||
color: current == v.$1
|
||
? AppColors.primary
|
||
: null),
|
||
title: Text(v.$2,
|
||
style: TextStyle(
|
||
fontWeight: current == v.$1
|
||
? FontWeight.w600
|
||
: FontWeight.normal,
|
||
color: current == v.$1
|
||
? AppColors.primary
|
||
: null,
|
||
)),
|
||
subtitle: Text(v.$3,
|
||
style: const TextStyle(fontSize: 12)),
|
||
trailing: current == v.$1
|
||
? const Icon(Icons.check_circle,
|
||
color: AppColors.primary)
|
||
: null,
|
||
onTap: () {
|
||
ref
|
||
.read(settingsProvider.notifier)
|
||
.setTtsVoice(v.$1);
|
||
Navigator.pop(ctx);
|
||
},
|
||
))
|
||
.toList(),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
static const _engines = [
|
||
('claude_agent_sdk', 'Agent SDK', '支持工具审批、技能注入、会话恢复'),
|
||
('claude_api', 'Claude API', '直连 API,响应更快'),
|
||
];
|
||
|
||
void _showEngineTypePicker(String current) {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
builder: (ctx) => Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_handle(),
|
||
const SizedBox(height: 12),
|
||
Text('选择对话引擎',
|
||
style: Theme.of(ctx).textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
)),
|
||
const SizedBox(height: 8),
|
||
..._engines.map((e) => ListTile(
|
||
leading: Icon(
|
||
e.$1 == 'claude_agent_sdk'
|
||
? Icons.psychology
|
||
: Icons.api,
|
||
color: e.$1 == current ? AppColors.primary : null,
|
||
),
|
||
title: Text(e.$2,
|
||
style: TextStyle(
|
||
fontWeight: e.$1 == current
|
||
? FontWeight.bold
|
||
: FontWeight.normal,
|
||
color: e.$1 == current ? AppColors.primary : null,
|
||
)),
|
||
subtitle:
|
||
Text(e.$3, style: const TextStyle(fontSize: 12)),
|
||
trailing: e.$1 == current
|
||
? const Icon(Icons.check_circle,
|
||
color: AppColors.primary)
|
||
: null,
|
||
onTap: () {
|
||
ref
|
||
.read(settingsProvider.notifier)
|
||
.setEngineType(e.$1);
|
||
Navigator.pop(ctx);
|
||
},
|
||
)),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
static const _stylePresets = [
|
||
('专业干练', '用专业、简洁、干练的语气说话,不拖泥带水。'),
|
||
('温柔耐心', '用温柔、耐心的语气说话,像一个贴心的朋友。'),
|
||
('轻松活泼', '用轻松、活泼的语气说话,带一点幽默感。'),
|
||
('严肃正式', '用严肃、正式的语气说话,像在正式会议中发言。'),
|
||
('科幻AI', '用科幻电影中AI的语气说话,冷静、理性、略带未来感。'),
|
||
];
|
||
|
||
String _styleDisplayName(String style) {
|
||
if (style.isEmpty) return '默认';
|
||
for (final p in _stylePresets) {
|
||
if (p.$2 == style) return p.$1;
|
||
}
|
||
return style.length > 6 ? '${style.substring(0, 6)}...' : style;
|
||
}
|
||
|
||
void _showStylePicker(String current) {
|
||
final controller = TextEditingController(
|
||
text: _stylePresets.any((p) => p.$2 == current) ? '' : current,
|
||
);
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
builder: (ctx) => Padding(
|
||
padding: EdgeInsets.fromLTRB(
|
||
24, 24, 24, MediaQuery.of(ctx).viewInsets.bottom + 24),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_handle(),
|
||
const SizedBox(height: 12),
|
||
Text('选择语音风格',
|
||
style: Theme.of(ctx).textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
)),
|
||
const SizedBox(height: 12),
|
||
Wrap(
|
||
spacing: 8,
|
||
runSpacing: 8,
|
||
children: _stylePresets
|
||
.map((p) => ChoiceChip(
|
||
label: Text(p.$1),
|
||
selected: current == p.$2,
|
||
onSelected: (_) {
|
||
ref
|
||
.read(settingsProvider.notifier)
|
||
.setTtsStyle(p.$2);
|
||
Navigator.pop(ctx);
|
||
},
|
||
))
|
||
.toList(),
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextField(
|
||
controller: controller,
|
||
decoration: InputDecoration(
|
||
labelText: '自定义风格',
|
||
hintText: '例如:用东北话说话,幽默风趣',
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
maxLines: 2,
|
||
),
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: TextButton(
|
||
onPressed: () {
|
||
ref
|
||
.read(settingsProvider.notifier)
|
||
.setTtsStyle('');
|
||
Navigator.pop(ctx);
|
||
},
|
||
child: const Text('恢复默认'),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: FilledButton(
|
||
onPressed: () {
|
||
final text = controller.text.trim();
|
||
if (text.isNotEmpty) {
|
||
ref
|
||
.read(settingsProvider.notifier)
|
||
.setTtsStyle(text);
|
||
}
|
||
Navigator.pop(ctx);
|
||
},
|
||
child: const Text('确认'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showEditNameDialog(String currentName) {
|
||
final controller = TextEditingController(text: currentName);
|
||
showDialog(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
shape:
|
||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||
title: const Text('修改显示名称'),
|
||
content: TextField(
|
||
controller: controller,
|
||
autofocus: true,
|
||
decoration: InputDecoration(
|
||
labelText: '显示名称',
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(ctx).pop(),
|
||
child: const Text('取消'),
|
||
),
|
||
FilledButton(
|
||
onPressed: () async {
|
||
final name = controller.text.trim();
|
||
if (name.isEmpty) return;
|
||
Navigator.of(ctx).pop();
|
||
final success = await ref
|
||
.read(accountProfileProvider.notifier)
|
||
.updateDisplayName(name);
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(success ? '名称已更新' : '更新失败')),
|
||
);
|
||
}
|
||
},
|
||
child: const Text('保存'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showChangePasswordSheet() {
|
||
final currentCtrl = TextEditingController();
|
||
final newCtrl = TextEditingController();
|
||
final confirmCtrl = TextEditingController();
|
||
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
builder: (ctx) => Padding(
|
||
padding: EdgeInsets.fromLTRB(
|
||
24, 24, 24, MediaQuery.of(ctx).viewInsets.bottom + 24),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
_handle(),
|
||
const SizedBox(height: 12),
|
||
Text('修改密码',
|
||
style: Theme.of(ctx)
|
||
.textTheme
|
||
.titleLarge
|
||
?.copyWith(fontWeight: FontWeight.w600)),
|
||
const SizedBox(height: 20),
|
||
TextField(
|
||
controller: currentCtrl,
|
||
obscureText: true,
|
||
decoration: InputDecoration(
|
||
labelText: '当前密码',
|
||
prefixIcon: const Icon(Icons.lock_outline),
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
),
|
||
const SizedBox(height: 14),
|
||
TextField(
|
||
controller: newCtrl,
|
||
obscureText: true,
|
||
decoration: InputDecoration(
|
||
labelText: '新密码',
|
||
prefixIcon: const Icon(Icons.lock_reset),
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
),
|
||
const SizedBox(height: 14),
|
||
TextField(
|
||
controller: confirmCtrl,
|
||
obscureText: true,
|
||
decoration: InputDecoration(
|
||
labelText: '确认新密码',
|
||
prefixIcon: const Icon(Icons.lock_reset),
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
FilledButton(
|
||
style: FilledButton.styleFrom(
|
||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
onPressed: () async {
|
||
if (newCtrl.text != confirmCtrl.text) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('两次输入的密码不一致')),
|
||
);
|
||
return;
|
||
}
|
||
if (newCtrl.text.length < 6) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text('新密码至少6个字符')),
|
||
);
|
||
return;
|
||
}
|
||
Navigator.of(ctx).pop();
|
||
final result = await ref
|
||
.read(accountProfileProvider.notifier)
|
||
.changePassword(
|
||
currentPassword: currentCtrl.text,
|
||
newPassword: newCtrl.text,
|
||
);
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(result.success
|
||
? '密码已修改'
|
||
: result.message ?? '修改失败'),
|
||
),
|
||
);
|
||
}
|
||
},
|
||
child: const Text('确认修改', style: TextStyle(fontSize: 16)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _confirmLogout() async {
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
shape:
|
||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||
title: const Text('退出登录'),
|
||
content: const Text('确定要退出登录吗?'),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(ctx).pop(false),
|
||
child: const Text('取消'),
|
||
),
|
||
FilledButton(
|
||
style:
|
||
FilledButton.styleFrom(backgroundColor: AppColors.error),
|
||
onPressed: () => Navigator.of(ctx).pop(true),
|
||
child: const Text('退出'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (confirmed == true) {
|
||
await ref.read(authStateProvider.notifier).logout();
|
||
if (mounted) context.go('/login');
|
||
}
|
||
}
|
||
|
||
Widget _handle() => Center(
|
||
child: Container(
|
||
width: 40,
|
||
height: 4,
|
||
decoration: BoxDecoration(
|
||
color: Colors.grey[400],
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// =============================================================================
|
||
// Shared settings widgets (copied from settings_page to keep this file self-contained)
|
||
// =============================================================================
|
||
|
||
class _SettingsGroup extends StatelessWidget {
|
||
final Color cardColor;
|
||
final List<Widget> children;
|
||
|
||
const _SettingsGroup(
|
||
{required this.cardColor, required this.children});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Card(
|
||
color: cardColor,
|
||
elevation: 0,
|
||
shape:
|
||
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: Column(
|
||
children: [
|
||
for (int i = 0; i < children.length; i++) ...[
|
||
children[i],
|
||
if (i < children.length - 1)
|
||
Divider(
|
||
height: 1,
|
||
indent: 56,
|
||
color: Theme.of(context).dividerColor.withAlpha(80),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SettingsRow extends StatelessWidget {
|
||
final IconData icon;
|
||
final Color iconBg;
|
||
final String title;
|
||
final Widget? trailing;
|
||
final VoidCallback? onTap;
|
||
|
||
const _SettingsRow({
|
||
required this.icon,
|
||
required this.iconBg,
|
||
required this.title,
|
||
this.trailing,
|
||
this.onTap,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ListTile(
|
||
leading: Container(
|
||
width: 32,
|
||
height: 32,
|
||
decoration: BoxDecoration(
|
||
color: iconBg,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Icon(icon, color: Colors.white, size: 18),
|
||
),
|
||
title: Text(title),
|
||
trailing: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (trailing != null) trailing!,
|
||
if (onTap != null) ...[
|
||
const SizedBox(width: 4),
|
||
Icon(Icons.chevron_right,
|
||
size: 20,
|
||
color: Theme.of(context).hintColor),
|
||
],
|
||
],
|
||
),
|
||
onTap: onTap,
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SettingsToggleRow extends StatelessWidget {
|
||
final IconData icon;
|
||
final Color iconBg;
|
||
final String title;
|
||
final bool value;
|
||
final ValueChanged<bool>? onChanged;
|
||
|
||
const _SettingsToggleRow({
|
||
required this.icon,
|
||
required this.iconBg,
|
||
required this.title,
|
||
required this.value,
|
||
this.onChanged,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return SwitchListTile(
|
||
secondary: Container(
|
||
width: 32,
|
||
height: 32,
|
||
decoration: BoxDecoration(
|
||
color: iconBg,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Icon(icon, color: Colors.white, size: 18),
|
||
),
|
||
title: Text(title),
|
||
value: value,
|
||
onChanged: onChanged,
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ThemeLabel extends StatelessWidget {
|
||
final ThemeMode mode;
|
||
const _ThemeLabel({required this.mode});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final label = switch (mode) {
|
||
ThemeMode.dark => '深色',
|
||
ThemeMode.light => '浅色',
|
||
ThemeMode.system => '跟随系统',
|
||
};
|
||
return Text(label,
|
||
style: TextStyle(
|
||
color: Theme.of(context).hintColor, fontSize: 14));
|
||
}
|
||
}
|
||
|
||
class _ThemeOption extends StatelessWidget {
|
||
final IconData icon;
|
||
final String label;
|
||
final bool selected;
|
||
final VoidCallback onTap;
|
||
|
||
const _ThemeOption({
|
||
required this.icon,
|
||
required this.label,
|
||
required this.selected,
|
||
required this.onTap,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return ListTile(
|
||
leading: Icon(icon,
|
||
color: selected
|
||
? Theme.of(context).colorScheme.primary
|
||
: null),
|
||
title: Text(label,
|
||
style: TextStyle(
|
||
fontWeight:
|
||
selected ? FontWeight.w600 : FontWeight.normal,
|
||
color: selected
|
||
? Theme.of(context).colorScheme.primary
|
||
: null,
|
||
)),
|
||
trailing: selected
|
||
? Icon(Icons.check_circle,
|
||
color: Theme.of(context).colorScheme.primary)
|
||
: null,
|
||
onTap: onTap,
|
||
);
|
||
}
|
||
}
|