diff --git a/lib/features/dialog/dialog-record.dart b/lib/features/dialog/dialog-record.dart index 6460053..6d3f81b 100644 --- a/lib/features/dialog/dialog-record.dart +++ b/lib/features/dialog/dialog-record.dart @@ -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 actions; @@ -18,8 +20,8 @@ class RecordDialog extends StatelessWidget { VoidCallback? onPressed, bool barrierDismissible = true, }) { - return showDialog( - 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( - context: context, + return _present( + context, barrierDismissible: barrierDismissible, builder: (dialogContext) { return RecordDialog( @@ -74,6 +76,51 @@ class RecordDialog extends StatelessWidget { ); } + static Future _present( + BuildContext context, { + required Widget Function(BuildContext dialogContext) builder, + required bool barrierDismissible, + }) { + return showGeneralDialog( + 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 animation, + Animation secondaryAnimation, + Widget child, + ) { + final curved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + + return FadeTransition( + opacity: curved, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.08), + end: Offset.zero, + ).animate(curved), + child: ScaleTransition( + scale: Tween(begin: 0.92, end: 1).animate(curved), + child: child, + ), + ), + ); + } + @override Widget build(BuildContext context) { final actionWidgets = actions diff --git a/lib/features/recording/pages/page_record.dart b/lib/features/recording/pages/page_record.dart index 4c30435..bd815ff 100644 --- a/lib/features/recording/pages/page_record.dart +++ b/lib/features/recording/pages/page_record.dart @@ -263,34 +263,38 @@ class _RecordingPageState extends ConsumerState { if (!state.isPreviewReady && state.errorMessage == null) const RecordingLoadingOverlayWidget(message: '正在启动相机…'), const RecordTimerWidget(), - RecordingHudWidget( - state: state, - showClipboardHint: showClipboardInfo, - clipboardAddress: clipboard.address.trim(), - onStart: _onStartRecording, - onStop: () async { - await viewModel.stopRecording(); - if (!context.mounted) return; - final latest = ref - .read(recordingViewModelProvider) - .session; - if (latest.gallerySaveFailed) { - AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限'); - return; - } - await _showRecordingSavedDialogIfNeeded(); - }, - onOpenDnd: () async { - await viewModel.openDndSettings(); - await viewModel.refreshDndAccess(); - }, - onOpenBattery: () async { - await viewModel.openBatterySettings(); - await viewModel.refreshBatteryOptimization(); - }, - onToggleTouchLock: () { - viewModel.setTouchLocked(!state.isTouchLocked); - }, + RepaintBoundary( + child: RecordingHudWidget( + state: state, + showClipboardHint: showClipboardInfo, + clipboardAddress: clipboard.address.trim(), + onStart: _onStartRecording, + onStop: () async { + await viewModel.stopRecording(); + if (!context.mounted) return; + final latest = ref + .read(recordingViewModelProvider) + .session; + if (latest.gallerySaveFailed) { + AppToast.show( + latest.errorMessage ?? '保存到相册失败,请开启相册权限', + ); + return; + } + await _showRecordingSavedDialogIfNeeded(); + }, + 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/record_content_transition.dart b/lib/features/recording/widgets/record_content_transition.dart new file mode 100644 index 0000000..399f25d --- /dev/null +++ b/lib/features/recording/widgets/record_content_transition.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +/// 录制页内容切换时的统一过渡动画。 +class RecordContentTransition { + RecordContentTransition._(); + + static const duration = Duration(milliseconds: 600); + + static Widget builder(Widget child, Animation animation) { + final curved = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + + return FadeTransition( + opacity: curved, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.12), + end: Offset.zero, + ).animate(curved), + child: child, + ), + ); + } + + static Widget stackLayoutBuilder( + Widget? currentChild, + List previousChildren, + ) { + return Stack( + alignment: Alignment.center, + clipBehavior: Clip.none, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ); + } + + static Widget bottomStackLayoutBuilder( + Widget? currentChild, + List previousChildren, + ) { + return Stack( + alignment: Alignment.bottomLeft, + clipBehavior: Clip.none, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ); + } +} diff --git a/lib/features/recording/widgets/widget_clipboard_address_clock_chip.dart b/lib/features/recording/widgets/widget_clipboard_address_clock_chip.dart index e994ff2..cf82c6b 100644 --- a/lib/features/recording/widgets/widget_clipboard_address_clock_chip.dart +++ b/lib/features/recording/widgets/widget_clipboard_address_clock_chip.dart @@ -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')), + ), + ], + ), ); } } diff --git a/lib/features/recording/widgets/widget_record_header.dart b/lib/features/recording/widgets/widget_record_header.dart index 7d11d16..b8bf1db 100644 --- a/lib/features/recording/widgets/widget_record_header.dart +++ b/lib/features/recording/widgets/widget_record_header.dart @@ -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'; @@ -27,6 +28,27 @@ class RecordHeaderWidget extends StatelessWidget { bool get _showEventTitle => hasValidClipboardInfo; + Widget _buildHeaderContent() { + if (_showEventTitle) { + return _HeaderEventTitleRow( + key: ValueKey('title-${eventTitle ?? ''}'), + title: eventTitle ?? '', + isRecording: isRecording, + onClearEventInfo: onClearEventInfo, + ); + } + + if (_showPasteButtons) { + return _HeaderPasteActions( + key: const ValueKey('paste-actions'), + onMockCopy: _mockCopyEventInfo, + onPasteEventInfo: onPasteEventInfo, + ); + } + + return const SizedBox.shrink(key: ValueKey('header-empty')); + } + void _mockCopyEventInfo() { const strTemp = '{"title":"蔡依婷vs夏志豪 空中格斗赛 初中组","address":"黑龙江省鹤岗市11111","filename":"蔡依婷_夏志豪_测试循环赛-7_空中格斗赛"}'; @@ -52,18 +74,14 @@ 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: AnimatedSwitcher( + duration: RecordContentTransition.duration, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + layoutBuilder: RecordContentTransition.stackLayoutBuilder, + transitionBuilder: RecordContentTransition.builder, + child: _buildHeaderContent(), + ), ), ], ), @@ -75,6 +93,7 @@ class RecordHeaderWidget extends StatelessWidget { class _HeaderEventTitleRow extends StatelessWidget { const _HeaderEventTitleRow({ + super.key, required this.title, required this.isRecording, required this.onClearEventInfo, @@ -95,30 +114,45 @@ 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: '删除', - ), + AnimatedSwitcher( + duration: RecordContentTransition.duration, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: RecordContentTransition.builder, + child: !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), + tooltip: '删除', + ) + : const SizedBox.shrink(key: ValueKey('clear-event-info-hidden')), + ), ], ); } @@ -126,6 +160,7 @@ class _HeaderEventTitleRow extends StatelessWidget { class _HeaderPasteActions extends StatelessWidget { const _HeaderPasteActions({ + super.key, required this.onMockCopy, required this.onPasteEventInfo, }); diff --git a/lib/features/recording/widgets/widget_recording_hud.dart b/lib/features/recording/widgets/widget_recording_hud.dart index 143a372..a21d531 100644 --- a/lib/features/recording/widgets/widget_recording_hud.dart +++ b/lib/features/recording/widgets/widget_recording_hud.dart @@ -3,6 +3,7 @@ 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'; @@ -84,12 +85,23 @@ 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) Positioned( left: 16.r,