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 {
const RecordDialog({super.key, required this.title, required this.actions});
static const _transitionDuration = Duration(milliseconds: 280);
final String title;
final List<RecordDialogAction> actions;
@@ -18,8 +20,8 @@ class RecordDialog extends StatelessWidget {
VoidCallback? onPressed,
bool barrierDismissible = true,
}) {
return showDialog<void>(
context: context,
return _present(
context,
barrierDismissible: barrierDismissible,
builder: (dialogContext) {
return RecordDialog(
@@ -47,8 +49,8 @@ class RecordDialog extends StatelessWidget {
VoidCallback? onRightPressed,
bool barrierDismissible = false,
}) {
return showDialog<void>(
context: context,
return _present(
context,
barrierDismissible: barrierDismissible,
builder: (dialogContext) {
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
Widget build(BuildContext context) {
final actionWidgets = actions

View File

@@ -98,7 +98,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
return '录制完成';
}
/// 从剪贴板粘贴赛事信息(与 header「粘贴赛事信息」一致)。
/// 从剪贴板粘贴赛事信息(与 header「粘贴选手信息」一致)。
Future<void> _pasteEventInfo() async {
final result = await ref
.read(recordingViewModelProvider.notifier)
@@ -167,6 +167,18 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
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() {
final notifier = ref.read(recordingViewModelProvider.notifier);
@@ -227,32 +239,13 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
@override
/// 构建录制页 UI
Widget build(BuildContext context) {
final recordingInfo = ref.watch(recordingViewModelProvider);
final state = recordingInfo.session;
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('录制中无法返回,请先停止录制');
}
},
return _RecordingPopScope(
onExitRecordingMode: _exitRecordingMode,
child: Scaffold(
backgroundColor: Colors.black,
body: Column(
children: [
RecordHeaderWidget(
hasValidClipboardInfo: showClipboardInfo,
eventTitle: showClipboardInfo ? clipboard.title : null,
isRecording: state.isRecording,
elapsedLabel: state.elapsedLabel,
_RecordHeaderSection(
onPasteEventInfo: _pasteEventInfo,
onClearEventInfo: _clearClipboardForNewRound,
),
@@ -260,48 +253,16 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
child: Stack(
children: [
const CameraPreviewWidget(),
if (!state.isPreviewReady && state.errorMessage == null)
const RecordingLoadingOverlayWidget(message: '正在启动相机…'),
const _PreviewLoadingLayer(),
const RecordTimerWidget(),
RecordingHudWidget(
state: state,
showClipboardHint: showClipboardInfo,
clipboardAddress: clipboard.address.trim(),
_RecordingHudLayer(
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();
},
onOpenDnd: () async {
await viewModel.openDndSettings();
await viewModel.refreshDndAccess();
},
onOpenBattery: () async {
await viewModel.openBatterySettings();
await viewModel.refreshBatteryOptimization();
},
onToggleTouchLock: () {
viewModel.setTouchLocked(!state.isTouchLocked);
},
onStop: _stopRecordingAndShowResult,
),
if (state.isTouchLocked && state.isRecording)
RecordingTouchLockOverlayWidget(
enabled: true,
onUnlocked: () => viewModel.setTouchLocked(false),
),
if (state.isStartingRecording)
RecordingLoadingOverlayWidget(
message: '正在开始录制…',
backgroundColor: Colors.black.withValues(alpha: 0.24),
),
_TouchLockOverlayLayer(
onStopRecording: _stopRecordingAndShowResult,
),
const _StartingRecordingOverlay(),
],
),
),
@@ -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_screenutil/flutter_screenutil.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 {
@@ -48,14 +49,32 @@ class _ClipboardAddressClockChipWidgetState
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(_nowText, style: _textStyle),
if (widget.address.isNotEmpty)
Text(widget.address, style: _textStyle),
],
return AnimatedSize(
duration: RecordContentTransition.duration,
curve: Curves.easeOutCubic,
alignment: Alignment.topLeft,
clipBehavior: Clip.none,
child: Column(
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/services.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/shared/widgets/app_toast.dart';
@@ -11,7 +12,6 @@ class RecordHeaderWidget extends StatelessWidget {
required this.hasValidClipboardInfo,
this.eventTitle,
required this.isRecording,
required this.elapsedLabel,
required this.onPasteEventInfo,
required this.onClearEventInfo,
});
@@ -19,7 +19,6 @@ class RecordHeaderWidget extends StatelessWidget {
final bool hasValidClipboardInfo;
final String? eventTitle;
final bool isRecording;
final String elapsedLabel;
final Future<void> Function() onPasteEventInfo;
final VoidCallback onClearEventInfo;
@@ -27,6 +26,19 @@ class RecordHeaderWidget extends StatelessWidget {
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() {
const strTemp =
'{"title":"蔡依婷vs夏志豪 空中格斗赛 初中组","address":"黑龙江省鹤岗市11111","filename":"蔡依婷_夏志豪_测试循环赛-7_空中格斗赛"}';
@@ -52,18 +64,27 @@ class RecordHeaderWidget extends StatelessWidget {
fit: BoxFit.contain,
),
Expanded(
child: _showEventTitle
? _HeaderEventTitleRow(
title: eventTitle ?? '',
isRecording: isRecording,
onClearEventInfo: onClearEventInfo,
)
: _showPasteButtons
? _HeaderPasteActions(
onMockCopy: _mockCopyEventInfo,
onPasteEventInfo: onPasteEventInfo,
)
: const SizedBox.shrink(),
child: Stack(
alignment: Alignment.center,
children: [
AnimatedSwitcher(
duration: RecordContentTransition.duration,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
layoutBuilder: RecordContentTransition.stackLayoutBuilder,
transitionBuilder: RecordContentTransition.builder,
child: _buildAnimatedHeaderContent(),
),
if (_showPasteButtons)
Align(
alignment: Alignment.centerRight,
child: _HeaderPasteActions(
onMockCopy: _mockCopyEventInfo,
onPasteEventInfo: onPasteEventInfo,
),
),
],
),
),
],
),
@@ -75,6 +96,7 @@ class RecordHeaderWidget extends StatelessWidget {
class _HeaderEventTitleRow extends StatelessWidget {
const _HeaderEventTitleRow({
super.key,
required this.title,
required this.isRecording,
required this.onClearEventInfo,
@@ -95,30 +117,40 @@ class _HeaderEventTitleRow extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Text(
title,
style: _overlayTextStyle.copyWith(
fontSize: 12.sp,
fontWeight: FontWeight.w600,
child: AnimatedSwitcher(
duration: RecordContentTransition.duration,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
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)
IconButton(
onPressed: onClearEventInfo,
icon: Assets.images.imageDelete.image(
width: 15.r,
height: 15.r,
fit: BoxFit.contain,
excludeFromSemantics: true,
),
padding: EdgeInsets.zero,
constraints: BoxConstraints(minWidth: 40.r, minHeight: 40.r),
tooltip: '删除',
),
!isRecording
? IconButton(
key: const ValueKey('clear-event-info'),
onPressed: onClearEventInfo,
icon: Assets.images.imageDelete.image(
width: 15.r,
height: 15.r,
fit: BoxFit.contain,
excludeFromSemantics: true,
),
padding: EdgeInsets.zero,
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> {
@override
Widget build(BuildContext context) {
final session = ref.watch(
recordingViewModelProvider.select((value) => value.session),
final timerState = ref.watch(
recordingViewModelProvider.select(
(m) => (m.session.isRecording, m.session.elapsedLabel),
),
);
final isRecording = session.isRecording;
final displayTime = isRecording ? session.elapsedLabel : '00:00:00';
final (isRecording, elapsedLabel) = timerState;
final displayTime = isRecording ? elapsedLabel : '00:00:00';
return Positioned(
top: 13.r,
left: 0,
right: 0,
child: Center(
child: Container(
child: AnimatedContainer(
duration: const Duration(milliseconds: 380),
curve: Curves.easeOutCubic,
padding: EdgeInsets.symmetric(horizontal: 5.r, vertical: 0.r),
decoration: BoxDecoration(
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_screenutil/flutter_screenutil.dart';
/// 录制控制按钮:白圈 + 红色内芯,录制中为圆角矩形。
class RecordingControlButton extends StatelessWidget {
class RecordingControlButton extends StatefulWidget {
const RecordingControlButton({
super.key,
required this.isRecording,
required this.onTap,
this.isStartingRecording = false,
this.enabled = true,
this.size,
});
final bool isRecording;
final bool isStartingRecording;
final VoidCallback? onTap;
final bool enabled;
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
Widget build(BuildContext context) {
final buttonSize = size ?? 70.r;
final buttonSize = widget.size ?? 70.r;
final borderWidth = 4.r;
final idleInnerSize = 62.r;
final recordingInnerSize = 22.r;
final idleCornerRadius = idleInnerSize / 2;
final recordingCornerRadius = 6.r;
final innerSize = isRecording ? recordingInnerSize : idleInnerSize;
final borderRadius = isRecording
? recordingCornerRadius
: idleInnerSize / 2;
return GestureDetector(
onTap: enabled ? onTap : null,
child: SizedBox(
width: buttonSize,
height: buttonSize,
child: Stack(
alignment: Alignment.center,
children: [
Container(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => _handlePressDown(),
onTapUp: (_) => _handlePressUp(),
onTapCancel: _handlePressUp,
onTap: widget.enabled ? widget.onTap : null,
child: AnimatedBuilder(
animation: Listenable.merge([_morphController, _pressController]),
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,
height: buttonSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: borderWidth),
child: Stack(
alignment: Alignment.center,
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:permission_handler/permission_handler.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_recording_button.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 {
const RecordingHudWidget({
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.clipboardAddress = '',
required this.onStart,
@@ -21,7 +28,14 @@ class RecordingHudWidget extends StatelessWidget {
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 String clipboardAddress;
final Future<void> Function() onStart;
@@ -49,23 +63,23 @@ class RecordingHudWidget extends StatelessWidget {
children: [
SizedBox(height: 8.h),
const Spacer(),
if (state.errorMessage != null)
if (errorMessage != null)
Padding(
padding: EdgeInsets.all(12.r),
child: Text(
state.errorMessage!,
errorMessage!,
style: const TextStyle(color: Colors.amber),
textAlign: TextAlign.center,
),
),
if (state.permissionWarning != null)
if (permissionWarning != null)
Padding(
padding: EdgeInsets.symmetric(
horizontal: 16.r,
vertical: 8.r,
),
child: Text(
state.permissionWarning!,
permissionWarning!,
style: TextStyle(
color: Colors.orangeAccent,
fontSize: 12.sp,
@@ -74,9 +88,9 @@ class RecordingHudWidget extends StatelessWidget {
),
),
RecordingSetupHintsWidget(
hasDndAccess: state.hasDndAccess,
isBatteryIgnored: state.isBatteryOptimizedIgnored,
notificationsGranted: state.notificationsGranted,
hasDndAccess: hasDndAccess,
isBatteryIgnored: isBatteryOptimizedIgnored,
notificationsGranted: notificationsGranted,
onOpenDnd: onOpenDnd,
onOpenBattery: onOpenBattery,
onOpenNotificationSettings: openAppSettings,
@@ -84,13 +98,24 @@ class RecordingHudWidget extends StatelessWidget {
],
),
),
if (showClipboardHint)
Positioned(
left: _overlayInfoLeft,
bottom: _overlayInfoBottom,
child: ClipboardAddressClockChipWidget(address: clipboardAddress),
Positioned(
left: _overlayInfoLeft,
bottom: _overlayInfoBottom,
child: AnimatedSwitcher(
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(
left: 16.r,
bottom: _recordButtonBottom,
@@ -100,7 +125,7 @@ class RecordingHudWidget extends StatelessWidget {
child: IconButton(
onPressed: onToggleTouchLock,
icon: Icon(
state.isTouchLocked ? Icons.lock : Icons.lock_open,
isTouchLocked ? Icons.lock : Icons.lock_open,
color: Colors.white,
size: 28.r,
),
@@ -114,11 +139,12 @@ class RecordingHudWidget extends StatelessWidget {
bottom: _recordButtonBottom,
child: Center(
child: RecordingControlButton(
isRecording: state.isRecording,
enabled: !state.isStartingRecording,
isRecording: isRecording,
isStartingRecording: isStartingRecording,
enabled: !isStartingRecording,
size: _recordButtonSize,
onTap: () {
if (state.isRecording) {
if (isRecording) {
RateLimit.instance.debounce<void>(
key: 'recording.session.stop',
value: null,

View File

@@ -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<RecordingTouchLockUnlockIntent> 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<double>(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<int>(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<double>(
begin: 0.6,
end: 1,
).animate(animation),
child: FadeTransition(
opacity: animation,
child: child,
),
);
},
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(
color: Colors.white70,
color: Colors.white,
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,
eventTitle: eventTitle,
isRecording: false,
elapsedLabel: '00:00',
onPasteEventInfo: () async {},
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_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);