fix(app): 优化平板帧率与评分任务交互
性能与页面切换:将公共背景图预缩放并预合成透明度,避免大图重复解码;根据 ELE2-W00 实测将 Android 渲染后端从 Impeller/Vulkan 切换到更快的 OpenGL;评分路由先整体显示加载页,再并行获取评分表和完成角色控制器初始化,返回时避开转场刷新任务列表。 任务交互:拆分按钮外观可用性和打开期间的点击拦截,防止点击一项任务时其他任务按钮同步变灰;重试画板时复用忙碌保护,避免重复触发。补齐加载及异常提示的英文文案。 验证:新增导航加载态、背景资源及任务按钮状态回归测试;flutter analyze --no-pub 无问题,flutter test --no-pub 全部 99 项通过;在 ELE2-W00 上覆盖安装 Release APK,验证任务查看与返回,任务列表滑动帧间隔主要约 11.1 ms。
This commit is contained in:
parent
f56ed293e8
commit
9ad8f955e0
@ -5,9 +5,12 @@
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round">
|
||||
<!-- On the ELE2-W00, Impeller/Vulkan took ~43 ms (p90) to rasterize
|
||||
the task list; Flutter's OpenGL renderer took ~4-7 ms in the
|
||||
same release-mode gesture. Keep the measured faster backend. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.EnableImpeller"
|
||||
android:value="true" />
|
||||
android:value="false" />
|
||||
<activity
|
||||
android:name=".SplashActivity"
|
||||
android:exported="true"
|
||||
|
||||
BIN
assets/images/background_wave_2048.png
Normal file
BIN
assets/images/background_wave_2048.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 244 KiB |
@ -210,6 +210,7 @@ abstract final class AppStrings {
|
||||
'仅展示最近的待执裁赛事,可选择任意场次评分。':
|
||||
'Only the next pending event is shown. Choose any session to score.',
|
||||
'正在加载裁判任务…': 'Loading judging tasks…',
|
||||
'正在加载评分表…': 'Loading score sheet…',
|
||||
'暂时无法获取执裁任务': 'Could not load judging tasks',
|
||||
'重新加载': 'Reload',
|
||||
'当前没有待评分赛事任务': 'No pending judging tasks',
|
||||
@ -404,6 +405,14 @@ abstract final class AppStrings {
|
||||
'请刷新任务后重试': 'Refresh tasks and try again',
|
||||
'裁判席位已更新': 'Judge seat updated',
|
||||
'无法打开评分表': 'Unable to open score sheet',
|
||||
'正在打开评分表': 'Opening score sheet',
|
||||
'评分表加载完成后即可返回任务列表':
|
||||
'You can return to tasks after the score sheet finishes loading',
|
||||
'评分表准备失败,请返回任务列表后重试':
|
||||
'Could not prepare the score sheet. Return to tasks and try again.',
|
||||
'修正任务暂不可用,请刷新任务后重试':
|
||||
'Correction task unavailable. Refresh tasks and try again.',
|
||||
'登录已失效,请重新登录': 'Session expired. Please sign in again.',
|
||||
'无法打开修正任务': 'Unable to open correction task',
|
||||
'该任务已失效或后台已撤回,请刷新后重试':
|
||||
'This task expired or was withdrawn. Refresh and try again.',
|
||||
|
||||
@ -29,39 +29,32 @@ abstract final class AppPages {
|
||||
),
|
||||
GetPage(
|
||||
name: AppRoutes.home,
|
||||
page: () =>
|
||||
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),
|
||||
),
|
||||
|
||||
@ -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<double>(gridOpacity),
|
||||
filterQuality: FilterQuality.low,
|
||||
excludeFromSemantics: true,
|
||||
gaplessPlayback: true,
|
||||
),
|
||||
),
|
||||
|
||||
@ -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<T>(
|
||||
TaskQueueCandidate<T> a,
|
||||
|
||||
@ -221,25 +221,39 @@ class _HomePageState extends State<HomePage> {
|
||||
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<HomePage> {
|
||||
}
|
||||
|
||||
Future<void> _retryAnnotation(String sessionEntryId) async {
|
||||
await Get.find<BackgroundSyncService>().retryAnnotation(sessionEntryId);
|
||||
if (mounted) setState(() {});
|
||||
_openingEntryId = sessionEntryId;
|
||||
try {
|
||||
await Get.find<BackgroundSyncService>().retryAnnotation(sessionEntryId);
|
||||
if (mounted) setState(() {});
|
||||
} finally {
|
||||
_openingEntryId = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openResults() async {
|
||||
@ -387,77 +406,21 @@ class _HomePageState extends State<HomePage> {
|
||||
_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<ScoringController>()) {
|
||||
await Get.delete<ScoringController>(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<ScoreRepository>(),
|
||||
Get.find<LocalStorageService>(),
|
||||
),
|
||||
);
|
||||
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<void>.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<HomePage> {
|
||||
} on ApiException catch (error) {
|
||||
AppNotice.show('无法打开评分表', error.message);
|
||||
} finally {
|
||||
if (mounted) setState(() => _openingEntryId = null);
|
||||
_openingEntryId = null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<void>([
|
||||
sheetFuture.then<void>((_) {}),
|
||||
Future<void>.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<ScoringController>()) {
|
||||
await Get.delete<ScoringController>(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<ScoreRepository>(),
|
||||
Get.find<LocalStorageService>(),
|
||||
),
|
||||
);
|
||||
await scoringController.initialize();
|
||||
if (assignmentChanged) {
|
||||
AppNotice.show(
|
||||
'裁判席位已更新',
|
||||
'已按最新评分表切换为${RemoteScoringAdapter.roleNameForType(sheet.header.judgeRoleType)} · ${sheet.header.judgeRole}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<ScoringPage> with WidgetsBindingObserver {
|
||||
ScoringController get controller => Get.find<ScoringController>();
|
||||
|
||||
bool _launchReady = true;
|
||||
String? _launchError;
|
||||
List<_CorrectionTask> _correctionTasks = const [];
|
||||
Future<void>? _correctionLoadFuture;
|
||||
Timer? _correctionTimer;
|
||||
@ -44,8 +47,34 @@ class _ScoringPageState extends State<ScoringPage> 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<void>) {
|
||||
_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<ScoringPage> 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<void> _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,
|
||||
|
||||
@ -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')), '登录系统');
|
||||
},
|
||||
);
|
||||
|
||||
57
test/navigation_performance_test.dart
Normal file
57
test/navigation_performance_test.dart
Normal file
@ -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<void>();
|
||||
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<Image>(find.byType(Image));
|
||||
expect(image.image, isA<AssetImage>());
|
||||
expect(
|
||||
(image.image as AssetImage).assetName,
|
||||
'assets/images/background_wave_2048.png',
|
||||
);
|
||||
expect(image.opacity, isNull);
|
||||
});
|
||||
}
|
||||
@ -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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user