Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -0,0 +1,478 @@
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('重试')),
],
);
}
}