it0/it0_app/lib/features/profile/presentation/pages/profile_page.dart

1142 lines
40 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 'package:it0_app/l10n/app_localizations.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';
import '../../../notifications/presentation/providers/notification_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 l10n = AppLocalizations.of(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: Text(l10n.navProfile),
),
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: l10n.profileSubscriptionLabel,
trailing: Text(
'Free',
style: TextStyle(color: subtitleColor, fontSize: 14),
),
onTap: () => context.push('/billing'),
),
_SettingsRow(
icon: Icons.card_giftcard_outlined,
iconBg: const Color(0xFF6366F1),
title: l10n.profileReferralLabel,
trailing: Text(
l10n.profileReferralHint,
style: TextStyle(color: subtitleColor, fontSize: 14),
),
onTap: () => context.push('/referral'),
),
_InSiteMessageRow(subtitleColor: subtitleColor),
_SettingsRow(
icon: Icons.tune_outlined,
iconBg: const Color(0xFF8B5CF6),
title: l10n.notificationPreferencesTitle,
onTap: () => context.push('/notifications/preferences'),
),
],
),
const SizedBox(height: 20),
// ── General ─────────────────────────────────────────────────
_SettingsGroup(
cardColor: cardColor,
children: [
_SettingsRow(
icon: Icons.palette_outlined,
iconBg: const Color(0xFF6366F1),
title: l10n.appearanceThemeLabel,
trailing: _ThemeLabel(mode: settings.themeMode),
onTap: () => _showThemePicker(settings.themeMode),
),
_SettingsRow(
icon: Icons.language,
iconBg: const Color(0xFF3B82F6),
title: l10n.languageLabel,
trailing: Text(
_languageDisplayLabel(settings.language, l10n),
style: TextStyle(color: subtitleColor, fontSize: 14),
),
onTap: () => _showLanguagePicker(settings.language),
),
],
),
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: l10n.conversationEngineLabel,
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: l10n.ttsVoiceLabel,
trailing: Text(
_voiceDisplayLabel(context, settings.ttsVoice),
style: TextStyle(color: subtitleColor, fontSize: 14),
),
onTap: () => _showVoicePicker(settings.ttsVoice),
),
_SettingsRow(
icon: Icons.tune,
iconBg: const Color(0xFFF97316),
title: l10n.ttsStyleLabel,
trailing: Text(
_styleDisplayName(context, 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: l10n.pushNotificationsLabel,
value: settings.notificationsEnabled,
onChanged: (v) => ref
.read(settingsProvider.notifier)
.setNotificationsEnabled(v),
),
_SettingsToggleRow(
icon: Icons.volume_up_outlined,
iconBg: const Color(0xFFF59E0B),
title: l10n.soundLabel,
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: l10n.changePasswordLabel,
onTap: _showChangePasswordSheet,
),
],
),
const SizedBox(height: 20),
// ── About / Dev ──────────────────────────────────────────────
_SettingsGroup(
cardColor: cardColor,
children: [
_SettingsRow(
icon: Icons.info_outline,
iconBg: const Color(0xFF64748B),
title: l10n.versionLabel,
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: l10n.tenantLabel,
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: Text(l10n.logoutButton,
style: const TextStyle(fontSize: 16)),
),
),
const SizedBox(height: 48),
],
),
);
}
// ── Profile card ──────────────────────────────────────────────────────────
Widget _buildProfileCard(
AccountProfile profile, Color cardColor, Color? subtitleColor) {
final l10n = AppLocalizations.of(context);
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
: l10n.loadingLabel,
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),
],
),
),
),
);
}
// ── Helpers ───────────────────────────────────────────────────────────────
String _languageDisplayLabel(String lang, AppLocalizations l10n) {
return switch (lang) {
'zh' => l10n.languageZh,
'zh_TW' => l10n.languageZhTW,
'en' => l10n.languageEn,
_ => l10n.languageAuto,
};
}
// ── Pickers ───────────────────────────────────────────────────────────────
void _showThemePicker(ThemeMode current) {
final l10n = AppLocalizations.of(context);
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(l10n.selectThemeTitle,
style: Theme.of(ctx).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
)),
const SizedBox(height: 8),
_ThemeOption(
icon: Icons.dark_mode,
label: l10n.darkModeLabel,
selected: current == ThemeMode.dark,
onTap: () {
ref
.read(settingsProvider.notifier)
.setThemeMode(ThemeMode.dark);
Navigator.pop(ctx);
},
),
_ThemeOption(
icon: Icons.light_mode,
label: l10n.lightModeLabel,
selected: current == ThemeMode.light,
onTap: () {
ref
.read(settingsProvider.notifier)
.setThemeMode(ThemeMode.light);
Navigator.pop(ctx);
},
),
_ThemeOption(
icon: Icons.settings_brightness,
label: l10n.followSystemLabel,
selected: current == ThemeMode.system,
onTap: () {
ref
.read(settingsProvider.notifier)
.setThemeMode(ThemeMode.system);
Navigator.pop(ctx);
},
),
const SizedBox(height: 16),
],
),
),
);
}
void _showLanguagePicker(String current) {
final l10n = AppLocalizations.of(context);
final options = [
('', Icons.phone_android, l10n.languageAuto),
('zh', Icons.language, l10n.languageZh),
('zh_TW', Icons.language, l10n.languageZhTW),
('en', Icons.language, l10n.languageEn),
];
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(l10n.selectLanguageTitle,
style: Theme.of(ctx).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
)),
const SizedBox(height: 8),
for (final (code, icon, label) in options)
_ThemeOption(
icon: icon,
label: label,
selected: current == code,
onTap: () {
ref.read(settingsProvider.notifier).setLanguage(code);
Navigator.pop(ctx);
},
),
const SizedBox(height: 16),
],
),
),
);
}
List<(String, String, String)> _voiceList(BuildContext context) {
final l10n = AppLocalizations.of(context);
return [
('coral', 'Coral', l10n.voiceCoralDesc),
('nova', 'Nova', l10n.voiceNovaDesc),
('sage', 'Sage', l10n.voiceSageDesc),
('shimmer', 'Shimmer', l10n.voiceShimmerDesc),
('ash', 'Ash', l10n.voiceAshDesc),
('echo', 'Echo', l10n.voiceEchoDesc),
('onyx', 'Onyx', l10n.voiceOnyxDesc),
('alloy', 'Alloy', l10n.voiceAlloyDesc),
];
}
String _voiceDisplayLabel(BuildContext context, String voice) {
for (final v in _voiceList(context)) {
if (v.$1 == voice) return '${v.$2} · ${v.$3}';
}
return voice.isNotEmpty
? voice[0].toUpperCase() + voice.substring(1)
: 'Coral';
}
void _showVoicePicker(String current) {
final l10n = AppLocalizations.of(context);
final voices = _voiceList(context);
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(l10n.selectVoiceTitle,
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) {
final l10n = AppLocalizations.of(context);
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(l10n.selectEngineTitle,
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);
},
)),
],
),
),
);
}
List<(String, String)> _stylePresetList(BuildContext context) {
final l10n = AppLocalizations.of(context);
return [
(l10n.styleProfessionalName, '用专业、简洁、干练的语气说话,不拖泥带水。'),
(l10n.styleGentleName, '用温柔、耐心的语气说话,像一个贴心的朋友。'),
(l10n.styleRelaxedName, '用轻松、活泼的语气说话,带一点幽默感。'),
(l10n.styleFormalName, '用严肃、正式的语气说话,像在正式会议中发言。'),
(l10n.styleScifiName, '用科幻电影中AI的语气说话冷静、理性、略带未来感。'),
];
}
String _styleDisplayName(BuildContext context, String style) {
final l10n = AppLocalizations.of(context);
if (style.isEmpty) return l10n.defaultStyleLabel;
for (final p in _stylePresetList(context)) {
if (p.$2 == style) return p.$1;
}
return style.length > 6 ? '${style.substring(0, 6)}...' : style;
}
void _showStylePicker(String current) {
final l10n = AppLocalizations.of(context);
final stylePresets = _stylePresetList(context);
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(l10n.selectStyleTitle,
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: l10n.customStyleLabel,
hintText: l10n.customStyleHint,
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: Text(l10n.resetToDefaultButton),
),
),
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 l10n = AppLocalizations.of(context);
final controller = TextEditingController(text: currentName);
showDialog(
context: context,
builder: (ctx) => AlertDialog(
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Text(l10n.editNameDialogTitle),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(
labelText: l10n.displayNameLabel,
hintText: l10n.displayNameHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12)),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(l10n.cancelButton),
),
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 ? l10n.nameUpdatedMessage : l10n.updateFailedMessage)),
);
}
},
child: Text(l10n.saveButton),
),
],
),
);
}
void _showChangePasswordSheet() {
final l10n = AppLocalizations.of(context);
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(l10n.changePasswordTitle,
style: Theme.of(ctx)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.w600)),
const SizedBox(height: 20),
TextField(
controller: currentCtrl,
obscureText: true,
decoration: InputDecoration(
labelText: l10n.currentPasswordLabel,
prefixIcon: const Icon(Icons.lock_outline),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(height: 14),
TextField(
controller: newCtrl,
obscureText: true,
decoration: InputDecoration(
labelText: l10n.newPasswordLabel,
prefixIcon: const Icon(Icons.lock_reset),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(height: 14),
TextField(
controller: confirmCtrl,
obscureText: true,
decoration: InputDecoration(
labelText: l10n.confirmPasswordLabel,
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
? l10n.passwordChangedMessage
: result.message ?? l10n.changeFailedMessage),
),
);
}
},
child: Text(l10n.confirmChangeButton, style: const TextStyle(fontSize: 16)),
),
],
),
),
);
}
void _confirmLogout() async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Text(l10n.logoutDialogTitle),
content: Text(l10n.logoutConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text(l10n.cancelButton),
),
FilledButton(
style:
FilledButton.styleFrom(backgroundColor: AppColors.error),
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(l10n.logoutConfirmButton),
),
],
),
);
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) {
final row = Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: iconBg,
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, color: Colors.white, size: 18),
),
const SizedBox(width: 16),
Expanded(
child: Text(
title,
style: Theme.of(context).textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
if (trailing != null) ...[const SizedBox(width: 8), trailing!],
if (onTap != null) ...[
const SizedBox(width: 4),
Icon(Icons.chevron_right,
size: 20, color: Theme.of(context).hintColor),
],
],
),
);
return onTap != null
? InkWell(onTap: onTap, child: row)
: row;
}
}
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 l10n = AppLocalizations.of(context);
final label = switch (mode) {
ThemeMode.dark => l10n.darkModeLabel,
ThemeMode.light => l10n.lightModeLabel,
ThemeMode.system => l10n.followSystemLabel,
};
return Text(label,
style: TextStyle(
color: Theme.of(context).hintColor, fontSize: 14));
}
}
/// In-site message row with unread badge.
class _InSiteMessageRow extends ConsumerWidget {
final Color? subtitleColor;
const _InSiteMessageRow({this.subtitleColor});
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context);
final unreadAsync = ref.watch(inSiteUnreadCountProvider);
final unread = unreadAsync.valueOrNull ?? 0;
return ListTile(
leading: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: const Color(0xFFEF4444),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.notifications_outlined,
color: Colors.white, size: 18),
),
title: Text(l10n.profileInSiteMessagesLabel),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (unread > 0)
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFEF4444),
borderRadius: BorderRadius.circular(10),
),
child: Text(
unread > 99 ? '99+' : '$unread',
style: const TextStyle(
fontSize: 11,
color: Colors.white,
fontWeight: FontWeight.bold),
),
)
else
Text(l10n.profileViewMessagesLabel,
style: TextStyle(color: subtitleColor, fontSize: 14)),
const SizedBox(width: 4),
Icon(Icons.chevron_right,
size: 20, color: Theme.of(context).hintColor),
],
),
onTap: () => context.push('/notifications/inbox'),
);
}
}
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,
);
}
}