5 Commits

14 changed files with 1001 additions and 244 deletions

3
devtools_options.yaml Normal file
View File

@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

@@ -8,6 +8,8 @@ import 'package:recording_tool/gen/assets.gen.dart';
class RecordDialog extends StatelessWidget { class RecordDialog extends StatelessWidget {
const RecordDialog({super.key, required this.title, required this.actions}); const RecordDialog({super.key, required this.title, required this.actions});
static const _transitionDuration = Duration(milliseconds: 280);
final String title; final String title;
final List<RecordDialogAction> actions; final List<RecordDialogAction> actions;
@@ -18,8 +20,8 @@ class RecordDialog extends StatelessWidget {
VoidCallback? onPressed, VoidCallback? onPressed,
bool barrierDismissible = true, bool barrierDismissible = true,
}) { }) {
return showDialog<void>( return _present(
context: context, context,
barrierDismissible: barrierDismissible, barrierDismissible: barrierDismissible,
builder: (dialogContext) { builder: (dialogContext) {
return RecordDialog( return RecordDialog(
@@ -47,8 +49,8 @@ class RecordDialog extends StatelessWidget {
VoidCallback? onRightPressed, VoidCallback? onRightPressed,
bool barrierDismissible = false, bool barrierDismissible = false,
}) { }) {
return showDialog<void>( return _present(
context: context, context,
barrierDismissible: barrierDismissible, barrierDismissible: barrierDismissible,
builder: (dialogContext) { builder: (dialogContext) {
return RecordDialog( return RecordDialog(
@@ -74,6 +76,51 @@ class RecordDialog extends StatelessWidget {
); );
} }
static Future<void> _present(
BuildContext context, {
required Widget Function(BuildContext dialogContext) builder,
required bool barrierDismissible,
}) {
return showGeneralDialog<void>(
context: context,
barrierDismissible: barrierDismissible,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
barrierColor: Colors.black54,
transitionDuration: _transitionDuration,
pageBuilder: (dialogContext, animation, secondaryAnimation) {
return builder(dialogContext);
},
transitionBuilder: _buildTransition,
);
}
static Widget _buildTransition(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return FadeTransition(
opacity: curved,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.08),
end: Offset.zero,
).animate(curved),
child: ScaleTransition(
scale: Tween<double>(begin: 0.92, end: 1).animate(curved),
child: child,
),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final actionWidgets = actions final actionWidgets = actions

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);
@@ -227,32 +239,13 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
@override @override
/// 构建录制页 UI /// 构建录制页 UI
Widget build(BuildContext context) { Widget build(BuildContext context) {
final recordingInfo = ref.watch(recordingViewModelProvider); return _RecordingPopScope(
final state = recordingInfo.session; onExitRecordingMode: _exitRecordingMode,
final viewModel = ref.read(recordingViewModelProvider.notifier);
final clipboard = recordingInfo.clipboardRecordingModel;
final showClipboardInfo = recordingInfo.hasValidClipboardInfo;
return PopScope(
canPop: !state.isRecording,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) {
await _exitRecordingMode();
return;
}
if (state.isRecording) {
AppToast.show('录制中无法返回,请先停止录制');
}
},
child: Scaffold( child: Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: Column( body: Column(
children: [ children: [
RecordHeaderWidget( _RecordHeaderSection(
hasValidClipboardInfo: showClipboardInfo,
eventTitle: showClipboardInfo ? clipboard.title : null,
isRecording: state.isRecording,
elapsedLabel: state.elapsedLabel,
onPasteEventInfo: _pasteEventInfo, onPasteEventInfo: _pasteEventInfo,
onClearEventInfo: _clearClipboardForNewRound, onClearEventInfo: _clearClipboardForNewRound,
), ),
@@ -260,48 +253,16 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
child: Stack( child: Stack(
children: [ children: [
const CameraPreviewWidget(), const CameraPreviewWidget(),
if (!state.isPreviewReady && state.errorMessage == null) const _PreviewLoadingLayer(),
const RecordingLoadingOverlayWidget(message: '正在启动相机…'),
const RecordTimerWidget(), const RecordTimerWidget(),
RecordingHudWidget( _RecordingHudLayer(
state: state,
showClipboardHint: showClipboardInfo,
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 {
await viewModel.openDndSettings();
await viewModel.refreshDndAccess();
},
onOpenBattery: () async {
await viewModel.openBatterySettings();
await viewModel.refreshBatteryOptimization();
},
onToggleTouchLock: () {
viewModel.setTouchLocked(!state.isTouchLocked);
},
), ),
if (state.isTouchLocked && state.isRecording) _TouchLockOverlayLayer(
RecordingTouchLockOverlayWidget( onStopRecording: _stopRecordingAndShowResult,
enabled: true, ),
onUnlocked: () => viewModel.setTouchLocked(false), const _StartingRecordingOverlay(),
),
if (state.isStartingRecording)
RecordingLoadingOverlayWidget(
message: '正在开始录制…',
backgroundColor: Colors.black.withValues(alpha: 0.24),
),
], ],
), ),
), ),
@@ -312,3 +273,207 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
); );
} }
} }
class _RecordingPopScope extends ConsumerWidget {
const _RecordingPopScope({
required this.onExitRecordingMode,
required this.child,
});
final Future<void> Function() onExitRecordingMode;
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isRecording = ref.watch(
recordingViewModelProvider.select((m) => m.session.isRecording),
);
return PopScope(
canPop: !isRecording,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) {
await onExitRecordingMode();
return;
}
if (isRecording) {
AppToast.show('录制中无法返回,请先停止录制');
}
},
child: child,
);
}
}
class _RecordHeaderSection extends ConsumerWidget {
const _RecordHeaderSection({
required this.onPasteEventInfo,
required this.onClearEventInfo,
});
final Future<void> Function() onPasteEventInfo;
final VoidCallback onClearEventInfo;
@override
Widget build(BuildContext context, WidgetRef ref) {
final headerState = ref.watch(
recordingViewModelProvider.select(
(m) => (
m.hasValidClipboardInfo,
m.hasValidClipboardInfo ? m.clipboardRecordingModel.title : null,
m.session.isRecording,
),
),
);
final (hasValidClipboardInfo, eventTitle, isRecording) = headerState;
return RecordHeaderWidget(
hasValidClipboardInfo: hasValidClipboardInfo,
eventTitle: eventTitle,
isRecording: isRecording,
onPasteEventInfo: onPasteEventInfo,
onClearEventInfo: onClearEventInfo,
);
}
}
class _PreviewLoadingLayer extends ConsumerWidget {
const _PreviewLoadingLayer();
@override
Widget build(BuildContext context, WidgetRef ref) {
final showLoading = ref.watch(
recordingViewModelProvider.select(
(m) => !m.session.isPreviewReady && m.session.errorMessage == null,
),
);
if (!showLoading) {
return const SizedBox.shrink();
}
return const RecordingLoadingOverlayWidget(message: '正在启动相机…');
}
}
class _RecordingHudLayer extends ConsumerWidget {
const _RecordingHudLayer({
required this.onStart,
required this.onStop,
});
final Future<void> Function() onStart;
final Future<void> Function() onStop;
@override
Widget build(BuildContext context, WidgetRef ref) {
final hudState = ref.watch(
recordingViewModelProvider.select(
(m) => (
m.session.errorMessage,
m.session.permissionWarning,
m.session.hasDndAccess,
m.session.isBatteryOptimizedIgnored,
m.session.notificationsGranted,
m.session.isRecording,
m.session.isStartingRecording,
m.session.isTouchLocked,
m.hasValidClipboardInfo,
m.clipboardRecordingModel.address.trim(),
),
),
);
final (
errorMessage,
permissionWarning,
hasDndAccess,
isBatteryOptimizedIgnored,
notificationsGranted,
isRecording,
isStartingRecording,
isTouchLocked,
showClipboardHint,
clipboardAddress,
) = hudState;
final viewModel = ref.read(recordingViewModelProvider.notifier);
return RecordingHudWidget(
errorMessage: errorMessage,
permissionWarning: permissionWarning,
hasDndAccess: hasDndAccess,
isBatteryOptimizedIgnored: isBatteryOptimizedIgnored,
notificationsGranted: notificationsGranted,
isRecording: isRecording,
isStartingRecording: isStartingRecording,
isTouchLocked: isTouchLocked,
showClipboardHint: showClipboardHint,
clipboardAddress: clipboardAddress,
onStart: onStart,
onStop: onStop,
onOpenDnd: () async {
await viewModel.openDndSettings();
await viewModel.refreshDndAccess();
},
onOpenBattery: () async {
await viewModel.openBatterySettings();
await viewModel.refreshBatteryOptimization();
},
onToggleTouchLock: () {
final locked = ref.read(recordingViewModelProvider).session.isTouchLocked;
viewModel.setTouchLocked(!locked);
},
);
}
}
class _TouchLockOverlayLayer extends ConsumerWidget {
const _TouchLockOverlayLayer({required this.onStopRecording});
final Future<void> Function() onStopRecording;
@override
Widget build(BuildContext context, WidgetRef ref) {
final overlayState = ref.watch(
recordingViewModelProvider.select(
(m) => (m.session.isTouchLocked, m.session.isRecording),
),
);
final (isTouchLocked, isRecording) = overlayState;
if (!isTouchLocked || !isRecording) {
return const SizedBox.shrink();
}
final viewModel = ref.read(recordingViewModelProvider.notifier);
return RecordingTouchLockOverlayWidget(
enabled: true,
onUnlocked: (intent) async {
viewModel.setTouchLocked(false);
if (intent == RecordingTouchLockUnlockIntent.stopRecording) {
await onStopRecording();
}
},
);
}
}
class _StartingRecordingOverlay extends ConsumerWidget {
const _StartingRecordingOverlay();
@override
Widget build(BuildContext context, WidgetRef ref) {
final isStartingRecording = ref.watch(
recordingViewModelProvider.select((m) => m.session.isStartingRecording),
);
if (!isStartingRecording) {
return const SizedBox.shrink();
}
return RecordingLoadingOverlayWidget(
message: '正在开始录制…',
backgroundColor: Colors.black.withValues(alpha: 0.24),
);
}
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
/// 录制页内容切换时的统一过渡动画。
class RecordContentTransition {
RecordContentTransition._();
static const duration = Duration(milliseconds: 600);
static Widget builder(Widget child, Animation<double> animation) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return FadeTransition(
opacity: curved,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.12),
end: Offset.zero,
).animate(curved),
child: child,
),
);
}
static Widget stackLayoutBuilder(
Widget? currentChild,
List<Widget> previousChildren,
) {
return Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [...previousChildren, ?currentChild],
);
}
static Widget bottomStackLayoutBuilder(
Widget? currentChild,
List<Widget> previousChildren,
) {
return Stack(
alignment: Alignment.bottomLeft,
clipBehavior: Clip.none,
children: [...previousChildren, ?currentChild],
);
}
}

