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 createState() => _ProfilePageState(); } class _ProfilePageState extends ConsumerState { String _appVersion = ''; @override void initState() { super.initState(); Future.microtask( () => ref.read(accountProfileProvider.notifier).loadProfile()); _loadVersion(); } Future _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( 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 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? 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, ); } }