Files
2026-07-14 11:11:36 +08:00

479 lines
17 KiB
Dart
Raw Permalink 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: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<GeneralSettingsTab> createState() => _GeneralSettingsTabState();
}
class _GeneralSettingsTabState extends ConsumerState<GeneralSettingsTab> {
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<ThemeMode>(
value: data.themeMode,
options: ThemeMode.values,
labelOf: themeModeLabel,
onChanged: (v) {
if (v != null) {
ref.read(appearanceProvider.notifier).setThemeMode(v);
}
},
),
),
SettingRow(
title: '顶栏背景',
trailing: SettingSelect<HeaderMode>(
value: data.headerMode,
options: HeaderMode.values,
labelOf: headerModeLabel,
onChanged: (v) {
if (v != null) {
ref.read(appearanceProvider.notifier).setHeaderMode(v);
}
},
),
),
SettingRow(
title: '启动页面',
subtitle: '下次打开软件时进入的页面',
trailing: SettingSelect<StartupPage>(
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<void> _manualCheckUpdate() async {
await ref.read(updateProvider.notifier).manualCheck();
}
Future<void> _openDownloadPage(Uri uri) async {
if (!uri.hasScheme) return;
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
Future<void> _openAccountSheet() async {
final message = await showAccountCenterSheet(context);
if (!mounted || message == null || message.isEmpty) return;
widget.onFeedback(message, tone: AppAlertTone.success);
}
Future<void> _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<void> _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<Uri> 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('重试')),
],
);
}
}