fix(app): 完善英文评分布局与顶部提示

将操作提示移至顶部居中,增加滑入、淡入和轻微缩放动画,保持下方操作可用。

补齐规定赛、同步花样、车轮跳及交互绳等项目英文名称,缩短英文计数按钮文案。

窄横屏下将评分页顶部操作按钮移至第二行,计分表按钮按可用宽度换行;补充翻译、提示和英文布局回归测试。
This commit is contained in:
ltx 2026-10-14 17:23:27 +08:00
parent 780411037c
commit ccdfae4160
7 changed files with 223 additions and 92 deletions

View File

@ -92,21 +92,31 @@ class AppNoticeHost extends StatelessWidget {
Positioned(
left: 16,
right: 16,
bottom: media.viewInsets.bottom + media.padding.bottom + 20,
top: media.padding.top + 28,
child: IgnorePointer(
child: Center(
child: SizedBox(
width: width,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
duration: const Duration(milliseconds: 300),
reverseDuration: const Duration(milliseconds: 180),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) => SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, .18),
begin: const Offset(0, -.28),
end: Offset.zero,
).animate(animation),
child: FadeTransition(opacity: animation, child: child),
child: FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween<double>(
begin: .97,
end: 1,
).animate(animation),
child: child,
),
),
),
child: notice == null
? const SizedBox.shrink(key: ValueKey('notice-empty'))

View File

