From 6ec45e423423bd1ce2eb40174b35fde6446fbf38 Mon Sep 17 00:00:00 2001 From: Nash Aschenbath Date: Thu, 12 Nov 2026 23:19:09 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(ui):=20=E7=BB=9F=E4=B8=80=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E5=88=86=E7=B1=BB=E4=B8=8E=E6=92=AD=E6=94=BE=E5=99=A8?= =?UTF-8?q?=E9=A1=B5=E7=AD=BE=20(#275)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): unify category and player tabs * fix(ui): reset subcategory scroll when switching channels * ci: validate pull requests and pushes on dev --- .github/workflows/android.yml | 6 +- lib/common/widgets/app_tab_button.dart | 129 +++++++++++++++++++++++++ lib/pages/home_album_section.dart | 99 ++++++++++--------- lib/pages/home_page.dart | 86 +++++------------ lib/pages/player_panel.dart | 39 ++++---- 5 files changed, 230 insertions(+), 129 deletions(-) create mode 100644 lib/common/widgets/app_tab_button.dart diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index e014b3b..9951969 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -2,10 +2,10 @@ name: Build and release on: push: - branches: [main] + branches: [main, dev] tags: ['v*'] pull_request: - branches: [main] + branches: [main, dev] workflow_dispatch: env: @@ -27,7 +27,7 @@ jobs: validate: needs: native-tools - if: github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main') + if: github.event_name == 'pull_request' || (github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev')) runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v5 diff --git a/lib/common/widgets/app_tab_button.dart b/lib/common/widgets/app_tab_button.dart new file mode 100644 index 0000000..5a38acc --- /dev/null +++ b/lib/common/widgets/app_tab_button.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; + +/// A quiet tab with a fixed label footprint and a short selection indicator. +class AppTabButton extends StatelessWidget { + const AppTabButton({ + required this.label, + required this.selected, + required this.onPressed, + this.icon, + super.key, + }); + + final String label; + final bool selected; + final VoidCallback? onPressed; + final IconData? icon; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final duration = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 180); + return Semantics( + selected: selected, + inMutuallyExclusiveGroup: true, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 240), + child: TextButton( + onPressed: onPressed, + style: TextButton.styleFrom( + minimumSize: const Size(48, 48), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + foregroundColor: selected + ? colors.primary + : colors.onSurfaceVariant, + textStyle: theme.textTheme.labelLarge?.copyWith( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ).copyWith(animationDuration: duration), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 18), + const SizedBox(width: 8), + ], + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 6), + SizedBox( + width: 22, + height: 3, + child: AnimatedOpacity( + opacity: selected ? 1 : 0, + duration: duration, + curve: Curves.easeOutCubic, + child: DecoratedBox( + decoration: BoxDecoration( + color: onPressed == null + ? colors.onSurface.withValues(alpha: .38) + : colors.primary, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +/// Keeps category rows at one height and makes overflow visible on desktop. +class AppTabStrip extends StatefulWidget { + const AppTabStrip({required this.children, super.key}); + + final List children; + + @override + State createState() => _AppTabStripState(); +} + +class _AppTabStripState extends State { + final _scroll = ScrollController(); + + @override + void dispose() { + _scroll.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: Scrollbar( + controller: _scroll, + thumbVisibility: true, + interactive: true, + scrollbarOrientation: ScrollbarOrientation.bottom, + thickness: 3, + radius: const Radius.circular(3), + child: SingleChildScrollView( + controller: _scroll, + primary: false, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.only(bottom: 8), + child: Row(mainAxisSize: MainAxisSize.min, children: widget.children), + ), + ), + ); +} diff --git a/lib/pages/home_album_section.dart b/lib/pages/home_album_section.dart index cd8017f..6fa01b1 100644 --- a/lib/pages/home_album_section.dart +++ b/lib/pages/home_album_section.dart @@ -128,7 +128,11 @@ class _HomeAlbumCard extends StatelessWidget { album.title, maxLines: 2, overflow: TextOverflow.ellipsis, - style: theme.textTheme.titleMedium?.copyWith(fontSize: 16, height: 1.4), + style: theme.textTheme.titleMedium?.copyWith( + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.4, + ), ); final artist = Text( album.artist, @@ -156,6 +160,9 @@ class _HomeAlbumCard extends StatelessWidget { clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, + hoverColor: colors.primary.withValues(alpha: .045), + focusColor: colors.primary.withValues(alpha: .09), + highlightColor: colors.primary.withValues(alpha: .07), child: Padding( padding: EdgeInsets.all(ranking ? 12 : 8), child: ranking @@ -205,36 +212,12 @@ class _HomeAlbumCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (showCover) ...[ - Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: CoverImage( - uri: album.coverUrl, - size: coverSize, - ), - ), - if (stats?.score != null) - Positioned( - right: 8, - bottom: 8, - child: DecoratedBox( - decoration: BoxDecoration( - color: colors.surface.withValues( - alpha: .94, - ), - borderRadius: BorderRadius.circular(8), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - child: _score(context), - ), - ), - ), - ], + ClipRRect( + borderRadius: BorderRadius.circular(12), + child: CoverImage( + uri: album.coverUrl, + size: coverSize, + ), ), const SizedBox(height: 12), ], @@ -242,14 +225,24 @@ class _HomeAlbumCard extends StatelessWidget { const SizedBox(height: 4), artist, const SizedBox(height: 8), - Text( - details.join(' · '), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall?.copyWith( - height: 1.4, - color: colors.onSurfaceVariant, - ), + Row( + children: [ + Expanded( + child: Text( + details.join(' · '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + height: 1.4, + color: colors.onSurfaceVariant, + ), + ), + ), + if (stats?.score != null) ...[ + const SizedBox(width: 8), + _score(context), + ], + ], ), ], ), @@ -259,12 +252,28 @@ class _HomeAlbumCard extends StatelessWidget { ); } - Widget _score(BuildContext context) => Text( - '${stats!.score!.toStringAsFixed(1)} 分', - style: Theme.of(context).textTheme.labelMedium?.copyWith( - height: 1.4, - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w600, + Widget _score(BuildContext context) => Semantics( + label: '评分 ${stats!.score!.toStringAsFixed(1)} 分', + child: ExcludeSemantics( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.star_rounded, + size: 14, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 3), + Text( + stats!.score!.toStringAsFixed(1), + style: Theme.of(context).textTheme.labelMedium?.copyWith( + height: 1.4, + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), ), ); } diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 6d26111..d128343 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import '../api/xmly_experience.dart'; import '../common/widgets/adaptive_text.dart'; import '../common/widgets/app_header.dart'; +import '../common/widgets/app_tab_button.dart'; import '../common/widgets/content_cross_fade.dart'; import '../models/xmly_models.dart'; import '../services/app_runtime.dart'; @@ -264,30 +265,21 @@ class _HomePageState extends State { if (_channels.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 16, bottom: 4), - child: SizedBox( - height: - 36 + MediaQuery.textScalerOf(context).scale(16), - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: _channels.length, - separatorBuilder: (_, _) => - const SizedBox(width: 8), - itemBuilder: (_, index) { - final channel = _channels[index]; - return Center( + child: AppTabStrip( + key: const ValueKey('homeCategoryTabs'), + children: [ + for (final channel in _channels) + AppTabButton( key: ValueKey(channel.pageId), - child: _categoryChip( - title: channel.title, - selected: channel.pageId == _pageId, - onSelected: (_) { - if (channel.pageId != _pageId) { - _loadHome(channel.pageId); - } - }, - ), - ); - }, - ), + label: channel.title, + selected: channel.pageId == _pageId, + onPressed: () { + if (channel.pageId != _pageId) { + _loadHome(channel.pageId); + } + }, + ), + ], ), ), AnimatedSize( @@ -303,19 +295,21 @@ class _HomePageState extends State { child: ExcludeSemantics( excluding: !tabsCurrent, child: Padding( + key: ValueKey(_tabsPageId), padding: const EdgeInsets.symmetric( vertical: 4, ), - child: Wrap( - spacing: 8, - runSpacing: 4, + child: AppTabStrip( + key: const ValueKey( + 'homeSubcategoryTabs', + ), children: [ for (final tab in _tabs) - _categoryChip( + AppTabButton( key: ValueKey(tab.tabId), - title: tab.title, + label: tab.title, selected: tab.tabId == _tabId, - onSelected: (_) { + onPressed: () { if (tabsCurrent && tab.tabId != _tabId) { _loadHome( @@ -435,40 +429,6 @@ class _HomePageState extends State { ); }, ); - - Widget _categoryChip({ - Key? key, - required String title, - required bool selected, - required ValueChanged? onSelected, - }) { - final colors = Theme.of(context).colorScheme; - return ConstrainedBox( - key: key, - constraints: const BoxConstraints(maxWidth: 220), - child: ChoiceChip( - label: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), - labelStyle: TextStyle( - fontWeight: FontWeight.w500, - color: selected ? colors.onPrimaryContainer : colors.onSurfaceVariant, - ), - labelPadding: const EdgeInsets.symmetric(horizontal: 12), - backgroundColor: colors.surface, - selectedColor: colors.primaryContainer, - side: BorderSide.none, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - selected: selected, - showCheckmark: false, - chipAnimationStyle: ChipAnimationStyle( - selectAnimation: AnimationStyle( - duration: _motionDuration, - reverseDuration: _motionDuration, - ), - ), - onSelected: onSelected, - ), - ); - } } class _HomeFeed extends StatefulWidget { diff --git a/lib/pages/player_panel.dart b/lib/pages/player_panel.dart index 14e568c..751beef 100644 --- a/lib/pages/player_panel.dart +++ b/lib/pages/player_panel.dart @@ -8,6 +8,7 @@ import '../api/xmly_client.dart'; import '../api/xmly_experience.dart'; import '../common/widgets/adaptive_text.dart'; import '../common/widgets/app_header.dart'; +import '../common/widgets/app_tab_button.dart'; import '../common/widgets/confirmation_dialog.dart'; import '../common/widgets/local_panel.dart'; import '../models/xmly_models.dart'; @@ -1251,24 +1252,26 @@ class _PlayerPanelState extends State { required IconData icon, required bool selected, required VoidCallback? onPressed, - }) => Semantics( - selected: selected, - child: TextButton.icon( - key: ValueKey(key), - onPressed: tabs && selected ? () {} : onPressed, - style: TextButton.styleFrom( - foregroundColor: tabs && !selected - ? colors.onSurfaceVariant - : null, - backgroundColor: tabs && selected - ? colors.surfaceContainerHighest - : Colors.transparent, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - ), - icon: Icon(icon, size: 18), - label: Text(label), - ), - ); + }) => tabs + ? AppTabButton( + key: ValueKey(key), + label: label, + icon: icon, + selected: selected, + onPressed: selected ? () {} : onPressed, + ) + : TextButton.icon( + key: ValueKey(key), + onPressed: onPressed, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + ), + icon: Icon(icon, size: 18), + label: Text(label), + ); return Wrap( alignment: WrapAlignment.center, spacing: tabs ? 4 : 12, From 004112ee4d5f288726865650969a3a36750707e3 Mon Sep 17 00:00:00 2001 From: Nash Aschenbath Date: Fri, 13 Nov 2026 14:26:43 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20=E9=80=80=E5=87=BA=E5=A4=87=E4=BB=BD?= =?UTF-8?q?=E9=A1=B5=E6=97=B6=E5=81=9C=E6=AD=A2=E6=96=87=E4=BB=B6=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E5=92=8C=E8=B4=A6=E5=8F=B7=E6=A0=B8=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/backup_page.dart | 37 ++++++++++++++++++++++++++++----- lib/storage/config_archive.dart | 28 +++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/lib/pages/backup_page.dart b/lib/pages/backup_page.dart index 0027fa1..7a90e16 100644 --- a/lib/pages/backup_page.dart +++ b/lib/pages/backup_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:typed_data'; import 'package:file_picker/file_picker.dart'; @@ -22,6 +23,8 @@ class BackupPage extends StatefulWidget { class _BackupPageState extends State { late final ConfigArchive _archive = widget.runtime.backupArchive; + Completer? _cancelled; + StreamIterator? _importStream; bool _includeAccount = false; bool _busy = false; bool _waitingForReload = false; @@ -31,6 +34,14 @@ class _BackupPageState extends State { ? '备份已恢复,未完成任务保持暂停' : _message; + @override + void dispose() { + _cancelled?.complete(); + // Provider cleanup errors cannot be shown after leaving the page. + unawaited(_importStream?.cancel().catchError((_) {})); + super.dispose(); + } + Future _confirm(String title, Widget content, String action) => showConfirmationDialog( context, @@ -41,6 +52,7 @@ class _BackupPageState extends State { Future _run(Future Function() action) async { if (_busy || !mounted) return; + final cancelled = _cancelled = Completer(); setState(() { _busy = true; _waitingForReload = false; @@ -60,6 +72,8 @@ class _BackupPageState extends State { }, ); } finally { + if (!cancelled.isCompleted) cancelled.complete(); + _cancelled = null; if (mounted) setState(() => _busy = false); } } @@ -94,18 +108,31 @@ class _BackupPageState extends State { ); if (file == null || !mounted) return; final length = await file.length(); + if (!mounted) return; if (length != null && length > ConfigArchive.maxBytes) { throw const ConfigArchiveException('备份超过 64 MiB'); } // A bounded stream also handles providers which do not report file length. final bytes = BytesBuilder(copy: false); - await for (final chunk in file.readAsByteStream()) { - if (bytes.length + chunk.length > ConfigArchive.maxBytes) { - throw const ConfigArchiveException('备份超过 64 MiB'); + final stream = _importStream = StreamIterator(file.readAsByteStream()); + try { + while (await stream.moveNext()) { + if (!mounted) return; + final chunk = stream.current; + if (bytes.length + chunk.length > ConfigArchive.maxBytes) { + throw const ConfigArchiveException('备份超过 64 MiB'); + } + bytes.add(chunk); } - bytes.add(chunk); + } finally { + _importStream = null; + await stream.cancel(); } - final draft = await _archive.inspect(bytes.takeBytes()); + if (!mounted) return; + final draft = await _archive.inspect( + bytes.takeBytes(), + cancelled: _cancelled?.future, + ); if (!mounted) return; final replacements = {}; for (final oldRoot in draft.missingDirectories) { diff --git a/lib/storage/config_archive.dart b/lib/storage/config_archive.dart index ece636b..5ad04fb 100644 --- a/lib/storage/config_archive.dart +++ b/lib/storage/config_archive.dart @@ -1,6 +1,7 @@ // ZIP layout adapted from PiliPlus lib/utils/config_archive.dart, commit // d9e9626f1a3f89cd9ca85e18f61aaba02634e12a (GNU GPL v3). // Source: https://github.com/bggRGjQaUbCoE/PiliPlus +import 'dart:async'; import 'dart:convert'; import 'dart:io'; @@ -169,7 +170,26 @@ class ConfigArchive { return bytes; } - Future inspect(List bytes) async { + Future inspect( + List bytes, { + Future? cancelled, + }) async { + var stopped = false; + XmlyClient? checkingAccount; + unawaited( + cancelled?.then((_) { + stopped = true; + // These clients belong to the preview, never to the active accounts. + checkingAccount?.close(); + }), + ); + void checkCancelled() { + if (stopped) throw const ConfigArchiveException('备份导入已取消'); + } + + // Observe an already completed cancellation before parsing the ZIP. + await Future.value(); + checkCancelled(); final Map payload; try { if (bytes.length > maxBytes || bytes.isEmpty) @@ -225,19 +245,22 @@ class ConfigArchive { // No mutation, SMS, claim or report: candidate verification is config only. final account = payload['account.json'] as Map?; for (final record in account?['accounts'] as List? ?? const []) { + checkCancelled(); if ((record as Map)['credentials'] == null) continue; final pair = Credentials.parse(record['credentials']); - final client = XmlyClient(credentials: pair); + final client = checkingAccount = XmlyClient(credentials: pair); try { final quota = await client.queryQuota(); if (quota.remainingSeconds < 0) throw const ConfigArchiveException('备份账号无法核验'); } finally { client.close(); + checkingAccount = null; } } final missing = {}; Future roots(Object? node) async { + checkCancelled(); if (node is Map) { for (final entry in node.entries) { if (_rootKeys.contains(entry.key) && @@ -256,6 +279,7 @@ class ConfigArchive { } await roots(payload); + checkCancelled(); final sameOrigin = payload['meta.json']['origin'] == _origin; final summary = [ if (payload.containsKey('setting.json')) '替换应用设置', From 6fc3bc254054d808664e45623dafe962b878f388 Mon Sep 17 00:00:00 2001 From: Nash Aschenbath Date: Fri, 13 Nov 2026 14:37:13 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=E8=BF=94=E5=9B=9E=E6=97=B6=E7=AB=8B?= =?UTF-8?q?=E5=8D=B3=E5=8F=96=E6=B6=88=E5=A4=87=E4=BB=BD=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/backup_page.dart | 39 ++++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/lib/pages/backup_page.dart b/lib/pages/backup_page.dart index 7a90e16..d967457 100644 --- a/lib/pages/backup_page.dart +++ b/lib/pages/backup_page.dart @@ -28,6 +28,7 @@ class _BackupPageState extends State { bool _includeAccount = false; bool _busy = false; bool _waitingForReload = false; + bool get _importActive => mounted && _cancelled?.isCompleted == false; String? _message; String? get _statusMessage => _waitingForReload && !widget.runtime.restoreNeedsReload @@ -36,10 +37,15 @@ class _BackupPageState extends State { @override void dispose() { - _cancelled?.complete(); + _cancelOperation(); + super.dispose(); + } + + void _cancelOperation() { + final cancelled = _cancelled; + if (cancelled != null && !cancelled.isCompleted) cancelled.complete(); // Provider cleanup errors cannot be shown after leaving the page. unawaited(_importStream?.cancel().catchError((_) {})); - super.dispose(); } Future _confirm(String title, Widget content, String action) => @@ -61,7 +67,7 @@ class _BackupPageState extends State { try { await action(); } catch (error) { - if (!mounted) return; + if (!mounted || cancelled.isCompleted) return; setState( () => _message = switch (error) { ConfigArchiveException e => e.message, @@ -106,9 +112,9 @@ class _BackupPageState extends State { type: FileType.custom, allowedExtensions: ['zip'], ); - if (file == null || !mounted) return; + if (file == null || !_importActive) return; final length = await file.length(); - if (!mounted) return; + if (!_importActive) return; if (length != null && length > ConfigArchive.maxBytes) { throw const ConfigArchiveException('备份超过 64 MiB'); } @@ -117,7 +123,7 @@ class _BackupPageState extends State { final stream = _importStream = StreamIterator(file.readAsByteStream()); try { while (await stream.moveNext()) { - if (!mounted) return; + if (!_importActive) return; final chunk = stream.current; if (bytes.length + chunk.length > ConfigArchive.maxBytes) { throw const ConfigArchiveException('备份超过 64 MiB'); @@ -128,12 +134,12 @@ class _BackupPageState extends State { _importStream = null; await stream.cancel(); } - if (!mounted) return; + if (!_importActive) return; final draft = await _archive.inspect( bytes.takeBytes(), cancelled: _cancelled?.future, ); - if (!mounted) return; + if (!_importActive) return; final replacements = {}; for (final oldRoot in draft.missingDirectories) { if (!await _confirm( @@ -142,11 +148,11 @@ class _BackupPageState extends State { '选择目录', )) return; - if (!mounted) return; + if (!_importActive) return; final selected = await FilePicker.getDirectoryPath( dialogTitle: '选择新的下载目录', ); - if (selected == null || !mounted) return; + if (selected == null || !_importActive) return; replacements[oldRoot] = selected; } final accepted = await _confirm( @@ -167,7 +173,7 @@ class _BackupPageState extends State { ), '确认恢复', ); - if (!accepted || !mounted) return; + if (!accepted || !_importActive) return; try { await widget.runtime.restoreBackup(draft, relocatedRoots: replacements); } catch (_) { @@ -186,9 +192,14 @@ class _BackupPageState extends State { }); @override - Widget build(BuildContext context) => ListenableBuilder( - listenable: widget.runtime, - builder: (context, _) => _content(context), + Widget build(BuildContext context) => PopScope( + onPopInvokedWithResult: (didPop, _) { + if (didPop) _cancelOperation(); + }, + child: ListenableBuilder( + listenable: widget.runtime, + builder: (context, _) => _content(context), + ), ); Widget _content(BuildContext context) => Center( From 1244225e39a13a5ed27c50ba3577fbc527607461 Mon Sep 17 00:00:00 2001 From: Nash Aschenbath Date: Fri, 13 Nov 2026 15:12:18 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=E5=8F=96=E6=B6=88=E5=A4=87=E4=BB=BD?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E6=97=B6=E5=81=9C=E6=AD=A2=E5=87=86=E5=A4=87?= =?UTF-8?q?=E5=92=8C=E5=90=8E=E7=BB=AD=E5=87=AD=E6=8D=AE=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/backup_page.dart | 29 ++++++++++++++++------------- lib/storage/config_archive.dart | 17 ++++++++++++++++- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/lib/pages/backup_page.dart b/lib/pages/backup_page.dart index d967457..83939e0 100644 --- a/lib/pages/backup_page.dart +++ b/lib/pages/backup_page.dart @@ -28,7 +28,7 @@ class _BackupPageState extends State { bool _includeAccount = false; bool _busy = false; bool _waitingForReload = false; - bool get _importActive => mounted && _cancelled?.isCompleted == false; + bool get _operationActive => mounted && _cancelled?.isCompleted == false; String? _message; String? get _statusMessage => _waitingForReload && !widget.runtime.restoreNeedsReload @@ -93,9 +93,12 @@ class _BackupPageState extends State { '仍要导出', )) return; - if (!mounted) return; - final bytes = await _archive.export(includeAccount: includeAccount); - if (!mounted) return; + if (!_operationActive) return; + final bytes = await _archive.export( + includeAccount: includeAccount, + cancelled: _cancelled?.future, + ); + if (!_operationActive) return; final date = DateTime.now().toIso8601String().substring(0, 10); final saved = await FilePicker.saveFile( fileName: 'XMPlus-$date${includeAccount ? '-含账号' : ''}.zip', @@ -103,7 +106,7 @@ class _BackupPageState extends State { mimeType: 'application/zip', dialogTitle: '保存备份', ); - if (saved != null && mounted) setState(() => _message = '备份已保存'); + if (saved != null && _operationActive) setState(() => _message = '备份已保存'); }); Future _import() => _run(() async { @@ -112,9 +115,9 @@ class _BackupPageState extends State { type: FileType.custom, allowedExtensions: ['zip'], ); - if (file == null || !_importActive) return; + if (file == null || !_operationActive) return; final length = await file.length(); - if (!_importActive) return; + if (!_operationActive) return; if (length != null && length > ConfigArchive.maxBytes) { throw const ConfigArchiveException('备份超过 64 MiB'); } @@ -123,7 +126,7 @@ class _BackupPageState extends State { final stream = _importStream = StreamIterator(file.readAsByteStream()); try { while (await stream.moveNext()) { - if (!_importActive) return; + if (!_operationActive) return; final chunk = stream.current; if (bytes.length + chunk.length > ConfigArchive.maxBytes) { throw const ConfigArchiveException('备份超过 64 MiB'); @@ -134,12 +137,12 @@ class _BackupPageState extends State { _importStream = null; await stream.cancel(); } - if (!_importActive) return; + if (!_operationActive) return; final draft = await _archive.inspect( bytes.takeBytes(), cancelled: _cancelled?.future, ); - if (!_importActive) return; + if (!_operationActive) return; final replacements = {}; for (final oldRoot in draft.missingDirectories) { if (!await _confirm( @@ -148,11 +151,11 @@ class _BackupPageState extends State { '选择目录', )) return; - if (!_importActive) return; + if (!_operationActive) return; final selected = await FilePicker.getDirectoryPath( dialogTitle: '选择新的下载目录', ); - if (selected == null || !_importActive) return; + if (selected == null || !_operationActive) return; replacements[oldRoot] = selected; } final accepted = await _confirm( @@ -173,7 +176,7 @@ class _BackupPageState extends State { ), '确认恢复', ); - if (!accepted || !_importActive) return; + if (!accepted || !_operationActive) return; try { await widget.runtime.restoreBackup(draft, relocatedRoots: replacements); } catch (_) { diff --git a/lib/storage/config_archive.dart b/lib/storage/config_archive.dart index 5ad04fb..27ef36b 100644 --- a/lib/storage/config_archive.dart +++ b/lib/storage/config_archive.dart @@ -103,7 +103,19 @@ class ConfigArchive { String get _origin => sha256.convert(utf8.encode(storage.directory)).toString(); - Future> export({bool includeAccount = false}) async { + Future> export({ + bool includeAccount = false, + Future? cancelled, + }) async { + var stopped = false; + unawaited(cancelled?.then((_) => stopped = true)); + void checkCancelled() { + if (stopped) throw const ConfigArchiveException('备份导出已取消'); + } + + // Observe an already completed cancellation before preparing the archive. + await Future.value(); + checkCancelled(); final state = storage.state; final tasks = { 'downloads': _clean(state['downloads']), @@ -120,11 +132,13 @@ class ConfigArchive { if (includeAccount) { final records = >[]; for (final entry in (state['accounts'] as Map).entries) { + checkCancelled(); final record = entry.value as Map; Map? pair; final ref = record['credentialRef']; if (ref is String) { final encoded = await secrets.read(ref); + checkCancelled(); if (encoded == null) throw const ConfigArchiveException('安全凭据缺失,未导出备份'); final credentials = Credentials.parse(encoded); @@ -145,6 +159,7 @@ class ConfigArchive { ...tasks, }; } + checkCancelled(); files['meta.json'] = { 'type': 'data_archive', 'formatVersion': 65536, From f9e36ef8a055bb4758222c035c8c5372295056e5 Mon Sep 17 00:00:00 2001 From: Nash Aschenbath Date: Fri, 13 Nov 2026 16:11:35 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=E7=A6=BB=E5=BC=80=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=97=B6=E5=8F=96=E6=B6=88=E6=97=A5=E5=BF=97=E5=A4=8D=E5=88=B6?= =?UTF-8?q?=E5=92=8C=E5=AF=BC=E5=87=BA=E5=87=86=E5=A4=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pages/developer_settings_page.dart | 33 ++++++++++++++++++++++---- lib/services/log_export.dart | 8 +++++-- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/lib/pages/developer_settings_page.dart b/lib/pages/developer_settings_page.dart index 1f7e308..fdfa7a4 100644 --- a/lib/pages/developer_settings_page.dart +++ b/lib/pages/developer_settings_page.dart @@ -30,6 +30,14 @@ class _DeveloperSettingsPageState extends State { String? _selected; String? _error; bool _busy = false; + bool _leaving = false; + + bool _canContinueExport() { + AppLog.instance.trace( + "lib/pages/developer_settings_page.dart::_DeveloperSettingsPageState._canContinueExport", + ); + return mounted && !_leaving; + } @override void initState() { @@ -202,7 +210,7 @@ class _DeveloperSettingsPageState extends State { "lib/pages/developer_settings_page.dart::_DeveloperSettingsPageState._export.closure#1", ); - if (_busy) return; + if (_busy || !_canContinueExport()) return; final session = _sessions.where((session) { AppLog.instance.trace( "lib/pages/developer_settings_page.dart::_DeveloperSettingsPageState._export.closure#1.closure#1", @@ -221,19 +229,25 @@ class _DeveloperSettingsPageState extends State { try { if (clipboard) { final snapshot = await _log.snapshot(session.fileName); + if (!_canContinueExport()) return; if (!snapshot.canCopyToClipboard(android: Platform.isAndroid)) { throw StateError('日志过大,无法完整复制,请导出到文件'); } final text = await snapshot.readForClipboard( android: Platform.isAndroid, ); + if (!_canContinueExport()) return; await Clipboard.setData(ClipboardData(text: text)); - if (mounted) _message('日志已复制'); + if (_canContinueExport()) _message('日志已复制'); } else { - final destination = await exportLogSession(session); - if (destination != null && mounted) _message('日志已导出'); + final destination = await exportLogSession( + session, + canStart: _canContinueExport, + ); + if (destination != null && _canContinueExport()) _message('日志已导出'); } } catch (error, stack) { + if (!_canContinueExport()) return; if (!_log.errorWasRecorded(error)) { _log.write( AppLogLevel.warning, @@ -301,7 +315,7 @@ class _DeveloperSettingsPageState extends State { "lib/pages/developer_settings_page.dart::_DeveloperSettingsPageState.build", ); - return ListenableBuilder( + final content = ListenableBuilder( listenable: widget.runtime, builder: (context, _) { AppLog.instance.trace( @@ -515,5 +529,14 @@ class _DeveloperSettingsPageState extends State { ); }, ); + return PopScope( + child: content, + onPopInvokedWithResult: (didPop, _) { + AppLog.instance.trace( + "lib/pages/developer_settings_page.dart::_DeveloperSettingsPageState.build.closure#2", + ); + if (didPop) _leaving = true; + }, + ); } } diff --git a/lib/services/log_export.dart b/lib/services/log_export.dart index 27983ab..37a9fcc 100644 --- a/lib/services/log_export.dart +++ b/lib/services/log_export.dart @@ -8,16 +8,19 @@ import 'android_platform.dart'; import 'app_log.dart'; /// Exports only a session returned by the logger, never an arbitrary path. -/// A null result means the user cancelled the platform save dialog. -Future exportLogSession(LogSession session) { +/// A null result means the caller or the platform save dialog cancelled. +/// [canStart] gates preparation; an opened save dialog can still finish. +Future exportLogSession(LogSession session, {bool Function()? canStart}) { AppLog.instance.trace("lib/services/log_export.dart::exportLogSession"); return AppLog.instance.runWithErrorScope(() async { AppLog.instance.trace( "lib/services/log_export.dart::exportLogSession.closure#1", ); + if (canStart?.call() == false) return null; final log = AppLog.instance; final sessions = await log.sessions(); + if (canStart?.call() == false) return null; if (!sessions.any((candidate) { AppLog.instance.trace( "lib/services/log_export.dart::exportLogSession.closure#1.closure#1", @@ -35,6 +38,7 @@ Future exportLogSession(LogSession session) { throw ArgumentError.value(name, 'fileName', '日志文件名无效'); } final snapshot = await log.snapshot(name); + if (canStart?.call() == false) return null; if (Platform.isAndroid) { return AndroidPlatform.exportLogSnapshot(snapshot, name); }