升级 Gradle → 8.14、AGP → 8.11、Kotlin → 2.2.20 JVM 堆降到 -Xmx4G

This commit is contained in:
2026-06-04 13:35:10 +08:00
parent 250f21a2b8
commit 66435302b3
22 changed files with 755 additions and 291 deletions

View File

@@ -1,15 +1,19 @@
/// 剪切板内容数据模型
/// 小程序复制到剪切板的录制信息。
class ClipboardRecordingModel {
final String title;
final int startTimestamp;
final int endTimestamp;
final String address;
/// 录制文件名模板如「选手名称_选手ID_赛事名称_赛项」。
final String? filename;
ClipboardRecordingModel({
required this.title,
required this.startTimestamp,
required this.endTimestamp,
required this.address,
this.filename,
});
factory ClipboardRecordingModel.fromJson(Map<String, dynamic> json) {
@@ -18,6 +22,7 @@ class ClipboardRecordingModel {
startTimestamp: _readInt(json, 'startTimestamp'),
endTimestamp: _readInt(json, 'endTimestamp'),
address: _readString(json, 'address'),
filename: _readOptionalString(json, 'filename'),
);
}
@@ -27,9 +32,20 @@ class ClipboardRecordingModel {
'startTimestamp': startTimestamp,
'endTimestamp': endTimestamp,
'address': address,
if (filename != null) 'filename': filename,
};
}
static String? _readOptionalString(Map<String, dynamic> json, String key) {
final value = json[key];
if (value == null) return null;
if (value is String && value.isNotEmpty) return value;
if (value is! String) {
throw FormatException('Clipboard field "$key" must be a String.');
}
return null;
}
static String _readString(Map<String, dynamic> json, String key) {
final value = json[key];
if (value is String) return value;

View File

@@ -4,7 +4,13 @@ class RecordingModel {
/// 剪切板内容
final ClipboardRecordingModel clipboardRecordingModel;
RecordingModel({required this.clipboardRecordingModel});
/// 剪切板是否包含有效的小程序录制信息
final bool hasValidClipboardInfo;
RecordingModel({
required this.clipboardRecordingModel,
this.hasValidClipboardInfo = false,
});
factory RecordingModel.fromJson(Map<String, dynamic> json) {
return RecordingModel(
@@ -17,10 +23,15 @@ class RecordingModel {
return {'clipboardRecordingModel': clipboardRecordingModel.toJson()};
}
RecordingModel copyWith({ClipboardRecordingModel? clipboardRecordingModel}) {
RecordingModel copyWith({
ClipboardRecordingModel? clipboardRecordingModel,
bool? hasValidClipboardInfo,
}) {
return RecordingModel(
clipboardRecordingModel:
clipboardRecordingModel ?? this.clipboardRecordingModel,
hasValidClipboardInfo:
hasValidClipboardInfo ?? this.hasValidClipboardInfo,
);
}
}

View File

@@ -0,0 +1,53 @@
import 'dart:io';
/// 非法文件名字符(路径分隔符等)。
final _invalidNameChars = RegExp(r'[/\\:*?"<>|]');
const _maxBaseNameLength = 120;
/// 清洗小程序复制的文件名基底(不含扩展名)。
String? sanitizeRecordingBaseName(String raw) {
var name = raw.replaceAll(_invalidNameChars, '_').trim();
if (name.isEmpty) return null;
if (name.length > _maxBaseNameLength) {
name = name.substring(0, _maxBaseNameLength);
}
return name;
}
/// 解析录制展示名:优先剪切板 filename否则 REC_时间戳。
String resolveRecordingDisplayName(String? clipboardFilename) {
final sanitized = clipboardFilename == null
? null
: sanitizeRecordingBaseName(clipboardFilename);
if (sanitized != null) return sanitized;
final now = DateTime.now();
final stamp =
'${now.year}'
'${now.month.toString().padLeft(2, '0')}'
'${now.day.toString().padLeft(2, '0')}_'
'${now.hour.toString().padLeft(2, '0')}'
'${now.minute.toString().padLeft(2, '0')}'
'${now.second.toString().padLeft(2, '0')}';
return 'REC_$stamp';
}
/// 为展示名补全视频扩展名(已有 .mp4/.mov 则保留)。
String withVideoExtension(String baseName, {bool? isIOS}) {
final ios = isIOS ?? Platform.isIOS;
final ext = ios ? '.mov' : '.mp4';
final lower = baseName.toLowerCase();
if (lower.endsWith('.mp4') || lower.endsWith('.mov')) {
return baseName;
}
return '$baseName$ext';
}
/// 传给原生的完整文件名(含扩展名)。
String recordingFileNameForPlatform(
String? clipboardFilename, {
bool? isIOS,
}) {
final base = resolveRecordingDisplayName(clipboardFilename);
return withVideoExtension(base, isIOS: isIOS);
}

View File

@@ -3,13 +3,13 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:permission_handler/permission_handler.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_touch_lock_overlay.dart';
import 'package:recording_tool/shared/widgets/widgets.dart';
import 'package:permission_handler/permission_handler.dart';
class RecordingPage extends ConsumerStatefulWidget {
const RecordingPage({super.key});
@@ -28,7 +28,13 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
}
Future<void> _bootstrap() async {
await ref.read(recordingViewModelProvider.notifier).getClipboardContent();
final clipboardResult = await ref
.read(recordingViewModelProvider.notifier)
.getClipboardContent();
if (!mounted) return;
if (clipboardResult == ClipboardReadResult.invalid) {
AppToast.show('无选手信息');
}
await _enterRecordingMode();
// Allow PlatformView to attach before binding CameraX preview.
await Future<void>.delayed(const Duration(milliseconds: 400));
@@ -71,7 +77,10 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
@override
Widget build(BuildContext context) {
final state = ref.watch(recordingSessionControllerProvider);
final recordingInfo = ref.watch(recordingViewModelProvider);
final controller = ref.read(recordingSessionControllerProvider.notifier);
final clipboard = recordingInfo.clipboardRecordingModel;
final showClipboardInfo = recordingInfo.hasValidClipboardInfo;
return PopScope(
canPop: !state.isRecording,
@@ -97,8 +106,17 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
),
_RecordingHud(
state: state,
eventTitle: showClipboardInfo ? clipboard.title : null,
eventAddress: showClipboardInfo ? clipboard.address : null,
onStart: () => controller.startRecording(),
onStop: () => controller.stopRecording(),
onStop: () async {
await controller.stopRecording();
if (!context.mounted) return;
final latest = ref.read(recordingSessionControllerProvider);
if (latest.gallerySaveFailed) {
AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限');
}
},
onOpenDnd: () async {
await controller.openDndSettings();
await controller.refreshDndAccess();
@@ -121,6 +139,8 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
class _RecordingHud extends StatelessWidget {
const _RecordingHud({
required this.state,
this.eventTitle,
this.eventAddress,
required this.onStart,
required this.onStop,
required this.onOpenDnd,
@@ -129,117 +149,166 @@ class _RecordingHud extends StatelessWidget {
});
final RecordingSessionState state;
final String? eventTitle;
final String? eventAddress;
final VoidCallback onStart;
final VoidCallback onStop;
final VoidCallback onOpenDnd;
final VoidCallback onOpenBattery;
final VoidCallback onToggleTouchLock;
static const _overlayTextStyle = TextStyle(
color: Colors.white,
shadows: [Shadow(color: Colors.black54, blurRadius: 6)],
);
@override
Widget build(BuildContext context) {
return SafeArea(
child: Column(
child: Stack(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
const Spacer(),
if (state.isRecording)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
Column(
children: [
SizedBox(
height: eventTitle != null || state.isRecording ? 56 : 8,
),
const Spacer(),
if (state.errorMessage != null)
Padding(
padding: const EdgeInsets.all(12),
child: Text(
state.errorMessage!,
style: const TextStyle(color: Colors.amber),
textAlign: TextAlign.center,
),
),
if (state.permissionWarning != null)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
state.permissionWarning!,
style: const TextStyle(
color: Colors.orangeAccent,
fontSize: 12,
),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'REC ${state.elapsedLabel}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
textAlign: TextAlign.center,
),
),
_SetupHints(
hasDndAccess: state.hasDndAccess,
isBatteryIgnored: state.isBatteryOptimizedIgnored,
notificationsGranted: state.notificationsGranted,
onOpenDnd: onOpenDnd,
onOpenBattery: onOpenBattery,
onOpenNotificationSettings: openAppSettings,
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
if (state.isRecording)
IconButton(
onPressed: onToggleTouchLock,
icon: Icon(
state.isTouchLocked ? Icons.lock : Icons.lock_open,
color: Colors.white,
size: 28,
),
),
GestureDetector(
onTap: state.isRecording ? onStop : onStart,
child: Container(
width: 76,
height: 76,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4),
color: state.isRecording ? Colors.white : Colors.red,
),
child: Icon(
state.isRecording
? Icons.stop
: Icons.fiber_manual_record,
color: state.isRecording ? Colors.red : Colors.white,
size: 36,
),
),
),
),
],
),
),
const Spacer(),
if (state.errorMessage != null)
Padding(
padding: const EdgeInsets.all(12),
child: Text(
state.errorMessage!,
style: const TextStyle(color: Colors.amber),
textAlign: TextAlign.center,
),
),
if (state.permissionWarning != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
state.permissionWarning!,
style: const TextStyle(
color: Colors.orangeAccent,
fontSize: 12,
const SizedBox(width: 48),
],
),
textAlign: TextAlign.center,
),
),
_SetupHints(
hasDndAccess: state.hasDndAccess,
isBatteryIgnored: state.isBatteryOptimizedIgnored,
notificationsGranted: state.notificationsGranted,
onOpenDnd: onOpenDnd,
onOpenBattery: onOpenBattery,
onOpenNotificationSettings: openAppSettings,
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
if (state.isRecording)
IconButton(
onPressed: onToggleTouchLock,
icon: Icon(
state.isTouchLocked ? Icons.lock : Icons.lock_open,
color: Colors.white,
size: 28,
),
),
GestureDetector(
onTap: state.isRecording ? onStop : onStart,
child: Container(
width: 76,
height: 76,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 4),
color: state.isRecording ? Colors.white : Colors.red,
),
child: Icon(
state.isRecording
? Icons.stop
: Icons.fiber_manual_record,
color: state.isRecording ? Colors.red : Colors.white,
size: 36,
),
if (state.lastSavedDisplayName != null &&
!state.isRecording &&
!state.gallerySaveFailed)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
'已保存到相册:${state.lastSavedDisplayName}',
style: const TextStyle(color: Colors.white70, fontSize: 12),
textAlign: TextAlign.center,
),
),
const SizedBox(width: 48),
],
),
],
),
if (state.lastOutputPath != null && !state.isRecording)
Padding(
padding: const EdgeInsets.only(bottom: 16),
if (eventTitle != null)
Positioned(
top: 8,
left: 12,
right: 12,
child: Padding(
padding: EdgeInsets.only(right: state.isRecording ? 96 : 0),
child: Text(
eventTitle!,
style: _overlayTextStyle.copyWith(
fontSize: 16,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
if (state.isRecording)
Positioned(
top: 8,
right: 12,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'REC ${state.elapsedLabel}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
if (eventAddress != null && eventAddress!.isNotEmpty)
Positioned(
left: 16,
bottom: 108,
right: 120,
child: Text(
'已保存:${state.lastOutputPath}',
style: const TextStyle(color: Colors.white70, fontSize: 12),
textAlign: TextAlign.center,
eventAddress!,
style: _overlayTextStyle.copyWith(
fontSize: 13,
color: Colors.white70,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],

View File

@@ -84,12 +84,14 @@ class RecordingPlatform {
static Future<RecordingStartResult> startRecording({
bool withAudio = true,
bool enableDoNotDisturb = true,
String? displayName,
}) async {
final result = await _channel.invokeMapMethod<String, dynamic>(
'startRecording',
<String, dynamic>{
'withAudio': withAudio,
'enableDoNotDisturb': enableDoNotDisturb,
if (displayName != null) 'displayName': displayName,
},
);
return RecordingStartResult(
@@ -104,12 +106,7 @@ class RecordingPlatform {
final result = await _channel.invokeMapMethod<String, dynamic>(
'stopRecording',
);
return RecordingStopResult(
outputPath: result?['outputPath'] as String?,
status: RecordingStatus.fromMap(
Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}),
),
);
return RecordingStopResult.fromMap(result);
}
static Future<void> disposePreview() =>
@@ -163,8 +160,26 @@ class RecordingStartResult {
}
class RecordingStopResult {
const RecordingStopResult({this.outputPath, required this.status});
const RecordingStopResult({
this.outputPath,
required this.status,
this.gallerySaved = true,
this.galleryErrorMessage,
});
final String? outputPath;
final RecordingStatus status;
final bool gallerySaved;
final String? galleryErrorMessage;
factory RecordingStopResult.fromMap(Map<String, dynamic>? result) {
return RecordingStopResult(
outputPath: result?['outputPath'] as String?,
status: RecordingStatus.fromMap(
Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}),
),
gallerySaved: result?['gallerySaved'] as bool? ?? true,
galleryErrorMessage: result?['galleryErrorMessage'] as String?,
);
}
}

View File

@@ -4,7 +4,9 @@ import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:recording_tool/core/permission/permission_service.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/view-model/view_model_recording.dart';
import 'package:permission_handler/permission_handler.dart';
class RecordingSessionState {
@@ -17,8 +19,10 @@ class RecordingSessionState {
this.notificationsGranted = true,
this.isMicrophoneGranted = false,
this.lastOutputPath,
this.lastSavedDisplayName,
this.errorMessage,
this.permissionWarning,
this.gallerySaveFailed = false,
});
final RecordingStatus status;
@@ -29,8 +33,10 @@ class RecordingSessionState {
final bool notificationsGranted;
final bool isMicrophoneGranted;
final String? lastOutputPath;
final String? lastSavedDisplayName;
final String? errorMessage;
final String? permissionWarning;
final bool gallerySaveFailed;
bool get isRecording => status.isRecording;
@@ -50,9 +56,12 @@ class RecordingSessionState {
bool? notificationsGranted,
bool? isMicrophoneGranted,
String? lastOutputPath,
String? lastSavedDisplayName,
String? errorMessage,
String? permissionWarning,
bool? gallerySaveFailed,
bool clearPermissionWarning = false,
bool clearLastSaved = false,
}) {
return RecordingSessionState(
status: status ?? this.status,
@@ -64,10 +73,14 @@ class RecordingSessionState {
notificationsGranted: notificationsGranted ?? this.notificationsGranted,
isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted,
lastOutputPath: lastOutputPath ?? this.lastOutputPath,
lastSavedDisplayName: clearLastSaved
? null
: (lastSavedDisplayName ?? this.lastSavedDisplayName),
errorMessage: errorMessage,
permissionWarning: clearPermissionWarning
? null
: (permissionWarning ?? this.permissionWarning),
gallerySaveFailed: gallerySaveFailed ?? this.gallerySaveFailed,
);
}
}
@@ -88,7 +101,7 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
Future<void> prepareSession() async {
if (!RecordingPlatform.isSupported) {
state = state.copyWith(errorMessage: '仅支持 Android 录制');
state = state.copyWith(errorMessage: '当前设备不支持录制');
return;
}
@@ -96,6 +109,7 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
Permission.camera,
Permission.microphone,
if (Platform.isAndroid) Permission.notification,
..._galleryPermissions(),
]);
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
@@ -117,6 +131,9 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
if (!microphoneGranted) {
warnings.add('未授予麦克风权限,当前将以静音模式录制');
}
if (!_isGalleryPermissionGranted(permissions)) {
warnings.add('未授予相册权限,录制结束后可能无法保存到相册');
}
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
final batteryIgnored =
@@ -167,18 +184,43 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
throw StateError('initializePreview retry exhausted');
}
List<Permission> _galleryPermissions() {
if (Platform.isIOS) {
return [Permission.photosAddOnly, Permission.photos];
}
if (Platform.isAndroid) {
return [Permission.videos, Permission.storage];
}
return const [];
}
bool _isGalleryPermissionGranted(Map<Permission, PermissionStatus> permissions) {
for (final permission in _galleryPermissions()) {
if (permissions[permission]?.isGranted ?? false) {
return true;
}
}
return _galleryPermissions().isEmpty;
}
Future<void> startRecording({bool enableDoNotDisturb = true}) async {
if (!state.isPreviewReady || state.isRecording) return;
final clipboard = ref.read(recordingViewModelProvider).clipboardRecordingModel;
final displayName = recordingFileNameForPlatform(clipboard.filename);
try {
final result = await RecordingPlatform.startRecording(
enableDoNotDisturb: enableDoNotDisturb && state.hasDndAccess,
displayName: displayName,
);
state = state.copyWith(
status: result.status,
lastOutputPath: result.outputPath,
isTouchLocked: true,
errorMessage: null,
gallerySaveFailed: false,
clearLastSaved: true,
);
} on PlatformException catch (error) {
state = state.copyWith(errorMessage: error.message ?? '开始录制失败');
@@ -190,10 +232,18 @@ class RecordingSessionController extends Notifier<RecordingSessionState> {
try {
final result = await RecordingPlatform.stopRecording();
final galleryFailed = !result.gallerySaved;
final savedName = recordingFileNameForPlatform(
ref.read(recordingViewModelProvider).clipboardRecordingModel.filename,
);
state = state.copyWith(
status: result.status,
lastOutputPath: result.outputPath ?? state.lastOutputPath,
errorMessage: null,
lastSavedDisplayName: galleryFailed ? null : savedName,
errorMessage: galleryFailed
? (result.galleryErrorMessage ?? '保存到相册失败,请开启相册权限')
: null,
gallerySaveFailed: galleryFailed,
);
} on PlatformException catch (error) {
state = state.copyWith(errorMessage: error.message ?? '停止录制失败');

View File

@@ -12,6 +12,18 @@ final recordingViewModelProvider =
return RecordingViewModel(ref);
});
/// 剪切板读取结果,供 UI 决定是否提示用户。
enum ClipboardReadResult {
/// 剪切板为空,不提示
empty,
/// 解析成功
success,
/// 有内容但格式不符合小程序录制信息
invalid,
}
class RecordingViewModel extends StateNotifier<RecordingModel> {
RecordingViewModel(this.ref)
: super(
@@ -26,8 +38,15 @@ class RecordingViewModel extends StateNotifier<RecordingModel> {
);
final Ref ref;
/// 从剪切板获取内容
Future<void> getClipboardContent() async {
static final _defaultClipboard = ClipboardRecordingModel(
title: '',
startTimestamp: 0,
endTimestamp: 0,
address: '',
);
/// 从剪切板获取小程序复制的录制信息。
Future<ClipboardReadResult> getClipboardContent() async {
try {
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
final text = clipboardData?.text;
@@ -35,22 +54,45 @@ class RecordingViewModel extends StateNotifier<RecordingModel> {
if (text == null || text.trim().isEmpty) {
AppLogger.info('剪切板内容为空,跳过录制信息解析');
return;
_resetClipboardInfo();
return ClipboardReadResult.empty;
}
final decoded = jsonDecode(text);
final decoded = jsonDecode(text.trim());
if (decoded is! Map<String, dynamic>) {
AppLogger.warning('剪切板内容不是 JSON 对象,跳过录制信息解析');
return;
_resetClipboardInfo();
return ClipboardReadResult.invalid;
}
final clipboardRecordingModel = ClipboardRecordingModel.fromJson(decoded);
state = state.copyWith(clipboardRecordingModel: clipboardRecordingModel);
if (clipboardRecordingModel.title.trim().isEmpty) {
AppLogger.warning('剪切板录制信息缺少有效 title');
_resetClipboardInfo();
return ClipboardReadResult.invalid;
}
state = state.copyWith(
clipboardRecordingModel: clipboardRecordingModel,
hasValidClipboardInfo: true,
);
AppLogger.info('剪切板录制信息解析成功:${clipboardRecordingModel.toJson()}');
return ClipboardReadResult.success;
} on FormatException catch (error) {
AppLogger.warning('剪切板录制信息格式错误:$error');
_resetClipboardInfo();
return ClipboardReadResult.invalid;
} catch (error, stackTrace) {
AppLogger.debug('读取剪切板录制信息失败', error: error, stackTrace: stackTrace);
_resetClipboardInfo();
return ClipboardReadResult.invalid;
}
}
void _resetClipboardInfo() {
state = state.copyWith(
clipboardRecordingModel: _defaultClipboard,
hasValidClipboardInfo: false,
);
}
}