@ -145,6 +145,10 @@ abstract final class AppStrings {
if (joined.length > 1) {
return joined.map((part) => translate(part, locale: locale)).join(' · ');
}
final spaced = source.split(' ');
if (spaced.length > 1) {
return spaced.map((part) => translate(part, locale: locale)).join(' ');
}
return source;
}
@ -293,8 +297,8 @@ abstract final class AppStrings {
'当前累计扣分(分)': 'Current total deductions (points)',
'本裁判预览;综合系数见成绩汇总': 'Judge preview; combined factor in results',
'减数量 / 加数量': 'Decreases / increases',
'减数量': 'Decrease count',
'加数量': 'Increase count',
'减数量': '− Count',
'加数量': '+ Count',
'失误项目': 'Error item',
'次数': 'Times',
'失误次数': 'Error count',
@ -484,8 +488,21 @@ abstract final class AppStrings {
'完成质量裁判': 'Execution judge',
'主裁判': 'Chief judge',
'规定赛': 'Prescribed routine',
'规定赛(个人参赛)': 'Prescribed routine (individual)',
'规定赛(集体参赛)': 'Prescribed routine (team)',
'个人花样': 'Individual freestyle',
'个人花样(精英级)': 'Individual freestyle (elite)',
'两人同步花样': 'Two-person synchronized freestyle',
'两人同步花样(精英级)': 'Two-person synchronized freestyle (elite)',
'四人同步花样': 'Four-person synchronized freestyle',
'四人同步花样(精英级)': 'Four-person synchronized freestyle (elite)',
'两人车轮跳花样': 'Two-person wheel rope freestyle',
'两人车轮跳花样(精英级)': 'Two-person wheel rope freestyle (elite)',
'车轮跳花样(精英级)': 'Wheel rope freestyle (elite)',
'三人交互绳花样': 'Three-person Double Dutch freestyle',
'三人交互绳花样(精英级)': 'Three-person Double Dutch freestyle (elite)',
'四人交互绳花样': 'Four-person Double Dutch freestyle',
'四人交互绳花样(精英级)': 'Four-person Double Dutch freestyle (elite)',
'交互绳花样(精英级)': 'Double Dutch freestyle (elite)',
'表演赛(精英级)': 'Show routine (elite)',
'个人参赛': 'Individual entry',

View File

@ -835,8 +835,10 @@ class _DifficultyPanel extends StatelessWidget {
)
: _NumberText('${controller.difficultyCounts[levels.single] ?? 0}'),
_NumberText(subtotal.toStringAsFixed(2)),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
Wrap(
alignment: WrapAlignment.center,
spacing: 6,
runSpacing: 6,
children: [
for (final level in levels)
_ActionButton(
@ -1284,8 +1286,10 @@ class _PerformancePanel extends StatelessWidget {
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 6,
children: [
_DeltaButton(
label: '减数量',
@ -1306,7 +1310,6 @@ class _PerformancePanel extends StatelessWidget {
}
},
),
const SizedBox(width: 8),
_ActionButton(
label: '加数量',
onTap: controller.isReadOnly

View File

@ -74,90 +74,88 @@ class CompetitionHeader extends StatelessWidget {
child: LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 1180;
final stackActions = constraints.maxWidth < 1100;
final titleWidth = (constraints.maxWidth * .19)
.clamp(compact ? 170.0 : 220.0, 340.0)
.toDouble();
final gap = compact ? 6.0 : 10.0;
return Row(
children: [
IconButton(
onPressed: onBack,
tooltip: AppStrings.of(context, '返回任务列表'),
icon: const Icon(
Icons.double_arrow_rounded,
color: AppColors.primary,
size: 28,
),
final leading = <Widget>[
IconButton(
onPressed: onBack,
tooltip: AppStrings.of(context, '返回任务列表'),
icon: const Icon(
Icons.double_arrow_rounded,
color: AppColors.primary,
size: 28,
),
SizedBox(
width: titleWidth,
child: Tooltip(
message: eventLabel,
child: AppText(
eventLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: compact ? 16 : 18,
fontWeight: FontWeight.w700,
),
),
SizedBox(
width: titleWidth,
child: Tooltip(
message: eventLabel,
child: AppText(
eventLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: compact ? 16 : 18,
fontWeight: FontWeight.w700,
),
),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: gap),
child: const Icon(
Icons.double_arrow_rounded,
color: AppColors.primary,
),
),
Expanded(
child: Row(
children: [
Expanded(
flex: 3,
child: _InfoPill(
icon: Icons.person_outline,
text: competitionLabel,
compact: compact,
),
),
SizedBox(width: gap),
Expanded(
flex: 2,
child: _InfoPill(
icon: Icons.layers_outlined,
text: groupLabel,
compact: compact,
),
),
SizedBox(width: gap),
Expanded(
flex: 2,
child: _InfoPill(
icon: Icons.location_on_outlined,
text: sessionLabel,
compact: compact,
),
),
SizedBox(width: gap),
Expanded(
flex: 2,
child: _InfoPill(
icon: Icons.hub_outlined,
text: profile.roleName,
compact: compact,
),
),
],
),
),
const SizedBox(width: 6),
const Icon(
Icons.chevron_right_rounded,
),
Padding(
padding: EdgeInsets.symmetric(horizontal: gap),
child: const Icon(
Icons.double_arrow_rounded,
color: AppColors.primary,
size: 30,
),
),
];
final taskInfo = Expanded(
child: Row(
children: [
Expanded(
flex: 3,
child: _InfoPill(
icon: Icons.person_outline,
text: competitionLabel,
compact: compact,
),
),
SizedBox(width: gap),
Expanded(
flex: 2,
child: _InfoPill(
icon: Icons.layers_outlined,
text: groupLabel,
compact: compact,
),
),
SizedBox(width: gap),
Expanded(
flex: 2,
child: _InfoPill(
icon: Icons.location_on_outlined,
text: sessionLabel,
compact: compact,
),
),
SizedBox(width: gap),
Expanded(
flex: 2,
child: _InfoPill(
icon: Icons.hub_outlined,
text: profile.roleName,
compact: compact,
),
),
],
),
);
final actions = Row(
mainAxisSize: MainAxisSize.min,
children: [
Badge(
isLabelVisible: correctionTaskCount > 0,
backgroundColor: const Color(0xFFE9A400),
@ -206,6 +204,28 @@ class CompetitionHeader extends StatelessWidget {
),
],
);
if (stackActions) {
return Column(
children: [
Row(children: [...leading, taskInfo]),
const SizedBox(height: 8),
Align(alignment: Alignment.centerRight, child: actions),
],
);
}
return Row(
children: [
...leading,
taskInfo,
const SizedBox(width: 6),
const Icon(
Icons.chevron_right_rounded,
color: AppColors.primary,
size: 30,
),
actions,
],
);
},
),
),
@ -327,7 +347,7 @@ class _InfoPill extends StatelessWidget {
SizedBox(width: compact ? 4 : 7),
Flexible(
child: Tooltip(
message: text,
message: AppStrings.of(context, text),
child: AppText(
text,
maxLines: 1,

View File

@ -27,6 +27,39 @@ void main() {
},
);
test('English translates every built-in competition project', () {
const english = Locale('en');
const projects = <String, String>{
'规定赛(个人参赛)': 'Prescribed routine (individual)',
'规定赛(集体参赛)': 'Prescribed routine (team)',
'个人花样(精英级)': 'Individual freestyle (elite)',
'两人同步花样(精英级)': 'Two-person synchronized freestyle (elite)',
'四人同步花样(精英级)': 'Four-person synchronized freestyle (elite)',
'两人车轮跳花样(精英级)': 'Two-person wheel rope freestyle (elite)',
'三人交互绳花样(精英级)': 'Three-person Double Dutch freestyle (elite)',
'四人交互绳花样(精英级)': 'Four-person Double Dutch freestyle (elite)',
'表演赛(精英级)': 'Show routine (elite)',
};
for (final project in projects.entries) {
expect(AppStrings.translate(project.key, locale: english), project.value);
}
expect(
AppStrings.translate('四人交互绳花样', locale: english),
'Four-person Double Dutch freestyle',
);
expect(
AppStrings.translate('四人交互绳花样(精英级) 1-2 张三', locale: english),
'Four-person Double Dutch freestyle (elite) 1-2 张三',
);
expect(AppStrings.translate('四人交互绳花样纪念赛', locale: english), '四人交互绳花样纪念赛');
});
test('English scoring controls use concise labels', () {
const english = Locale('en');
expect(AppStrings.translate('减数量', locale: english), '− Count');
expect(AppStrings.translate('加数量', locale: english), '+ Count');
});
test('language selection persists and is restored after restart', () async {
final directory = await Directory.systemTemp.createTemp('scoring-locale-');
Hive.init(directory.path);

View File

@ -9,12 +9,17 @@ void main() {
var presses = 0;
await tester.pumpWidget(
MaterialApp(
builder: (context, child) => AppNoticeHost(child: child!),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(padding: const EdgeInsets.only(top: 24)),
child: AppNoticeHost(child: child!),
),
home: Scaffold(
body: Align(
alignment: Alignment.bottomCenter,
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.only(top: 50),
child: SizedBox(
width: 380,
height: 80,
@ -37,10 +42,19 @@ void main() {
await tester.tap(find.text('操作按钮'));
await tester.pumpAndSettle();
expect(find.text('保存成功'), findsOneWidget);
final cardRect = tester.getRect(
find.byKey(const ValueKey('app-notice-card')),
);
expect(cardRect.top, greaterThanOrEqualTo(24 + 28));
expect(
tester
.getRect(find.byKey(const ValueKey('app-notice-card')))
.overlaps(tester.getRect(find.byType(ElevatedButton))),
cardRect.center.dx,
closeTo(
tester.view.physicalSize.width / tester.view.devicePixelRatio / 2,
1,
),
);
expect(
cardRect.overlaps(tester.getRect(find.byType(ElevatedButton))),
isTrue,
);

View File

@ -1,5 +1,6 @@
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/core/models/judge_account.dart';
import 'package:yd_pattern_scoring_system/modules/scoring/widgets/competition_header.dart';
@ -18,6 +19,7 @@ void main() {
required Size size,
required String eventLabel,
int correctionTaskCount = 0,
bool correctionMode = false,
}) async {
await tester.binding.setSurfaceSize(size);
addTearDown(() => tester.binding.setSurfaceSize(null));
@ -36,6 +38,7 @@ void main() {
sessionLabel: '决赛场地一第十二场',
teamLabel: '跃动科技代表队',
correctionTaskCount: correctionTaskCount,
correctionMode: correctionMode,
),
),
),
@ -73,4 +76,35 @@ void main() {
expect(find.text('3'), findsOneWidget);
expect(tester.takeException(), isNull);
});
testWidgets('英文评分页顶部在平板宽度下保留全部操作按钮', (tester) async {
AppLanguageService.instance = AppLanguageService.memory(initialCode: 'en');
addTearDown(() => AppLanguageService.instance = null);
await pumpHeader(
tester,
size: const Size(900, 720),
eventLabel: '2026年全国花样跳绳精英挑战赛暨青少年锦标赛总决赛上海分站',
correctionTaskCount: 3,
);
expect(find.text('Correction\ntasks'), findsOneWidget);
expect(find.text('Submit score'), findsOneWidget);
expect(tester.getTopRight(find.text('Submit score')).dx, lessThan(900));
expect(tester.takeException(), isNull);
});
testWidgets('英文修正按钮在单行布局阈值下也不溢出', (tester) async {
AppLanguageService.instance = AppLanguageService.memory(initialCode: 'en');
addTearDown(() => AppLanguageService.instance = null);
await pumpHeader(
tester,
size: const Size(1100, 720),
eventLabel: '2026年全国花样跳绳精英挑战赛暨青少年锦标赛总决赛上海分站',
correctionMode: true,
);
expect(find.text('Submit correction'), findsOneWidget);
expect(tester.getTopRight(find.text('Submit correction')).dx, lessThan(1100));
expect(tester.takeException(), isNull);
});
}