479 lines
17 KiB
Dart
479 lines
17 KiB
Dart
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('重试')),
|
||
],
|
||
);
|
||
}
|
||
}
|