diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3ae2614..1006203 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,9 +5,12 @@ android:name="${applicationName}" android:icon="@mipmap/ic_launcher" android:roundIcon="@mipmap/ic_launcher_round"> + + android:value="false" /> - const AnimatedAppBackground(gridOpacity: .11, child: HomePage()), + page: () => const AnimatedAppBackground(child: HomePage()), transition: Transition.rightToLeft, transitionDuration: Duration(milliseconds: 200), ), GetPage( name: AppRoutes.scoring, - page: () => - const AnimatedAppBackground(gridOpacity: .11, child: ScoringPage()), + page: () => const AnimatedAppBackground(child: ScoringPage()), binding: ScoringBinding(), transition: Transition.rightToLeft, transitionDuration: Duration(milliseconds: 200), ), GetPage( name: AppRoutes.messages, - page: () => - const AnimatedAppBackground(gridOpacity: .11, child: MessagesPage()), + page: () => const AnimatedAppBackground(child: MessagesPage()), transition: Transition.rightToLeft, transitionDuration: Duration(milliseconds: 200), ), GetPage( name: AppRoutes.corrections, - page: () => const AnimatedAppBackground( - gridOpacity: .11, - child: CorrectionsPage(), - ), + page: () => const AnimatedAppBackground(child: CorrectionsPage()), transition: Transition.rightToLeft, transitionDuration: Duration(milliseconds: 200), ), GetPage( name: AppRoutes.results, - page: () => - const AnimatedAppBackground(gridOpacity: .11, child: ResultsPage()), + page: () => const AnimatedAppBackground(child: ResultsPage()), transition: Transition.rightToLeft, transitionDuration: Duration(milliseconds: 200), ), diff --git a/lib/common/widgets/animated_app_background.dart b/lib/common/widgets/animated_app_background.dart index ee926bb..2ad99b4 100644 --- a/lib/common/widgets/animated_app_background.dart +++ b/lib/common/widgets/animated_app_background.dart @@ -4,21 +4,13 @@ import 'package:flutter/material.dart'; /// Shared static background for business pages. It performs no per-frame work. class AnimatedAppBackground extends StatelessWidget { - const AnimatedAppBackground({ - super.key, - required this.child, - this.gridOpacity = .80, - }); + const AnimatedAppBackground({super.key, required this.child}); final Widget child; - final double gridOpacity; @override Widget build(BuildContext context) { final mediaQuery = MediaQuery.of(context); - final decodedWidth = (mediaQuery.size.width * mediaQuery.devicePixelRatio) - .ceil() - .clamp(1, 2560); return DecoratedBox( decoration: const BoxDecoration( @@ -51,12 +43,14 @@ class AnimatedAppBackground extends StatelessWidget { child: IgnorePointer( child: RepaintBoundary( child: Image.asset( - 'assets/images/huaban63273919611.png', + // This asset is already downsampled with the .11 opacity + // baked into its alpha channel. No per-route opacity layer + // or large source-image decode is needed. + 'assets/images/background_wave_2048.png', fit: BoxFit.contain, alignment: Alignment.bottomCenter, - cacheWidth: decodedWidth, - filterQuality: FilterQuality.medium, - opacity: AlwaysStoppedAnimation(gridOpacity), + filterQuality: FilterQuality.low, + excludeFromSemantics: true, gaplessPlayback: true, ), ), diff --git a/lib/core/models/task_queue_policy.dart b/lib/core/models/task_queue_policy.dart index c2b3ff1..fd9febe 100644 --- a/lib/core/models/task_queue_policy.dart +++ b/lib/core/models/task_queue_policy.dart @@ -60,6 +60,13 @@ abstract final class TaskQueuePolicy { return -1; } + /// Stable task availability for button appearance. A brief navigation + /// operation must not make unrelated task buttons look disabled. + static bool isTaskActionAvailable({ + required int index, + required int pendingSubmissionIndex, + }) => pendingSubmissionIndex < 0 || index == pendingSubmissionIndex; + /// An unconfirmed submission must be resolved before switching tasks, but /// ordinary unfinished tasks do not lock later entries in the queue. static bool canOpenTask({ @@ -68,7 +75,10 @@ abstract final class TaskQueuePolicy { required bool opening, }) => !opening && - (pendingSubmissionIndex < 0 || index == pendingSubmissionIndex); + isTaskActionAvailable( + index: index, + pendingSubmissionIndex: pendingSubmissionIndex, + ); static int _compareCandidates( TaskQueueCandidate a, diff --git a/lib/modules/home/views/home_page.dart b/lib/modules/home/views/home_page.dart index f05843e..aa79a3d 100644 --- a/lib/modules/home/views/home_page.dart +++ b/lib/modules/home/views/home_page.dart @@ -221,25 +221,39 @@ class _HomePageState extends State { blockingIndex >= 0 && entry.$1 != blockingIndex, onTap: - !TaskQueuePolicy.canOpenTask( + !TaskQueuePolicy.isTaskActionAvailable( index: entry.$1, pendingSubmissionIndex: blockingIndex, - opening: _openingEntryId != null, ) ? null - : statuses[entry.$1] == - ScoringTaskStatus - .submittedAnnotationPending - .label - ? () => _retryAnnotation( - _remoteTasks[entry.$1] - .entry - .sessionEntryId, - ) - : () => _openTask( - entry.$1, - _remoteTasks[entry.$1], - ), + : () { + if (!TaskQueuePolicy.canOpenTask( + index: entry.$1, + pendingSubmissionIndex: blockingIndex, + opening: _openingEntryId != null, + )) { + return; + } + if (statuses[entry.$1] == + ScoringTaskStatus + .submittedAnnotationPending + .label) { + unawaited( + _retryAnnotation( + _remoteTasks[entry.$1] + .entry + .sessionEntryId, + ), + ); + } else { + unawaited( + _openTask( + entry.$1, + _remoteTasks[entry.$1], + ), + ); + } + }, ), ), ), @@ -325,8 +339,13 @@ class _HomePageState extends State { } Future _retryAnnotation(String sessionEntryId) async { - await Get.find().retryAnnotation(sessionEntryId); - if (mounted) setState(() {}); + _openingEntryId = sessionEntryId; + try { + await Get.find().retryAnnotation(sessionEntryId); + if (mounted) setState(() {}); + } finally { + _openingEntryId = null; + } } Future _openResults() async { @@ -387,77 +406,21 @@ class _HomePageState extends State { _RemoteTask remoteTask, { bool correction = false, }) async { - setState(() => _openingEntryId = remoteTask.entry.sessionEntryId); + _openingEntryId = remoteTask.entry.sessionEntryId; try { - final sheetResult = await _api.loadSheet( - remoteTask.entry.sessionEntryId, - requireFresh: correction || remoteTask.entry.scoreStatus == 3, + // Show a complete route immediately. Its loading surface remains visible + // until the sheet, local context and role-specific controller are ready. + final preparation = _prepareScoringTask(index, remoteTask, correction); + final routeResult = await Get.toNamed( + AppRoutes.scoring, + arguments: preparation, ); - final session = _auth.session!; - final sheet = sheetResult.data; - if (correction && (!sheet.isCorrection || !sheet.canEdit)) { - AppNotice.show('修正任务暂不可用', sheet.lockReason ?? '请刷新任务后重试'); - return; - } - final assignmentChanged = !RemoteScoringAdapter.sheetRoleMatchesSession( - sheet, - remoteTask.session, - ); - final profile = RemoteScoringAdapter.accountFor( - sheet, - userId: session.user.id, - userName: session.user.name, - ); - final task = RemoteScoringAdapter.taskFor( - remoteTask.session, - remoteTask.entry, - sheet, - ); - if (Get.isRegistered()) { - await Get.delete(force: true); - } - await _storage.settings.putAll({ - 'judgeId': profile.account, - 'judgeDisplayName': session.user.name, - 'judgeMobile': session.user.mobile, - 'judgeType': profile.judgeType.name, - 'judgeRoleKey': profile.storageKey, - 'activeTaskIndex': index, - ScoringStorageKeys.activeTaskSetting: task.encode(), - ScoringStorageKeys.activePlayerIdSetting: task.teamId, - ScoringStorageKeys.prescribedEntryTypeSetting: - task.prescribedEntryType.name, - ScoringStorageKeys.activeCompetitionLabelSetting: task.project, - ScoringStorageKeys.activeAthleteLabelSetting: task.athleteLabel, - ScoringStorageKeys.activeTaskVariantSetting: task.taskVariant, - ScoringStorageKeys.apiActiveSheetSetting: sheet.encode(), - ScoringStorageKeys.apiActiveSessionSetting: jsonEncode( - remoteTask.session.toJson(), - ), - ScoringStorageKeys.apiActiveEntrySetting: jsonEncode( - remoteTask.entry.toJson(), - ), - ScoringStorageKeys.apiResumeScoringSetting: true, - 'judge-api-last-entry-${session.user.id}': - remoteTask.entry.sessionEntryId, - }); - await _storage.settings.flush(); - final scoringController = Get.put( - ScoringController( - Get.find(), - Get.find(), - ), - ); - await scoringController.initialize(); - if (assignmentChanged) { - AppNotice.show( - '裁判席位已更新', - '已按最新评分表切换为${RemoteScoringAdapter.roleNameForType(sheet.header.judgeRoleType)} · ${sheet.header.judgeRole}', - ); - } - final routeResult = await Get.toNamed(AppRoutes.scoring); final continueNext = routeResult == true; final correctionEntryId = routeResult is String ? routeResult : null; + // Get.toNamed completes when the route starts popping. Keep storage and + // task-list refresh work off the 200 ms reverse transition. + await Future.delayed(const Duration(milliseconds: 220)); + if (!mounted || _auth.session == null) return; await _storage.settings.put( ScoringStorageKeys.apiResumeScoringSetting, false, @@ -475,7 +438,87 @@ class _HomePageState extends State { } on ApiException catch (error) { AppNotice.show('无法打开评分表', error.message); } finally { - if (mounted) setState(() => _openingEntryId = null); + _openingEntryId = null; + } + } + + Future _prepareScoringTask( + int index, + _RemoteTask remoteTask, + bool correction, + ) async { + final sheetFuture = _api.loadSheet( + remoteTask.entry.sessionEntryId, + requireFresh: correction || remoteTask.entry.scoreStatus == 3, + ); + // Fetch concurrently with the route animation, but do not construct the + // large role-specific scoring tree during the first transition frames. + await Future.wait([ + sheetFuture.then((_) {}), + Future.delayed(const Duration(milliseconds: 240)), + ]); + final sheetResult = await sheetFuture; + final session = _auth.session; + if (session == null) throw const ApiException('登录已失效,请重新登录'); + final sheet = sheetResult.data; + if (correction && (!sheet.isCorrection || !sheet.canEdit)) { + throw ApiException(sheet.lockReason ?? '修正任务暂不可用,请刷新任务后重试'); + } + final assignmentChanged = !RemoteScoringAdapter.sheetRoleMatchesSession( + sheet, + remoteTask.session, + ); + final profile = RemoteScoringAdapter.accountFor( + sheet, + userId: session.user.id, + userName: session.user.name, + ); + final task = RemoteScoringAdapter.taskFor( + remoteTask.session, + remoteTask.entry, + sheet, + ); + if (Get.isRegistered()) { + await Get.delete(force: true); + } + await _storage.settings.putAll({ + 'judgeId': profile.account, + 'judgeDisplayName': session.user.name, + 'judgeMobile': session.user.mobile, + 'judgeType': profile.judgeType.name, + 'judgeRoleKey': profile.storageKey, + 'activeTaskIndex': index, + ScoringStorageKeys.activeTaskSetting: task.encode(), + ScoringStorageKeys.activePlayerIdSetting: task.teamId, + ScoringStorageKeys.prescribedEntryTypeSetting: + task.prescribedEntryType.name, + ScoringStorageKeys.activeCompetitionLabelSetting: task.project, + ScoringStorageKeys.activeAthleteLabelSetting: task.athleteLabel, + ScoringStorageKeys.activeTaskVariantSetting: task.taskVariant, + ScoringStorageKeys.apiActiveSheetSetting: sheet.encode(), + ScoringStorageKeys.apiActiveSessionSetting: jsonEncode( + remoteTask.session.toJson(), + ), + ScoringStorageKeys.apiActiveEntrySetting: jsonEncode( + remoteTask.entry.toJson(), + ), + ScoringStorageKeys.apiResumeScoringSetting: true, + 'judge-api-last-entry-${session.user.id}': + remoteTask.entry.sessionEntryId, + }); + await _storage.settings.flush(); + final scoringController = Get.put( + ScoringController( + Get.find(), + Get.find(), + ), + ); + await scoringController.initialize(); + if (assignmentChanged) { + AppNotice.show( + '裁判席位已更新', + '已按最新评分表切换为${RemoteScoringAdapter.roleNameForType(sheet.header.judgeRoleType)} · ${sheet.header.judgeRole}', + ); } } diff --git a/lib/modules/scoring/views/scoring_page.dart b/lib/modules/scoring/views/scoring_page.dart index 42341b3..4869cf0 100644 --- a/lib/modules/scoring/views/scoring_page.dart +++ b/lib/modules/scoring/views/scoring_page.dart @@ -8,6 +8,7 @@ import '../../../app/localization/app_text.dart'; import '../../../app/routes/app_routes.dart'; import '../../../app/theme/app_theme.dart'; import '../../../common/widgets/app_gradient_button.dart'; +import '../../../common/widgets/page_loading_view.dart'; import '../../../core/models/elite_scoring_rule.dart'; import '../../../core/models/judge_account.dart'; import '../../../core/network/api_client.dart'; @@ -35,6 +36,8 @@ class ScoringPage extends StatefulWidget { class _ScoringPageState extends State with WidgetsBindingObserver { ScoringController get controller => Get.find(); + bool _launchReady = true; + String? _launchError; List<_CorrectionTask> _correctionTasks = const []; Future? _correctionLoadFuture; Timer? _correctionTimer; @@ -44,8 +47,34 @@ class _ScoringPageState extends State with WidgetsBindingObserver { void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - WidgetsBinding.instance.addPostFrameCallback((_) => _loadCorrections()); - _correctionTimer = Timer.periodic( + final preparation = Get.arguments; + if (preparation is Future) { + _launchReady = false; + preparation.then( + (_) { + if (!mounted) return; + setState(() => _launchReady = true); + _startCorrectionUpdates(); + }, + onError: (Object error, StackTrace _) { + if (!mounted) return; + setState(() { + _launchError = error is ApiException + ? error.message + : '评分表准备失败,请返回任务列表后重试'; + }); + }, + ); + } else { + _startCorrectionUpdates(); + } + } + + void _startCorrectionUpdates() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _loadCorrections(); + }); + _correctionTimer ??= Timer.periodic( const Duration(seconds: 30), (_) => _loadCorrections(), ); @@ -66,38 +95,52 @@ class _ScoringPageState extends State with WidgetsBindingObserver { @override Widget build(BuildContext context) { return PopScope( - canPop: false, + canPop: _launchError != null, onPopInvokedWithResult: (didPop, _) { - if (!didPop) _backToTasks(); + if (didPop) return; + if (!_launchReady) { + AppNotice.show('正在打开评分表', '评分表加载完成后即可返回任务列表'); + return; + } + _backToTasks(); }, - child: Scaffold( - backgroundColor: Colors.transparent, - body: SafeArea( - top: true, - bottom: false, - child: Obx(() { - if (!controller.isReady.value) { - return const Center(child: CircularProgressIndicator()); - } - // Keep the readiness observer small. Score changes are observed by - // dedicated descendants, so a single tap no longer rebuilds the - // full-screen animated background and page structure. - return _ReadyScoringPage( - controller: controller, - onBack: _backToTasks, - onCorrection: _openCorrectionTasks, - correctionTaskCount: _correctionTasks.length, - onSubmit: _submit, - onServerSubmissionConfirmed: () => - _afterSuccessfulSubmission(controller.isCorrectionMode.value), - onDrawingBoard: _showDrawingBoard, - ); - }), - ), - ), + child: _launchError != null + ? _ScoringLaunchError( + message: _launchError!, + onBack: () => Get.back(), + ) + : !_launchReady + ? const PageLoadingView(label: '正在加载评分表…') + : _buildReadyPage(), ); } + Widget _buildReadyPage() => Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + top: true, + bottom: false, + child: Obx(() { + if (!controller.isReady.value) { + return const Center(child: CircularProgressIndicator()); + } + // Keep the readiness observer small. Score changes are observed by + // dedicated descendants, so a single tap no longer rebuilds the + // full-screen animated background and page structure. + return _ReadyScoringPage( + controller: controller, + onBack: _backToTasks, + onCorrection: _openCorrectionTasks, + correctionTaskCount: _correctionTasks.length, + onSubmit: _submit, + onServerSubmissionConfirmed: () => + _afterSuccessfulSubmission(controller.isCorrectionMode.value), + onDrawingBoard: _showDrawingBoard, + ); + }), + ), + ); + Future _submit() async { if (controller.isSubmitPending.value) { await controller.retryPendingSubmission(); @@ -449,6 +492,48 @@ class _CorrectionTaskDialog extends StatelessWidget { ); } +class _ScoringLaunchError extends StatelessWidget { + const _ScoringLaunchError({required this.message, required this.onBack}); + + final String message; + final VoidCallback onBack; + + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor: Colors.transparent, + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.cloud_off_outlined, + size: 44, + color: AppColors.primary, + ), + const SizedBox(height: 16), + const AppText( + '无法打开评分表', + style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + AppText(message, textAlign: TextAlign.center), + const SizedBox(height: 22), + AppGradientButton( + onPressed: onBack, + minimumSize: const Size(160, 48), + child: const AppText('返回任务列表'), + ), + ], + ), + ), + ), + ), + ); +} + class _ReadyScoringPage extends StatefulWidget { const _ReadyScoringPage({ required this.controller, diff --git a/test/app_localization_test.dart b/test/app_localization_test.dart index 0af2dca..508c9c6 100644 --- a/test/app_localization_test.dart +++ b/test/app_localization_test.dart @@ -23,6 +23,10 @@ void main() { 'Level 3 difficulty: 4 items', ); expect(AppStrings.translate('2026上海公开赛', locale: english), '2026上海公开赛'); + expect( + AppStrings.translate('正在加载评分表…', locale: english), + 'Loading score sheet…', + ); expect(AppStrings.translate('登录系统', locale: const Locale('zh')), '登录系统'); }, ); diff --git a/test/navigation_performance_test.dart b/test/navigation_performance_test.dart new file mode 100644 index 0000000..ebd6356 --- /dev/null +++ b/test/navigation_performance_test.dart @@ -0,0 +1,57 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get/get.dart'; +import 'package:yd_pattern_scoring_system/common/widgets/animated_app_background.dart'; +import 'package:yd_pattern_scoring_system/core/network/api_client.dart'; +import 'package:yd_pattern_scoring_system/modules/scoring/views/scoring_page.dart'; + +void main() { + tearDown(Get.reset); + + testWidgets('评分路由先完整显示加载页,失败后提供返回入口', (tester) async { + final preparation = Completer(); + await tester.pumpWidget( + GetMaterialApp( + home: Scaffold( + body: TextButton( + onPressed: () => + Get.toNamed('/scoring', arguments: preparation.future), + child: const Text('打开'), + ), + ), + getPages: [GetPage(name: '/scoring', page: ScoringPage.new)], + ), + ); + + await tester.tap(find.text('打开')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 250)); + expect(find.byType(ScoringPage), findsOneWidget); + expect(find.text('正在加载评分表…'), findsOneWidget); + expect(find.text('无法打开评分表'), findsNothing); + + preparation.completeError(const ApiException('网络暂不可用')); + await tester.pump(); + expect(find.text('网络暂不可用'), findsOneWidget); + expect(find.text('返回任务列表'), findsOneWidget); + + await tester.tap(find.text('返回任务列表')); + await tester.pump(const Duration(milliseconds: 250)); + expect(find.text('打开'), findsOneWidget); + }); + + testWidgets('业务页背景使用已优化的单张静态图', (tester) async { + await tester.pumpWidget( + const MaterialApp(home: AnimatedAppBackground(child: SizedBox())), + ); + final image = tester.widget(find.byType(Image)); + expect(image.image, isA()); + expect( + (image.image as AssetImage).assetName, + 'assets/images/background_wave_2048.png', + ); + expect(image.opacity, isNull); + }); +} diff --git a/test/task_queue_policy_test.dart b/test/task_queue_policy_test.dart index ee8fd8b..b166809 100644 --- a/test/task_queue_policy_test.dart +++ b/test/task_queue_policy_test.dart @@ -56,6 +56,33 @@ void main() { ); }); + test('打开某项任务时其他按钮保持原外观,但重复点击不会再次打开', () { + for (final index in [0, 1, 2]) { + expect( + TaskQueuePolicy.isTaskActionAvailable( + index: index, + pendingSubmissionIndex: -1, + ), + isTrue, + ); + expect( + TaskQueuePolicy.canOpenTask( + index: index, + pendingSubmissionIndex: -1, + opening: true, + ), + isFalse, + ); + } + expect( + TaskQueuePolicy.isTaskActionAvailable( + index: 0, + pendingSubmissionIndex: 1, + ), + isFalse, + ); + }); + test('秩序册全局顺序优先于组别场次顺序', () { final values = TaskQueuePolicy.nearestPendingEvent([ _candidate('A-1', 'event', 0, 1, false, 9, scheduleOrder: 1),