View File

@@ -3,6 +3,7 @@ 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';
import 'package:recording_tool/core/utils/date_time_formatter.dart'; import 'package:recording_tool/core/utils/date_time_formatter.dart';
import 'package:recording_tool/features/recording/widgets/record_content_transition.dart';
/// 左下角实时时钟与剪贴板地址 /// 左下角实时时钟与剪贴板地址
class ClipboardAddressClockChipWidget extends StatefulWidget { class ClipboardAddressClockChipWidget extends StatefulWidget {
@@ -48,14 +49,32 @@ class _ClipboardAddressClockChipWidgetState
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return AnimatedSize(
crossAxisAlignment: CrossAxisAlignment.start, duration: RecordContentTransition.duration,
mainAxisSize: MainAxisSize.min, curve: Curves.easeOutCubic,
children: [ alignment: Alignment.topLeft,
Text(_nowText, style: _textStyle), clipBehavior: Clip.none,
if (widget.address.isNotEmpty) child: Column(
Text(widget.address, style: _textStyle), crossAxisAlignment: CrossAxisAlignment.start,
], mainAxisSize: MainAxisSize.min,
children: [
Text(_nowText, style: _textStyle),
AnimatedSwitcher(
duration: RecordContentTransition.duration,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
layoutBuilder: RecordContentTransition.bottomStackLayoutBuilder,
transitionBuilder: RecordContentTransition.builder,
child: widget.address.isNotEmpty
? Text(
widget.address,
key: ValueKey(widget.address),
style: _textStyle,
)
: const SizedBox.shrink(key: ValueKey('clipboard-address-empty')),
),
],
),
); );
} }
} }

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/features/recording/widgets/record_content_transition.dart';
import 'package:recording_tool/gen/assets.gen.dart'; import 'package:recording_tool/gen/assets.gen.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart'; import 'package:recording_tool/shared/widgets/app_toast.dart';
@@ -11,7 +12,6 @@ class RecordHeaderWidget extends StatelessWidget {
required this.hasValidClipboardInfo, required this.hasValidClipboardInfo,
this.eventTitle, this.eventTitle,
required this.isRecording, required this.isRecording,
required this.elapsedLabel,
required this.onPasteEventInfo, required this.onPasteEventInfo,
required this.onClearEventInfo, required this.onClearEventInfo,
}); });
@@ -19,7 +19,6 @@ class RecordHeaderWidget extends StatelessWidget {
final bool hasValidClipboardInfo; final bool hasValidClipboardInfo;
final String? eventTitle; final String? eventTitle;
final bool isRecording; final bool isRecording;
final String elapsedLabel;
final Future<void> Function() onPasteEventInfo; final Future<void> Function() onPasteEventInfo;
final VoidCallback onClearEventInfo; final VoidCallback onClearEventInfo;
@@ -27,6 +26,19 @@ class RecordHeaderWidget extends StatelessWidget {
bool get _showEventTitle => hasValidClipboardInfo; bool get _showEventTitle => hasValidClipboardInfo;
Widget _buildAnimatedHeaderContent() {
if (_showEventTitle) {
return _HeaderEventTitleRow(
key: ValueKey('title-${eventTitle ?? ''}'),
title: eventTitle ?? '',
isRecording: isRecording,
onClearEventInfo: onClearEventInfo,
);
}
return const SizedBox.shrink(key: ValueKey('header-empty'));
}
void _mockCopyEventInfo() { void _mockCopyEventInfo() {
const strTemp = const strTemp =
'{"title":"蔡依婷vs夏志豪 空中格斗赛 初中组","address":"黑龙江省鹤岗市11111","filename":"蔡依婷_夏志豪_测试循环赛-7_空中格斗赛"}'; '{"title":"蔡依婷vs夏志豪 空中格斗赛 初中组","address":"黑龙江省鹤岗市11111","filename":"蔡依婷_夏志豪_测试循环赛-7_空中格斗赛"}';
@@ -52,18 +64,27 @@ class RecordHeaderWidget extends StatelessWidget {
fit: BoxFit.contain, fit: BoxFit.contain,
), ),
Expanded( Expanded(
child: _showEventTitle child: Stack(
? _HeaderEventTitleRow( alignment: Alignment.center,
title: eventTitle ?? '', children: [
isRecording: isRecording, AnimatedSwitcher(
onClearEventInfo: onClearEventInfo, duration: RecordContentTransition.duration,
) switchInCurve: Curves.easeOutCubic,
: _showPasteButtons switchOutCurve: Curves.easeInCubic,
? _HeaderPasteActions( layoutBuilder: RecordContentTransition.stackLayoutBuilder,
onMockCopy: _mockCopyEventInfo, transitionBuilder: RecordContentTransition.builder,
onPasteEventInfo: onPasteEventInfo, child: _buildAnimatedHeaderContent(),
) ),
: const SizedBox.shrink(), if (_showPasteButtons)
Align(
alignment: Alignment.centerRight,
child: _HeaderPasteActions(
onMockCopy: _mockCopyEventInfo,
onPasteEventInfo: onPasteEventInfo,
),
),
],
),
), ),
], ],
), ),
@@ -75,6 +96,7 @@ class RecordHeaderWidget extends StatelessWidget {
class _HeaderEventTitleRow extends StatelessWidget { class _HeaderEventTitleRow extends StatelessWidget {
const _HeaderEventTitleRow({ const _HeaderEventTitleRow({
super.key,
required this.title, required this.title,
required this.isRecording, required this.isRecording,
required this.onClearEventInfo, required this.onClearEventInfo,
@@ -95,30 +117,40 @@ class _HeaderEventTitleRow extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Expanded( Expanded(
child: Text( child: AnimatedSwitcher(
title, duration: RecordContentTransition.duration,
style: _overlayTextStyle.copyWith( switchInCurve: Curves.easeOutCubic,
fontSize: 12.sp, switchOutCurve: Curves.easeInCubic,
fontWeight: FontWeight.w600, transitionBuilder: RecordContentTransition.builder,
child: Text(
title,
key: ValueKey(title),
style: _overlayTextStyle.copyWith(
fontSize: 12.sp,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
), ),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
), ),
), ),
if (!isRecording) !isRecording
IconButton( ? IconButton(
onPressed: onClearEventInfo, key: const ValueKey('clear-event-info'),
icon: Assets.images.imageDelete.image( onPressed: onClearEventInfo,
width: 15.r, icon: Assets.images.imageDelete.image(
height: 15.r, width: 15.r,
fit: BoxFit.contain, height: 15.r,
excludeFromSemantics: true, fit: BoxFit.contain,
), excludeFromSemantics: true,
padding: EdgeInsets.zero, ),
constraints: BoxConstraints(minWidth: 40.r, minHeight: 40.r), padding: EdgeInsets.zero,
tooltip: '删除', constraints: BoxConstraints(minWidth: 40.r, minHeight: 40.r),
), alignment: Alignment.centerRight,
tooltip: '删除',
)
: const SizedBox.shrink(key: ValueKey('clear-event-info-hidden')),
], ],
); );
} }

View File

@@ -13,18 +13,22 @@ class RecordTimerWidget extends ConsumerStatefulWidget {
class _RecordTimerWidgetState extends ConsumerState<RecordTimerWidget> { class _RecordTimerWidgetState extends ConsumerState<RecordTimerWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final session = ref.watch( final timerState = ref.watch(
recordingViewModelProvider.select((value) => value.session), recordingViewModelProvider.select(
(m) => (m.session.isRecording, m.session.elapsedLabel),
),
); );
final isRecording = session.isRecording; final (isRecording, elapsedLabel) = timerState;
final displayTime = isRecording ? session.elapsedLabel : '00:00:00'; final displayTime = isRecording ? elapsedLabel : '00:00:00';
return Positioned( return Positioned(
top: 13.r, top: 13.r,
left: 0, left: 0,
right: 0, right: 0,
child: Center( child: Center(
child: Container( child: AnimatedContainer(
duration: const Duration(milliseconds: 380),
curve: Curves.easeOutCubic,
padding: EdgeInsets.symmetric(horizontal: 5.r, vertical: 0.r), padding: EdgeInsets.symmetric(horizontal: 5.r, vertical: 0.r),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isRecording ? Colors.red : Colors.transparent, color: isRecording ? Colors.red : Colors.transparent,

View File

@@ -1,62 +1,164 @@
import 'dart:ui' show lerpDouble;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
/// 录制控制按钮:白圈 + 红色内芯,录制中为圆角矩形。 /// 录制控制按钮:白圈 + 红色内芯,录制中为圆角矩形。
class RecordingControlButton extends StatelessWidget { class RecordingControlButton extends StatefulWidget {
const RecordingControlButton({ const RecordingControlButton({
super.key, super.key,
required this.isRecording, required this.isRecording,
required this.onTap, required this.onTap,
this.isStartingRecording = false,
this.enabled = true, this.enabled = true,
this.size, this.size,
}); });
final bool isRecording; final bool isRecording;
final bool isStartingRecording;
final VoidCallback? onTap; final VoidCallback? onTap;
final bool enabled; final bool enabled;
final double? size; final double? size;
@override
State<RecordingControlButton> createState() => _RecordingControlButtonState();
}
class _RecordingControlButtonState extends State<RecordingControlButton>
with TickerProviderStateMixin {
static const _morphDuration = Duration(milliseconds: 380);
static const _pressDownDuration = Duration(milliseconds: 120);
static const _pressUpDuration = Duration(milliseconds: 180);
late final AnimationController _morphController;
late final AnimationController _pressController;
late final CurvedAnimation _morphAnimation;
late final Animation<double> _pressScale;
bool get _targetIsRecording =>
widget.isRecording || widget.isStartingRecording;
@override
void initState() {
super.initState();
_morphController = AnimationController(
vsync: this,
duration: _morphDuration,
value: _targetIsRecording ? 1 : 0,
);
_morphAnimation = CurvedAnimation(
parent: _morphController,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
_pressController = AnimationController(
vsync: this,
duration: _pressDownDuration,
);
_pressScale = Tween<double>(begin: 1, end: 0.94).animate(
CurvedAnimation(
parent: _pressController,
curve: Curves.easeOut,
reverseCurve: Curves.easeOutBack,
),
);
}
@override
void didUpdateWidget(covariant RecordingControlButton oldWidget) {
super.didUpdateWidget(oldWidget);
final oldTarget =
oldWidget.isRecording || oldWidget.isStartingRecording;
final newTarget = _targetIsRecording;
if (oldTarget != newTarget) {
if (newTarget) {
_morphController.forward();
} else {
_morphController.reverse();
}
}
}
@override
void dispose() {
_morphAnimation.dispose();
_morphController.dispose();
_pressController.dispose();
super.dispose();
}
void _handlePressDown() {
if (!widget.enabled) return;
_pressController.duration = _pressDownDuration;
_pressController.forward();
}
void _handlePressUp() {
if (!widget.enabled) return;
_pressController.duration = _pressUpDuration;
_pressController.reverse();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final buttonSize = size ?? 70.r; final buttonSize = widget.size ?? 70.r;
final borderWidth = 4.r; final borderWidth = 4.r;
final idleInnerSize = 62.r; final idleInnerSize = 62.r;
final recordingInnerSize = 22.r; final recordingInnerSize = 22.r;
final idleCornerRadius = idleInnerSize / 2;
final recordingCornerRadius = 6.r; final recordingCornerRadius = 6.r;
final innerSize = isRecording ? recordingInnerSize : idleInnerSize;
final borderRadius = isRecording
? recordingCornerRadius
: idleInnerSize / 2;
return GestureDetector( return GestureDetector(
onTap: enabled ? onTap : null, behavior: HitTestBehavior.opaque,
child: SizedBox( onTapDown: (_) => _handlePressDown(),
width: buttonSize, onTapUp: (_) => _handlePressUp(),
height: buttonSize, onTapCancel: _handlePressUp,
child: Stack( onTap: widget.enabled ? widget.onTap : null,
alignment: Alignment.center, child: AnimatedBuilder(
children: [ animation: Listenable.merge([_morphController, _pressController]),
Container( builder: (context, child) {
final morph = _morphAnimation.value;
final innerSize = lerpDouble(
idleInnerSize,
recordingInnerSize,
morph,
)!;
final cornerRadius = lerpDouble(
idleCornerRadius,
recordingCornerRadius,
morph,
)!;
return Transform.scale(
scale: _pressScale.value,
child: SizedBox(
width: buttonSize, width: buttonSize,
height: buttonSize, height: buttonSize,
decoration: BoxDecoration( child: Stack(
shape: BoxShape.circle, alignment: Alignment.center,
border: Border.all(color: Colors.white, width: borderWidth), children: [
Container(
width: buttonSize,
height: buttonSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: borderWidth),
),
),
Container(
width: innerSize,
height: innerSize,
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(cornerRadius),
),
),
],
), ),
), ),
AnimatedContainer( );
duration: const Duration(milliseconds: 500), },
curve: Curves.ease,
width: innerSize,
height: innerSize,
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(borderRadius),
),
),
],
),
), ),
); );
} }

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:recording_tool/core/utils/rate_limiter.dart'; import 'package:recording_tool/core/utils/rate_limiter.dart';
import 'package:recording_tool/features/recording/model/model_recording_session.dart'; import 'package:recording_tool/features/recording/widgets/record_content_transition.dart';
import 'package:recording_tool/features/recording/widgets/widget_clipboard_address_clock_chip.dart'; import 'package:recording_tool/features/recording/widgets/widget_clipboard_address_clock_chip.dart';
import 'package:recording_tool/features/recording/widgets/widget_recording_button.dart'; import 'package:recording_tool/features/recording/widgets/widget_recording_button.dart';
import 'package:recording_tool/features/recording/widgets/widget_recording_setup_hints.dart'; import 'package:recording_tool/features/recording/widgets/widget_recording_setup_hints.dart';
@@ -11,7 +11,14 @@ import 'package:recording_tool/features/recording/widgets/widget_recording_setup
class RecordingHudWidget extends StatelessWidget { class RecordingHudWidget extends StatelessWidget {
const RecordingHudWidget({ const RecordingHudWidget({
super.key, super.key,
required this.state, this.errorMessage,
this.permissionWarning,
required this.hasDndAccess,
required this.isBatteryOptimizedIgnored,
required this.notificationsGranted,
required this.isRecording,
required this.isStartingRecording,
required this.isTouchLocked,
this.showClipboardHint = false, this.showClipboardHint = false,
this.clipboardAddress = '', this.clipboardAddress = '',
required this.onStart, required this.onStart,
@@ -21,7 +28,14 @@ class RecordingHudWidget extends StatelessWidget {
required this.onToggleTouchLock, required this.onToggleTouchLock,
}); });
final RecordingSessionState state; final String? errorMessage;
final String? permissionWarning;
final bool hasDndAccess;
final bool isBatteryOptimizedIgnored;
final bool notificationsGranted;
final bool isRecording;
final bool isStartingRecording;
final bool isTouchLocked;
final bool showClipboardHint; final bool showClipboardHint;
final String clipboardAddress; final String clipboardAddress;
final Future<void> Function() onStart; final Future<void> Function() onStart;
@@ -49,23 +63,23 @@ class RecordingHudWidget extends StatelessWidget {
children: [ children: [
SizedBox(height: 8.h), SizedBox(height: 8.h),
const Spacer(), const Spacer(),
if (state.errorMessage != null) if (errorMessage != null)
Padding( Padding(
padding: EdgeInsets.all(12.r), padding: EdgeInsets.all(12.r),
child: Text( child: Text(
state.errorMessage!, errorMessage!,
style: const TextStyle(color: Colors.amber), style: const TextStyle(color: Colors.amber),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
), ),
if (state.permissionWarning != null) if (permissionWarning != null)
Padding( Padding(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: 16.r, horizontal: 16.r,
vertical: 8.r, vertical: 8.r,
), ),
child: Text( child: Text(
state.permissionWarning!, permissionWarning!,
style: TextStyle( style: TextStyle(
color: Colors.orangeAccent, color: Colors.orangeAccent,
fontSize: 12.sp, fontSize: 12.sp,
@@ -74,9 +88,9 @@ class RecordingHudWidget extends StatelessWidget {
), ),
), ),
RecordingSetupHintsWidget( RecordingSetupHintsWidget(
hasDndAccess: state.hasDndAccess, hasDndAccess: hasDndAccess,
isBatteryIgnored: state.isBatteryOptimizedIgnored, isBatteryIgnored: isBatteryOptimizedIgnored,
notificationsGranted: state.notificationsGranted, notificationsGranted: notificationsGranted,
onOpenDnd: onOpenDnd, onOpenDnd: onOpenDnd,
onOpenBattery: onOpenBattery, onOpenBattery: onOpenBattery,
onOpenNotificationSettings: openAppSettings, onOpenNotificationSettings: openAppSettings,
@@ -84,13 +98,24 @@ class RecordingHudWidget extends StatelessWidget {
], ],
), ),
), ),
if (showClipboardHint) Positioned(
Positioned( left: _overlayInfoLeft,
left: _overlayInfoLeft, bottom: _overlayInfoBottom,
bottom: _overlayInfoBottom, child: AnimatedSwitcher(
child: ClipboardAddressClockChipWidget(address: clipboardAddress), duration: RecordContentTransition.duration,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
layoutBuilder: RecordContentTransition.bottomStackLayoutBuilder,
transitionBuilder: RecordContentTransition.builder,
child: showClipboardHint
? ClipboardAddressClockChipWidget(
key: const ValueKey('clipboard-info'),
address: clipboardAddress,
)
: const SizedBox.shrink(key: ValueKey('clipboard-info-hidden')),
), ),
if (state.isRecording) ),
if (isRecording)
Positioned( Positioned(
left: 16.r, left: 16.r,
bottom: _recordButtonBottom, bottom: _recordButtonBottom,
@@ -100,7 +125,7 @@ class RecordingHudWidget extends StatelessWidget {
child: IconButton( child: IconButton(
onPressed: onToggleTouchLock, onPressed: onToggleTouchLock,
icon: Icon( icon: Icon(
state.isTouchLocked ? Icons.lock : Icons.lock_open, isTouchLocked ? Icons.lock : Icons.lock_open,
color: Colors.white, color: Colors.white,
size: 28.r, size: 28.r,
), ),
@@ -114,11 +139,12 @@ class RecordingHudWidget extends StatelessWidget {
bottom: _recordButtonBottom, bottom: _recordButtonBottom,
child: Center( child: Center(
child: RecordingControlButton( child: RecordingControlButton(
isRecording: state.isRecording, isRecording: isRecording,
enabled: !state.isStartingRecording, isStartingRecording: isStartingRecording,
enabled: !isStartingRecording,
size: _recordButtonSize, size: _recordButtonSize,
onTap: () { onTap: () {
if (state.isRecording) { if (isRecording) {
RateLimit.instance.debounce<void>( RateLimit.instance.debounce<void>(
key: 'recording.session.stop', key: 'recording.session.stop',
value: null, value: null,

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

@@ -20,7 +20,6 @@ void main() {
hasValidClipboardInfo: hasValidClipboardInfo, hasValidClipboardInfo: hasValidClipboardInfo,
eventTitle: eventTitle, eventTitle: eventTitle,
isRecording: false, isRecording: false,
elapsedLabel: '00:00',
onPasteEventInfo: () async {}, onPasteEventInfo: () async {},
onClearEventInfo: () {}, onClearEventInfo: () {},
), ),

View File

@@ -0,0 +1,138 @@
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_button.dart';
void main() {
const designSize = Size(375, 812);
const morphDuration = Duration(milliseconds: 380);
Future<void> pumpButton(
WidgetTester tester, {
required bool isRecording,
bool isStartingRecording = false,
}) async {
await tester.pumpWidget(
ScreenUtilInit(
designSize: designSize,
builder: (context, _) {
return MaterialApp(
home: Scaffold(
body: Center(
child: RecordingControlButton(
isRecording: isRecording,
isStartingRecording: isStartingRecording,
onTap: () {},
),
),
),
);
},
),
);
await tester.pump();
}
Size innerCoreSize(WidgetTester tester) {
final finder = find.byWidgetPredicate(
(widget) =>
widget is Container &&
widget.decoration is BoxDecoration &&
(widget.decoration! as BoxDecoration).color == Colors.red,
);
return tester.getSize(finder);
}
testWidgets('idle state uses large circular inner core', (tester) async {
await pumpButton(tester, isRecording: false);
final size = innerCoreSize(tester);
expect(size.width, closeTo(62.r, 0.5));
expect(size.height, closeTo(62.r, 0.5));
});
testWidgets('isStartingRecording morphs to stop square before isRecording', (
tester,
) async {
await pumpButton(
tester,
isRecording: false,
isStartingRecording: true,
);
await tester.pump(morphDuration);
await tester.pump();
final size = innerCoreSize(tester);
expect(size.width, closeTo(22.r, 0.5));
expect(size.height, closeTo(22.r, 0.5));
});
testWidgets('isRecording forward and reverse morph without errors', (
tester,
) async {
await pumpButton(tester, isRecording: false);
await tester.pumpWidget(
ScreenUtilInit(
designSize: designSize,
builder: (context, _) {
return MaterialApp(
home: Scaffold(
body: Center(
child: RecordingControlButton(
isRecording: true,
onTap: () {},
),
),
),
);
},
),
);
await tester.pump(morphDuration);
await tester.pump();
expect(innerCoreSize(tester).width, closeTo(22.r, 0.5));
await tester.pumpWidget(
ScreenUtilInit(
designSize: designSize,
builder: (context, _) {
return MaterialApp(
home: Scaffold(
body: Center(
child: RecordingControlButton(
isRecording: false,
onTap: () {},
),
),
),
);
},
),
);
await tester.pump(morphDuration);
await tester.pump();
expect(innerCoreSize(tester).width, closeTo(62.r, 0.5));
});
testWidgets('failed start rolls morph back to idle circle', (tester) async {
await pumpButton(
tester,
isRecording: false,
isStartingRecording: true,
);
await tester.pump(morphDuration);
await tester.pump();
expect(innerCoreSize(tester).width, closeTo(22.r, 0.5));
await pumpButton(tester, isRecording: false, isStartingRecording: false);
await tester.pump(morphDuration);
await tester.pump();
expect(innerCoreSize(tester).width, closeTo(62.r, 0.5));
});
}

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);