优化录制页面功能,修正剪贴板信息提示,新增停止录制后的结果提示,改进触摸锁定解锁逻辑,提升用户交互体验。

This commit is contained in:
2026-06-08 11:10:22 +08:00
parent 942d15e54c
commit 7031765b4d
6 changed files with 276 additions and 103 deletions

View File

@@ -98,7 +98,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
return '录制完成'; return '录制完成';
} }
/// 从剪贴板粘贴赛事信息(与 header「粘贴赛事信息」一致)。 /// 从剪贴板粘贴赛事信息(与 header「粘贴选手信息」一致)。
Future<void> _pasteEventInfo() async { Future<void> _pasteEventInfo() async {
final result = await ref final result = await ref
.read(recordingViewModelProvider.notifier) .read(recordingViewModelProvider.notifier)
@@ -167,6 +167,18 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
await ref.read(recordingViewModelProvider.notifier).startRecording(); await ref.read(recordingViewModelProvider.notifier).startRecording();
} }
/// 停止录制并按结果显示保存提示。
Future<void> _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() { void _clearClipboardForNewRound() {
final notifier = ref.read(recordingViewModelProvider.notifier); final notifier = ref.read(recordingViewModelProvider.notifier);
@@ -269,20 +281,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
showClipboardHint: showClipboardInfo, showClipboardHint: showClipboardInfo,
clipboardAddress: clipboard.address.trim(), clipboardAddress: clipboard.address.trim(),
onStart: _onStartRecording, onStart: _onStartRecording,
onStop: () async { onStop: _stopRecordingAndShowResult,
await viewModel.stopRecording();
if (!context.mounted) return;
final latest = ref
.read(recordingViewModelProvider)
.session;
if (latest.gallerySaveFailed) {
AppToast.show(
latest.errorMessage ?? '保存到相册失败,请开启相册权限',
);
return;
}
await _showRecordingSavedDialogIfNeeded();
},
onOpenDnd: () async { onOpenDnd: () async {
await viewModel.openDndSettings(); await viewModel.openDndSettings();
await viewModel.refreshDndAccess(); await viewModel.refreshDndAccess();
@@ -299,7 +298,13 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
if (state.isTouchLocked && state.isRecording) if (state.isTouchLocked && state.isRecording)
RecordingTouchLockOverlayWidget( RecordingTouchLockOverlayWidget(
enabled: true, enabled: true,
onUnlocked: () => viewModel.setTouchLocked(false), onUnlocked: (intent) async {
viewModel.setTouchLocked(false);
if (intent ==
RecordingTouchLockUnlockIntent.stopRecording) {
await _stopRecordingAndShowResult();
}
},
), ),
if (state.isStartingRecording) if (state.isStartingRecording)
RecordingLoadingOverlayWidget( RecordingLoadingOverlayWidget(

View File

@@ -32,10 +32,7 @@ class RecordContentTransition {
return Stack( return Stack(
alignment: Alignment.center, alignment: Alignment.center,
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [...previousChildren, ?currentChild],
...previousChildren,
if (currentChild != null) currentChild,
],
); );
} }
@@ -46,10 +43,7 @@ class RecordContentTransition {
return Stack( return Stack(
alignment: Alignment.bottomLeft, alignment: Alignment.bottomLeft,
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [...previousChildren, ?currentChild],
...previousChildren,
if (currentChild != null) currentChild,
],
); );
} }
} }

View File

@@ -149,6 +149,7 @@ class _HeaderEventTitleRow extends StatelessWidget {
), ),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: BoxConstraints(minWidth: 40.r, minHeight: 40.r), constraints: BoxConstraints(minWidth: 40.r, minHeight: 40.r),
alignment: Alignment.centerRight,
tooltip: '删除', tooltip: '删除',
) )
: const SizedBox.shrink(key: ValueKey('clear-event-info-hidden')), : const SizedBox.shrink(key: ValueKey('clear-event-info-hidden')),

View File

