Initial commit
This commit is contained in:
@@ -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('重试')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user