完成录制功能

This commit is contained in:
2026-06-03 16:04:52 +08:00
parent 9eb8d1cc37
commit fb61e28e2f
20 changed files with 1788 additions and 17 deletions

View File

@@ -20,7 +20,7 @@ class AppConfig {
static late EnvironmentValues current;
static PackageInfo? packageInfo;
static const appName = 'Flutter Template';
static const appName = '飞行极控';
static void configure({
required AppEnvironment environment,

View File

@@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_template/app/config/app_config.dart';
import 'package:flutter_template/app/theme/app_theme.dart';
import 'package:flutter_template/features/demo/demo_controller.dart';
import 'package:flutter_template/features/recording/recording_page.dart';
import 'package:flutter_template/shared/widgets/widgets.dart';
class DemoPage extends ConsumerWidget {
@@ -13,7 +15,7 @@ class DemoPage extends ConsumerWidget {
final controller = ref.read(demoControllerProvider.notifier);
return Scaffold(
appBar: AppBar(title: const Text('Flutter Template')),
appBar: AppBar(title: const Text(AppConfig.appName)),
body: SafeAreaWrapper(
child: ListView(
padding: const EdgeInsets.all(AppSpacing.lg),
@@ -85,6 +87,18 @@ class DemoPage extends ConsumerWidget {
),
),
const SizedBox(height: AppSpacing.lg),
AppButton(
label: '打开录制',
icon: const Icon(Icons.videocam, size: 18),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const RecordingPage(),
),
);
},
),
const SizedBox(height: AppSpacing.lg),
AppStatusView(
status: AppViewStatus.empty,
empty: AppEmptyView(

View File

@@ -0,0 +1,332 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_template/features/recording/recording_platform.dart';
import 'package:flutter_template/features/recording/recording_session_controller.dart';
import 'package:flutter_template/features/recording/widgets/camera_preview_widget.dart';
import 'package:flutter_template/features/recording/widgets/recording_touch_lock_overlay.dart';
import 'package:flutter_template/shared/widgets/widgets.dart';
import 'package:permission_handler/permission_handler.dart';
class RecordingPage extends ConsumerStatefulWidget {
const RecordingPage({super.key});
@override
ConsumerState<RecordingPage> createState() => _RecordingPageState();
}
class _RecordingPageState extends ConsumerState<RecordingPage> {
var _immersiveApplied = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _bootstrap());
}
Future<void> _bootstrap() async {
await _enterRecordingMode();
// Allow PlatformView to attach before binding CameraX preview.
await Future<void>.delayed(const Duration(milliseconds: 400));
if (!mounted) return;
await ref.read(recordingSessionControllerProvider.notifier).prepareSession();
}
Future<void> _enterRecordingMode() async {
if (!Platform.isAndroid) return;
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
await RecordingPlatform.setImmersiveMode(enabled: true);
_immersiveApplied = true;
}
Future<void> _exitRecordingMode() async {
if (!_immersiveApplied) return;
await ref.read(recordingSessionControllerProvider.notifier).teardown();
await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
await RecordingPlatform.setImmersiveMode(enabled: false);
_immersiveApplied = false;
}
@override
void dispose() {
if (_immersiveApplied) {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
RecordingPlatform.setImmersiveMode(enabled: false);
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(recordingSessionControllerProvider);
final controller = ref.read(recordingSessionControllerProvider.notifier);
return PopScope(
canPop: !state.isRecording,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) {
await _exitRecordingMode();
return;
}
if (state.isRecording) {
AppToast.show('录制中无法返回,请先停止录制');
}
},
child: Scaffold(
backgroundColor: Colors.black,
body: Stack(
fit: StackFit.expand,
children: [
const CameraPreviewWidget(),
if (state.isTouchLocked && state.isRecording)
RecordingTouchLockOverlay(
enabled: true,
onUnlocked: () => controller.setTouchLocked(false),
),
_RecordingHud(
state: state,
onStart: () => controller.startRecording(),
onStop: () => controller.stopRecording(),
onOpenDnd: () async {
await controller.openDndSettings();
await controller.refreshDndAccess();
},
onOpenBattery: () async {
await controller.openBatterySettings();
await controller.refreshBatteryOptimization();
},
onToggleTouchLock: () {
controller.setTouchLocked(!state.isTouchLocked);
},
),
],
),
),
);
}
}
class _RecordingHud extends StatelessWidget {
const _RecordingHud({
required this.state,
required this.onStart,
required this.onStop,
required this.onOpenDnd,
required this.onOpenBattery,
required this.onToggleTouchLock,
});
final RecordingSessionState state;
final VoidCallback onStart;
final VoidCallback onStop;
final VoidCallback onOpenDnd;
final VoidCallback onOpenBattery;
final VoidCallback onToggleTouchLock;
@override
Widget build(BuildContext context) {
return SafeArea(
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
IconButton(
onPressed: state.isRecording
? null
: () => Navigator.of(context).maybePop(),
icon: const Icon(Icons.close, color: Colors.white),
),
const Spacer(),
if (state.isRecording)
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,
),
),
),
],
),
),
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),
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 SizedBox(width: 48),
],
),
),
if (state.lastOutputPath != null && !state.isRecording)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
'已保存:${state.lastOutputPath}',
style: const TextStyle(color: Colors.white70, fontSize: 12),
textAlign: TextAlign.center,
),
),
],
),
);
}
}
class _SetupHints extends StatelessWidget {
const _SetupHints({
required this.hasDndAccess,
required this.isBatteryIgnored,
required this.notificationsGranted,
required this.onOpenDnd,
required this.onOpenBattery,
required this.onOpenNotificationSettings,
});
final bool hasDndAccess;
final bool isBatteryIgnored;
final bool notificationsGranted;
final VoidCallback onOpenDnd;
final VoidCallback onOpenBattery;
final VoidCallback onOpenNotificationSettings;
@override
Widget build(BuildContext context) {
if (hasDndAccess && isBatteryIgnored && notificationsGranted) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
children: [
if (!notificationsGranted) ...[
_HintChip(
label: '开启通知权限以显示录制前台服务',
onTap: onOpenNotificationSettings,
),
const SizedBox(height: 8),
],
if (!hasDndAccess)
_HintChip(
label: '开启勿扰权限可减少录制中断',
onTap: onOpenDnd,
),
if (!isBatteryIgnored) ...[
const SizedBox(height: 8),
_HintChip(
label: '关闭电池优化可提升息屏续录稳定性',
onTap: onOpenBattery,
),
],
],
),
);
}
}
class _HintChip extends StatelessWidget {
const _HintChip({required this.label, required this.onTap});
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white12,
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Row(
children: [
Expanded(
child: Text(
label,
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
),
const Icon(Icons.chevron_right, color: Colors.white54, size: 18),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,163 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
enum RecordingState {
idle,
previewing,
recording,
stopping,
error;
static RecordingState fromRaw(String? raw) {
return RecordingState.values.firstWhere(
(value) => value.name == raw,
orElse: () => RecordingState.idle,
);
}
}
class RecordingStatus {
const RecordingStatus({
required this.state,
this.outputPath,
this.elapsedMillis = 0,
this.message,
});
final RecordingState state;
final String? outputPath;
final int elapsedMillis;
final String? message;
factory RecordingStatus.fromMap(Map<dynamic, dynamic> map) {
return RecordingStatus(
state: RecordingState.fromRaw(map['state'] as String?),
outputPath: map['outputPath'] as String?,
elapsedMillis: (map['elapsedMillis'] as num?)?.toInt() ?? 0,
message: map['message'] as String?,
);
}
bool get isRecording => state == RecordingState.recording;
}
class RecordingPlatform {
RecordingPlatform._();
static const MethodChannel _channel = MethodChannel(
'com.example.flutter_template/recording',
);
static const EventChannel _events = EventChannel(
'com.example.flutter_template/recording_events',
);
static bool get isSupported => Platform.isAndroid;
static Stream<RecordingStatus>? _statusStream;
static Stream<RecordingStatus> statusStream() {
if (!isSupported) {
return const Stream.empty();
}
_statusStream ??= _events
.receiveBroadcastStream()
.map((event) => RecordingStatus.fromMap(Map<dynamic, dynamic>.from(event as Map)));
return _statusStream!;
}
static Future<RecordingStatus> initializePreview() async {
final result = await _channel.invokeMapMethod<String, dynamic>(
'initializePreview',
);
return RecordingStatus.fromMap(result ?? const {});
}
static Future<RecordingStartResult> startRecording({
bool withAudio = true,
bool enableDoNotDisturb = true,
}) async {
final result = await _channel.invokeMapMethod<String, dynamic>(
'startRecording',
<String, dynamic>{
'withAudio': withAudio,
'enableDoNotDisturb': enableDoNotDisturb,
},
);
return RecordingStartResult(
outputPath: result?['outputPath'] as String?,
status: RecordingStatus.fromMap(
Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}),
),
);
}
static Future<RecordingStopResult> stopRecording() async {
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 {}),
),
);
}
static Future<void> disposePreview() => _channel.invokeMethod('disposePreview');
static Future<bool> hasNotificationPolicyAccess() async {
return await _channel.invokeMethod<bool>('hasNotificationPolicyAccess') ??
false;
}
static Future<void> openNotificationPolicySettings() {
return _channel.invokeMethod('openNotificationPolicySettings');
}
static Future<bool> enableDoNotDisturb() async {
return await _channel.invokeMethod<bool>('enableDoNotDisturb') ?? false;
}
static Future<void> disableDoNotDisturb() {
return _channel.invokeMethod('disableDoNotDisturb');
}
static Future<bool> isIgnoringBatteryOptimizations() async {
return await _channel.invokeMethod<bool>(
'isIgnoringBatteryOptimizations',
) ??
true;
}
static Future<void> openBatteryOptimizationSettings() {
return _channel.invokeMethod('openBatteryOptimizationSettings');
}
static Future<void> setImmersiveMode({required bool enabled}) {
return _channel.invokeMethod(
'setImmersiveMode',
<String, dynamic>{'enabled': enabled},
);
}
static Future<RecordingStatus> getStatus() async {
final result = await _channel.invokeMapMethod<String, dynamic>('getStatus');
return RecordingStatus.fromMap(result ?? const {});
}
}
class RecordingStartResult {
const RecordingStartResult({this.outputPath, required this.status});
final String? outputPath;
final RecordingStatus status;
}
class RecordingStopResult {
const RecordingStopResult({this.outputPath, required this.status});
final String? outputPath;
final RecordingStatus status;
}