@@ -3,6 +3,31 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.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 { class RecordingTouchLockOverlayWidget extends StatefulWidget {
const RecordingTouchLockOverlayWidget({ const RecordingTouchLockOverlayWidget({
super.key, super.key,
@@ -12,7 +37,7 @@ class RecordingTouchLockOverlayWidget extends StatefulWidget {
}); });
final bool enabled; final bool enabled;
final VoidCallback onUnlocked; final ValueChanged<RecordingTouchLockUnlockIntent> onUnlocked;
final Duration unlockHoldDuration; final Duration unlockHoldDuration;
@override @override
@@ -25,6 +50,8 @@ class _RecordingTouchLockOverlayWidgetState
Timer? _holdTimer; Timer? _holdTimer;
bool _isHolding = false; bool _isHolding = false;
int? _remainingSeconds; int? _remainingSeconds;
Offset? _holdStartPosition;
Size? _holdStartSize;
@override @override
void didUpdateWidget(RecordingTouchLockOverlayWidget oldWidget) { void didUpdateWidget(RecordingTouchLockOverlayWidget oldWidget) {
@@ -48,16 +75,20 @@ class _RecordingTouchLockOverlayWidgetState
setState(() { setState(() {
_isHolding = false; _isHolding = false;
_remainingSeconds = null; _remainingSeconds = null;
_holdStartPosition = null;
_holdStartSize = null;
}); });
} }
void _startHold() { void _startHold(Offset position, Size size) {
if (!widget.enabled) return; if (!widget.enabled) return;
final totalSeconds = widget.unlockHoldDuration.inSeconds; final totalSeconds = widget.unlockHoldDuration.inSeconds;
_holdTimer?.cancel(); _holdTimer?.cancel();
setState(() { setState(() {
_isHolding = true; _isHolding = true;
_remainingSeconds = totalSeconds; _remainingSeconds = totalSeconds;
_holdStartPosition = position;
_holdStartSize = size;
}); });
var elapsed = 0; var elapsed = 0;
@@ -70,11 +101,17 @@ class _RecordingTouchLockOverlayWidgetState
if (elapsed >= totalSeconds) { if (elapsed >= totalSeconds) {
timer.cancel(); timer.cancel();
_holdTimer = null; _holdTimer = null;
widget.onUnlocked(); final intent = resolveRecordingTouchLockUnlockIntent(
position: _holdStartPosition ?? Offset.zero,
size: _holdStartSize ?? Size.zero,
);
setState(() { setState(() {
_isHolding = false; _isHolding = false;
_remainingSeconds = null; _remainingSeconds = null;
_holdStartPosition = null;
_holdStartSize = null;
}); });
widget.onUnlocked(intent);
return; return;
} }
setState(() => _remainingSeconds = totalSeconds - elapsed); setState(() => _remainingSeconds = totalSeconds - elapsed);
@@ -88,83 +125,93 @@ class _RecordingTouchLockOverlayWidgetState
} }
return Positioned.fill( return Positioned.fill(
child: Listener( child: LayoutBuilder(
behavior: HitTestBehavior.opaque, builder: (context, constraints) {
onPointerDown: (_) => _startHold(), final overlaySize = Size(constraints.maxWidth, constraints.maxHeight);
onPointerUp: (_) => _cancelHold(), return Listener(
onPointerCancel: (_) => _cancelHold(), behavior: HitTestBehavior.opaque,
child: ColoredBox( onPointerDown: (event) =>
color: Colors.black.withValues(alpha: 0.01), _startHold(event.localPosition, overlaySize),
child: Align( onPointerUp: (_) => _cancelHold(),
alignment: Alignment.topCenter, onPointerCancel: (_) => _cancelHold(),
child: Padding( child: ColoredBox(
padding: EdgeInsets.only(top: 68.r), color: Colors.black.withValues(alpha: 0.01),
child: DecoratedBox( child: Align(
decoration: BoxDecoration( alignment: Alignment.topCenter,
color: Colors.black54,
borderRadius: BorderRadius.circular(24.r),
),
child: Padding( child: Padding(
padding: EdgeInsets.symmetric( padding: EdgeInsets.only(top: 68.r),
horizontal: 16.r, child: DecoratedBox(
vertical: 8.r, decoration: BoxDecoration(
), color: Colors.black54,
child: _isHolding && _remainingSeconds != null borderRadius: BorderRadius.circular(24.r),
? Builder( ),
builder: (context) { child: Padding(
final remainingSeconds = _remainingSeconds!; padding: EdgeInsets.symmetric(
return Column( horizontal: 16.r,
mainAxisSize: MainAxisSize.min, vertical: 8.r,
children: [ ),
AnimatedSwitcher( child: _isHolding && _remainingSeconds != null
duration: const Duration(milliseconds: 280), ? Builder(
switchInCurve: Curves.easeOut, builder: (context) {
switchOutCurve: Curves.easeIn, final remainingSeconds = _remainingSeconds!;
transitionBuilder: (child, animation) { return Column(
return ScaleTransition( mainAxisSize: MainAxisSize.min,
scale: Tween<double>(begin: 0.6, end: 1) children: [
.animate(animation), AnimatedSwitcher(
child: FadeTransition( duration: const Duration(
opacity: animation, milliseconds: 280,
child: child,
), ),
); switchInCurve: Curves.easeOut,
}, switchOutCurve: Curves.easeIn,
child: Text( transitionBuilder: (child, animation) {
'${remainingSeconds}s', return ScaleTransition(
key: ValueKey<int>(remainingSeconds), scale: Tween<double>(
style: TextStyle( begin: 0.6,
color: Colors.white, end: 1,
fontSize: 18.sp, ).animate(animation),
fontWeight: FontWeight.w600, child: FadeTransition(
height: 1.1, opacity: animation,
), child: child,
), ),
), );
SizedBox(height: 2.r), },
Text( child: Text(
'保持按住解锁', '${remainingSeconds}s',
key: ValueKey<int>(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( style: TextStyle(
color: Colors.white70, color: Colors.white,
fontSize: 10.sp, fontSize: 10.sp,
), ),
), ),
], ),
); ),
},
)
: Text(
'防误触已开启,按住 ${widget.unlockHoldDuration.inSeconds}s 解锁',
style: TextStyle(
color: Colors.white,
fontSize: 10.sp,
),
),
), ),
), ),
), ),
), );
), },
), ),
); );
} }

View File

@@ -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<void> pumpOverlay(
WidgetTester tester, {
required Size surfaceSize,
required ValueChanged<RecordingTouchLockUnlockIntent> 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);
});
});
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:recording_tool/app/app.dart'; import 'package:recording_tool/app/app.dart';
import 'package:recording_tool/features/recording/widgets/widget_recording_button.dart';
void main() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); TestWidgetsFlutterBinding.ensureInitialized();
@@ -40,11 +41,11 @@ void main() {
testWidgets('recording app renders recording page', (tester) async { testWidgets('recording app renders recording page', (tester) async {
await pumpRecordingApp(tester); await pumpRecordingApp(tester);
final recordIcon = find.byIcon(Icons.fiber_manual_record); final recordButton = find.byType(RecordingControlButton);
expect(recordIcon, findsOneWidget); expect(recordButton, findsOneWidget);
expect( expect(
tester.getCenter(recordIcon).dx, tester.getCenter(recordButton).dx,
closeTo(tester.getCenter(find.byType(Scaffold)).dx, 0.5), closeTo(tester.getCenter(find.byType(Scaffold)).dx, 0.5),
); );
}); });
@@ -56,7 +57,7 @@ void main() {
await pumpRecordingApp(tester); await pumpRecordingApp(tester);
expect(find.text('粘贴赛事信息'), findsOneWidget); expect(find.text('粘贴选手信息'), findsOneWidget);
}); });
testWidgets('pastes valid event info from clipboard', (tester) async { testWidgets('pastes valid event info from clipboard', (tester) async {
@@ -65,11 +66,10 @@ void main() {
await pumpRecordingApp(tester); await pumpRecordingApp(tester);
clipboardText = validClipboardText; clipboardText = validClipboardText;
await tester.tap(find.text('粘贴赛事信息')); await tester.tap(find.text('粘贴选手信息'));
await tester.pump(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 700));
expect(find.text('王东方 丨李想 空中格斗赛'), findsOneWidget); expect(find.text('王东方 丨李想 空中格斗赛'), findsOneWidget);
expect(find.text('粘贴赛事信息'), findsNothing);
}); });
testWidgets('shows no event info toast when pasted clipboard is invalid', ( testWidgets('shows no event info toast when pasted clipboard is invalid', (
@@ -80,7 +80,7 @@ void main() {
await pumpRecordingApp(tester); await pumpRecordingApp(tester);
clipboardText = 'hello'; clipboardText = 'hello';
await tester.tap(find.text('粘贴赛事信息')); await tester.tap(find.text('粘贴选手信息'));
await tester.pump(); await tester.pump();
expect(find.text('王东方 丨李想 空中格斗赛'), findsNothing); expect(find.text('王东方 丨李想 空中格斗赛'), findsNothing);