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),