1070 lines
37 KiB
Dart
1070 lines
37 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
import 'package:it0_app/l10n/app_localizations.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 '../providers/settings_providers.dart';
|
||
|
||
class SettingsPage extends ConsumerStatefulWidget {
|
||
const SettingsPage({super.key});
|
||
|
||
@override
|
||
ConsumerState<SettingsPage> createState() => _SettingsPageState();
|
||
}
|
||
|
||
class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||
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 theme = Theme.of(context);
|
||
final isDark = theme.brightness == Brightness.dark;
|
||
final cardColor = isDark ? AppColors.surface : Colors.white;
|
||
final subtitleColor = isDark ? AppColors.textSecondary : Colors.grey[600];
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(title: Text(AppLocalizations.of(context).settingsPageTitle)),
|
||
body: ListView(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||
children: [
|
||
// ===== Profile Card (large, like WeChat/Feishu) =====
|
||
_buildProfileCard(profile, theme, cardColor, subtitleColor),
|
||
const SizedBox(height: 24),
|
||
|
||
// ===== General Group =====
|
||
_SettingsGroup(
|
||
cardColor: cardColor,
|
||
children: [
|
||
_SettingsRow(
|
||
icon: Icons.palette_outlined,
|
||
iconBg: const Color(0xFF6366F1),
|
||
title: AppLocalizations.of(context).appearanceThemeLabel,
|
||
trailing: _ThemeLabel(mode: settings.themeMode),
|
||
onTap: () => _showThemePicker(settings.themeMode),
|
||
),
|
||
_SettingsRow(
|
||
icon: Icons.language,
|
||
iconBg: const Color(0xFF3B82F6),
|
||
title: AppLocalizations.of(context).languageLabel,
|
||
trailing: Text(
|
||
_languageDisplayLabel(settings.language),
|
||
style: TextStyle(color: subtitleColor, fontSize: 14),
|
||
),
|
||
onTap: () => _showLanguagePicker(settings.language),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 24),
|
||
|
||
// ===== Notifications Group =====
|
||
_SettingsGroup(
|
||
cardColor: cardColor,
|
||
children: [
|
||
_SettingsToggleRow(
|
||
icon: Icons.notifications_outlined,
|
||
iconBg: const Color(0xFFEF4444),
|
||
title: AppLocalizations.of(context).pushNotificationsLabel,
|
||
value: settings.notificationsEnabled,
|
||
onChanged: (v) =>
|
||
ref.read(settingsProvider.notifier).setNotificationsEnabled(v),
|
||
),
|
||
_SettingsToggleRow(
|
||
icon: Icons.volume_up_outlined,
|
||
iconBg: const Color(0xFFF59E0B),
|
||
title: AppLocalizations.of(context).soundLabel,
|
||
value: settings.soundEnabled,
|
||
onChanged: settings.notificationsEnabled
|
||
? (v) =>
|
||
ref.read(settingsProvider.notifier).setSoundEnabled(v)
|
||
: null,
|
||
),
|
||
_SettingsToggleRow(
|
||
icon: Icons.vibration,
|
||
iconBg: const Color(0xFF22C55E),
|
||
title: AppLocalizations.of(context).hapticFeedbackLabel,
|
||
value: settings.hapticFeedback,
|
||
onChanged: settings.notificationsEnabled
|
||
? (v) =>
|
||
ref.read(settingsProvider.notifier).setHapticFeedback(v)
|
||
: null,
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 24),
|
||
|
||
// ===== Voice Group =====
|
||
_SettingsGroup(
|
||
cardColor: cardColor,
|
||
children: [
|
||
_SettingsRow(
|
||
icon: Icons.psychology,
|
||
iconBg: const Color(0xFF7C3AED),
|
||
title: AppLocalizations.of(context).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: AppLocalizations.of(context).ttsVoiceLabel,
|
||
trailing: Text(
|
||
_voiceDisplayLabel(settings.ttsVoice),
|
||
style: TextStyle(color: subtitleColor, fontSize: 14),
|
||
),
|
||
onTap: () => _showVoicePicker(settings.ttsVoice),
|
||
),
|
||
_SettingsRow(
|
||
icon: Icons.tune,
|
||
iconBg: const Color(0xFFF97316),
|
||
title: AppLocalizations.of(context).ttsStyleLabel,
|
||
trailing: Text(
|
||
_styleDisplayName(settings.ttsStyle),
|
||
style: TextStyle(color: subtitleColor, fontSize: 14),
|
||
),
|
||
onTap: () => _showStylePicker(settings.ttsStyle),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 24),
|
||
|
||
// ===== Billing Group =====
|
||
_SettingsGroup(
|
||
cardColor: cardColor,
|
||
children: [
|
||
_SettingsRow(
|
||
icon: Icons.credit_card_outlined,
|
||
iconBg: const Color(0xFF10B981),
|
||
title: AppLocalizations.of(context).subscriptionLabel,
|
||
onTap: () => context.push('/settings/billing'),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 24),
|
||
|
||
// ===== Security Group =====
|
||
_SettingsGroup(
|
||
cardColor: cardColor,
|
||
children: [
|
||
_SettingsRow(
|
||
icon: Icons.lock_outline,
|
||
iconBg: const Color(0xFF8B5CF6),
|
||
title: AppLocalizations.of(context).changePasswordLabel,
|
||
onTap: _showChangePasswordSheet,
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 24),
|
||
|
||
// ===== About Group =====
|
||
_SettingsGroup(
|
||
cardColor: cardColor,
|
||
children: [
|
||
_SettingsRow(
|
||
icon: Icons.info_outline,
|
||
iconBg: const Color(0xFF64748B),
|
||
title: AppLocalizations.of(context).versionLabel,
|
||
trailing: Text(
|
||
_appVersion.isNotEmpty ? _appVersion : 'v1.0.0',
|
||
style: TextStyle(color: subtitleColor, fontSize: 14)),
|
||
),
|
||
_SettingsRow(
|
||
icon: Icons.system_update_outlined,
|
||
iconBg: const Color(0xFF22C55E),
|
||
title: AppLocalizations.of(context).checkUpdateLabel,
|
||
onTap: () => UpdateService().manualCheckUpdate(context),
|
||
),
|
||
if (settings.selectedTenantName != null)
|
||
_SettingsRow(
|
||
icon: Icons.business_outlined,
|
||
iconBg: const Color(0xFF0EA5E9),
|
||
title: AppLocalizations.of(context).tenantLabel,
|
||
trailing: Text(settings.selectedTenantName!,
|
||
style: TextStyle(color: subtitleColor, fontSize: 14)),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 24),
|
||
|
||
// ===== Dev / Debug Group =====
|
||
_SettingsGroup(
|
||
cardColor: cardColor,
|
||
children: [
|
||
_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()),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 32),
|
||
|
||
// ===== Logout =====
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: OutlinedButton(
|
||
onPressed: _confirmLogout,
|
||
style: OutlinedButton.styleFrom(
|
||
foregroundColor: AppColors.error,
|
||
side: const BorderSide(color: AppColors.error, width: 1),
|
||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
child: Text(AppLocalizations.of(context).logoutButton, style: const TextStyle(fontSize: 16)),
|
||
),
|
||
),
|
||
const SizedBox(height: 48),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---- Profile Card (top) ---------------------------------------------------
|
||
|
||
Widget _buildProfileCard(
|
||
AccountProfile profile, ThemeData theme, 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: [
|
||
// Avatar
|
||
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),
|
||
// Name & email
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
profile.displayName.isNotEmpty
|
||
? profile.displayName
|
||
: '加载中...',
|
||
style: theme.textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
fontSize: 18,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
profile.email.isNotEmpty ? profile.email : ' ',
|
||
style: TextStyle(color: subtitleColor, fontSize: 14),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Icon(Icons.chevron_right, color: subtitleColor),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---- Language Picker ------------------------------------------------------
|
||
|
||
String _languageDisplayLabel(String lang) => switch (lang) {
|
||
'zh_TW' => '繁體中文',
|
||
'en' => 'English',
|
||
_ => '简体中文',
|
||
};
|
||
|
||
void _showLanguagePicker(String current) {
|
||
const options = [
|
||
('zh', '简体中文'),
|
||
('zh_TW', '繁體中文'),
|
||
('en', 'English'),
|
||
];
|
||
showModalBottomSheet(
|
||
context: context,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
builder: (ctx) => SafeArea(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
width: 36,
|
||
height: 4,
|
||
margin: const EdgeInsets.symmetric(vertical: 12),
|
||
decoration: BoxDecoration(
|
||
color: Colors.grey.withOpacity(0.4),
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
const Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||
child: Text('选择语言 / 語言 / Language',
|
||
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
|
||
),
|
||
const Divider(height: 1),
|
||
...options.map((o) => ListTile(
|
||
title: Text(o.$2),
|
||
trailing: o.$1 == current
|
||
? const Icon(Icons.check, color: Colors.blue)
|
||
: null,
|
||
onTap: () {
|
||
Navigator.pop(ctx);
|
||
ref.read(settingsProvider.notifier).setLanguage(o.$1);
|
||
},
|
||
)),
|
||
const SizedBox(height: 8),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---- Theme Picker ---------------------------------------------------------
|
||
|
||
void _showThemePicker(ThemeMode current) {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
builder: (ctx) {
|
||
return SafeArea(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const SizedBox(height: 12),
|
||
Container(
|
||
width: 40,
|
||
height: 4,
|
||
decoration: BoxDecoration(
|
||
color: Colors.grey[400],
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text(AppLocalizations.of(context).selectThemeTitle,
|
||
style: Theme.of(ctx).textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
)),
|
||
const SizedBox(height: 8),
|
||
_ThemeOption(
|
||
icon: Icons.dark_mode,
|
||
label: AppLocalizations.of(context).darkModeLabel,
|
||
selected: current == ThemeMode.dark,
|
||
onTap: () {
|
||
ref.read(settingsProvider.notifier).setThemeMode(ThemeMode.dark);
|
||
Navigator.pop(ctx);
|
||
},
|
||
),
|
||
_ThemeOption(
|
||
icon: Icons.light_mode,
|
||
label: AppLocalizations.of(context).lightModeLabel,
|
||
selected: current == ThemeMode.light,
|
||
onTap: () {
|
||
ref.read(settingsProvider.notifier).setThemeMode(ThemeMode.light);
|
||
Navigator.pop(ctx);
|
||
},
|
||
),
|
||
_ThemeOption(
|
||
icon: Icons.settings_brightness,
|
||
label: AppLocalizations.of(context).followSystemLabel,
|
||
selected: current == ThemeMode.system,
|
||
onTap: () {
|
||
ref.read(settingsProvider.notifier).setThemeMode(ThemeMode.system);
|
||
Navigator.pop(ctx);
|
||
},
|
||
),
|
||
const SizedBox(height: 16),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
// ---- Voice Picker ----------------------------------------------------------
|
||
|
||
List<(String, String, String)> _voices(BuildContext context) {
|
||
final l = AppLocalizations.of(context);
|
||
return [
|
||
('coral', 'Coral', l.voiceCoralDesc),
|
||
('nova', 'Nova', l.voiceNovaDesc),
|
||
('sage', 'Sage', l.voiceSageDesc),
|
||
('shimmer', 'Shimmer', l.voiceShimmerDesc),
|
||
('marin', 'Marin', l.voiceMarinDesc),
|
||
('ash', 'Ash', l.voiceAshDesc),
|
||
('echo', 'Echo', l.voiceEchoDesc),
|
||
('onyx', 'Onyx', l.voiceOnyxDesc),
|
||
('verse', 'Verse', l.voiceVerseDesc),
|
||
('ballad', 'Ballad', l.voiceBalladDesc),
|
||
('cedar', 'Cedar', l.voiceCedarDesc),
|
||
('alloy', 'Alloy', l.voiceAlloyDesc),
|
||
('fable', 'Fable', l.voiceFableDesc),
|
||
];
|
||
}
|
||
|
||
void _showEngineTypePicker(String current) {
|
||
final l = AppLocalizations.of(context);
|
||
final engines = [
|
||
('claude_agent_sdk', 'Agent SDK', l.agentSdkDesc),
|
||
('claude_api', 'Claude API', l.claudeApiDesc),
|
||
];
|
||
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: [
|
||
Container(
|
||
width: 36,
|
||
height: 4,
|
||
decoration: BoxDecoration(
|
||
color: Colors.grey[400],
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text(AppLocalizations.of(context).selectEngineTitle,
|
||
style: Theme.of(ctx).textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
)),
|
||
const SizedBox(height: 12),
|
||
...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);
|
||
},
|
||
)),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _showVoicePicker(String current) {
|
||
showModalBottomSheet(
|
||
context: context,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
builder: (ctx) {
|
||
return SafeArea(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const SizedBox(height: 12),
|
||
Container(
|
||
width: 40,
|
||
height: 4,
|
||
decoration: BoxDecoration(
|
||
color: Colors.grey[400],
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text(AppLocalizations.of(context).selectVoiceTitle,
|
||
style: Theme.of(ctx).textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
)),
|
||
const SizedBox(height: 8),
|
||
Flexible(
|
||
child: ListView(
|
||
shrinkWrap: true,
|
||
children: _voices(context)
|
||
.map((v) => ListTile(
|
||
leading: Icon(
|
||
Icons.record_voice_over,
|
||
color: current == v.$1
|
||
? Theme.of(ctx).colorScheme.primary
|
||
: null,
|
||
),
|
||
title: Text(
|
||
v.$2,
|
||
style: TextStyle(
|
||
fontWeight: current == v.$1
|
||
? FontWeight.w600
|
||
: FontWeight.normal,
|
||
color: current == v.$1
|
||
? Theme.of(ctx).colorScheme.primary
|
||
: null,
|
||
),
|
||
),
|
||
subtitle: Text(v.$3,
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: Theme.of(ctx).hintColor)),
|
||
trailing: current == v.$1
|
||
? Icon(Icons.check_circle,
|
||
color:
|
||
Theme.of(ctx).colorScheme.primary)
|
||
: null,
|
||
onTap: () {
|
||
ref
|
||
.read(settingsProvider.notifier)
|
||
.setTtsVoice(v.$1);
|
||
Navigator.pop(ctx);
|
||
},
|
||
))
|
||
.toList(),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
// ---- Style Picker ---------------------------------------------------------
|
||
|
||
String _voiceDisplayLabel(String voice) {
|
||
for (final v in _voices(context)) {
|
||
if (v.$1 == voice) return '${v.$2} · ${v.$3}';
|
||
}
|
||
return voice[0].toUpperCase() + voice.substring(1);
|
||
}
|
||
|
||
List<(String, String)> _stylePresets(BuildContext context) {
|
||
final l = AppLocalizations.of(context);
|
||
return [
|
||
(l.styleProfessionalName, '用专业、简洁、干练的语气说话,不拖泥带水。'),
|
||
(l.styleGentleName, '用温柔、耐心的语气说话,像一个贴心的朋友。'),
|
||
(l.styleRelaxedName, '用轻松、活泼的语气说话,带一点幽默感。'),
|
||
(l.styleFormalName, '用严肃、正式的语气说话,像在正式会议中发言。'),
|
||
(l.styleScifiName, '用科幻电影中AI的语气说话,冷静、理性、略带未来感。'),
|
||
];
|
||
}
|
||
|
||
String _styleDisplayName(String style) {
|
||
if (style.isEmpty) return AppLocalizations.of(context).defaultStyleLabel;
|
||
for (final p in _stylePresets(context)) {
|
||
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(context).any((p) => p.$2 == current) ? '' : current,
|
||
);
|
||
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
builder: (ctx) {
|
||
return Padding(
|
||
padding: EdgeInsets.fromLTRB(
|
||
24, 24, 24, MediaQuery.of(ctx).viewInsets.bottom + 24),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
width: 40,
|
||
height: 4,
|
||
decoration: BoxDecoration(
|
||
color: Colors.grey[400],
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text(AppLocalizations.of(context).selectStyleTitle,
|
||
style: Theme.of(ctx).textTheme.titleMedium?.copyWith(
|
||
fontWeight: FontWeight.w600,
|
||
)),
|
||
const SizedBox(height: 16),
|
||
Wrap(
|
||
spacing: 8,
|
||
runSpacing: 8,
|
||
children: _stylePresets(context)
|
||
.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: 16),
|
||
TextField(
|
||
controller: controller,
|
||
decoration: InputDecoration(
|
||
labelText: AppLocalizations.of(context).customStyleLabel,
|
||
hintText: AppLocalizations.of(context).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(AppLocalizations.of(context).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: Text(AppLocalizations.of(context).confirmButton),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
// ---- Edit Name Dialog -----------------------------------------------------
|
||
|
||
void _showEditNameDialog(String currentName) {
|
||
final controller = TextEditingController(text: currentName);
|
||
showDialog(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||
title: Text(AppLocalizations.of(context).editNameDialogTitle),
|
||
content: TextField(
|
||
controller: controller,
|
||
autofocus: true,
|
||
decoration: InputDecoration(
|
||
labelText: AppLocalizations.of(context).displayNameLabel,
|
||
hintText: AppLocalizations.of(context).displayNameHint,
|
||
border:
|
||
OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(ctx).pop(),
|
||
child: Text(AppLocalizations.of(context).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 ? AppLocalizations.of(context).nameUpdatedMessage : AppLocalizations.of(context).updateFailedMessage)),
|
||
);
|
||
}
|
||
},
|
||
child: Text(AppLocalizations.of(context).saveButton),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---- Change Password Sheet ------------------------------------------------
|
||
|
||
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: [
|
||
Center(
|
||
child: Container(
|
||
width: 40,
|
||
height: 4,
|
||
decoration: BoxDecoration(
|
||
color: Colors.grey[400],
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text(AppLocalizations.of(context).changePasswordTitle,
|
||
style: Theme.of(ctx)
|
||
.textTheme
|
||
.titleLarge
|
||
?.copyWith(fontWeight: FontWeight.w600)),
|
||
const SizedBox(height: 20),
|
||
TextField(
|
||
controller: currentCtrl,
|
||
obscureText: true,
|
||
decoration: InputDecoration(
|
||
labelText: AppLocalizations.of(context).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: AppLocalizations.of(context).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: AppLocalizations.of(context).confirmPasswordLabel,
|
||
prefixIcon: const Icon(Icons.lock_reset),
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(12)),
|
||
),
|
||
),
|
||
const SizedBox(height: 24),
|
||
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
|
||
? AppLocalizations.of(context).passwordChangedMessage
|
||
: result.message ?? AppLocalizations.of(context).changeFailedMessage),
|
||
),
|
||
);
|
||
}
|
||
},
|
||
child: Text(AppLocalizations.of(context).confirmChangeButton, style: const TextStyle(fontSize: 16)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ---- Confirm Logout -------------------------------------------------------
|
||
|
||
void _confirmLogout() async {
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||
title: Text(AppLocalizations.of(context).logoutDialogTitle),
|
||
content: Text(AppLocalizations.of(context).logoutConfirmMessage),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(ctx).pop(false),
|
||
child: Text(AppLocalizations.of(context).cancelButton),
|
||
),
|
||
FilledButton(
|
||
style: FilledButton.styleFrom(backgroundColor: AppColors.error),
|
||
onPressed: () => Navigator.of(ctx).pop(true),
|
||
child: Text(AppLocalizations.of(context).logoutConfirmButton),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (confirmed == true) {
|
||
await ref.read(authStateProvider.notifier).logout();
|
||
if (mounted) context.go('/login');
|
||
}
|
||
}
|
||
}
|
||
|
||
// =============================================================================
|
||
// Reusable settings widgets
|
||
// =============================================================================
|
||
|
||
/// A group of settings rows wrapped in a rounded card.
|
||
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),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A single settings row with icon, title, optional trailing, and tap action.
|
||
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,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A settings row with a switch toggle.
|
||
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,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Label showing the current theme mode.
|
||
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));
|
||
}
|
||
}
|
||
|
||
/// Theme option in the bottom sheet picker.
|
||
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,
|
||
);
|
||
}
|
||
}
|