View File

@@ -0,0 +1,243 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_template/features/recording/recording_platform.dart';
import 'package:permission_handler/permission_handler.dart';
class RecordingSessionState {
const RecordingSessionState({
this.status = const RecordingStatus(state: RecordingState.idle),
this.isTouchLocked = true,
this.isPreviewReady = false,
this.hasDndAccess = false,
this.isBatteryOptimizedIgnored = true,
this.notificationsGranted = true,
this.isMicrophoneGranted = false,
this.lastOutputPath,
this.errorMessage,
this.permissionWarning,
});
final RecordingStatus status;
final bool isTouchLocked;
final bool isPreviewReady;
final bool hasDndAccess;
final bool isBatteryOptimizedIgnored;
final bool notificationsGranted;
final bool isMicrophoneGranted;
final String? lastOutputPath;
final String? errorMessage;
final String? permissionWarning;
bool get isRecording => status.isRecording;
String get elapsedLabel {
final totalSeconds = status.elapsedMillis ~/ 1000;
final minutes = (totalSeconds ~/ 60).toString().padLeft(2, '0');
final seconds = (totalSeconds % 60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
RecordingSessionState copyWith({
RecordingStatus? status,
bool? isTouchLocked,
bool? isPreviewReady,
bool? hasDndAccess,
bool? isBatteryOptimizedIgnored,
bool? notificationsGranted,
bool? isMicrophoneGranted,
String? lastOutputPath,
String? errorMessage,
String? permissionWarning,
bool clearPermissionWarning = false,
}) {
return RecordingSessionState(
status: status ?? this.status,
isTouchLocked: isTouchLocked ?? this.isTouchLocked,
isPreviewReady: isPreviewReady ?? this.isPreviewReady,
hasDndAccess: hasDndAccess ?? this.hasDndAccess,
isBatteryOptimizedIgnored:
isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored,
notificationsGranted: notificationsGranted ?? this.notificationsGranted,
isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted,
lastOutputPath: lastOutputPath ?? this.lastOutputPath,
errorMessage: errorMessage,
permissionWarning: clearPermissionWarning
? null
: (permissionWarning ?? this.permissionWarning),
);
}
}
final recordingSessionControllerProvider =
NotifierProvider<RecordingSessionController, RecordingSessionState>(
RecordingSessionController.new,
);
class RecordingSessionController extends Notifier<RecordingSessionState> {
StreamSubscription<RecordingStatus>? _statusSubscription;
@override
RecordingSessionState build() {
ref.onDispose(_dispose);
return const RecordingSessionState();
}
Future<void> prepareSession() async {
if (!RecordingPlatform.isSupported) {
state = state.copyWith(errorMessage: '仅支持 Android 录制');
return;
}
final permissions = await <Permission>[
Permission.camera,
Permission.microphone,
if (Platform.isAndroid) Permission.notification,
].request();
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
if (!cameraGranted) {
state = state.copyWith(errorMessage: '需要相机权限才能录制');
return;
}
final microphoneGranted =
permissions[Permission.microphone]?.isGranted ?? false;
final notificationsGranted = Platform.isAndroid
? (permissions[Permission.notification]?.isGranted ?? false)
: true;
final warnings = <String>[];
if (Platform.isAndroid && !notificationsGranted) {
warnings.add('未授予通知权限,录制时可能看不到前台服务通知,系统更容易结束后台录制');
}
if (!microphoneGranted) {
warnings.add('未授予麦克风权限,当前将以静音模式录制');
}
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
final batteryIgnored =
await RecordingPlatform.isIgnoringBatteryOptimizations();
state = state.copyWith(
hasDndAccess: hasDnd,
isBatteryOptimizedIgnored: batteryIgnored,
isMicrophoneGranted: microphoneGranted,
notificationsGranted: notificationsGranted,
permissionWarning: warnings.isEmpty ? null : warnings.join('\n'),
errorMessage: null,
clearPermissionWarning: warnings.isEmpty,
);
await _listenStatus();
try {
final status = await _initializePreviewWithRetry();
state = state.copyWith(
status: status,
isPreviewReady: status.state == RecordingState.previewing,
errorMessage: status.state == RecordingState.previewing
? null
: (status.message ?? '相机预览初始化失败'),
);
} on PlatformException catch (error) {
state = state.copyWith(
isPreviewReady: false,
errorMessage: error.message ?? '相机预览初始化失败',
);
}
}
Future<RecordingStatus> _initializePreviewWithRetry() async {
const maxAttempts = 8;
for (var attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await RecordingPlatform.initializePreview();
} on PlatformException catch (error) {
final shouldRetry =
error.code == 'NO_PREVIEW' && attempt < maxAttempts - 1;
if (!shouldRetry) {
rethrow;
}
await Future<void>.delayed(
Duration(milliseconds: 150 * (attempt + 1)),
);
}
}
throw StateError('initializePreview retry exhausted');
}
Future<void> startRecording({bool enableDoNotDisturb = true}) async {
if (!state.isPreviewReady || state.isRecording) return;
try {
final result = await RecordingPlatform.startRecording(
enableDoNotDisturb: enableDoNotDisturb && state.hasDndAccess,
);
state = state.copyWith(
status: result.status,
lastOutputPath: result.outputPath,
isTouchLocked: true,
errorMessage: null,
);
} on PlatformException catch (error) {
state = state.copyWith(errorMessage: error.message ?? '开始录制失败');
}
}
Future<void> stopRecording() async {
if (!state.isRecording) return;
try {
final result = await RecordingPlatform.stopRecording();
state = state.copyWith(
status: result.status,
lastOutputPath: result.outputPath ?? state.lastOutputPath,
errorMessage: null,
);
} on PlatformException catch (error) {
state = state.copyWith(errorMessage: error.message ?? '停止录制失败');
}
}
void setTouchLocked(bool locked) {
state = state.copyWith(isTouchLocked: locked);
}
Future<void> openDndSettings() =>
RecordingPlatform.openNotificationPolicySettings();
Future<void> refreshDndAccess() async {
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
state = state.copyWith(hasDndAccess: hasDnd);
}
Future<void> openBatterySettings() =>
RecordingPlatform.openBatteryOptimizationSettings();
Future<void> refreshBatteryOptimization() async {
final ignored = await RecordingPlatform.isIgnoringBatteryOptimizations();
state = state.copyWith(isBatteryOptimizedIgnored: ignored);
}
Future<void> teardown() async {
await RecordingPlatform.setImmersiveMode(enabled: false);
await RecordingPlatform.disableDoNotDisturb();
await RecordingPlatform.disposePreview();
await _statusSubscription?.cancel();
_statusSubscription = null;
state = const RecordingSessionState();
}
Future<void> _listenStatus() async {
await _statusSubscription?.cancel();
_statusSubscription = RecordingPlatform.statusStream().listen((status) {
state = state.copyWith(status: status);
});
}
Future<void> _dispose() async {
await _statusSubscription?.cancel();
}
}

View File

@@ -0,0 +1,25 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class CameraPreviewWidget extends StatelessWidget {
const CameraPreviewWidget({super.key});
@override
Widget build(BuildContext context) {
if (!Platform.isAndroid) {
return const ColoredBox(
color: Colors.black,
child: Center(child: Text('仅 Android 支持相机预览')),
);
}
return AndroidView(
viewType: 'recording-camera-preview',
layoutDirection: TextDirection.ltr,
creationParams: const <String, dynamic>{},
creationParamsCodec: const StandardMessageCodec(),
);
}
}

View File

@@ -0,0 +1,100 @@
import 'dart:async';
import 'package:flutter/material.dart';
class RecordingTouchLockOverlay extends StatefulWidget {
const RecordingTouchLockOverlay({
super.key,
required this.enabled,
required this.onUnlocked,
this.unlockHoldDuration = const Duration(seconds: 2),
});
final bool enabled;
final VoidCallback onUnlocked;
final Duration unlockHoldDuration;
@override
State<RecordingTouchLockOverlay> createState() =>
_RecordingTouchLockOverlayState();
}
class _RecordingTouchLockOverlayState extends State<RecordingTouchLockOverlay> {
Timer? _holdTimer;
bool _isHolding = false;
@override
void didUpdateWidget(RecordingTouchLockOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (!widget.enabled) {
_cancelHold();
}
}
@override
void dispose() {
_cancelHold();
super.dispose();
}
void _cancelHold() {
_holdTimer?.cancel();
_holdTimer = null;
_isHolding = false;
}
void _startHold() {
if (!widget.enabled) return;
setState(() => _isHolding = true);
_holdTimer?.cancel();
_holdTimer = Timer(widget.unlockHoldDuration, () {
if (!mounted) return;
_cancelHold();
widget.onUnlocked();
setState(() {});
});
}
@override
Widget build(BuildContext context) {
if (!widget.enabled) {
return const SizedBox.shrink();
}
return Positioned.fill(
child: Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (_) => _startHold(),
onPointerUp: (_) => _cancelHold(),
onPointerCancel: (_) => _cancelHold(),
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.01),
child: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: const EdgeInsets.only(top: 48),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(24),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text(
_isHolding
? '保持按住 ${widget.unlockHoldDuration.inSeconds}s 解锁…'
: '防误触已开启,按住 ${widget.unlockHoldDuration.inSeconds}s 解锁',
style: const TextStyle(color: Colors.white, fontSize: 13),
),
),
),
),
),
),
),
);
}
}