it0/it0_app/lib/features/settings/presentation/pages/settings_page.dart

1004 lines
34 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 '../../../../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: const Text('设置')),
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: '外观主题',
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: 24),
// ===== Notifications Group =====
_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,
),
_SettingsToggleRow(
icon: Icons.vibration,
iconBg: const Color(0xFF22C55E),
title: '震动反馈',
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: '对话引擎',
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: 24),
// ===== Billing Group =====
_SettingsGroup(
cardColor: cardColor,
children: [
_SettingsRow(
icon: Icons.credit_card_outlined,
iconBg: const Color(0xFF10B981),
title: '订阅与用量',
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: '修改密码',
onTap: _showChangePasswordSheet,
),
],
),
const SizedBox(height: 24),
// ===== About Group =====
_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)),
),
_SettingsRow(
icon: Icons.system_update_outlined,
iconBg: const Color(0xFF22C55E),
title: '检查更新',
onTap: () => UpdateService().manualCheckUpdate(context),
),
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),
// ===== 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: const Text('退出登录', style: 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),
],
),
),
),
);
}
// ---- 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('选择主题',
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),
],
),
);
},
);
}
// ---- Voice Picker ----------------------------------------------------------
static const _voices = [
('coral', 'Coral', '女 · 温暖'),
('nova', 'Nova', '女 · 活泼'),
('sage', 'Sage', '女 · 知性'),
('shimmer', 'Shimmer', '女 · 柔和'),
('marin', 'Marin', '女 · 清澈'),
('ash', 'Ash', '男 · 沉稳'),
('echo', 'Echo', '男 · 清朗'),
('onyx', 'Onyx', '男 · 低沉'),
('verse', 'Verse', '男 · 磁性'),
('ballad', 'Ballad', '男 · 浑厚'),
('cedar', 'Cedar', '男 · 自然'),
('alloy', 'Alloy', '中性'),
('fable', 'Fable', '中性 · 叙事'),
];
void _showEngineTypePicker(String current) {
final engines = [
('claude_agent_sdk', 'Agent SDK', '支持工具审批、技能注入、会话恢复'),
('claude_api', 'Claude API', '直连 API响应更快'),
];
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('选择对话引擎',
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('选择语音音色',
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
? 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) {
if (v.$1 == voice) return '${v.$2} · ${v.$3}';
}
return voice[0].toUpperCase() + voice.substring(1);
}
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) {
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('选择语音风格',
style: Theme.of(ctx).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
)),
const SizedBox(height: 16),
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: 16),
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('确认'),
),
),
],
),
],
),
);
},
);
}
// ---- 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: const Text('修改显示名称'),
content: TextField(
controller: controller,
autofocus: true,
decoration: InputDecoration(
labelText: '显示名称',
hintText: '输入新的显示名称',
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('保存'),
),
],
),
);
}
// ---- 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('修改密码',
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: 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
? '密码已修改'
: result.message ?? '修改失败'),
),
);
}
},
child: const Text('确认修改', style: 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: 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');
}
}
}
// =============================================================================
// 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,
);
}
}