import 'dart:io'; import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:forui/forui.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../../shared/utils/format_utils.dart'; import '../../../providers/appearance_provider.dart'; import '../../../providers/danmaku_settings_provider.dart'; import '../../../providers/di_providers.dart'; import '../../../providers/server_provider.dart'; import '../../../providers/session_provider.dart'; import '../../../providers/sm_account_provider.dart'; import '../../../providers/update_provider.dart'; import '../../../shared/app_info.dart'; import '../../../shared/theme/app_theme.dart'; import '../../../shared/utils/user_error_formatter.dart'; import '../../../shared/widgets/app_inline_alert.dart'; import '../../../shared/widgets/app_loading_ring.dart'; import '../../../shared/widgets/setting_select.dart'; import '../../account/account_center_page.dart'; import '../utils/settings_labels.dart'; import '../widgets/settings_card.dart'; class GeneralSettingsTab extends ConsumerStatefulWidget { final void Function(String message, {AppAlertTone tone}) onFeedback; const GeneralSettingsTab({super.key, required this.onFeedback}); @override ConsumerState createState() => _GeneralSettingsTabState(); } class _GeneralSettingsTabState extends ConsumerState { bool _installTriggered = false; @override Widget build(BuildContext context) { final appearance = ref.watch(appearanceProvider); final updateState = ref.watch(updateProvider); final appVersion = ref.watch(appVersionProvider).value ?? kSmPlayerVersionFallback; if (updateState.status != UpdateStatus.readyToInstall) { _installTriggered = false; } else if (!updateState.silent && !_installTriggered) { _installTriggered = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; ref.read(updateProvider.notifier).install(); }); } final account = ref.watch(smAccountProvider).value; return SettingsTabScroll( children: [ SettingsCard( children: [ SettingRow( title: account?.loggedIn == true ? (account!.email.isNotEmpty ? account.email : 'Player 账号') : '账号', subtitle: account?.loggedIn == true ? null : '登录以使用云同步等服务', trailing: const Icon(Icons.chevron_right, size: 18), onTap: _openAccountSheet, ), ], ), appearance.when( data: (data) => SettingsCard( children: [ SettingRow( title: '主题模式', trailing: SettingSelect( value: data.themeMode, options: ThemeMode.values, labelOf: themeModeLabel, onChanged: (v) { if (v != null) { ref.read(appearanceProvider.notifier).setThemeMode(v); } }, ), ), SettingRow( title: '顶栏背景', trailing: SettingSelect( value: data.headerMode, options: HeaderMode.values, labelOf: headerModeLabel, onChanged: (v) { if (v != null) { ref.read(appearanceProvider.notifier).setHeaderMode(v); } }, ), ), SettingRow( title: '启动页面', subtitle: '下次打开软件时进入的页面', trailing: SettingSelect( value: data.startupPage, options: StartupPage.values, labelOf: startupPageLabel, onChanged: (v) { if (v != null) { ref.read(appearanceProvider.notifier).setStartupPage(v); } }, ), ), ], ), loading: () => const SizedBox.shrink(), error: (e, _) => settingsLoadError(e), ), SettingsCard( children: [ SettingRow( title: '检查更新', subtitle: _updateStatusText(updateState, appVersion), trailing: FilledButton.tonalIcon( onPressed: _canManualCheck(updateState) ? _manualCheckUpdate : null, icon: updateState.status == UpdateStatus.checking && !updateState.silent ? const AppLoadingRing(size: 14) : const Icon(Icons.system_update_alt_rounded, size: 16), label: const Text('检查更新'), ), ), if (_shouldShowInlineUpdate(updateState)) _InlineUpdatePanel( state: updateState, appVersion: appVersion, onCheckAgain: _manualCheckUpdate, onDownload: () => ref.read(updateProvider.notifier).startDownload(), onCancel: () => ref.read(updateProvider.notifier).cancel(), onOpenDownloadPage: _openDownloadPage, ), ], ), SettingsCard( children: [ SettingRow( title: '导出到文件', trailing: const Icon(Icons.upload_file, size: 18), onTap: () => _exportBackup(context), ), SettingRow( title: '从文件导入', trailing: const Icon(Icons.download, size: 18), onTap: () => _importBackup(context), ), ], ), ], ); } bool _canManualCheck(UpdateState state) { return state.status != UpdateStatus.checking && state.status != UpdateStatus.downloading && state.status != UpdateStatus.readyToInstall; } bool _shouldShowInlineUpdate(UpdateState state) { if (state.silent) return false; return state.status != UpdateStatus.idle; } String _updateStatusText(UpdateState state, String appVersion) { if (!state.silent) { switch (state.status) { case UpdateStatus.checking: return '当前版本 $appVersion,正在检查'; case UpdateStatus.upToDate: return '当前版本 $appVersion,已是最新版本'; case UpdateStatus.error: return '当前版本 $appVersion,${state.error ?? '检查失败'}'; case UpdateStatus.available: return '当前版本 $appVersion,发现新版本 ${state.info?.version ?? ''}'; case UpdateStatus.downloading: return '当前版本 $appVersion,正在下载更新'; case UpdateStatus.readyToInstall: return '当前版本 $appVersion,正在打开安装程序'; case UpdateStatus.idle: break; } } return '当前版本 $appVersion'; } Future _manualCheckUpdate() async { await ref.read(updateProvider.notifier).manualCheck(); } Future _openDownloadPage(Uri uri) async { if (!uri.hasScheme) return; await launchUrl(uri, mode: LaunchMode.externalApplication); } Future _openAccountSheet() async { final message = await showAccountCenterSheet(context); if (!mounted || message == null || message.isEmpty) return; widget.onFeedback(message, tone: AppAlertTone.success); } Future _exportBackup(BuildContext context) async { try { final location = await getSaveLocation( suggestedName: _backupFileName(DateTime.now()), acceptedTypeGroups: const [ XTypeGroup(label: 'JSON', extensions: ['json']), ], ); if (location == null) return; final usecase = await ref.read(exportBackupUseCaseProvider.future); final result = await usecase.execute(); await File(location.path).writeAsString(result.json, flush: true); if (!context.mounted) return; widget.onFeedback( '已导出 ${result.serverCount} 个服务器、${result.sessionCount} 个登录态、' '${result.danmakuSourceCount} 个弹幕源、' '${result.scriptWidgetCount} 个模块、${result.scriptWidgetSubscriptionCount} 个订阅。' '备份文件含登录凭据与模块用户参数,请妥善保管。', tone: AppAlertTone.success, ); } catch (e) { if (!context.mounted) return; widget.onFeedback('导出失败:${formatUserError(e)}', tone: AppAlertTone.error); } } Future _importBackup(BuildContext context) async { try { final file = await openFile( acceptedTypeGroups: const [ XTypeGroup(label: 'JSON', extensions: ['json']), ], ); if (file == null) return; final raw = await File(file.path).readAsString(); final usecase = await ref.read(importBackupUseCaseProvider.future); final result = await usecase.execute(raw); await ref.read(serverListProvider.notifier).refresh(); await ref.read(sessionProvider.notifier).refresh(); ref.invalidate(danmakuSettingsProvider); ref.invalidate(installedScriptWidgetsProvider); ref.invalidate(scriptWidgetSubscriptionsProvider); if (!context.mounted) return; widget.onFeedback( '已导入 ${result.serverCount} 个服务器、${result.sessionCount} 个登录态、' '${result.danmakuSourceCount} 个弹幕源、' '${result.scriptWidgetCount} 个模块、${result.scriptWidgetSubscriptionCount} 个订阅', tone: AppAlertTone.success, ); } catch (e) { if (!context.mounted) return; widget.onFeedback('导入失败:${formatUserError(e)}', tone: AppAlertTone.error); } } static String _backupFileName(DateTime now) { String two(int v) => v.toString().padLeft(2, '0'); return 'smplayer-backup-${now.year}${two(now.month)}${two(now.day)}' '-${two(now.hour)}${two(now.minute)}.json'; } } class _InlineUpdatePanel extends StatelessWidget { final UpdateState state; final String appVersion; final VoidCallback onCheckAgain; final VoidCallback onDownload; final VoidCallback onCancel; final ValueChanged onOpenDownloadPage; const _InlineUpdatePanel({ required this.state, required this.appVersion, required this.onCheckAgain, required this.onDownload, required this.onCancel, required this.onOpenDownloadPage, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final tokens = context.appTokens; final info = state.info; final isChecking = state.status == UpdateStatus.checking; final isUpToDate = state.status == UpdateStatus.upToDate; final isError = state.status == UpdateStatus.error; final isDownloading = state.status == UpdateStatus.downloading; final isReady = state.status == UpdateStatus.readyToInstall; return Padding( padding: const EdgeInsets.fromLTRB(16, 14, 16, 16), child: AnimatedSwitcher( duration: const Duration(milliseconds: 180), child: Column( key: ValueKey(state.status), crossAxisAlignment: CrossAxisAlignment.start, children: [ if (isChecking) _StatusLine( icon: Icons.sync_rounded, iconColor: tokens.mutedForeground, text: '正在检查更新...', ) else if (isUpToDate) _StatusLine( icon: Icons.check_circle_outline_rounded, iconColor: Colors.green, text: '当前版本 $appVersion 已是最新版本。', ) else if (isError) _ErrorBlock( message: state.error ?? '检查更新失败,请稍后重试', onRetry: onCheckAgain, ) else if (info != null) ...[ Text( '发现新版本 ${info.version}', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 4), Text( '当前版本 $appVersion → 最新版本 ${info.version}', style: theme.textTheme.bodySmall?.copyWith( color: tokens.mutedForeground, ), ), const SizedBox(height: 12), ConstrainedBox( constraints: const BoxConstraints(maxHeight: 150), child: SingleChildScrollView( child: Text( info.releaseNotes.trim().isEmpty ? '暂无更新说明' : info.releaseNotes.trim(), style: theme.textTheme.bodySmall?.copyWith(height: 1.45), ), ), ), if (isDownloading) ...[ const SizedBox(height: 16), FDeterminateProgress( value: downloadProgressValue( received: state.received, total: state.total, ) ?? 0, ), const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( '${formatDownloadBytes(state.received)} / ${formatDownloadBytes(state.total)}', style: theme.textTheme.bodySmall?.copyWith( color: tokens.mutedForeground, ), ), Text( formatDownloadSpeed(state.speedBytesPerSec), style: theme.textTheme.bodySmall?.copyWith( color: tokens.mutedForeground, ), ), ], ), ], if (isReady) ...[ const SizedBox(height: 16), _StatusLine( icon: Icons.install_desktop_rounded, iconColor: tokens.mutedForeground, text: '安装程序已下载,正在打开。', ), ], const SizedBox(height: 14), Wrap( spacing: 8, runSpacing: 8, children: [ if (isDownloading) OutlinedButton( onPressed: onCancel, child: const Text('取消下载'), ) else if (info.noAssetForPlatform) FilledButton.tonalIcon( onPressed: () => onOpenDownloadPage(info.htmlUrl), icon: const Icon(Icons.open_in_new_rounded, size: 16), label: const Text('前往下载页'), ) else FilledButton.tonalIcon( onPressed: isReady ? null : onDownload, icon: const Icon(Icons.download_rounded, size: 16), label: const Text('下载并安装'), ), TextButton( onPressed: isDownloading || isReady ? null : onCheckAgain, child: const Text('重新检查'), ), ], ), ], ], ), ), ); } } class _StatusLine extends StatelessWidget { final IconData icon; final Color iconColor; final String text; const _StatusLine({ required this.icon, required this.iconColor, required this.text, }); @override Widget build(BuildContext context) { return Row( children: [ Icon(icon, size: 18, color: iconColor), const SizedBox(width: 8), Expanded(child: Text(text)), ], ); } } class _ErrorBlock extends StatelessWidget { final String message; final VoidCallback onRetry; const _ErrorBlock({required this.message, required this.onRetry}); @override Widget build(BuildContext context) { final color = Theme.of(context).colorScheme.error; return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.error_outline_rounded, size: 18, color: color), const SizedBox(width: 8), Expanded( child: Text(message, style: TextStyle(color: color)), ), const SizedBox(width: 8), TextButton(onPressed: onRetry, child: const Text('重试')), ], ); } }