Files
app/lib/presentation/pages/settings/app_settings_page.dart
T
2026-05-27 19:19:11 +08:00

972 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 'dart:io';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:open_file/open_file.dart';
import 'package:logger/logger.dart' show Level;
import 'package:provider/provider.dart';
import '../../../core/constants/storage_keys.dart';
import '../../../core/utils/app_logger.dart';
import '../../../data/models/cache_settings_model.dart';
import '../../../services/cache_manager_service.dart';
import '../../../services/download_service.dart';
import '../../../services/storage_service.dart';
import '../../providers/download_manager_provider.dart';
import '../../providers/theme_provider.dart';
import '../../providers/user_setting_provider.dart';
import '../../widgets/glassmorphism_container.dart';
import '../../widgets/toast_helper.dart';
import '../../widgets/desktop_constrained.dart';
import 'log_viewer_page.dart';
/// 应用设置页(缓存、主题、语言)
class AppSettingsPage extends StatefulWidget {
const AppSettingsPage({super.key});
@override
State<AppSettingsPage> createState() => _AppSettingsPageState();
}
class _AppSettingsPageState extends State<AppSettingsPage> {
CacheSettingsModel _cacheSettings = CacheSettingsModel();
bool _isLoading = true;
int? _currentCacheSize;
bool _isCleaning = false;
bool _wifiOnlyEnabled = false;
int _downloadRetries = 3;
int _taskRetentionDays = 7;
bool _gravatarMirrorEnabled = true;
String _gravatarMirrorUrl = 'https://weavatar.com';
String _logFilePath = '';
int? _logFileSize;
String _cacheDirPath = '';
Level _logLevel = Level.info;
@override
void initState() {
super.initState();
_loadCacheSettings();
_loadWifiOnlySetting();
_loadGravatarMirrorSetting();
_loadLogInfo();
_loadLogLevel();
}
Future<void> _loadCacheSettings() async {
try {
final service = CacheManagerService.instance;
await service.initialize();
final settings = service.settings;
if (mounted) {
setState(() {
_cacheSettings = settings;
_isLoading = false;
});
}
Future.delayed(const Duration(milliseconds: 100), () async {
final cacheSize = await service.getCacheSize();
final cacheDir = await service.getCacheDir();
if (mounted) {
setState(() {
_currentCacheSize = cacheSize;
_cacheDirPath = cacheDir.path;
});
}
});
} catch (e) {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _saveCacheSettings() async {
final service = CacheManagerService.instance;
await service.saveSettings(_cacheSettings);
if (mounted) ToastHelper.success('设置已保存');
}
Future<void> _loadLogInfo() async {
final path = await AppLogger.logFilePath;
final size = await AppLogger.logFileSize;
if (mounted) {
setState(() {
_logFilePath = path;
_logFileSize = size;
});
}
}
Future<void> _loadWifiOnlySetting() async {
final enabled = await StorageService.instance
.getBool(StorageKeys.downloadWifiOnly) ??
false;
final retries = await StorageService.instance
.getInt(StorageKeys.downloadRetries) ??
3;
final retentionDays = await StorageService.instance
.getInt(StorageKeys.taskRetentionDays) ??
7;
if (mounted) {
setState(() {
_wifiOnlyEnabled = enabled;
_downloadRetries = retries;
_taskRetentionDays = retentionDays;
});
}
}
Future<void> _loadGravatarMirrorSetting() async {
final enabled = await StorageService.instance
.getBool(StorageKeys.gravatarMirrorEnabled) ??
true;
final url = await StorageService.instance
.getString(StorageKeys.gravatarMirrorUrl) ??
'https://weavatar.com';
if (mounted) {
setState(() {
_gravatarMirrorEnabled = enabled;
_gravatarMirrorUrl = url;
});
}
}
Future<void> _loadLogLevel() async {
final saved = await StorageService.instance.getString(StorageKeys.logLevel);
if (saved != null && mounted) {
setState(() {
_logLevel = _parseLogLevel(saved);
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('应用设置')),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: DesktopConstrained(
child: ListView(
children: [
_buildSection(
title: '外观',
children: [
ListTile(
leading: const Icon(Icons.dark_mode_outlined),
title: const Text('深色模式'),
subtitle: Text(_themeModeLabel(context)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showThemeModeDialog(context),
),
ListTile(
leading: const Icon(Icons.palette_outlined),
title: const Text('主题色'),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
backgroundColor: context.watch<ThemeProvider>().seedColor,
radius: 10,
),
const SizedBox(width: 8),
const Icon(Icons.chevron_right),
],
),
onTap: () => _showThemeColorPicker(context),
),
ListTile(
leading: const Icon(Icons.language),
title: const Text('语言'),
subtitle: const Text('跟随系统'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showLanguageDialog(context),
),
],
),
_buildSection(
title: 'Gravatar 镜像',
children: [
SwitchListTile(
title: const Text('启用 Gravatar 镜像'),
subtitle: const Text('国内网络建议启用,加速 Gravatar 头像加载'),
value: _gravatarMirrorEnabled,
onChanged: (value) async {
setState(() => _gravatarMirrorEnabled = value);
await StorageService.instance
.setBool(StorageKeys.gravatarMirrorEnabled, value);
},
),
if (_gravatarMirrorEnabled)
ListTile(
leading: const Icon(Icons.dns_outlined),
title: const Text('镜像地址'),
subtitle: Text(_gravatarMirrorUrl),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showGravatarMirrorUrlDialog(context),
),
],
),
_buildSection(
title: '下载设置',
children: [
SwitchListTile(
title: const Text('仅WiFi下载'),
subtitle: const Text('非WiFi环境下暂停下载,等待WiFi后自动恢复'),
value: _wifiOnlyEnabled,
onChanged: (value) async {
setState(() => _wifiOnlyEnabled = value);
await StorageService.instance
.setBool(StorageKeys.downloadWifiOnly, value);
if (mounted) {
if (!context.mounted) return;
context
.read<DownloadManagerProvider>()
.setWifiOnlyEnabled(value);
}
},
),
ListTile(
title: const Text('重试次数'),
subtitle: Text(_downloadRetries == 0 ? '不重试' : '失败后自动重试 $_downloadRetries'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showRetriesDialog(context),
),
ListTile(
title: const Text('任务记录保留'),
subtitle: Text(_taskRetentionDays == -1 ? '永久保留' : '保留 $_taskRetentionDays'),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showRetentionDaysDialog(context),
),
],
),
_buildSection(
title: '缓存设置',
children: [
ListTile(
title: const Text('最大缓存大小'),
subtitle: Text(_cacheSettings.maxCacheSizeReadable),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showMaxCacheSizeDialog(context),
),
ListTile(
title: const Text('缓存过期时间'),
subtitle: Text(_cacheSettings.cacheExpireDurationReadable),
trailing: const Icon(Icons.chevron_right),
onTap: () => _showCacheExpireDurationDialog(context),
),
SwitchListTile(
title: const Text('自动清理最旧文件'),
subtitle: const Text('当超过最大缓存大小时自动清理'),
value: _cacheSettings.autoCleanOldFiles,
onChanged: (value) {
setState(() {
_cacheSettings = _cacheSettings.copyWith(autoCleanOldFiles: value);
});
_saveCacheSettings();
},
),
],
),
_buildSection(
title: '缓存信息',
children: [
if (_cacheDirPath.isNotEmpty)
ListTile(
title: const Text('缓存目录'),
subtitle: Text(
_cacheDirPath,
style: const TextStyle(fontSize: 11),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
trailing: (Platform.isWindows || Platform.isLinux)
? const Icon(Icons.open_in_new, size: 18)
: null,
onTap: (Platform.isWindows || Platform.isLinux)
? _openCacheDir
: null,
),
ListTile(
title: const Text('当前缓存大小'),
subtitle: Text(_formatBytes(_currentCacheSize)),
),
ListTile(
title: const Text('清空缓存'),
leading: const Icon(Icons.delete_outline, color: Colors.red),
trailing: _isCleaning
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.chevron_right),
onTap: _isCleaning ? null : _clearCache,
),
],
),
_buildSection(
title: '日志管理',
children: [
ListTile(
leading: const Icon(Icons.tune),
title: const Text('日志级别'),
subtitle: Text(_logLevelLabel(_logLevel)),
trailing: const Icon(Icons.chevron_right),
onTap: () => _pickLogLevel(),
),
ListTile(
title: const Text('日志文件路径'),
subtitle: Text(
_logFilePath,
style: const TextStyle(fontSize: 11),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
ListTile(
title: const Text('日志文件大小'),
subtitle: Text(_formatBytes(_logFileSize)),
),
if (!Platform.isAndroid)
ListTile(
title: const Text('打开日志目录'),
leading: const Icon(Icons.folder_open),
trailing: const Icon(Icons.chevron_right),
onTap: _openLogFolder,
),
ListTile(
title: const Text('导出日志'),
leading: const Icon(Icons.file_download_outlined),
subtitle: const Text('导出到 Download 目录'),
trailing: const Icon(Icons.chevron_right),
onTap: _exportLog,
),
ListTile(
title: const Text('预览日志'),
leading: const Icon(Icons.visibility_outlined),
trailing: const Icon(Icons.chevron_right),
onTap: _previewLog,
),
ListTile(
title: const Text('清空日志'),
leading: const Icon(Icons.delete_outline, color: Colors.red),
trailing: const Icon(Icons.chevron_right),
onTap: _clearLog,
),
],
),
],
),
),
);
}
Widget _buildSection({required String title, required List<Widget> children}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
),
Card(
margin: const EdgeInsets.symmetric(horizontal: 16),
child: Column(children: children),
),
],
),
);
}
Future<void> _showThemeColorPicker(BuildContext context) async {
final colors = [
('默认蓝', Colors.blue),
('靛蓝', Colors.indigo),
('紫色', Colors.purple),
('粉红', Colors.pink),
('红色', Colors.red),
('橙色', Colors.orange),
('琥珀', Colors.amber),
('绿色', Colors.green),
('青色', Colors.teal),
('青蓝', Colors.cyan),
];
final currentColor = context.read<ThemeProvider>().seedColor;
final selected = await showDialog<Color>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('选择主题色'),
children: colors.map((c) {
final isSelected = currentColor.toARGB32() == c.$2.toARGB32();
return SimpleDialogOption(
onPressed: () => Navigator.of(ctx).pop(c.$2),
child: Row(
children: [
CircleAvatar(backgroundColor: c.$2, radius: 14),
const SizedBox(width: 12),
Expanded(child: Text(c.$1)),
if (isSelected)
Icon(Icons.check, color: Theme.of(ctx).colorScheme.primary),
],
),
);
}).toList(),
),
);
if (selected == null || !mounted) return;
if (!context.mounted) return;
// 立即更新本地主题
await context.read<ThemeProvider>().setSeedColor(selected);
if (!context.mounted) return;
// 同步到服务端
final hex = '#${selected.toARGB32().toRadixString(16).padLeft(8, '0').substring(2)}';
final success = await context.read<UserSettingProvider>().updatePreferredTheme(hex);
if (!mounted) return;
if (success) {
ToastHelper.success('主题色已更新');
} else {
ToastHelper.failure('同步主题色到服务端失败');
}
}
String _themeModeLabel(BuildContext context) {
final mode = context.watch<ThemeProvider>().themeMode;
return switch (mode) {
AppThemeMode.light => '浅色',
AppThemeMode.dark => '深色',
AppThemeMode.system => '跟随系统',
};
}
Future<void> _showThemeModeDialog(BuildContext context) async {
final currentMode = context.read<ThemeProvider>().themeMode;
final options = [
(AppThemeMode.system, '跟随系统', Icons.brightness_auto),
(AppThemeMode.light, '浅色', Icons.light_mode),
(AppThemeMode.dark, '深色', Icons.dark_mode),
];
final selected = await showDialog<AppThemeMode>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('深色模式'),
children: options.map((opt) {
final isSelected = currentMode == opt.$1;
return SimpleDialogOption(
onPressed: () => Navigator.of(ctx).pop(opt.$1),
child: Row(
children: [
Icon(opt.$3),
const SizedBox(width: 12),
Expanded(child: Text(opt.$2)),
if (isSelected)
Icon(Icons.check, color: Theme.of(ctx).colorScheme.primary),
],
),
);
}).toList(),
),
);
if (selected == null || !mounted) return;
if (!context.mounted) return;
await context.read<ThemeProvider>().setThemeMode(selected);
}
Future<void> _showLanguageDialog(BuildContext context) async {
final languages = [
('zh-CN', '简体中文'),
('zh-TW', '繁體中文'),
('en-US', 'English'),
('ja-JP', '日本語'),
];
final selected = await showDialog<String>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('选择语言'),
children: languages.map((l) {
return SimpleDialogOption(
onPressed: () => Navigator.of(ctx).pop(l.$1),
child: Text(l.$2),
);
}).toList(),
),
);
if (selected == null || !mounted) return;
if (!context.mounted) return;
final success = await context.read<UserSettingProvider>().updateLanguage(selected);
if (!mounted) return;
if (success) {
ToastHelper.success('语言偏好已保存');
} else {
ToastHelper.failure('更新语言失败');
}
}
Future<void> _showMaxCacheSizeDialog(BuildContext context) async {
final availableSizes = CacheSettingsModel.availableSizes;
final currentValue = _cacheSettings.maxCacheSize ~/ (1024 * 1024);
final selected = await _showGlassOptionDialog<int>(
context,
title: '最大缓存大小',
icon: LucideIcons.hardDrive,
options: availableSizes.map((size) => (size, '$size MB', currentValue == size)).toList(),
);
if (selected != null && mounted) {
setState(() => _cacheSettings = CacheSettingsModel.fromMB(selected));
_saveCacheSettings();
}
}
Future<void> _showCacheExpireDurationDialog(BuildContext context) async {
final availableDurations = CacheSettingsModel.availableDurations;
final currentValue = _cacheSettings.cacheExpireDuration ~/ (24 * 60 * 60 * 1000);
final selected = await _showGlassOptionDialog<int>(
context,
title: '缓存过期时间',
icon: LucideIcons.timer,
options: availableDurations.map((days) => (days, '$days', currentValue == days)).toList(),
);
if (selected != null && mounted) {
setState(() => _cacheSettings = CacheSettingsModel.fromDays(selected));
_saveCacheSettings();
}
}
Future<void> _showRetriesDialog(BuildContext context) async {
final retriesOptions = [0, 1, 2, 3, 5, 10];
final selected = await _showGlassOptionDialog<int>(
context,
title: '重试次数',
icon: LucideIcons.refreshCw,
subtitle: '下载失败后自动重试的次数',
options: retriesOptions.map((retries) =>
(retries, retries == 0 ? '不重试' : '$retries', _downloadRetries == retries)).toList(),
);
if (selected != null && mounted) {
setState(() => _downloadRetries = selected);
await StorageService.instance
.setInt(StorageKeys.downloadRetries, selected);
}
}
Future<void> _showRetentionDaysDialog(BuildContext context) async {
final options = [
(7, '7 天'),
(15, '15 天'),
(30, '30 天'),
(-1, '永久保留'),
];
final selected = await _showGlassOptionDialog<int>(
context,
title: '任务记录保留时间',
icon: LucideIcons.clock,
subtitle: '超过保留时间的已完成任务将被自动清理',
options: options.map((opt) => (opt.$1, opt.$2, _taskRetentionDays == opt.$1)).toList(),
);
if (selected != null && mounted) {
setState(() => _taskRetentionDays = selected);
await StorageService.instance
.setInt(StorageKeys.taskRetentionDays, selected);
}
}
/// 通用毛玻璃选项选择对话框
Future<T?> _showGlassOptionDialog<T>(
BuildContext context, {
required String title,
required IconData icon,
String? subtitle,
required List<(T, String, bool)> options,
}) {
return showGeneralDialog<T>(
context: context,
barrierDismissible: true,
barrierLabel: title,
barrierColor: Colors.black38,
transitionDuration: const Duration(milliseconds: 250),
transitionBuilder: (context, animation, secondaryAnimation, child) {
final scaleAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
).drive(Tween(begin: 0.92, end: 1.0));
final fadeAnim = CurvedAnimation(
parent: animation,
curve: Curves.easeOut,
).drive(Tween(begin: 0.0, end: 1.0));
return ScaleTransition(
scale: scaleAnim,
child: FadeTransition(opacity: fadeAnim, child: child),
);
},
pageBuilder: (context, animation, secondaryAnimation) {
final screenWidth = MediaQuery.of(context).size.width;
final dialogWidth = screenWidth >= 600 ? 380.0 : screenWidth - 48.0;
final colorScheme = Theme.of(context).colorScheme;
final theme = Theme.of(context);
return Center(
child: SizedBox(
width: dialogWidth,
child: GlassmorphismContainer(
borderRadius: 16,
sigmaX: 20,
sigmaY: 20,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Material(
color: Colors.transparent,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 8, 12),
child: Row(
children: [
Icon(icon, size: 20, color: colorScheme.primary),
const SizedBox(width: 10),
Expanded(
child: Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
IconButton(
icon: const Icon(LucideIcons.x, size: 20),
onPressed: () => Navigator.of(context).pop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
),
],
),
),
if (subtitle != null)
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
subtitle,
style: TextStyle(fontSize: 13, color: theme.hintColor),
),
),
),
const Divider(height: 1),
// Options
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.5,
),
child: ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: options.length,
itemBuilder: (context, index) {
final (value, label, isSelected) = options[index];
return ListTile(
leading: Icon(
isSelected
? LucideIcons.checkCircle2
: LucideIcons.circle,
size: 20,
color: isSelected
? colorScheme.primary
: theme.hintColor,
),
title: Text(label),
selected: isSelected,
onTap: () => Navigator.of(context).pop(value),
);
},
),
),
],
),
),
),
),
),
);
},
);
}
Future<void> _showGravatarMirrorUrlDialog(BuildContext context) async {
final controller = TextEditingController(text: _gravatarMirrorUrl);
final presets = [
'https://weavatar.com',
'https://gravatar.loli.net',
'https://cdn.v2ex.com/gravatar',
];
final selected = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Gravatar 镜像地址'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: controller,
decoration: const InputDecoration(
labelText: '镜像地址',
hintText: '例如: https://weavatar.com',
isDense: true,
),
),
const SizedBox(height: 16),
const Text('常用镜像:', style: TextStyle(fontSize: 12, color: Colors.grey)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: presets.map((url) => ActionChip(
label: Text(url, style: const TextStyle(fontSize: 11)),
onPressed: () => controller.text = url,
)).toList(),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(controller.text.trim()),
child: const Text('确定'),
),
],
),
);
if (selected != null && selected.isNotEmpty && mounted) {
var url = selected;
if (url.endsWith('/')) url = url.substring(0, url.length - 1);
setState(() => _gravatarMirrorUrl = url);
await StorageService.instance
.setString(StorageKeys.gravatarMirrorUrl, url);
}
}
Future<void> _clearCache() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('清空缓存'),
content: const Text('确定要清空所有缓存吗?'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error),
child: const Text('清空'),
),
],
),
);
if (confirmed == true && mounted) {
setState(() => _isCleaning = true);
try {
final service = CacheManagerService.instance;
await service.clearCache();
final newCacheSize = await service.getCacheSize();
if (mounted) {
setState(() {
_currentCacheSize = newCacheSize;
_isCleaning = false;
});
ToastHelper.success('缓存已清空');
}
} catch (e) {
if (mounted) {
setState(() => _isCleaning = false);
ToastHelper.failure('清空缓存失败: $e');
}
}
}
}
String _formatBytes(int? bytes) {
if (bytes == null) return '未知';
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
Future<void> _openLogFolder() async {
try {
final path = _logFilePath;
if (path.isEmpty) {
ToastHelper.error('日志文件路径未获取');
return;
}
final dir = File(path).parent.path;
final result = await OpenFile.open(dir);
if (result.type != ResultType.done) {
if (mounted) ToastHelper.error('无法打开目录:${result.message}');
}
} catch (e) {
if (mounted) ToastHelper.error('打开目录失败:$e');
}
}
Future<void> _openCacheDir() async {
try {
if (_cacheDirPath.isEmpty) {
ToastHelper.error('缓存目录路径未获取');
return;
}
final result = await OpenFile.open(_cacheDirPath);
if (result.type != ResultType.done) {
if (mounted) ToastHelper.error('无法打开目录:${result.message}');
}
} catch (e) {
if (mounted) ToastHelper.error('打开目录失败:$e');
}
}
Future<void> _exportLog() async {
try {
final dir = await DownloadService().getDownloadDirectory();
final destPath = await AppLogger.exportLog(dir.path);
if (destPath != null && mounted) {
ToastHelper.success('日志已导出到:$destPath');
} else if (mounted) {
ToastHelper.error('导出失败:日志文件不存在');
}
} catch (e) {
if (mounted) ToastHelper.error('导出日志失败:$e');
}
}
Future<void> _previewLog() async {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const LogViewerPage()),
);
}
Future<void> _clearLog() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('清空日志'),
content: const Text('确定要清空日志文件内容吗?此操作不可恢复。'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error),
child: const Text('清空'),
),
],
),
);
if (confirmed == true && mounted) {
await AppLogger.clearLog();
await _loadLogInfo();
if (mounted) ToastHelper.success('日志已清空');
}
}
String _logLevelLabel(Level level) {
return switch (level) {
Level.error => 'Error — 仅错误',
Level.warning => 'Warning — 错误 + 警告',
Level.info => 'Info — 常规信息',
Level.debug => 'Debug — 调试信息(含FFI交互)',
Level.trace => 'Trace — 全量追踪',
_ => level.name,
};
}
Level _parseLogLevel(String level) {
return switch (level) {
'error' => Level.error,
'warning' => Level.warning,
'info' => Level.info,
'debug' => Level.debug,
'trace' => Level.trace,
_ => Level.info,
};
}
Future<void> _pickLogLevel() async {
final levels = [
(Level.error, 'Error — 仅错误'),
(Level.warning, 'Warning — 错误 + 警告'),
(Level.info, 'Info — 常规信息'),
(Level.debug, 'Debug — 调试信息(含FFI'),
(Level.trace, 'Trace — 全量追踪'),
];
final result = await showDialog<Level>(
context: context,
builder: (ctx) => SimpleDialog(
title: const Text('日志级别'),
children: levels.map((e) {
final isSelected = _logLevel == e.$1;
return SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, e.$1),
child: Row(
children: [
Icon(
isSelected ? Icons.radio_button_checked : Icons.radio_button_unchecked,
size: 20,
color: isSelected ? Theme.of(ctx).colorScheme.primary : Theme.of(ctx).hintColor,
),
const SizedBox(width: 8),
Flexible(child: Text(e.$2)),
],
),
);
}).toList(),
),
);
if (result != null && result != _logLevel) {
setState(() => _logLevel = result);
AppLogger.setLevel(result);
await StorageService.instance.setString(
StorageKeys.logLevel,
result.name,
);
if (mounted) ToastHelper.success('日志级别已切换为 ${_logLevelLabel(result)}');
}
}
}