feat(app): 主裁本地计时与任意场次评分
主裁评分页新增仅本机运行的正计时器,支持开始、暂停、继续、清零;宽屏位于评分区左侧,窄屏移到上方,切换任务后重新计时。计时数据不进入评分模型、持久化或上传请求。 移除任务列表仅允许第一条未完成场次打分的限制。维持赛事编排排序,将首个未完成场次标为推荐;当前展示赛事中的其他场次可直接进入。未确认提交及页面打开中的并发操作仍受保护。 补充中英文文案、任务队列策略测试与计时器交互和布局测试。验证:flutter analyze 无问题,flutter test 全部通过。
This commit is contained in:
parent
2229a0198d
commit
a3bf1319a0
@ -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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
169
lib/modules/scoring/widgets/chief_stopwatch_panel.dart
Normal file
169
lib/modules/scoring/widgets/chief_stopwatch_panel.dart
Normal 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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
68
test/chief_stopwatch_panel_test.dart
Normal file
68
test/chief_stopwatch_panel_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
@ -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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user