1.确定 APP 包名

2.录制结束增加弹窗提示
3.完成读取剪切板内容、新增粘贴剪切板内容
This commit is contained in:
2026-06-04 16:25:26 +08:00
parent 5ddcb95358
commit 77d9c35592
23 changed files with 652 additions and 383 deletions

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -18,12 +20,31 @@ class AppBootstrapper {
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
await AppStorage.init();
final packageInfo = await AppPlatformInfo.packageInfo();
AppConfig.configure(environment: environment, packageInfo: packageInfo);
AppConfig.configure(environment: environment);
AppLogger.debug('App started in ${AppConfig.current.environment.name}');
runApp(const ProviderScope(child: FlutterTemplateApp()));
// Load native package metadata after the first frame can render.
// Awaiting MethodChannel calls before runApp() can stall the Android
// splash screen on some devices.
unawaited(_loadPackageInfo(environment));
}
static Future<void> _loadPackageInfo(AppEnvironment environment) async {
try {
final packageInfo = await AppPlatformInfo.packageInfo().timeout(
const Duration(seconds: 8),
);
AppConfig.configure(environment: environment, packageInfo: packageInfo);
} catch (error, stackTrace) {
AppLogger.debug(
'Native packageInfo unavailable',
error: error,
stackTrace: stackTrace,
);
}
}
}

View File

