diff --git a/lib/features/recording/pages/page_record.dart b/lib/features/recording/pages/page_record.dart index 978a602..70aef7b 100644 --- a/lib/features/recording/pages/page_record.dart +++ b/lib/features/recording/pages/page_record.dart @@ -275,25 +275,23 @@ class _RecordingPageState extends ConsumerState { if (!state.isPreviewReady && state.errorMessage == null) const RecordingLoadingOverlayWidget(message: '正在启动相机…'), const RecordTimerWidget(), - RepaintBoundary( - child: RecordingHudWidget( - state: state, - showClipboardHint: showClipboardInfo, - clipboardAddress: clipboard.address.trim(), - onStart: _onStartRecording, - onStop: _stopRecordingAndShowResult, - onOpenDnd: () async { - await viewModel.openDndSettings(); - await viewModel.refreshDndAccess(); - }, - onOpenBattery: () async { - await viewModel.openBatterySettings(); - await viewModel.refreshBatteryOptimization(); - }, - onToggleTouchLock: () { - viewModel.setTouchLocked(!state.isTouchLocked); - }, - ), + RecordingHudWidget( + state: state, + showClipboardHint: showClipboardInfo, + clipboardAddress: clipboard.address.trim(), + onStart: _onStartRecording, + onStop: _stopRecordingAndShowResult, + 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) RecordingTouchLockOverlayWidget( diff --git a/lib/features/recording/widgets/widget_record_timer.dart b/lib/features/recording/widgets/widget_record_timer.dart index 15c47a0..27ba7a0 100644 --- a/lib/features/recording/widgets/widget_record_timer.dart +++ b/lib/features/recording/widgets/widget_record_timer.dart @@ -24,7 +24,9 @@ class _RecordTimerWidgetState extends ConsumerState { 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, diff --git a/lib/features/recording/widgets/widget_recording_button.dart b/lib/features/recording/widgets/widget_recording_button.dart index 271dbfe..d7df5da 100644 --- a/lib/features/recording/widgets/widget_recording_button.dart +++ b/lib/features/recording/widgets/widget_recording_button.dart @@ -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 createState() => _RecordingControlButtonState(); +} + +class _RecordingControlButtonState extends State + 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 _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(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), - ), - ), - ], - ), + ); + }, ), ); } diff --git a/lib/features/recording/widgets/widget_recording_hud.dart b/lib/features/recording/widgets/widget_recording_hud.dart index a21d531..446e500 100644 --- a/lib/features/recording/widgets/widget_recording_hud.dart +++ b/lib/features/recording/widgets/widget_recording_hud.dart @@ -127,6 +127,7 @@ class RecordingHudWidget extends StatelessWidget { child: Center( child: RecordingControlButton( isRecording: state.isRecording, + isStartingRecording: state.isStartingRecording, enabled: !state.isStartingRecording, size: _recordButtonSize, onTap: () { diff --git a/test/features/recording/widget_recording_button_test.dart b/test/features/recording/widget_recording_button_test.dart new file mode 100644 index 0000000..2bf4680 --- /dev/null +++ b/test/features/recording/widget_recording_button_test.dart @@ -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 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)); + }); +}