优化交互体验增加动画效果
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -263,34 +263,38 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
if (!state.isPreviewReady && state.errorMessage == null)
|
if (!state.isPreviewReady && state.errorMessage == null)
|
||||||
const RecordingLoadingOverlayWidget(message: '正在启动相机…'),
|
const RecordingLoadingOverlayWidget(message: '正在启动相机…'),
|
||||||
const RecordTimerWidget(),
|
const RecordTimerWidget(),
|
||||||
RecordingHudWidget(
|
RepaintBoundary(
|
||||||
state: state,
|
child: RecordingHudWidget(
|
||||||
showClipboardHint: showClipboardInfo,
|
state: state,
|
||||||
clipboardAddress: clipboard.address.trim(),
|
showClipboardHint: showClipboardInfo,
|
||||||
onStart: _onStartRecording,
|
clipboardAddress: clipboard.address.trim(),
|
||||||
onStop: () async {
|
onStart: _onStartRecording,
|
||||||
await viewModel.stopRecording();
|
onStop: () async {
|
||||||
if (!context.mounted) return;
|
await viewModel.stopRecording();
|
||||||
final latest = ref
|
if (!context.mounted) return;
|
||||||
.read(recordingViewModelProvider)
|
final latest = ref
|
||||||
.session;
|
.read(recordingViewModelProvider)
|
||||||
if (latest.gallerySaveFailed) {
|
.session;
|
||||||
AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限');
|
if (latest.gallerySaveFailed) {
|
||||||
return;
|
AppToast.show(
|
||||||
}
|
latest.errorMessage ?? '保存到相册失败,请开启相册权限',
|
||||||
await _showRecordingSavedDialogIfNeeded();
|
);
|
||||||
},
|
return;
|
||||||
onOpenDnd: () async {
|
}
|
||||||
await viewModel.openDndSettings();
|
await _showRecordingSavedDialogIfNeeded();
|
||||||
await viewModel.refreshDndAccess();
|
},
|
||||||
},
|
onOpenDnd: () async {
|
||||||
onOpenBattery: () async {
|
await viewModel.openDndSettings();
|
||||||
await viewModel.openBatterySettings();
|
await viewModel.refreshDndAccess();
|
||||||
await viewModel.refreshBatteryOptimization();
|
},
|
||||||
},
|
onOpenBattery: () async {
|
||||||
onToggleTouchLock: () {
|
await viewModel.openBatterySettings();
|
||||||
viewModel.setTouchLocked(!state.isTouchLocked);
|
await viewModel.refreshBatteryOptimization();
|
||||||
},
|
},
|
||||||
|
onToggleTouchLock: () {
|
||||||
|
viewModel.setTouchLocked(!state.isTouchLocked);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (state.isTouchLocked && state.isRecording)
|
if (state.isTouchLocked && state.isRecording)
|
||||||
RecordingTouchLockOverlayWidget(
|
RecordingTouchLockOverlayWidget(
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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,
|
||||||
|
if (currentChild != null) currentChild,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Widget bottomStackLayoutBuilder(
|
||||||
|
Widget? currentChild,
|
||||||
|
List<Widget> previousChildren,
|
||||||
|
) {
|
||||||
|
return Stack(
|
||||||
|
alignment: Alignment.bottomLeft,
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
...previousChildren,
|
||||||
|
if (currentChild != null) currentChild,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -27,6 +28,27 @@ class RecordHeaderWidget extends StatelessWidget {
|
|||||||
|
|
||||||
bool get _showEventTitle => hasValidClipboardInfo;
|
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() {
|
void _mockCopyEventInfo() {
|
||||||
const strTemp =
|
const strTemp =
|
||||||
'{"title":"蔡依婷vs夏志豪 空中格斗赛 初中组","address":"黑龙江省鹤岗市11111","filename":"蔡依婷_夏志豪_测试循环赛-7_空中格斗赛"}';
|
'{"title":"蔡依婷vs夏志豪 空中格斗赛 初中组","address":"黑龙江省鹤岗市11111","filename":"蔡依婷_夏志豪_测试循环赛-7_空中格斗赛"}';
|
||||||
@@ -52,18 +74,14 @@ class RecordHeaderWidget extends StatelessWidget {
|
|||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _showEventTitle
|
child: AnimatedSwitcher(
|
||||||
? _HeaderEventTitleRow(
|
duration: RecordContentTransition.duration,
|
||||||
title: eventTitle ?? '',
|
switchInCurve: Curves.easeOutCubic,
|
||||||
isRecording: isRecording,
|
switchOutCurve: Curves.easeInCubic,
|
||||||
onClearEventInfo: onClearEventInfo,
|
layoutBuilder: RecordContentTransition.stackLayoutBuilder,
|
||||||
)
|
transitionBuilder: RecordContentTransition.builder,
|
||||||
: _showPasteButtons
|
child: _buildHeaderContent(),
|
||||||
? _HeaderPasteActions(
|
),
|
||||||
onMockCopy: _mockCopyEventInfo,
|
|
||||||
onPasteEventInfo: onPasteEventInfo,
|
|
||||||
)
|
|
||||||
: const SizedBox.shrink(),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -75,6 +93,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 +114,45 @@ 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)
|
AnimatedSwitcher(
|
||||||
IconButton(
|
duration: RecordContentTransition.duration,
|
||||||
onPressed: onClearEventInfo,
|
switchInCurve: Curves.easeOutCubic,
|
||||||
icon: Assets.images.imageDelete.image(
|
switchOutCurve: Curves.easeInCubic,
|
||||||
width: 15.r,
|
transitionBuilder: RecordContentTransition.builder,
|
||||||
height: 15.r,
|
child: !isRecording
|
||||||
fit: BoxFit.contain,
|
? IconButton(
|
||||||
excludeFromSemantics: true,
|
key: const ValueKey('clear-event-info'),
|
||||||
),
|
onPressed: onClearEventInfo,
|
||||||
padding: EdgeInsets.zero,
|
icon: Assets.images.imageDelete.image(
|
||||||
constraints: BoxConstraints(minWidth: 40.r, minHeight: 40.r),
|
width: 15.r,
|
||||||
tooltip: '删除',
|
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 {
|
class _HeaderPasteActions extends StatelessWidget {
|
||||||
const _HeaderPasteActions({
|
const _HeaderPasteActions({
|
||||||
|
super.key,
|
||||||
required this.onMockCopy,
|
required this.onMockCopy,
|
||||||
required this.onPasteEventInfo,
|
required this.onPasteEventInfo,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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/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';
|
||||||
@@ -84,12 +85,23 @@ 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 (state.isRecording)
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 16.r,
|
left: 16.r,
|
||||||
|
|||||||
Reference in New Issue
Block a user