feat(app): 主裁本地计时与任意场次评分

主裁评分页新增仅本机运行的正计时器,支持开始、暂停、继续、清零;宽屏位于评分区左侧,窄屏移到上方,切换任务后重新计时。计时数据不进入评分模型、持久化或上传请求。

移除任务列表仅允许第一条未完成场次打分的限制。维持赛事编排排序,将首个未完成场次标为推荐;当前展示赛事中的其他场次可直接进入。未确认提交及页面打开中的并发操作仍受保护。

补充中英文文案、任务队列策略测试与计时器交互和布局测试。验证:flutter analyze 无问题,flutter test 全部通过。
This commit is contained in:
ltx 2026-10-19 15:44:51 +08:00
parent 2229a0198d
commit a3bf1319a0
7 changed files with 352 additions and 29 deletions

View File

@ -207,6 +207,8 @@ abstract final class AppStrings {
'当前赛事任务': 'Current event tasks',
'仅展示最近的待执裁赛事,请按赛事编排顺序依次完成。':
'Only the next pending event is shown. Complete tasks in the scheduled order.',
'仅展示最近的待执裁赛事,可选择任意场次评分。':
'Only the next pending event is shown. Choose any session to score.',
'正在加载裁判任务…': 'Loading judging tasks…',
'暂时无法获取执裁任务': 'Could not load judging tasks',
'重新加载': 'Reload',
@ -217,11 +219,14 @@ abstract final class AppStrings {
'当前展示上一次同步的离线数据;恢复网络后下拉刷新。':
'Showing last synced offline data. Pull down to refresh when online.',
'当前场次': 'Current session',
'推荐场次': 'Suggested session',
'待修正': 'Correction needed',
'等待前序完成': 'Waiting for previous task',
'等待当前提交': 'Waiting for pending submission',
'修改成绩': 'Correct score',
'继续修改': 'Continue correction',
'等待前序': 'Waiting',
'等待提交': 'Submission pending',
'重试画板': 'Retry annotation',
'查看记录': 'View record',
'重试提交': 'Retry submission',
@ -267,6 +272,12 @@ abstract final class AppStrings {
'提交打分': 'Submit score',
'参赛运动员:': 'Athletes:',
'打分区域': 'Scoring area',
'正计时': 'Elapsed time',
'开始计时': 'Start',
'暂停计时': 'Pause',
'继续计时': 'Resume',
'清零': 'Reset',
'仅本机计时,不上传': 'Device-only timer; not uploaded',
'规定元素打分区域': 'Required elements',
'互动配合规定元素打分区域': 'Interaction required elements',
'难度打分区域': 'Difficulty scoring',

View File

@ -49,7 +49,8 @@ abstract final class TaskQueuePolicy {
];
}
/// Only the first unfinished item is scoreable.
/// The first unfinished item is suggested for the "continue" action only.
/// Other tasks in the visible event may be selected in any order.
static int activeIndex(Iterable<bool> submitted) {
var index = 0;
for (final value in submitted) {
@ -59,6 +60,16 @@ abstract final class TaskQueuePolicy {
return -1;
}
/// An unconfirmed submission must be resolved before switching tasks, but
/// ordinary unfinished tasks do not lock later entries in the queue.
static bool canOpenTask({
required int index,
required int pendingSubmissionIndex,
required bool opening,
}) =>
!opening &&
(pendingSubmissionIndex < 0 || index == pendingSubmissionIndex);
static int _compareCandidates<T>(
TaskQueueCandidate<T> a,
TaskQueueCandidate<T> b,

View File

@ -158,7 +158,7 @@ class _HomePageState extends State<HomePage> {
status == ScoringTaskStatus.waitingUpload.label ||
status == ScoringTaskStatus.syncing.label,
);
final activeIndex = TaskQueuePolicy.activeIndex(
final suggestedIndex = TaskQueuePolicy.activeIndex(
statuses.map(_isSubmittedStatus),
);
return Scaffold(
@ -216,21 +216,16 @@ class _HomePageState extends State<HomePage> {
_remoteTasks[entry.$1].session,
),
status: statuses[entry.$1],
isCurrent: entry.$1 == activeIndex,
isSuggested: entry.$1 == suggestedIndex,
isLocked:
!_isSubmittedStatus(statuses[entry.$1]) &&
entry.$1 != activeIndex,
blockingIndex >= 0 &&
entry.$1 != blockingIndex,
onTap:
_openingEntryId ==
_remoteTasks[entry.$1]
.entry
.sessionEntryId ||
(blockingIndex >= 0 &&
entry.$1 != blockingIndex) ||
(!_isSubmittedStatus(
statuses[entry.$1],
) &&
entry.$1 != activeIndex)
!TaskQueuePolicy.canOpenTask(
index: entry.$1,
pendingSubmissionIndex: blockingIndex,
opening: _openingEntryId != null,
)
? null
: statuses[entry.$1] ==
ScoringTaskStatus
@ -858,7 +853,7 @@ class _PageHeading extends StatelessWidget {
),
const SizedBox(height: 10),
const AppText(
'仅展示最近的待执裁赛事,请按赛事编排顺序依次完成。',
'仅展示最近的待执裁赛事,可选择任意场次评分。',
style: TextStyle(color: AppColors.textSecondary, fontSize: 15),
),
],
@ -900,7 +895,7 @@ class _TaskCard extends StatelessWidget {
required this.sessionDisplayOrder,
required this.roleLabel,
required this.status,
required this.isCurrent,
required this.isSuggested,
required this.isLocked,
required this.onTap,
});
@ -908,7 +903,7 @@ class _TaskCard extends StatelessWidget {
final int sessionDisplayOrder;
final String roleLabel;
final String status;
final bool isCurrent;
final bool isSuggested;
final bool isLocked;
final VoidCallback? onTap;
@ -967,10 +962,10 @@ class _TaskCard extends StatelessWidget {
border: Border.all(
color: correction
? const Color(0xFFF2C45A)
: isCurrent
: isSuggested
? AppColors.primary.withValues(alpha: .48)
: const Color(0xFFD9E6F6),
width: correction || isCurrent ? 1.4 : 1,
width: correction || isSuggested ? 1.4 : 1,
),
borderRadius: BorderRadius.circular(16),
boxShadow: const [
@ -1034,7 +1029,7 @@ class _TaskCard extends StatelessWidget {
),
),
),
if (isCurrent) ...[
if (isSuggested) ...[
const SizedBox(width: 10),
Container(
padding: const EdgeInsets.symmetric(
@ -1046,7 +1041,7 @@ class _TaskCard extends StatelessWidget {
borderRadius: BorderRadius.circular(7),
),
child: const AppText(
'当前场次',
'推荐场次',
style: TextStyle(
color: AppColors.primary,
fontSize: 11,
@ -1129,7 +1124,7 @@ class _TaskCard extends StatelessWidget {
correction
? '待修正'
: isLocked
? '等待前序完成'
? '等待当前提交'
: status,
style: TextStyle(
color: color,
@ -1162,7 +1157,7 @@ class _TaskCard extends StatelessWidget {
correction
? '修改成绩'
: isLocked
? '等待前序'
? '等待提交'
: annotationPending
? '重试画板'
: submitted

View File

@ -19,6 +19,7 @@ import '../../../core/storage/local_storage_service.dart';
import '../../../core/storage/scoring_storage_keys.dart';
import '../controllers/scoring_controller.dart';
import '../widgets/competition_header.dart';
import '../widgets/chief_stopwatch_panel.dart';
import '../widgets/drawing_board_dialog.dart';
import '../widgets/record_button.dart';
import '../widgets/scoring_dialogs.dart';
@ -1511,10 +1512,41 @@ class _ChiefPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (controller.requiredElementCounts.isEmpty) {
return _ChiefDeductionPanel(controller: controller);
}
return _ChiefRequiredDeductionPanel(controller: controller);
final scoringPanel = controller.requiredElementCounts.isEmpty
? _ChiefDeductionPanel(controller: controller)
: _ChiefRequiredDeductionPanel(controller: controller);
final stopwatch = ChiefStopwatchPanel(
key: ValueKey('chief-stopwatch-${controller.activeTask.taskId}'),
);
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth < 1250) {
return Column(
children: [
SizedBox(
height: 78,
child: ChiefStopwatchPanel(
key: ValueKey(
'chief-stopwatch-${controller.activeTask.taskId}',
),
compact: true,
),
),
const SizedBox(height: 12),
Expanded(child: scoringPanel),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(width: 205, child: stopwatch),
const SizedBox(width: 14),
Expanded(child: scoringPanel),
],
);
},
);
}
}

View File

@ -0,0 +1,169 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../../app/localization/app_text.dart';
import '../../../app/theme/app_theme.dart';
import '../../../common/widgets/app_gradient_button.dart';
/// An on-screen aid for the chief judge. Elapsed time is never persisted or
/// included in a score-sheet request.
class ChiefStopwatchPanel extends StatefulWidget {
const ChiefStopwatchPanel({super.key, this.compact = false});
final bool compact;
static String formatElapsed(Duration elapsed) {
final hours = elapsed.inHours.toString().padLeft(2, '0');
final minutes = (elapsed.inMinutes % 60).toString().padLeft(2, '0');
final seconds = (elapsed.inSeconds % 60).toString().padLeft(2, '0');
return '$hours:$minutes:$seconds';
}
@override
State<ChiefStopwatchPanel> createState() => _ChiefStopwatchPanelState();
}
class _ChiefStopwatchPanelState extends State<ChiefStopwatchPanel> {
final Stopwatch _stopwatch = Stopwatch();
Timer? _ticker;
@override
void dispose() {
_ticker?.cancel();
_stopwatch.stop();
super.dispose();
}
void _toggle() {
setState(() {
if (_stopwatch.isRunning) {
_stopwatch.stop();
_ticker?.cancel();
_ticker = null;
} else {
_stopwatch.start();
_ticker = Timer.periodic(const Duration(milliseconds: 250), (_) {
if (mounted) setState(() {});
});
}
});
}
void _reset() {
setState(() {
_ticker?.cancel();
_ticker = null;
_stopwatch
..stop()
..reset();
});
}
@override
Widget build(BuildContext context) {
final elapsed = ChiefStopwatchPanel.formatElapsed(_stopwatch.elapsed);
final action = _stopwatch.isRunning
? '暂停计时'
: _stopwatch.elapsed == Duration.zero
? '开始计时'
: '继续计时';
final startButton = AppGradientButton(
onPressed: _toggle,
minimumSize: Size(widget.compact ? 116 : 145, 42),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
icon: widget.compact
? _stopwatch.isRunning
? Icons.pause_rounded
: Icons.play_arrow_rounded
: null,
child: AppText(action),
);
final resetButton = OutlinedButton(
onPressed: _stopwatch.elapsed == Duration.zero ? null : _reset,
child: const AppText('清零'),
);
return Container(
padding: EdgeInsets.all(widget.compact ? 12 : 18),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .97),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
boxShadow: const [
BoxShadow(
color: Color(0x101767D2),
blurRadius: 12,
offset: Offset(0, 4),
),
],
),
child: widget.compact
? Row(
children: [
const Icon(Icons.timer_outlined, color: AppColors.primary),
const SizedBox(width: 8),
const AppText(
'正计时',
style: TextStyle(fontWeight: FontWeight.w700),
),
const SizedBox(width: 18),
AppText(
elapsed,
style: const TextStyle(
color: AppColors.primaryDark,
fontSize: 25,
fontFeatures: [FontFeature.tabularFigures()],
fontWeight: FontWeight.w800,
),
),
const Spacer(),
startButton,
const SizedBox(width: 8),
resetButton,
],
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.timer_outlined,
color: AppColors.primary,
size: 38,
),
const SizedBox(height: 8),
const AppText(
'正计时',
style: TextStyle(
color: AppColors.textPrimary,
fontSize: 18,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 12),
AppText(
elapsed,
style: const TextStyle(
color: AppColors.primaryDark,
fontSize: 27,
fontFeatures: [FontFeature.tabularFigures()],
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 8),
const AppText(
'仅本机计时,不上传',
textAlign: TextAlign.center,
style: TextStyle(
color: AppColors.textSecondary,
fontSize: 12,
),
),
const SizedBox(height: 22),
startButton,
const SizedBox(height: 8),
resetButton,
],
),
);
}
}

View File

@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:yd_pattern_scoring_system/app/localization/app_language_service.dart';
import 'package:yd_pattern_scoring_system/modules/scoring/widgets/chief_stopwatch_panel.dart';
void main() {
test('正计时以小时、分钟、秒展示,不在 24 小时处归零', () {
expect(ChiefStopwatchPanel.formatElapsed(Duration.zero), '00:00:00');
expect(
ChiefStopwatchPanel.formatElapsed(
const Duration(hours: 27, minutes: 2, seconds: 4),
),
'27:02:04',
);
});
testWidgets('主裁可开始、暂停、继续和清零本地计时', (tester) async {
AppLanguageService.instance = AppLanguageService.memory(initialCode: 'en');
addTearDown(() => AppLanguageService.instance = null);
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: Center(
child: SizedBox(
width: 205,
height: 500,
child: ChiefStopwatchPanel(),
),
),
),
),
);
expect(find.text('00:00:00'), findsOneWidget);
expect(find.text('Start'), findsOneWidget);
await tester.tap(find.text('Start'));
await tester.pump();
expect(find.text('Pause'), findsOneWidget);
await tester.runAsync(
() => Future<void>.delayed(const Duration(milliseconds: 1100)),
);
await tester.tap(find.text('Pause'));
await tester.pump();
expect(find.text('00:00:01'), findsOneWidget);
expect(find.text('Resume'), findsOneWidget);
await tester.tap(find.text('Reset'));
await tester.pump();
expect(find.text('Start'), findsOneWidget);
expect(find.text('00:00:00'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('窄横屏计时栏不会挤出评分区域', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: SizedBox(
width: 900,
height: 78,
child: ChiefStopwatchPanel(compact: true),
),
),
),
);
expect(find.text('开始计时'), findsOneWidget);
expect(tester.takeException(), isNull);
});
}

View File

@ -13,12 +13,49 @@ void main() {
expect(values, ['next-done', 'next-1', 'next-2']);
});
test('队列中只有第一个未完成任务可打分', () {
test('队列首个未完成任务只用于推荐下一场', () {
expect(TaskQueuePolicy.activeIndex([true, false, false]), 1);
expect(TaskQueuePolicy.activeIndex([false, false]), 0);
expect(TaskQueuePolicy.activeIndex([true, true]), -1);
});
test('同一赛事可任意选择待评分场次,未确认提交仍需先处理', () {
for (final index in [0, 1, 2]) {
expect(
TaskQueuePolicy.canOpenTask(
index: index,
pendingSubmissionIndex: -1,
opening: false,
),
isTrue,
);
}
expect(
TaskQueuePolicy.canOpenTask(
index: 2,
pendingSubmissionIndex: 1,
opening: false,
),
isFalse,
);
expect(
TaskQueuePolicy.canOpenTask(
index: 1,
pendingSubmissionIndex: 1,
opening: false,
),
isTrue,
);
expect(
TaskQueuePolicy.canOpenTask(
index: 1,
pendingSubmissionIndex: -1,
opening: true,
),
isFalse,
);
});
test('秩序册全局顺序优先于组别场次顺序', () {
final values = TaskQueuePolicy.nearestPendingEvent([
_candidate('A-1', 'event', 0, 1, false, 9, scheduleOrder: 1),