From 7031765b4d86cdd703f6e0e68b51f68d6682c570 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E9=94=8B?= <2535831261@qq.com> Date: Mon, 8 Jun 2026 11:10:22 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=BD=95=E5=88=B6=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E5=8A=9F=E8=83=BD=EF=BC=8C=E4=BF=AE=E6=AD=A3=E5=89=AA?= =?UTF-8?q?=E8=B4=B4=E6=9D=BF=E4=BF=A1=E6=81=AF=E6=8F=90=E7=A4=BA=EF=BC=8C?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=81=9C=E6=AD=A2=E5=BD=95=E5=88=B6=E5=90=8E?= =?UTF-8?q?=E7=9A=84=E7=BB=93=E6=9E=9C=E6=8F=90=E7=A4=BA=EF=BC=8C=E6=94=B9?= =?UTF-8?q?=E8=BF=9B=E8=A7=A6=E6=91=B8=E9=94=81=E5=AE=9A=E8=A7=A3=E9=94=81?= =?UTF-8?q?=E9=80=BB=E8=BE=91=EF=BC=8C=E6=8F=90=E5=8D=87=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E4=BD=93=E9=AA=8C=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/features/recording/pages/page_record.dart | 37 ++-- .../widgets/record_content_transition.dart | 10 +- .../widgets/widget_record_header.dart | 1 + .../widget_recording_touch_lock_overlay.dart | 189 +++++++++++------- ...get_recording_touch_lock_overlay_test.dart | 126 ++++++++++++ test/widget_test.dart | 16 +- 6 files changed, 276 insertions(+), 103 deletions(-) create mode 100644 test/features/recording/widget_recording_touch_lock_overlay_test.dart diff --git a/lib/features/recording/pages/page_record.dart b/lib/features/recording/pages/page_record.dart index bd815ff..978a602 100644 --- a/lib/features/recording/pages/page_record.dart +++ b/lib/features/recording/pages/page_record.dart @@ -98,7 +98,7 @@ class _RecordingPageState extends ConsumerState { return '录制完成'; } - /// 从剪贴板粘贴赛事信息(与 header「粘贴赛事信息」一致)。 + /// 从剪贴板粘贴赛事信息(与 header「粘贴选手信息」一致)。 Future _pasteEventInfo() async { final result = await ref .read(recordingViewModelProvider.notifier) @@ -167,6 +167,18 @@ class _RecordingPageState extends ConsumerState { await ref.read(recordingViewModelProvider.notifier).startRecording(); } + /// 停止录制并按结果显示保存提示。 + Future _stopRecordingAndShowResult() async { + await ref.read(recordingViewModelProvider.notifier).stopRecording(); + if (!mounted) return; + final latest = ref.read(recordingViewModelProvider).session; + if (latest.gallerySaveFailed) { + AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限'); + return; + } + await _showRecordingSavedDialogIfNeeded(); + } + /// 清空剪贴板信息,准备新一轮录制 void _clearClipboardForNewRound() { final notifier = ref.read(recordingViewModelProvider.notifier); @@ -269,20 +281,7 @@ class _RecordingPageState extends ConsumerState { showClipboardHint: showClipboardInfo, clipboardAddress: clipboard.address.trim(), onStart: _onStartRecording, - onStop: () async { - await viewModel.stopRecording(); - if (!context.mounted) return; - final latest = ref - .read(recordingViewModelProvider) - .session; - if (latest.gallerySaveFailed) { - AppToast.show( - latest.errorMessage ?? '保存到相册失败,请开启相册权限', - ); - return; - } - await _showRecordingSavedDialogIfNeeded(); - }, + onStop: _stopRecordingAndShowResult, onOpenDnd: () async { await viewModel.openDndSettings(); await viewModel.refreshDndAccess(); @@ -299,7 +298,13 @@ class _RecordingPageState extends ConsumerState { if (state.isTouchLocked && state.isRecording) RecordingTouchLockOverlayWidget( enabled: true, - onUnlocked: () => viewModel.setTouchLocked(false), + onUnlocked: (intent) async { + viewModel.setTouchLocked(false); + if (intent == + RecordingTouchLockUnlockIntent.stopRecording) { + await _stopRecordingAndShowResult(); + } + }, ), if (state.isStartingRecording) RecordingLoadingOverlayWidget( diff --git a/lib/features/recording/widgets/record_content_transition.dart b/lib/features/recording/widgets/record_content_transition.dart index 399f25d..447f58b 100644 --- a/lib/features/recording/widgets/record_content_transition.dart +++ b/lib/features/recording/widgets/record_content_transition.dart @@ -32,10 +32,7 @@ class RecordContentTransition { return Stack( alignment: Alignment.center, clipBehavior: Clip.none, - children: [ - ...previousChildren, - if (currentChild != null) currentChild, - ], + children: [...previousChildren, ?currentChild], ); } @@ -46,10 +43,7 @@ class RecordContentTransition { return Stack( alignment: Alignment.bottomLeft, clipBehavior: Clip.none, - children: [ - ...previousChildren, - if (currentChild != null) currentChild, - ], + children: [...previousChildren, ?currentChild], ); } } diff --git a/lib/features/recording/widgets/widget_record_header.dart b/lib/features/recording/widgets/widget_record_header.dart index b8bf1db..0ef128f 100644 --- a/lib/features/recording/widgets/widget_record_header.dart +++ b/lib/features/recording/widgets/widget_record_header.dart @@ -149,6 +149,7 @@ class _HeaderEventTitleRow extends StatelessWidget { ), padding: EdgeInsets.zero, constraints: BoxConstraints(minWidth: 40.r, minHeight: 40.r), + alignment: Alignment.centerRight, tooltip: '删除', ) : const SizedBox.shrink(key: ValueKey('clear-event-info-hidden')), diff --git a/lib/features/recording/widgets/widget_recording_touch_lock_overlay.dart b/lib/features/recording/widgets/widget_recording_touch_lock_overlay.dart index 18e1c90..d43b4f4 100644 --- a/lib/features/recording/widgets/widget_recording_touch_lock_overlay.dart +++ b/lib/features/recording/widgets/widget_recording_touch_lock_overlay.dart @@ -3,6 +3,31 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; +enum RecordingTouchLockUnlockIntent { unlockOnly, stopRecording } + +RecordingTouchLockUnlockIntent resolveRecordingTouchLockUnlockIntent({ + required Offset position, + required Size size, + double stopZoneFraction = 0.3, +}) { + if (size.width <= 0 || size.height <= 0 || stopZoneFraction <= 0) { + return RecordingTouchLockUnlockIntent.unlockOnly; + } + + final normalizedStopZone = stopZoneFraction.clamp(0.0, 1.0); + if (size.width <= size.height) { + final stopZoneTop = size.height * (1 - normalizedStopZone); + return position.dy >= stopZoneTop + ? RecordingTouchLockUnlockIntent.stopRecording + : RecordingTouchLockUnlockIntent.unlockOnly; + } + + final stopZoneLeft = size.width * (1 - normalizedStopZone); + return position.dx >= stopZoneLeft + ? RecordingTouchLockUnlockIntent.stopRecording + : RecordingTouchLockUnlockIntent.unlockOnly; +} + class RecordingTouchLockOverlayWidget extends StatefulWidget { const RecordingTouchLockOverlayWidget({ super.key, @@ -12,7 +37,7 @@ class RecordingTouchLockOverlayWidget extends StatefulWidget { }); final bool enabled; - final VoidCallback onUnlocked; + final ValueChanged onUnlocked; final Duration unlockHoldDuration; @override @@ -25,6 +50,8 @@ class _RecordingTouchLockOverlayWidgetState Timer? _holdTimer; bool _isHolding = false; int? _remainingSeconds; + Offset? _holdStartPosition; + Size? _holdStartSize; @override void didUpdateWidget(RecordingTouchLockOverlayWidget oldWidget) { @@ -48,16 +75,20 @@ class _RecordingTouchLockOverlayWidgetState setState(() { _isHolding = false; _remainingSeconds = null; + _holdStartPosition = null; + _holdStartSize = null; }); } - void _startHold() { + void _startHold(Offset position, Size size) { if (!widget.enabled) return; final totalSeconds = widget.unlockHoldDuration.inSeconds; _holdTimer?.cancel(); setState(() { _isHolding = true; _remainingSeconds = totalSeconds; + _holdStartPosition = position; + _holdStartSize = size; }); var elapsed = 0; @@ -70,11 +101,17 @@ class _RecordingTouchLockOverlayWidgetState if (elapsed >= totalSeconds) { timer.cancel(); _holdTimer = null; - widget.onUnlocked(); + final intent = resolveRecordingTouchLockUnlockIntent( + position: _holdStartPosition ?? Offset.zero, + size: _holdStartSize ?? Size.zero, + ); setState(() { _isHolding = false; _remainingSeconds = null; + _holdStartPosition = null; + _holdStartSize = null; }); + widget.onUnlocked(intent); return; } setState(() => _remainingSeconds = totalSeconds - elapsed); @@ -88,83 +125,93 @@ class _RecordingTouchLockOverlayWidgetState } return Positioned.fill( - child: Listener( - behavior: HitTestBehavior.opaque, - onPointerDown: (_) => _startHold(), - onPointerUp: (_) => _cancelHold(), - onPointerCancel: (_) => _cancelHold(), - child: ColoredBox( - color: Colors.black.withValues(alpha: 0.01), - child: Align( - alignment: Alignment.topCenter, - child: Padding( - padding: EdgeInsets.only(top: 68.r), - child: DecoratedBox( - decoration: BoxDecoration( - color: Colors.black54, - borderRadius: BorderRadius.circular(24.r), - ), + child: LayoutBuilder( + builder: (context, constraints) { + final overlaySize = Size(constraints.maxWidth, constraints.maxHeight); + return Listener( + behavior: HitTestBehavior.opaque, + onPointerDown: (event) => + _startHold(event.localPosition, overlaySize), + onPointerUp: (_) => _cancelHold(), + onPointerCancel: (_) => _cancelHold(), + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.01), + child: Align( + alignment: Alignment.topCenter, child: Padding( - padding: EdgeInsets.symmetric( - horizontal: 16.r, - vertical: 8.r, - ), - child: _isHolding && _remainingSeconds != null - ? Builder( - builder: (context) { - final remainingSeconds = _remainingSeconds!; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - AnimatedSwitcher( - duration: const Duration(milliseconds: 280), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - transitionBuilder: (child, animation) { - return ScaleTransition( - scale: Tween(begin: 0.6, end: 1) - .animate(animation), - child: FadeTransition( - opacity: animation, - child: child, + padding: EdgeInsets.only(top: 68.r), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(24.r), + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: 16.r, + vertical: 8.r, + ), + child: _isHolding && _remainingSeconds != null + ? Builder( + builder: (context) { + final remainingSeconds = _remainingSeconds!; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedSwitcher( + duration: const Duration( + milliseconds: 280, ), - ); - }, - child: Text( - '${remainingSeconds}s', - key: ValueKey(remainingSeconds), - style: TextStyle( - color: Colors.white, - fontSize: 18.sp, - fontWeight: FontWeight.w600, - height: 1.1, - ), - ), - ), - SizedBox(height: 2.r), - Text( - '保持按住解锁', + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, animation) { + return ScaleTransition( + scale: Tween( + begin: 0.6, + end: 1, + ).animate(animation), + child: FadeTransition( + opacity: animation, + child: child, + ), + ); + }, + child: Text( + '${remainingSeconds}s', + key: ValueKey(remainingSeconds), + style: TextStyle( + color: Colors.white, + fontSize: 18.sp, + fontWeight: FontWeight.w600, + height: 1.1, + ), + ), + ), + SizedBox(height: 2.r), + Text( + '保持按住解锁', + style: TextStyle( + color: Colors.white70, + fontSize: 10.sp, + ), + ), + ], + ); + }, + ) + : Text( + '防误触已开启,按住 ${widget.unlockHoldDuration.inSeconds}s 解锁', style: TextStyle( - color: Colors.white70, + color: Colors.white, fontSize: 10.sp, ), ), - ], - ); - }, - ) - : Text( - '防误触已开启,按住 ${widget.unlockHoldDuration.inSeconds}s 解锁', - style: TextStyle( - color: Colors.white, - fontSize: 10.sp, - ), - ), + ), + ), ), ), ), - ), - ), + ); + }, ), ); } diff --git a/test/features/recording/widget_recording_touch_lock_overlay_test.dart b/test/features/recording/widget_recording_touch_lock_overlay_test.dart new file mode 100644 index 0000000..24bbf60 --- /dev/null +++ b/test/features/recording/widget_recording_touch_lock_overlay_test.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:recording_tool/features/recording/widgets/widget_recording_touch_lock_overlay.dart'; + +void main() { + group('resolveRecordingTouchLockUnlockIntent', () { + test('returns stopRecording for portrait bottom 30 percent', () { + final intent = resolveRecordingTouchLockUnlockIntent( + position: const Offset(120, 466.9), + size: const Size(375, 667), + ); + + expect(intent, RecordingTouchLockUnlockIntent.stopRecording); + }); + + test('returns unlockOnly for portrait area outside bottom 30 percent', () { + final intent = resolveRecordingTouchLockUnlockIntent( + position: const Offset(120, 320), + size: const Size(375, 667), + ); + + expect(intent, RecordingTouchLockUnlockIntent.unlockOnly); + }); + + test('returns stopRecording for landscape right 30 percent', () { + final intent = resolveRecordingTouchLockUnlockIntent( + position: const Offset(466.9, 120), + size: const Size(667, 375), + ); + + expect(intent, RecordingTouchLockUnlockIntent.stopRecording); + }); + + test('returns unlockOnly for landscape area outside right 30 percent', () { + final intent = resolveRecordingTouchLockUnlockIntent( + position: const Offset(320, 120), + size: const Size(667, 375), + ); + + expect(intent, RecordingTouchLockUnlockIntent.unlockOnly); + }); + }); + + group('RecordingTouchLockOverlayWidget', () { + Future pumpOverlay( + WidgetTester tester, { + required Size surfaceSize, + required ValueChanged onUnlocked, + }) async { + await tester.binding.setSurfaceSize(surfaceSize); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + ScreenUtilInit( + designSize: const Size(375, 812), + builder: (context, _) { + return MaterialApp( + home: Scaffold( + body: Stack( + children: [ + RecordingTouchLockOverlayWidget( + enabled: true, + unlockHoldDuration: const Duration(seconds: 2), + onUnlocked: onUnlocked, + ), + ], + ), + ), + ); + }, + ), + ); + } + + testWidgets('long press in portrait bottom 30 percent stops recording', ( + tester, + ) async { + RecordingTouchLockUnlockIntent? receivedIntent; + await pumpOverlay( + tester, + surfaceSize: const Size(375, 667), + onUnlocked: (intent) => receivedIntent = intent, + ); + + final gesture = await tester.startGesture(const Offset(120, 600)); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + + expect(receivedIntent, RecordingTouchLockUnlockIntent.stopRecording); + }); + + testWidgets('long press outside stop area only unlocks', (tester) async { + RecordingTouchLockUnlockIntent? receivedIntent; + await pumpOverlay( + tester, + surfaceSize: const Size(375, 667), + onUnlocked: (intent) => receivedIntent = intent, + ); + + final gesture = await tester.startGesture(const Offset(120, 320)); + await tester.pump(const Duration(seconds: 2)); + await gesture.up(); + + expect(receivedIntent, RecordingTouchLockUnlockIntent.unlockOnly); + }); + + testWidgets('releasing before hold duration does not unlock', ( + tester, + ) async { + RecordingTouchLockUnlockIntent? receivedIntent; + await pumpOverlay( + tester, + surfaceSize: const Size(375, 667), + onUnlocked: (intent) => receivedIntent = intent, + ); + + final gesture = await tester.startGesture(const Offset(120, 600)); + await tester.pump(const Duration(milliseconds: 1500)); + await gesture.up(); + await tester.pump(const Duration(seconds: 1)); + + expect(receivedIntent, isNull); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 4a113ca..892a9cf 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:recording_tool/app/app.dart'; +import 'package:recording_tool/features/recording/widgets/widget_recording_button.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -40,11 +41,11 @@ void main() { testWidgets('recording app renders recording page', (tester) async { await pumpRecordingApp(tester); - final recordIcon = find.byIcon(Icons.fiber_manual_record); + final recordButton = find.byType(RecordingControlButton); - expect(recordIcon, findsOneWidget); + expect(recordButton, findsOneWidget); expect( - tester.getCenter(recordIcon).dx, + tester.getCenter(recordButton).dx, closeTo(tester.getCenter(find.byType(Scaffold)).dx, 0.5), ); }); @@ -56,7 +57,7 @@ void main() { await pumpRecordingApp(tester); - expect(find.text('粘贴赛事信息'), findsOneWidget); + expect(find.text('粘贴选手信息'), findsOneWidget); }); testWidgets('pastes valid event info from clipboard', (tester) async { @@ -65,11 +66,10 @@ void main() { await pumpRecordingApp(tester); clipboardText = validClipboardText; - await tester.tap(find.text('粘贴赛事信息')); - await tester.pump(const Duration(milliseconds: 500)); + await tester.tap(find.text('粘贴选手信息')); + await tester.pump(const Duration(milliseconds: 700)); expect(find.text('王东方 丨李想 空中格斗赛'), findsOneWidget); - expect(find.text('粘贴赛事信息'), findsNothing); }); testWidgets('shows no event info toast when pasted clipboard is invalid', ( @@ -80,7 +80,7 @@ void main() { await pumpRecordingApp(tester); clipboardText = 'hello'; - await tester.tap(find.text('粘贴赛事信息')); + await tester.tap(find.text('粘贴选手信息')); await tester.pump(); expect(find.text('王东方 丨李想 空中格斗赛'), findsNothing);