From 0183bd9a6d20e8bca713f46241011816640d15c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E9=94=8B?= <2535831261@qq.com> Date: Fri, 5 Jun 2026 15:46:16 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=BD=95=E5=88=B6=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=EF=BC=8C=E6=9B=B4=E6=96=B0=E5=AF=B9=E8=AF=9D=E6=A1=86?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E4=BC=98=E5=8C=96=E8=B5=9B=E4=BA=8B?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E7=B2=98=E8=B4=B4=E5=8A=9F=E8=83=BD=EF=BC=8C?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E7=9B=B8=E5=85=B3=E6=96=87=E6=9C=AC=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/features/dialog/dialog-record.dart | 234 ++++++++++++++++++ lib/features/recording/pages/page_record.dart | 46 ++-- .../widgets/widget_record_header.dart | 4 +- .../widget_recording_saved_dialog.dart | 115 +-------- test/features/dialog/record_dialog_test.dart | 130 ++++++++++ test/widget_test.dart | 6 +- 6 files changed, 400 insertions(+), 135 deletions(-) create mode 100644 lib/features/dialog/dialog-record.dart create mode 100644 test/features/dialog/record_dialog_test.dart diff --git a/lib/features/dialog/dialog-record.dart b/lib/features/dialog/dialog-record.dart new file mode 100644 index 0000000..47fb67e --- /dev/null +++ b/lib/features/dialog/dialog-record.dart @@ -0,0 +1,234 @@ +// ignore_for_file: file_names + +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:recording_tool/gen/assets.gen.dart'; + +/// 录制页统一弹窗,支持单按钮和双按钮。 +class RecordDialog extends StatelessWidget { + const RecordDialog({super.key, required this.title, required this.actions}); + + final String title; + final List actions; + + static Future showSingle( + BuildContext context, { + required String title, + required String buttonText, + VoidCallback? onPressed, + bool barrierDismissible = true, + }) { + return showDialog( + context: context, + barrierDismissible: barrierDismissible, + builder: (dialogContext) { + return RecordDialog( + title: title, + actions: [ + RecordDialogAction.primary( + text: buttonText, + onPressed: () { + Navigator.of(dialogContext).pop(); + onPressed?.call(); + }, + ), + ], + ); + }, + ); + } + + static Future showDouble( + BuildContext context, { + required String title, + required String leftText, + required String rightText, + VoidCallback? onLeftPressed, + VoidCallback? onRightPressed, + bool barrierDismissible = false, + }) { + return showDialog( + context: context, + barrierDismissible: barrierDismissible, + builder: (dialogContext) { + return RecordDialog( + title: title, + actions: [ + RecordDialogAction.secondary( + text: leftText, + onPressed: () { + Navigator.of(dialogContext).pop(); + onLeftPressed?.call(); + }, + ), + RecordDialogAction.primary( + text: rightText, + onPressed: () { + Navigator.of(dialogContext).pop(); + onRightPressed?.call(); + }, + ), + ], + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final actionWidgets = actions + .map((action) => Expanded(child: _RecordDialogButton(action: action))) + .toList(); + + return Dialog( + elevation: 0, + backgroundColor: Colors.transparent, + insetPadding: EdgeInsets.symmetric(horizontal: 37.w), + child: ClipRRect( + clipBehavior: Clip.none, + + borderRadius: BorderRadius.circular(18.r), + child: Container( + width: 315.w, + // height: 188.r, + decoration: BoxDecoration( + color: Colors.white, + + borderRadius: BorderRadius.circular(18.r), + ), + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + top: -88.r, + left: 0, + right: 0, + child: Image.asset( + Assets.images.imageDialogBg.path, + width: double.maxFinite, + height: 155.h, + fit: BoxFit.cover, + ), + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: EdgeInsets.fromLTRB(24.w, 44.h, 24.w, 26.h), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + textAlign: TextAlign.center, + style: TextStyle( + color: const Color(0xFF333333), + fontSize: 19.sp, + fontWeight: FontWeight.w600, + height: 1.35, + ), + ), + SizedBox(height: 22.h), + Row( + children: [ + for ( + var index = 0; + index < actionWidgets.length; + index++ + ) ...[ + if (index > 0) SizedBox(width: 16.w), + actionWidgets[index], + ], + ], + ), + ], + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class RecordDialogAction { + const RecordDialogAction._({ + required this.text, + required this.onPressed, + required this.isPrimary, + }); + + factory RecordDialogAction.primary({ + required String text, + required VoidCallback onPressed, + }) { + return RecordDialogAction._( + text: text, + onPressed: onPressed, + isPrimary: true, + ); + } + + factory RecordDialogAction.secondary({ + required String text, + required VoidCallback onPressed, + }) { + return RecordDialogAction._( + text: text, + onPressed: onPressed, + isPrimary: false, + ); + } + + final String text; + final VoidCallback onPressed; + final bool isPrimary; +} + +class _RecordDialogButton extends StatelessWidget { + const _RecordDialogButton({required this.action}); + + final RecordDialogAction action; + + @override + Widget build(BuildContext context) { + final child = Center( + child: Text( + action.text, + style: TextStyle( + color: action.isPrimary ? Colors.white : const Color(0xFF333333), + fontSize: 18.sp, + fontWeight: FontWeight.w600, + ), + ), + ); + + return SizedBox( + height: 48.h, + child: TextButton( + onPressed: action.onPressed, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24.r), + ), + backgroundColor: action.isPrimary ? null : const Color(0xFFF2F2F2), + ), + child: action.isPrimary + ? DecoratedBox( + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF2F85FF), Color(0xFF5DCCF4)], + ), + borderRadius: BorderRadius.circular(24.r), + ), + child: SizedBox.expand(child: child), + ) + : child, + ), + ); + } +} diff --git a/lib/features/recording/pages/page_record.dart b/lib/features/recording/pages/page_record.dart index 9bc2b82..c5e4916 100644 --- a/lib/features/recording/pages/page_record.dart +++ b/lib/features/recording/pages/page_record.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:recording_tool/core/platform/app_platform_info.dart'; import 'package:recording_tool/core/platform/device_health_checker.dart'; +import 'package:recording_tool/features/dialog/dialog-record.dart'; import 'package:recording_tool/features/recording/model/model_recording.dart'; import 'package:recording_tool/features/recording/platform/recording_platform.dart'; import 'package:recording_tool/features/recording/utils/recording_display_name.dart'; @@ -46,7 +47,11 @@ class _RecordingPageState extends ConsumerState { final lines = DeviceHealthChecker.warningLines(snapshot); if (lines.isEmpty) return; - await AppDialog.deviceHealthAlert(context, lines: lines); + await RecordDialog.showSingle( + context, + title: lines.join('\n'), + buttonText: '确定', + ); } /// 页面启动:健康检查、读剪贴板、进入录制模式、准备相机会话 @@ -92,21 +97,24 @@ class _RecordingPageState extends ConsumerState { return '录制完成'; } + /// 从剪贴板粘贴赛事信息(与 header「粘贴赛事信息」一致)。 + Future _pasteEventInfo() async { + final result = await ref + .read(recordingViewModelProvider.notifier) + .getClipboardContent(); + if (!mounted) return; + if (result != ClipboardReadResult.success) { + AppToast.show('无选手信息'); + } + } + /// 无选手信息时弹窗提示 Future _showNoPlayerInfoDialog() { - return showDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - content: const Text('无选手信息'), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('确定'), - ), - ], - ); - }, + return RecordDialog.showSingle( + context, + title: '无选手信息!', + buttonText: '粘贴', + onPressed: _pasteEventInfo, ); } @@ -208,15 +216,7 @@ class _RecordingPageState extends ConsumerState { eventTitle: showClipboardInfo ? clipboard.title : null, isRecording: state.isRecording, elapsedLabel: state.elapsedLabel, - onPasteEventInfo: () async { - final result = await ref - .read(recordingViewModelProvider.notifier) - .getClipboardContent(); - if (!context.mounted) return; - if (result != ClipboardReadResult.success) { - AppToast.show('无赛事信息'); - } - }, + onPasteEventInfo: _pasteEventInfo, onClearEventInfo: _clearClipboardForNewRound, ), Expanded( diff --git a/lib/features/recording/widgets/widget_record_header.dart b/lib/features/recording/widgets/widget_record_header.dart index 5511b5f..81fc0f6 100644 --- a/lib/features/recording/widgets/widget_record_header.dart +++ b/lib/features/recording/widgets/widget_record_header.dart @@ -133,9 +133,9 @@ class _HeaderPasteActions extends StatelessWidget { return Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - // _HeaderActionButton(label: '模拟复制赛事信息', onPressed: onMockCopy), + _HeaderActionButton(label: '模拟复制赛事信息', onPressed: onMockCopy), _HeaderActionButton( - label: '粘贴赛事信息', + label: '粘贴选手信息', onPressed: () => onPasteEventInfo(), ), ], diff --git a/lib/features/recording/widgets/widget_recording_saved_dialog.dart b/lib/features/recording/widgets/widget_recording_saved_dialog.dart index 4b30a70..4852775 100644 --- a/lib/features/recording/widgets/widget_recording_saved_dialog.dart +++ b/lib/features/recording/widgets/widget_recording_saved_dialog.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:recording_tool/features/dialog/dialog-record.dart'; /// 录制结束并保存到相册后的后续操作弹窗。 Future showRecordingSavedDialog( @@ -8,112 +8,13 @@ Future showRecordingSavedDialog( required VoidCallback onContinueRound, required VoidCallback onRecordNewRound, }) { - return showDialog( - context: context, + return RecordDialog.showDouble( + context, + title: '本轮比赛视频已保存到相册\n请选择后续录制信息', + leftText: '继续本轮', + rightText: '录制新轮', + onLeftPressed: onContinueRound, + onRightPressed: onRecordNewRound, barrierDismissible: false, - builder: (dialogContext) { - return RecordingSavedDialogWidget( - sessionTitle: sessionTitle, - onContinueRound: () { - Navigator.of(dialogContext).pop(); - onContinueRound(); - }, - onRecordNewRound: () { - Navigator.of(dialogContext).pop(); - onRecordNewRound(); - }, - ); - }, ); } - -class RecordingSavedDialogWidget extends StatelessWidget { - const RecordingSavedDialogWidget({ - super.key, - required this.sessionTitle, - required this.onContinueRound, - required this.onRecordNewRound, - }); - - final String sessionTitle; - final VoidCallback onContinueRound; - final VoidCallback onRecordNewRound; - - @override - Widget build(BuildContext context) { - return Dialog( - backgroundColor: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4.r), - side: const BorderSide(color: Colors.black, width: 1), - ), - insetPadding: EdgeInsets.symmetric(horizontal: 32.w), - child: Padding( - padding: EdgeInsets.fromLTRB(20.w, 20.h, 20.w, 16.h), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - sessionTitle, - style: TextStyle( - fontSize: 15.sp, - fontWeight: FontWeight.w600, - color: Colors.black87, - ), - textAlign: TextAlign.center, - ), - SizedBox(height: 16.h), - Text( - '本轮比赛视频已保存到相册', - style: TextStyle(fontSize: 14.sp, color: Colors.black87), - textAlign: TextAlign.center, - ), - SizedBox(height: 8.h), - SizedBox(height: 20.h), - Row( - children: [ - Expanded( - child: _RecordingDialogActionButton( - label: '继续本轮', - onPressed: onContinueRound, - ), - ), - SizedBox(width: 12.w), - Expanded( - child: _RecordingDialogActionButton( - label: '录制新轮', - onPressed: onRecordNewRound, - ), - ), - ], - ), - ], - ), - ), - ); - } -} - -class _RecordingDialogActionButton extends StatelessWidget { - const _RecordingDialogActionButton({ - required this.label, - required this.onPressed, - }); - - final String label; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - return TextButton( - onPressed: onPressed, - style: TextButton.styleFrom( - backgroundColor: const Color(0xFFE8E8E8), - foregroundColor: Colors.black87, - padding: EdgeInsets.symmetric(vertical: 10.h), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6.r)), - ), - child: Text(label, style: TextStyle(fontSize: 14.sp)), - ); - } -} diff --git a/test/features/dialog/record_dialog_test.dart b/test/features/dialog/record_dialog_test.dart new file mode 100644 index 0000000..128148f --- /dev/null +++ b/test/features/dialog/record_dialog_test.dart @@ -0,0 +1,130 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:recording_tool/features/dialog/dialog-record.dart'; +import 'package:recording_tool/features/recording/widgets/widget_recording_saved_dialog.dart'; +import 'package:recording_tool/gen/assets.gen.dart'; + +void main() { + Future pumpDialogHost(WidgetTester tester, Widget child) async { + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(375, 812), + builder: (context, _) { + return MaterialApp(home: Scaffold(body: child)); + }, + ), + ); + } + + testWidgets('single button dialog shows configured content and closes', ( + tester, + ) async { + var tapped = false; + + await pumpDialogHost( + tester, + Builder( + builder: (context) { + return TextButton( + onPressed: () { + RecordDialog.showSingle( + context, + title: '无选手信息!', + buttonText: '粘贴', + onPressed: () => tapped = true, + ); + }, + child: const Text('show'), + ); + }, + ), + ); + + await tester.tap(find.text('show')); + await tester.pumpAndSettle(); + + expect(find.byType(Image), findsOneWidget); + expect(find.image(AssetImage(Assets.images.imageDialogBg.path)), findsOne); + expect(find.text('无选手信息!'), findsOneWidget); + expect(find.text('粘贴'), findsOneWidget); + + await tester.tap(find.text('粘贴')); + await tester.pumpAndSettle(); + + expect(tapped, isTrue); + expect(find.text('无选手信息!'), findsNothing); + }); + + testWidgets('double button dialog dispatches each action', (tester) async { + var leftTapped = false; + var rightTapped = false; + + await pumpDialogHost( + tester, + Builder( + builder: (context) { + return TextButton( + onPressed: () { + RecordDialog.showDouble( + context, + title: '本轮比赛视频已保存到相册\n请选择后续录制信息', + leftText: '继续本轮', + rightText: '录制新轮', + onLeftPressed: () => leftTapped = true, + onRightPressed: () => rightTapped = true, + ); + }, + child: const Text('show'), + ); + }, + ), + ); + + await tester.tap(find.text('show')); + await tester.pumpAndSettle(); + await tester.tap(find.text('继续本轮')); + await tester.pumpAndSettle(); + + expect(leftTapped, isTrue); + expect(rightTapped, isFalse); + + await tester.tap(find.text('show')); + await tester.pumpAndSettle(); + await tester.tap(find.text('录制新轮')); + await tester.pumpAndSettle(); + + expect(rightTapped, isTrue); + }); + + testWidgets('recording saved dialog follows design title only', ( + tester, + ) async { + await pumpDialogHost( + tester, + Builder( + builder: (context) { + return TextButton( + onPressed: () { + showRecordingSavedDialog( + context, + sessionTitle: '王东方 丨李想 空中格斗赛', + onContinueRound: () {}, + onRecordNewRound: () {}, + ); + }, + child: const Text('show'), + ); + }, + ), + ); + + await tester.tap(find.text('show')); + await tester.pumpAndSettle(); + + expect(find.text('王东方 丨李想 空中格斗赛'), findsNothing); + expect(find.text('本轮比赛视频已保存到相册\n请选择后续录制信息'), findsOneWidget); + expect(find.text('继续本轮'), findsOneWidget); + expect(find.text('录制新轮'), findsOneWidget); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 3f7a824..4a113ca 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -34,7 +34,7 @@ void main() { await tester.pumpWidget(const ProviderScope(child: FlutterTemplateApp())); await tester.pump(); await tester.pump(const Duration(milliseconds: 500)); - await tester.pumpAndSettle(); + await tester.pump(const Duration(seconds: 1)); } testWidgets('recording app renders recording page', (tester) async { @@ -66,7 +66,7 @@ void main() { clipboardText = validClipboardText; await tester.tap(find.text('粘贴赛事信息')); - await tester.pumpAndSettle(); + await tester.pump(const Duration(milliseconds: 500)); expect(find.text('王东方 丨李想 空中格斗赛'), findsOneWidget); expect(find.text('粘贴赛事信息'), findsNothing); @@ -84,7 +84,7 @@ void main() { await tester.pump(); expect(find.text('王东方 丨李想 空中格斗赛'), findsNothing); - expect(find.text('无赛事信息'), findsOneWidget); + expect(find.text('无选手信息'), findsOneWidget); await tester.pump(const Duration(seconds: 2)); });