diff --git a/.gitignore b/.gitignore index 28bce45..bb965d8 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,5 @@ app.*.map.json .video_agent/ .mimosa/ .v2c/ +.zcode/ .zcodeignore diff --git a/lib/common/values/constants.dart b/lib/common/values/constants.dart index e22da94..72c24a3 100644 --- a/lib/common/values/constants.dart +++ b/lib/common/values/constants.dart @@ -15,8 +15,8 @@ class Constants { static const pwdEncKey = 'pigxpigxpigxpigx'; /// 服务 url 线上 - // static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; - static const apiUrl = 'https://www.zianxin.com/api'; + static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; + // static const apiUrl = 'https://www.zianxin.com/api'; // static const apiUrl = 'https://dzc-test-1.fy.takin.cc'; diff --git a/lib/common/widgets/app/app_soft_keyboard.dart b/lib/common/widgets/app/app_soft_keyboard.dart index 0f1c1eb..06a2366 100644 --- a/lib/common/widgets/app/app_soft_keyboard.dart +++ b/lib/common/widgets/app/app_soft_keyboard.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:ducafe_ui_core/ducafe_ui_core.dart'; import 'package:flutter/material.dart'; import 'package:forui/forui.dart'; @@ -249,10 +251,13 @@ class _AppSoftKeyboardPanelState extends State { ), ), ), - key( - '退格删除一位', + // 退格是全键盘唯一的长按键:点按删一位,长按连发持续删除。 + _Key( + semanticLabel: '退格删除,长按连续删除', onTap: _backspace, - content: FIcon( + onLongPressRepeat: _backspace, + width: keyWidth, + child: FIcon( FAssets.icons.delete, color: XfjDesignTokens.muted, size: 22.sp, @@ -351,15 +356,17 @@ class _AppSoftKeyboardPanelState extends State { content: Text('空格', style: actionStyle), onTap: () => _insert(' '), ), - actionKey( - '退格删除一位', + // 退格是全键盘唯一的长按键:点按删一位,长按连发持续删除。 + _Key( + semanticLabel: '退格删除,长按连续删除', + onTap: _backspace, + onLongPressRepeat: _backspace, width: avail * 2 / 9.5, - content: FIcon( + child: FIcon( FAssets.icons.delete, color: XfjDesignTokens.muted, size: 22.sp, ), - onTap: _backspace, ), if (widget.onCloseRequested != null) actionKey( @@ -433,9 +440,11 @@ class _AppSoftKeyboardPanelState extends State { width: avail * 2 / 5, child: Text('空格', style: actionStyle), ), + // 退格是全键盘唯一的长按键:点按删一位,长按连发持续删除。 _Key( - semanticLabel: '退格删除一位', + semanticLabel: '退格删除,长按连续删除', onTap: _backspace, + onLongPressRepeat: _backspace, width: avail * 1.5 / 5, child: FIcon( FAssets.icons.delete, @@ -626,9 +635,10 @@ class _SoftKeyboardDialogState extends State<_SoftKeyboardDialog> { /// 单个键盘按键;固定宽度布局(弹层处于无界高度约束,禁用 Expanded), /// 主操作键使用主题色底白字。 /// -/// 按压反馈走“整键缩放”而非涟漪:Listener 直接监听指针按下/抬起,只触发 -/// 本键的一次 Transform 动画,不参与手势竞技场、不引起布局重排,是触摸 -/// 反馈里开销最低的实现;透明 Material 上涟漪本就不可见。 +/// 按压反馈走“整键缩放 + 主题色渐变”而非涟漪:Listener 直接监听指针按下/ +/// 抬起,只触发本键的一次 Transform 与 decoration 隐式动画,不参与手势 +/// 竞技场、不引起布局重排,是触摸反馈里开销最低的实现;透明 Material 上 +/// 涟漪本就不可见。 class _Key extends StatefulWidget { const _Key({ required this.semanticLabel, @@ -637,6 +647,7 @@ class _Key extends StatefulWidget { required this.child, this.emphasized = false, this.height, + this.onLongPressRepeat, }); final String semanticLabel; @@ -654,6 +665,10 @@ class _Key extends StatefulWidget { /// 按键高度;默认 62。 final double? height; + /// 长按连发回调;非空时按住超过系统长按阈值后先触发一次,随后周期连发。 + /// 整个键盘目前只有退格键使用(长按持续删除)。 + final VoidCallback? onLongPressRepeat; + @override State<_Key> createState() => _KeyState(); } @@ -662,49 +677,143 @@ class _KeyState extends State<_Key> { /// 按压目标缩放:模拟实体键帽下沉,幅度保证一眼可辨。 static const double _pressedScale = 0.92; - /// 当前是否有指针按在本键上;驱动按压缩放。 + /// 缩放与渐变共用同一时长曲线,让按压表现为一次同步手势。 + static const Duration _pressDuration = Duration(milliseconds: 90); + + /// 长按连发周期:10 次/秒,短按删一位、长按可控地连续清空。 + static const Duration _longPressRepeatInterval = Duration(milliseconds: 100); + + /// 普通键按压渐变:品牌浅蓝自上而下过渡到主题蓝浅调。 + static final LinearGradient _normalPressedGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + XfjDesignTokens.primarySoft, + Color.alphaBlend( + XfjDesignTokens.primary.withValues(alpha: 0.18), + XfjDesignTokens.surface, + ), + ], + ); + + /// 主操作键按压渐变:主题蓝自上而下加深一档,替代抬起的纯色底。 + static final LinearGradient _emphasizedPressedGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + XfjDesignTokens.primary, + Color.alphaBlend( + Colors.black.withValues(alpha: 0.14), + XfjDesignTokens.primary, + ), + ], + ); + + /// 当前是否有指针按在本键上;驱动按压缩放与主题色渐变。 bool _pressed = false; + /// 长按连发定时器;只在按住期间存在。 + Timer? _repeatTimer; + void _setPressed(bool value) { if (!mounted || _pressed == value) return; setState(() => _pressed = value); } + /// 长按阈值到达:先删一次,再以固定周期连发,直到指针抬起或取消。 + void _startLongPressRepeat() { + widget.onLongPressRepeat?.call(); + _repeatTimer?.cancel(); + _repeatTimer = Timer.periodic(_longPressRepeatInterval, (_) { + if (!mounted) return; + widget.onLongPressRepeat?.call(); + }); + } + + void _stopLongPressRepeat() { + _repeatTimer?.cancel(); + _repeatTimer = null; + } + + /// 指针结束的统一收尾:松开按压视觉并停掉连发。 + void _pointerEnd() { + _setPressed(false); + _stopLongPressRepeat(); + } + + @override + void dispose() { + _stopLongPressRepeat(); + super.dispose(); + } + @override Widget build(BuildContext context) { final keyHeight = widget.height ?? 62.h; + final radius = BorderRadius.circular(XfjDesignTokens.controlRadius); + final BoxDecoration decoration; + if (_pressed) { + decoration = BoxDecoration( + gradient: widget.emphasized + ? _emphasizedPressedGradient + : _normalPressedGradient, + border: Border.all( + color: widget.emphasized + ? XfjDesignTokens.primary + : XfjDesignTokens.primary.withValues(alpha: 0.45), + ), + borderRadius: radius, + ); + } else { + decoration = BoxDecoration( + color: widget.emphasized + ? XfjDesignTokens.primary + : XfjDesignTokens.surface, + border: Border.all( + color: widget.emphasized + ? XfjDesignTokens.primary + : XfjDesignTokens.line, + ), + borderRadius: radius, + ); + } return Semantics( button: true, label: widget.semanticLabel, child: Listener( onPointerDown: (_) => _setPressed(true), - onPointerUp: (_) => _setPressed(false), - onPointerCancel: (_) => _setPressed(false), + onPointerUp: (_) => _pointerEnd(), + onPointerCancel: (_) => _pointerEnd(), child: AnimatedScale( scale: _pressed ? _pressedScale : 1, - duration: const Duration(milliseconds: 90), + duration: _pressDuration, curve: Curves.easeOut, child: Material( color: Colors.transparent, child: InkWell( onTap: widget.onTap, - borderRadius: BorderRadius.circular(XfjDesignTokens.controlRadius), - child: (widget.child ?? const SizedBox.shrink()) - .center() - .tight(width: widget.width, height: keyHeight) - .decorated( - color: widget.emphasized - ? XfjDesignTokens.primary - : XfjDesignTokens.surface, - border: Border.all( - color: widget.emphasized - ? XfjDesignTokens.primary - : XfjDesignTokens.line, - ), - borderRadius: BorderRadius.circular( - XfjDesignTokens.controlRadius, - ), - ), + onLongPress: widget.onLongPressRepeat == null + ? null + : _startLongPressRepeat, + borderRadius: radius, + // 渐变走 AnimatedContainer 隐式动画:只重绘本键 decoration, + // 不触发布局,与外层缩放共同构成零重排的按压反馈。 + // 尺寸放在外层 SizedBox、容器内部用恒定 infinity 撑满: + // 面板切换会按位置复用 _Key 元素,键宽变化若参与隐式动画, + // 行布局会在过渡帧溢出。 + child: SizedBox( + width: widget.width, + height: keyHeight, + child: AnimatedContainer( + duration: _pressDuration, + curve: Curves.easeOut, + width: double.infinity, + height: double.infinity, + alignment: Alignment.center, + decoration: decoration, + child: widget.child ?? const SizedBox.shrink(), + ), + ), ), ), ), diff --git a/lib/pages/terminal/menu_query/widgets/reservation_verification_dialog.dart b/lib/pages/terminal/menu_query/widgets/reservation_verification_dialog.dart index a942782..6915472 100644 --- a/lib/pages/terminal/menu_query/widgets/reservation_verification_dialog.dart +++ b/lib/pages/terminal/menu_query/widgets/reservation_verification_dialog.dart @@ -501,7 +501,7 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { ); } - /// 用餐人主卡是摘要区唯一着色卡片:白圈大头像 + 大号姓名 + 身份确认角标,内容整体居中。 + /// 用餐人主卡是摘要区唯一着色卡片:白圈大头像 + 主题色大号姓名,内容整体居中。 Widget _dinerHero(FThemeData theme, String? deptName) { final name = widget.result.maskedName; final hasDept = deptName?.isNotEmpty == true; @@ -528,18 +528,13 @@ class _ReservationOrderDialogState extends State<_ReservationOrderDialog> { name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: theme.typography.xl2.copyWith( - color: XfjDesignTokens.ink, + style: theme.typography.xl3.copyWith( + color: XfjDesignTokens.primary, fontWeight: FontWeight.w800, height: 1.2, ), ), ), - FIcon( - FAssets.icons.circleCheck, - color: XfjDesignTokens.success, - size: 26.sp, - ), ] .toRowSpace( space: 14.w, diff --git a/lib/pages/terminal/verification_records/controller.dart b/lib/pages/terminal/verification_records/controller.dart index 418b611..fc57544 100644 --- a/lib/pages/terminal/verification_records/controller.dart +++ b/lib/pages/terminal/verification_records/controller.dart @@ -77,6 +77,9 @@ class VerificationRecordsController extends GetxController { /// 是否正在提交兑换券重打,期间禁用重复点击。 final isReprintingVoucher = false.obs; + /// 是否正在提交兑换券失败重试,期间禁用重复点击。 + final isRetryingVoucher = false.obs; + /// 当前订单最新一条兑换券任务;列表由服务端按创建时间倒序返回。 ServicePrintRecord? get latestVoucherRecord { for (final record in selectedPrintRecords) { @@ -88,6 +91,9 @@ class VerificationRecordsController extends GetxController { /// 只有最新兑换券已经完成打印时才能创建下一打印序号。 bool get canReprintVoucher => latestVoucherRecord?.status == '2'; + /// 最新兑换券任务失败时允许人工重试,复用同一任务而不新增打印序号。 + bool get canRetryVoucher => latestVoucherRecord?.status == '3'; + List get visibleRecords { final keyword = keywordController.text.trim().toLowerCase(); if (keyword.isEmpty) return records; @@ -301,6 +307,40 @@ class VerificationRecordsController extends GetxController { } } + /// 手动重试失败的兑换券打印任务,服务端复用同一任务重新下发,打印序号不变。 + Future retryFailedVoucher() async { + final record = selectedRecord.value; + final voucher = latestVoucherRecord; + if (record == null || voucher == null || isRetryingVoucher.value) return; + if (voucher.status != '3') { + Toast.error(msg: '最新兑换券不是失败状态,暂不能重试'); + return; + } + if (voucher.failureStage == 'BINDING') { + // 服务端未绑定打印机时重试必然原样失败,直接提示避免无效往返。 + Toast.error(msg: '来源终端未绑定打印机,请先完成打印机绑定'); + return; + } + isRetryingVoucher.value = true; + try { + final accepted = await HardApi.createPrinterTask( + orderId: '${record.orderId}', + printType: '2', + ); + if (!accepted) throw StateError('服务端未接受重试任务'); + await _loadSelectedPrintRecords(record); + // MQTT 是实时通道,HTTP 补拉用于覆盖消息延迟或重连窗口,二者继续由任务 ID 幂等去重。 + await SyncPrintTool.pullPendingTasks(); + Toast.success(msg: '打印重试任务已提交'); + } catch (error, stack) { + logger.e( + '|核销记录|兑换券重试失败|orderId:${record.orderId}|task:${voucher.id}|error:$error|stack:$stack|'); + Toast.error(msg: '打印重试提交失败,请检查网络后重试'); + } finally { + isRetryingVoucher.value = false; + } + } + /// 关闭完整详情抽屉并取消其未完成请求的 UI 更新。 void closeOrderDetail() { _selectedDetailRequestId++; @@ -318,6 +358,7 @@ class VerificationRecordsController extends GetxController { selectedEvidenceError.value = ''; selectedEvidenceStatus.value = OrderRecordDetailLoadStatus.success; isReprintingVoucher.value = false; + isRetryingVoucher.value = false; } /// 按当前订单和 DEVICE-ID 查询服务端打印记录,失败不影响订单与识别凭证展示。 diff --git a/lib/pages/terminal/verification_records/view.dart b/lib/pages/terminal/verification_records/view.dart index 2b6f9d3..c0fa59e 100644 --- a/lib/pages/terminal/verification_records/view.dart +++ b/lib/pages/terminal/verification_records/view.dart @@ -67,10 +67,13 @@ class VerificationRecordsPage extends GetView { evidenceErrorMessage: controller.selectedEvidenceError.value, canReprintVoucher: controller.canReprintVoucher, isReprintingVoucher: controller.isReprintingVoucher.value, + canRetryVoucher: controller.canRetryVoucher, + isRetryingVoucher: controller.isRetryingVoucher.value, onRetry: controller.retryOrderDetail, onRefreshPrintRecords: controller.refreshPrintRecords, onRefreshEvidence: controller.refreshEvidence, onReprintVoucher: () => _requestVoucherReprint(context), + onRetryVoucher: controller.retryFailedVoucher, onClose: controller.closeOrderDetail, ); }), diff --git a/lib/pages/terminal/verification_records/widgets/verification_order_detail_drawer.dart b/lib/pages/terminal/verification_records/widgets/verification_order_detail_drawer.dart index 10d3038..6f48422 100644 --- a/lib/pages/terminal/verification_records/widgets/verification_order_detail_drawer.dart +++ b/lib/pages/terminal/verification_records/widgets/verification_order_detail_drawer.dart @@ -24,10 +24,13 @@ class VerificationOrderDetailDrawer extends StatelessWidget { required this.evidenceErrorMessage, required this.canReprintVoucher, required this.isReprintingVoucher, + required this.canRetryVoucher, + required this.isRetryingVoucher, required this.onRetry, required this.onRefreshPrintRecords, required this.onRefreshEvidence, required this.onReprintVoucher, + required this.onRetryVoucher, required this.onClose, }); @@ -70,6 +73,12 @@ class VerificationOrderDetailDrawer extends StatelessWidget { /// 是否正在向服务端提交兑换券重打任务。 final bool isReprintingVoucher; + /// 最新兑换券任务处于失败状态,允许人工重试同一任务。 + final bool canRetryVoucher; + + /// 是否正在向服务端提交兑换券失败重试。 + final bool isRetryingVoucher; + /// 重新请求订单和购物车数据。 final VoidCallback onRetry; @@ -82,6 +91,9 @@ class VerificationOrderDetailDrawer extends StatelessWidget { /// 打开重打原因弹框并提交最新已打印兑换券。 final VoidCallback onReprintVoucher; + /// 重试失败的最新兑换券任务,服务端复用同一任务且不新增打印序号。 + final VoidCallback onRetryVoucher; + /// 关闭右侧详情抽屉。 final VoidCallback onClose; @@ -362,6 +374,10 @@ class VerificationOrderDetailDrawer extends StatelessWidget { /// 构建打印凭证独立视图,加载、失败和空数据均不影响订单概览。 Widget _printRecords(BuildContext context, FThemeData theme) { + // 失败态优先重试:按钮语义随最新兑换券任务状态切换,其余状态维持重打按钮。 + final voucherBusy = isRetryingVoucher || isReprintingVoucher; + final voucherActionEnabled = !voucherBusy && (canRetryVoucher || canReprintVoucher); + final voucherAction = canRetryVoucher ? onRetryVoucher : onReprintVoucher; return ColoredBox( color: XfjDesignTokens.canvas, child: Column( @@ -381,12 +397,14 @@ class VerificationOrderDetailDrawer extends StatelessWidget { : onRefreshPrintRecords, ), TerminalToolbarButton( - label: isReprintingVoucher ? '提交中...' : '重新打印兑换券', + label: voucherBusy + ? '提交中...' + : canRetryVoucher + ? '重试失败打印' + : '重新打印兑换券', icon: FAssets.icons.printer, minWidth: 180.w, - onTap: canReprintVoucher && !isReprintingVoucher - ? onReprintVoucher - : null, + onTap: voucherActionEnabled ? voucherAction : null, ), ], ), diff --git a/test/common/verification_order_detail_drawer_test.dart b/test/common/verification_order_detail_drawer_test.dart index b1483e9..5246c48 100644 --- a/test/common/verification_order_detail_drawer_test.dart +++ b/test/common/verification_order_detail_drawer_test.dart @@ -177,10 +177,13 @@ void main() { evidenceErrorMessage: '', canReprintVoucher: false, isReprintingVoucher: false, + canRetryVoucher: false, + isRetryingVoucher: false, onRetry: () {}, onRefreshPrintRecords: () {}, onRefreshEvidence: () {}, onReprintVoucher: () {}, + onRetryVoucher: () {}, onClose: () {}, ), ], @@ -268,10 +271,13 @@ void main() { evidenceErrorMessage: '', canReprintVoucher: true, isReprintingVoucher: false, + canRetryVoucher: false, + isRetryingVoucher: false, onRetry: () {}, onRefreshPrintRecords: () {}, onRefreshEvidence: () {}, onReprintVoucher: () => requestedReprint = true, + onRetryVoucher: () {}, onClose: () {}, ), ], @@ -317,6 +323,131 @@ void main() { ); expect(tester.takeException(), isNull); }); + + testWidgets('最新兑换券失败时打印按钮切换为重试失败打印', (tester) async { + tester.view.physicalSize = const Size(1920, 1080); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var requestedRetry = false; + var requestedReprint = false; + + await tester.pumpWidget( + _terminalApp( + Stack( + fit: StackFit.expand, + children: [ + const ColoredBox(color: XfjDesignTokens.canvas), + VerificationOrderDetailDrawer( + record: _record(), + status: OrderRecordDetailLoadStatus.success, + order: _order(), + items: _items(), + errorMessage: '', + printStatus: OrderRecordDetailLoadStatus.success, + printRecords: [ + ServicePrintRecord( + id: '100', + orderId: '2094302933091917826', + hardId: '10', + hardName: '一号食堂终端', + printerHardId: '20', + printerHardName: '一号打印机', + printType: '2', + printNo: 1, + retryVersion: 0, + attemptCount: 0, + status: '3', + failureStage: 'MQTT_DISPATCH', + lastError: '【503】 during【POST】 to [http://pjcloud-mqtt/send-print]', + createTime: DateTime(2026, 8, 31, 16, 41, 9), + ), + ], + printErrorMessage: '', + evidenceStatus: OrderRecordDetailLoadStatus.success, + evidenceImages: const [], + evidenceErrorMessage: '', + canReprintVoucher: false, + isReprintingVoucher: false, + canRetryVoucher: true, + isRetryingVoucher: false, + onRetry: () {}, + onRefreshPrintRecords: () {}, + onRefreshEvidence: () {}, + onReprintVoucher: () => requestedReprint = true, + onRetryVoucher: () => requestedRetry = true, + onClose: () {}, + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('打印与核销凭证(1)')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.text('打印失败'), findsOneWidget); + expect(find.text('失败阶段:MQTT 下发'), findsOneWidget); + expect(find.text('重试失败打印'), findsOneWidget); + expect(find.text('重新打印兑换券'), findsNothing); + await tester.tap(find.text('重试失败打印')); + await tester.pump(); + expect(requestedRetry, isTrue); + expect(requestedReprint, isFalse); + }); + + testWidgets('兑换券重试提交期间按钮禁用并显示提交中', (tester) async { + tester.view.physicalSize = const Size(1920, 1080); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + var requestedRetry = false; + + await tester.pumpWidget( + _terminalApp( + Stack( + fit: StackFit.expand, + children: [ + const ColoredBox(color: XfjDesignTokens.canvas), + VerificationOrderDetailDrawer( + record: _record(), + status: OrderRecordDetailLoadStatus.success, + order: _order(), + items: _items(), + errorMessage: '', + printStatus: OrderRecordDetailLoadStatus.success, + printRecords: const [], + printErrorMessage: '', + evidenceStatus: OrderRecordDetailLoadStatus.success, + evidenceImages: const [], + evidenceErrorMessage: '', + canReprintVoucher: false, + isReprintingVoucher: false, + canRetryVoucher: true, + isRetryingVoucher: true, + onRetry: () {}, + onRefreshPrintRecords: () {}, + onRefreshEvidence: () {}, + onReprintVoucher: () {}, + onRetryVoucher: () => requestedRetry = true, + onClose: () {}, + ), + ], + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('打印与核销凭证(0)')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + expect(find.text('提交中...'), findsOneWidget); + expect(find.text('重试失败打印'), findsNothing); + await tester.tap(find.text('提交中...')); + await tester.pump(); + expect(requestedRetry, isFalse); + }); } /// 跳过远程加载,让完整页面测试只验证根布局和既有静态数据渲染。 diff --git a/test/pages/goldens/reservation_verification_dialog_preview.png b/test/pages/goldens/reservation_verification_dialog_preview.png index a2ba9b5..5d448b5 100644 Binary files a/test/pages/goldens/reservation_verification_dialog_preview.png and b/test/pages/goldens/reservation_verification_dialog_preview.png differ diff --git a/test/pages/terminal/reservation_verification_dialog_test.dart b/test/pages/terminal/reservation_verification_dialog_test.dart index 721e5fe..f536f4b 100644 --- a/test/pages/terminal/reservation_verification_dialog_test.dart +++ b/test/pages/terminal/reservation_verification_dialog_test.dart @@ -188,7 +188,16 @@ void main() { expect(find.text('确认核销'), findsNWidgets(2)); expect(find.text('可核销'), findsOneWidget); expect(find.text('单品'), findsNWidgets(2)); - expect(find.text('张*明 · 三年级二班'), findsOneWidget); + expect(find.text('张*明'), findsOneWidget); + // 部门信息自“核销突出姓名”改版后不再单独成文,仅在用餐人主卡的无障碍标签中保留。 + final semanticsHandle = tester.ensureSemantics(); + // 开启语义后需要一帧让已渲染的树生成语义节点;子节点文案会并入主卡标签,用包含匹配。 + await tester.pump(); + expect( + find.bySemanticsLabel(RegExp('用餐人 张\\*明,三年级二班')), + findsOneWidget, + ); + semanticsHandle.dispose(); expect(find.text('第一食堂'), findsOneWidget); expect(find.text('2026-07-27 周一'), findsOneWidget); expect(find.text('小青菜'), findsOneWidget); diff --git a/test/widgets/app_soft_keyboard_test.dart b/test/widgets/app_soft_keyboard_test.dart index 80e1280..4f65a7d 100644 --- a/test/widgets/app_soft_keyboard_test.dart +++ b/test/widgets/app_soft_keyboard_test.dart @@ -300,18 +300,87 @@ void main() { .ancestor(of: find.text('5'), matching: find.byType(AnimatedScale)) .first; expect(tester.widget(keyScale).scale, 1.0); + // 抬起态为纯色底,无渐变。 + final keyContainer = find + .ancestor(of: find.text('5'), matching: find.byType(AnimatedContainer)) + .first; + var keyDecoration = + tester.widget(keyContainer).decoration + as BoxDecoration; + expect(keyDecoration.gradient, isNull); - // 指针按下未抬起:键帽立即进入按压缩放态。 + // 指针按下未抬起:键帽立即进入按压缩放态并铺上主题色渐变。 final gesture = await tester.startGesture( tester.getCenter(find.text('5')), ); await tester.pump(); expect(tester.widget(keyScale).scale, closeTo(0.92, 0.001)); + keyDecoration = tester.widget(keyContainer).decoration + as BoxDecoration; + expect(keyDecoration.gradient, isA()); - // 抬起后键帽复原并完成一次输入。 + // 抬起后键帽复原、渐变退回纯色并完成一次输入。 await gesture.up(); await tester.pumpAndSettle(); expect(tester.widget(keyScale).scale, 1.0); + keyDecoration = tester.widget(keyContainer).decoration + as BoxDecoration; + expect(keyDecoration.gradient, isNull); expect(controller.text, '5'); }); + + testWidgets('长按退格键持续删除,抬起即停', (tester) async { + final semanticsHandle = tester.ensureSemantics(); + + final controller = TextEditingController(text: '12345678'); + tester.view.physicalSize = const Size(1920, 1080); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + await tester.pumpWidget( + wrapHost( + (context) => Scaffold( + body: Center( + child: FButton( + onPress: () => AppSoftKeyboard.show( + context, + controller: controller, + mode: AppSoftKeyboardMode.digits, + ), + label: const Text('打开键盘'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('打开键盘')); + await tester.pumpAndSettle(); + + final deleteFinder = find.bySemanticsLabel('退格删除,长按连续删除'); + final gesture = await tester.startGesture( + tester.getCenter(deleteFinder), + ); + + // 长按阈值(500ms)到达时触发第一次删除,连发尚未开始。 + await tester.pump(const Duration(milliseconds: 550)); + expect(controller.text, '1234567'); + + // 连发周期 100ms:推进 340ms 触发 600/700/800 三个周期。 + await tester.pump(const Duration(milliseconds: 340)); + expect(controller.text, '1234'); + + // 抬起后连发立即停止,文本不再变化。 + await gesture.up(); + await tester.pump(const Duration(seconds: 1)); + expect(controller.text, '1234'); + await tester.pumpAndSettle(); + + // 单击退格仍然只删除一位。 + await tester.tap(deleteFinder); + await tester.pump(); + expect(controller.text, '123'); + + semanticsHandle.dispose(); + }); }