@@ -59,7 +59,7 @@ class AppPlatformInfo {
AppPlatformInfo._();
static const MethodChannel _channel = MethodChannel(
'com.gdfw.fxjk/platform_info',
'com.qxy.dronex/platform_info',
);
static Future<AppPackageInfo> packageInfo() async {

View File

@@ -1,8 +1,8 @@
/// 小程序复制到剪切板的录制信息。
class ClipboardRecordingModel {
final String title;
final int startTimestamp;
final int endTimestamp;
int? startTimestamp;
int? endTimestamp;
final String address;
/// 录制文件名模板如「选手名称_选手ID_赛事名称_赛项」。
@@ -10,8 +10,8 @@ class ClipboardRecordingModel {
ClipboardRecordingModel({
required this.title,
required this.startTimestamp,
required this.endTimestamp,
this.startTimestamp,
this.endTimestamp,
required this.address,
this.filename,
});

View File

@@ -1,5 +1,5 @@
abstract final class RecordingChannelNames {
static const packageName = 'com.gdfw.fxjk';
static const packageName = 'com.qxy.dronex';
static const method = '$packageName/recording';
static const events = '$packageName/recording_events';
}

View File

@@ -2,13 +2,17 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:recording_tool/core/utils/date_time_formatter.dart';
import 'package:recording_tool/features/recording/model/model_recording.dart';
import 'package:recording_tool/features/recording/recording_display_name.dart';
import 'package:recording_tool/features/recording/recording_platform.dart';
import 'package:recording_tool/features/recording/recording_session_controller.dart';
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
import 'package:recording_tool/features/recording/widgets/camera_preview_widget.dart';
import 'package:recording_tool/features/recording/widgets/recording_saved_dialog.dart';
import 'package:recording_tool/features/recording/widgets/recording_touch_lock_overlay.dart';
import 'package:recording_tool/shared/widgets/widgets.dart';
@@ -52,6 +56,69 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
_immersiveApplied = true;
}
String _clipboardHintLabel(RecordingModel recordingInfo) {
if (!recordingInfo.hasValidClipboardInfo) return '';
final clip = recordingInfo.clipboardRecordingModel;
final lines = <String>[];
final address = clip.address.trim();
if (address.isNotEmpty) {
lines.add(address);
}
if (clip.startTimestamp > 0) {
final startTime = DateTime.fromMillisecondsSinceEpoch(
clip.startTimestamp * 1000,
).toLocal();
lines.add(
DateTimeFormatter.format(startTime, pattern: 'yyyy-M-d-H:mm:ss'),
);
}
return lines.join('\n');
}
String _savedDialogSessionTitle(
RecordingModel recordingInfo,
String? savedName,
) {
final clipboard = recordingInfo.clipboardRecordingModel;
if (recordingInfo.hasValidClipboardInfo &&
clipboard.title.trim().isNotEmpty) {
return clipboard.title.trim();
}
if (savedName != null && savedName.isNotEmpty) {
return resolveRecordingDisplayName(savedName);
}
return '录制完成';
}
Future<void> _showRecordingSavedDialogIfNeeded() async {
final session = ref.read(recordingSessionControllerProvider);
if (session.lastSavedDisplayName == null || session.gallerySaveFailed) {
return;
}
final recordingInfo = ref.read(recordingViewModelProvider);
final sessionTitle = _savedDialogSessionTitle(
recordingInfo,
session.lastSavedDisplayName,
);
await showRecordingSavedDialog(
context,
sessionTitle: sessionTitle,
onContinueRound: () {
ref
.read(recordingSessionControllerProvider.notifier)
.clearSavedRecordingResult();
},
onRecordNewRound: () {
ref.read(recordingViewModelProvider.notifier).resetClipboardInfo();
ref
.read(recordingSessionControllerProvider.notifier)
.clearSavedRecordingResult();
},
);
}
Future<void> _exitRecordingMode() async {
if (!_immersiveApplied) return;
await ref.read(recordingSessionControllerProvider.notifier).teardown();
@@ -100,6 +167,8 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
fit: StackFit.expand,
children: [
const CameraPreviewWidget(),
if (!state.isPreviewReady && state.errorMessage == null)
const _RecordingLoadingOverlay(message: '正在启动相机…'),
if (state.isTouchLocked && state.isRecording)
RecordingTouchLockOverlay(
enabled: true,
@@ -109,6 +178,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
state: state,
eventTitle: showClipboardInfo ? clipboard.title : null,
eventAddress: showClipboardInfo ? clipboard.address : null,
clipboardHintLabel: _clipboardHintLabel(recordingInfo),
onPasteEventInfo: () async {
final result = await ref
.read(recordingViewModelProvider.notifier)
@@ -125,7 +195,9 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
final latest = ref.read(recordingSessionControllerProvider);
if (latest.gallerySaveFailed) {
AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限');
return;
}
await _showRecordingSavedDialogIfNeeded();
},
onOpenDnd: () async {
await controller.openDndSettings();
@@ -139,6 +211,40 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
controller.setTouchLocked(!state.isTouchLocked);
},
),
if (state.isStartingRecording)
const _RecordingLoadingOverlay(message: '正在开始录制…'),
],
),
),
);
}
}
class _RecordingLoadingOverlay extends StatelessWidget {
const _RecordingLoadingOverlay({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return ColoredBox(
color: Colors.black,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox.square(
dimension: 32.r,
child: CircularProgressIndicator(
strokeWidth: 2.5.r,
color: Colors.white70,
),
),
SizedBox(height: 14.h),
Text(
message,
style: TextStyle(color: Colors.white70, fontSize: 14.sp),
),
],
),
),
@@ -151,6 +257,7 @@ class _RecordingHud extends StatelessWidget {
required this.state,
this.eventTitle,
this.eventAddress,
this.clipboardHintLabel,
required this.onPasteEventInfo,
required this.onStart,
required this.onStop,
@@ -162,6 +269,7 @@ class _RecordingHud extends StatelessWidget {
final RecordingSessionState state;
final String? eventTitle;
final String? eventAddress;
final String? clipboardHintLabel;
final Future<void> Function() onPasteEventInfo;
final VoidCallback onStart;
final VoidCallback onStop;
@@ -222,6 +330,7 @@ class _RecordingHud extends StatelessWidget {
hasDndAccess: state.hasDndAccess,
isBatteryIgnored: state.isBatteryOptimizedIgnored,
notificationsGranted: state.notificationsGranted,
clipboardHintLabel: clipboardHintLabel,
onOpenDnd: onOpenDnd,
onOpenBattery: onOpenBattery,
onOpenNotificationSettings: openAppSettings,
@@ -249,13 +358,18 @@ class _RecordingHud extends StatelessWidget {
Expanded(
child: Center(
child: GestureDetector(
onTap: state.isRecording ? onStop : onStart,
onTap: state.isStartingRecording
? null
: (state.isRecording ? onStop : onStart),
child: Container(
width: 76.w,
height: 76.h,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4.r),
border: Border.all(
color: Colors.white,
width: 4.r,
),
color: state.isRecording
? Colors.white
: Colors.red,
@@ -280,17 +394,6 @@ class _RecordingHud extends StatelessWidget {
],
),
),
if (state.lastSavedDisplayName != null &&
!state.isRecording &&
!state.gallerySaveFailed)
Padding(
padding: EdgeInsets.only(bottom: 16.r),
child: Text(
'已保存到相册:${state.lastSavedDisplayName}',
style: TextStyle(color: Colors.white70, fontSize: 12.sp),
textAlign: TextAlign.center,
),
),
],
),
if (showPasteEventInfo)
@@ -324,9 +427,7 @@ class _RecordingHud extends StatelessWidget {
left: 12.w,
right: 12.w,
child: Padding(
padding: EdgeInsets.only(
right: state.isRecording ? 96.w : 0,
),
padding: EdgeInsets.only(right: state.isRecording ? 96.w : 0),
child: Text(
eventTitle!,
style: _overlayTextStyle.copyWith(
@@ -344,10 +445,7 @@ class _RecordingHud extends StatelessWidget {
top: 8.r,
right: 12.w,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 12.r,
vertical: 6.r,
),
padding: EdgeInsets.symmetric(horizontal: 12.r, vertical: 6.r),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(20.r),
@@ -361,21 +459,21 @@ class _RecordingHud extends StatelessWidget {
),
),
),
if (eventAddress != null && eventAddress!.isNotEmpty)
Positioned(
left: 16.w,
bottom: 108.r,
right: 120.w,
child: Text(
eventAddress!,
style: _overlayTextStyle.copyWith(
fontSize: 13.sp,
color: Colors.white70,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
// if (eventAddress != null && eventAddress!.isNotEmpty)
// Positioned(
// left: 16.w,
// bottom: 108.r,
// right: 120.w,
// child: Text(
// eventAddress!,
// style: _overlayTextStyle.copyWith(
// fontSize: 13.sp,
// color: Colors.white70,
// ),
// maxLines: 2,
// overflow: TextOverflow.ellipsis,
// ),
// ),
],
),
);
@@ -387,6 +485,7 @@ class _SetupHints extends StatelessWidget {
required this.hasDndAccess,
required this.isBatteryIgnored,
required this.notificationsGranted,
this.clipboardHintLabel,
required this.onOpenDnd,
required this.onOpenBattery,
required this.onOpenNotificationSettings,
@@ -395,13 +494,18 @@ class _SetupHints extends StatelessWidget {
final bool hasDndAccess;
final bool isBatteryIgnored;
final bool notificationsGranted;
final String? clipboardHintLabel;
final VoidCallback onOpenDnd;
final VoidCallback onOpenBattery;
final VoidCallback onOpenNotificationSettings;
@override
Widget build(BuildContext context) {
if (hasDndAccess && isBatteryIgnored && notificationsGranted) {
final showPermissionHints =
!hasDndAccess || !isBatteryIgnored || !notificationsGranted;
final showClipboardHint =
clipboardHintLabel != null && clipboardHintLabel!.isNotEmpty;
if (!showPermissionHints && !showClipboardHint) {
return const SizedBox.shrink();
}
@@ -422,6 +526,11 @@ class _SetupHints extends StatelessWidget {
SizedBox(height: 8.h),
_HintChip(label: '关闭电池优化可提升息屏续录稳定性', onTap: onOpenBattery),
],
if (clipboardHintLabel != null &&
clipboardHintLabel!.isNotEmpty) ...[
SizedBox(height: 8.h),
_HintChip(label: clipboardHintLabel!, onTap: () {}),
],
],
),
);

View File

@@ -14,6 +14,7 @@ class RecordingSessionState {
this.status = const RecordingStatus(state: RecordingState.idle),
this.isTouchLocked = true,
this.isPreviewReady = false,
this.isStartingRecording = false,
this.hasDndAccess = false,
this.isBatteryOptimizedIgnored = true,
this.notificationsGranted = true,
@@ -28,6 +29,7 @@ class RecordingSessionState {
final RecordingStatus status;
final bool isTouchLocked;
final bool isPreviewReady;
final bool isStartingRecording;
final bool hasDndAccess;
final bool isBatteryOptimizedIgnored;
final bool notificationsGranted;
@@ -51,6 +53,7 @@ class RecordingSessionState {
RecordingStatus? status,
bool? isTouchLocked,
bool? isPreviewReady,
bool? isStartingRecording,
bool? hasDndAccess,
bool? isBatteryOptimizedIgnored,
bool? notificationsGranted,
@@ -67,6 +70,7 @@ class RecordingSessionState {
status: status ?? this.status,
isTouchLocked: isTouchLocked ?? this.isTouchLocked,
isPreviewReady: isPreviewReady ?? this.isPreviewReady,
isStartingRecording: isStartingRecording ?? this.isStartingRecording,
hasDndAccess: hasDndAccess ?? this.hasDndAccess,
isBatteryOptimizedIgnored:
isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored,
@@ -204,11 +208,16 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
}
Future<void> startRecording({bool enableDoNotDisturb = true}) async {
if (!state.isPreviewReady || state.isRecording) return;
if (!state.isPreviewReady ||
state.isRecording ||
state.isStartingRecording) {
return;
}
final clipboard = ref.read(recordingViewModelProvider).clipboardRecordingModel;
final displayName = recordingFileNameForPlatform(clipboard.filename);
state = state.copyWith(isStartingRecording: true, errorMessage: null);
try {
final result = await RecordingPlatform.startRecording(
enableDoNotDisturb: enableDoNotDisturb && state.hasDndAccess,
@@ -224,6 +233,8 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
);
} on PlatformException catch (error) {
state = state.copyWith(errorMessage: error.message ?? '开始录制失败');
} finally {
state = state.copyWith(isStartingRecording: false);
}
}
@@ -254,6 +265,10 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
state = state.copyWith(isTouchLocked: locked);
}
void clearSavedRecordingResult() {
state = state.copyWith(clearLastSaved: true);
}
Future<void> openDndSettings() =>
RecordingPlatform.openNotificationPolicySettings();

View File

@@ -89,6 +89,10 @@ class RecordingViewModel extends StateNotifier<RecordingModel> {
}
}
void resetClipboardInfo() {
_resetClipboardInfo();
}
void _resetClipboardInfo() {
state = state.copyWith(
clipboardRecordingModel: _defaultClipboard,

View File

@@ -0,0 +1,120 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// 录制结束并保存到相册后的后续操作弹窗。
Future<void> showRecordingSavedDialog(
BuildContext context, {
required String sessionTitle,
required VoidCallback onContinueRound,
required VoidCallback onRecordNewRound,
}) {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return _RecordingSavedDialog(
sessionTitle: sessionTitle,
onContinueRound: () {
Navigator.of(dialogContext).pop();
onContinueRound();
},
onRecordNewRound: () {
Navigator.of(dialogContext).pop();
onRecordNewRound();
},
);
},
);
}
class _RecordingSavedDialog extends StatelessWidget {
const _RecordingSavedDialog({
required this.sessionTitle,
required this.onContinueRound,
required this.onRecordNewRound,
});
final String sessionTitle;
final VoidCallback onContinueRound;
final VoidCallback onRecordNewRound;
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4.r),
side: const BorderSide(color: Colors.black, width: 1),
),
insetPadding: EdgeInsets.symmetric(horizontal: 32.w),
child: Padding(
padding: EdgeInsets.fromLTRB(20.w, 20.h, 20.w, 16.h),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
sessionTitle,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
textAlign: TextAlign.center,
),
SizedBox(height: 16.h),
Text(
'本轮比赛视频已保存到相册',
style: TextStyle(fontSize: 14.sp, color: Colors.black87),
textAlign: TextAlign.center,
),
SizedBox(height: 8.h),
// Text(
// '请选择后续录制信息',
// style: TextStyle(fontSize: 14.sp, color: Colors.black87),
// textAlign: TextAlign.center,
// ),
SizedBox(height: 20.h),
Row(
children: [
Expanded(
child: _DialogActionButton(
label: '继续本轮',
onPressed: onContinueRound,
),
),
SizedBox(width: 12.w),
Expanded(
child: _DialogActionButton(
label: '录制新轮',
onPressed: onRecordNewRound,
),
),
],
),
],
),
),
);
}
}
class _DialogActionButton extends StatelessWidget {
const _DialogActionButton({required this.label, required this.onPressed});
final String label;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: onPressed,
style: TextButton.styleFrom(
backgroundColor: const Color(0xFFE8E8E8),
foregroundColor: Colors.black87,
padding: EdgeInsets.symmetric(vertical: 10.h),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6.r)),
),
child: Text(label, style: TextStyle(fontSize: 14.sp)),
);
}
}