27 Commits

Author SHA1 Message Date
41fcd730f0 粘贴删除按钮不参与过渡 2026-06-08 15:51:05 +08:00
7ab03dd912 重构录制页面,优化 UI 组件,简化状态管理,移除不必要的参数,提升代码可读性和维护性。 2026-06-08 11:23:45 +08:00
29cfbdf8c4 录制按钮增加动画 2026-06-08 11:21:13 +08:00
7031765b4d 优化录制页面功能,修正剪贴板信息提示,新增停止录制后的结果提示,改进触摸锁定解锁逻辑,提升用户交互体验。 2026-06-08 11:10:22 +08:00
942d15e54c 优化交互体验增加动画效果 2026-06-08 10:58:10 +08:00
6b168ccd62 1.UI 优化
2.新增打包构建脚本
2026-06-08 10:19:19 +08:00
551d10dec4 兼容 IOS 端 2026-06-08 08:53:49 +08:00
e1446337e9 优化了 启动录制效果 2026-06-05 18:52:47 +08:00
26098114d2 更换 录制按钮 UI 2026-06-05 18:29:49 +08:00
1e08b70c39 更换 APP 图标 2026-06-05 18:08:25 +08:00
e821bd68a7 更换 APP 图标 2026-06-05 18:08:19 +08:00
9c21915bf7 取消 mock 按钮 2026-06-05 17:59:24 +08:00
1221b16c7f 优化录制页面的相机预览逻辑,增加预览未就绪时的错误提示,确保用户体验更流畅。 2026-06-05 16:26:37 +08:00
54738d53f9 用户开始录制,拒绝录音录像权限,增加弹窗引导用户前往系统页设置 2026-06-05 16:11:54 +08:00
4d83f38960 状态管理 函数增加注释 2026-06-05 16:02:08 +08:00
36da37c6c0 调整对话框背景图适应方式,简化赛事信息复制按钮标签 2026-06-05 16:00:53 +08:00
0183bd9a6d 重构录制页面,更新对话框逻辑,优化赛事信息粘贴功能,调整相关文本标签。 2026-06-05 15:46:16 +08:00
016aad49b7 解决录制结束后,无法重新预览相机问题 2026-06-05 15:10:03 +08:00
a39fcdb929 更新录制会话的时间格式,添加小时显示;在录制页面中引入计时器组件,并调整触摸锁定覆盖层的样式。 2026-06-05 15:03:53 +08:00
0a2cfe27ac 打开防误触模式逻辑 2026-06-05 14:41:12 +08:00
d598b36449 优化录制、停止录制逻辑 2026-06-05 14:30:56 +08:00
c0aa2db6db 1.还原 UI 稿,地址和时间与录制按钮排版优化 2026-06-05 14:09:25 +08:00
0d06975313 重构录制页面,优化HUD布局,添加头部和底部组件,移除触摸锁定功能,简化事件信息处理。 2026-06-05 14:02:01 +08:00
f6440ea8b7 规范化代码结构 2026-06-05 12:07:29 +08:00
1e936bfc12 更新pubspec。修改启动屏幕的启动后台XML,并通过删除过时的文件和增强会话管理来重构记录功能。 2026-06-05 11:44:51 +08:00
4c5bf22638 更新 gitignore 2026-06-05 11:07:48 +08:00
e387dfad0a 删除无用文件 2026-06-05 10:13:24 +08:00
77 changed files with 3163 additions and 1370 deletions

3
.gitignore vendored
View File

@@ -18,6 +18,7 @@ pubspec.lock
*.ipr
*.iws
.idea/
.cursor
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
@@ -44,3 +45,5 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
/android/.kotlin

View File

@@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

View File

@@ -1,5 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.qxy.dronex">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

View File

@@ -96,6 +96,15 @@ class RecordingCameraController(
return
}
if (
boundLifecycleOwner === lifecycleOwner &&
preview != null &&
videoCapture != null
) {
onReady(true)
return
}
try {
boundLifecycleOwner = lifecycleOwner
provider.unbindAll()

View File

@@ -161,22 +161,31 @@ class RecordingPlatformHandler(
controller.stopRecording { path ->
RecordingSession.stopForeground(activity)
DoNotDisturbHelper.disable(activity)
mainHandler.post {
val gallerySaved = path != null && controller.status.state != RecordingState.ERROR
val payload =
mutableMapOf<String, Any?>(
"outputPath" to path,
"status" to controller.status.toMap(),
"gallerySaved" to gallerySaved,
)
if (!gallerySaved) {
payload["galleryErrorMessage"] = controller.status.message ?: "保存到相册失败"
}
result.success(payload)
val previewView = activity.recordingPreviewView
if (previewView == null) {
mainHandler.post { deliverStopResult(result, path) }
return@stopRecording
}
controller.rebindForRecording(activity, previewView) { _ ->
mainHandler.post { deliverStopResult(result, path) }
}
}
}
private fun deliverStopResult(result: MethodChannel.Result, path: String?) {
val gallerySaved = path != null && controller.status.state != RecordingState.ERROR
val payload =
mutableMapOf<String, Any?>(
"outputPath" to path,
"status" to controller.status.toMap(),
"gallerySaved" to gallerySaved,
)
if (!gallerySaved) {
payload["galleryErrorMessage"] = controller.status.message ?: "保存到相册失败"
}
result.success(payload)
}
private fun setImmersiveMode(enabled: Boolean) {
val window = activity.window
WindowCompat.setDecorFitsSystemWindows(window, !enabled)

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

View File

@@ -1,12 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
android:gravity="fill"
android:src="@drawable/startup_background" />
</item>
</layer-list>

View File

@@ -1,12 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
android:gravity="fill"
android:src="@drawable/startup_background" />
</item>
</layer-list>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -1,7 +0,0 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 795 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1011 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
assets/images/image_vs.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

1
build-apk-split.sh Normal file
View File

@@ -0,0 +1 @@
flutter build apk --release --split-per-abi

1
build-apk.sh Normal file
View File

@@ -0,0 +1 @@
flutter build apk --release

3
devtools_options.yaml Normal file
View File

@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

View File

@@ -1,15 +1,54 @@
PODS:
- connectivity_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- permission_handler_apple (9.4.8):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- Flutter (from `Flutter`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
EXTERNAL SOURCES:
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
Flutter:
:path: Flutter
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
SPEC CHECKSUMS:
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
PODFILE CHECKSUM: 5a82b772179df87e6518bddc6f9bb8b4053ce48b

View File

@@ -12,7 +12,6 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
64BC3B3C75FABAA128C4FE7A /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D84231833EC084C45F76D9A0 /* Pods_RunnerTests.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
87DA76CA5231F693C9392B80 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B6AA1C86CE710449CC3E8FCD /* Pods_Runner.framework */; };
8D5D9D9E2C69000100F10001 /* RecordingPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */; };
8E5D9D9E2C69000100F10002 /* PlatformInfoPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E5D9D9D2C69000100F10002 /* PlatformInfoPlugin.swift */; };
@@ -54,7 +53,6 @@
69561E7442E8CC939DB2B482 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingPlugin.swift; sourceTree = "<group>"; };
8E5D9D9D2C69000100F10002 /* PlatformInfoPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformInfoPlugin.swift; sourceTree = "<group>"; };
@@ -86,7 +84,6 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
87DA76CA5231F693C9392B80 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -104,7 +101,6 @@
DFA822DC4965C5AFAFF6BB41 /* Pods-RunnerTests.release.xcconfig */,
99DBE6263F70650B13C33EAC /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
@@ -128,7 +124,6 @@
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@@ -208,15 +203,14 @@
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
7858230D5ADC7A99F778CB03 /* [CP] Embed Pods Frameworks */,
99E9790F23C2D0C0B51A6C19 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
dependencies = (
);
name = Runner;
packageProductDependencies = (
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
);
productName = Runner;
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
productType = "com.apple.product-type.application";
@@ -250,9 +244,6 @@
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
@@ -345,6 +336,23 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
7858230D5ADC7A99F778CB03 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -360,6 +368,23 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
99E9790F23C2D0C0B51A6C19 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -726,20 +751,6 @@
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 97C146E61CF9000F007C117D /* Project object */;
}

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1510"
version = "1.3">
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
@@ -70,7 +70,7 @@
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 622 B

After

Width:  |  Height:  |  Size: 976 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 945 B

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -129,14 +129,30 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
var statusListener: (([String: Any]) -> Void)?
func attach(previewView: RecordingPreviewView) {
self.previewView = previewView
previewView.previewLayer.session = session
let bindPreview = { [weak self, weak previewView] in
guard let self, let previewView else { return }
self.previewView = previewView
previewView.previewLayer.session = self.session
}
if Thread.isMainThread {
bindPreview()
} else {
DispatchQueue.main.async(execute: bindPreview)
}
}
func detach(previewView: RecordingPreviewView) {
if self.previewView === previewView {
self.previewView?.previewLayer.session = nil
self.previewView = nil
let unbindPreview = { [weak self, weak previewView] in
guard let self, let previewView else { return }
if self.previewView === previewView {
previewView.previewLayer.session = nil
self.previewView = nil
}
}
if Thread.isMainThread {
unbindPreview()
} else {
DispatchQueue.main.async(execute: unbindPreview)
}
}

View File

@@ -3,12 +3,12 @@ import 'package:flutter_easyloading/flutter_easyloading.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:recording_tool/app/config/app_config.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/app/theme/app_theme.dart';
import 'package:recording_tool/features/recording/recording_page.dart';
import 'package:recording_tool/features/recording/pages/page_record.dart';
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
class FlutterTemplateApp extends ConsumerStatefulWidget {
const FlutterTemplateApp({super.key});

View File

@@ -0,0 +1,281 @@
// ignore_for_file: file_names
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
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<RecordDialogAction> actions;
static Future<void> showSingle(
BuildContext context, {
required String title,
required String buttonText,
VoidCallback? onPressed,
bool barrierDismissible = true,
}) {
return _present(
context,
barrierDismissible: barrierDismissible,
builder: (dialogContext) {
return RecordDialog(
title: title,
actions: [
RecordDialogAction.primary(
text: buttonText,
onPressed: () {
Navigator.of(dialogContext).pop();
onPressed?.call();
},
),
],
);
},
);
}
static Future<void> showDouble(
BuildContext context, {
required String title,
required String leftText,
required String rightText,
VoidCallback? onLeftPressed,
VoidCallback? onRightPressed,
bool barrierDismissible = false,
}) {
return _present(
context,
barrierDismissible: barrierDismissible,
builder: (dialogContext) {
return RecordDialog(
title: title,
actions: [
RecordDialogAction.secondary(
text: leftText,
onPressed: () {
Navigator.of(dialogContext).pop();
onLeftPressed?.call();
},
),
RecordDialogAction.primary(
text: rightText,
onPressed: () {
Navigator.of(dialogContext).pop();
onRightPressed?.call();
},
),
],
);
},
);
}
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
Widget build(BuildContext context) {
final actionWidgets = actions
.map((action) => Expanded(child: _RecordDialogButton(action: action)))
.toList();
return Dialog(
elevation: 0,
backgroundColor: Colors.transparent,
insetPadding: EdgeInsets.symmetric(horizontal: 37.w),
child: ClipRRect(
clipBehavior: Clip.none,
borderRadius: BorderRadius.circular(18.r),
child: Container(
width: 315.w,
// height: 188.r,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18.r),
),
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
top: -88.r,
left: 0,
right: 0,
child: Image.asset(
Assets.images.imageDialogBg.path,
width: double.maxFinite,
height: 155.h,
fit: BoxFit.fitWidth,
),
),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.fromLTRB(24.w, 44.h, 24.w, 26.h),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
textAlign: TextAlign.center,
style: TextStyle(
color: const Color(0xFF333333),
fontSize: 19.sp,
fontWeight: FontWeight.w600,
height: 1.35,
),
),
SizedBox(height: 22.h),
Row(
children: [
for (
var index = 0;
index < actionWidgets.length;
index++
) ...[
if (index > 0) SizedBox(width: 16.w),
actionWidgets[index],
],
],
),
],
),
),
],
),
],
),
),
),
);
}
}
class RecordDialogAction {
const RecordDialogAction._({
required this.text,
required this.onPressed,
required this.isPrimary,
});
factory RecordDialogAction.primary({
required String text,
required VoidCallback onPressed,
}) {
return RecordDialogAction._(
text: text,
onPressed: onPressed,
isPrimary: true,
);
}
factory RecordDialogAction.secondary({
required String text,
required VoidCallback onPressed,
}) {
return RecordDialogAction._(
text: text,
onPressed: onPressed,
isPrimary: false,
);
}
final String text;
final VoidCallback onPressed;
final bool isPrimary;
}
class _RecordDialogButton extends StatelessWidget {
const _RecordDialogButton({required this.action});
final RecordDialogAction action;
@override
Widget build(BuildContext context) {
final child = Center(
child: Text(
action.text,
style: TextStyle(
color: action.isPrimary ? Colors.white : const Color(0xFF333333),
fontSize: 18.sp,
fontWeight: FontWeight.w600,
),
),
);
return SizedBox(
height: 48.h,
child: TextButton(
onPressed: action.onPressed,
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24.r),
),
backgroundColor: action.isPrimary ? null : const Color(0xFFF2F2F2),
),
child: action.isPrimary
? DecoratedBox(
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF2F85FF), Color(0xFF5DCCF4)],
),
borderRadius: BorderRadius.circular(24.r),
),
child: SizedBox.expand(child: child),
)
: child,
),
);
}
}

View File

@@ -1,4 +1,5 @@
import 'package:recording_tool/features/recording/model/model_clipboard.dart';
import 'package:recording_tool/features/recording/model/model_recording_session.dart';
class RecordingModel {
/// 剪切板内容
@@ -7,11 +8,17 @@ class RecordingModel {
/// 剪切板是否包含有效的小程序录制信息
final bool hasValidClipboardInfo;
/// 录制会话状态
final RecordingSessionState session;
RecordingModel({
required this.clipboardRecordingModel,
this.hasValidClipboardInfo = false,
this.session = const RecordingSessionState(),
});
bool get isRecording => session.isRecording;
factory RecordingModel.fromJson(Map<String, dynamic> json) {
return RecordingModel(
clipboardRecordingModel: ClipboardRecordingModel.fromJson(
@@ -32,12 +39,14 @@ class RecordingModel {
RecordingModel copyWith({
ClipboardRecordingModel? clipboardRecordingModel,
bool? hasValidClipboardInfo,
RecordingSessionState? session,
}) {
return RecordingModel(
clipboardRecordingModel:
clipboardRecordingModel ?? this.clipboardRecordingModel,
hasValidClipboardInfo:
hasValidClipboardInfo ?? this.hasValidClipboardInfo,
session: session ?? this.session,
);
}
}

View File

@@ -0,0 +1,83 @@
import 'package:recording_tool/features/recording/platform/recording_platform.dart';
/// 录制会话状态(相机预览、权限、录制进度等)。
class RecordingSessionState {
const 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,
this.isMicrophoneGranted = false,
this.lastOutputPath,
this.lastSavedDisplayName,
this.errorMessage,
this.permissionWarning,
this.gallerySaveFailed = false,
});
final RecordingStatus status;
final bool isTouchLocked;
final bool isPreviewReady;
final bool isStartingRecording;
final bool hasDndAccess;
final bool isBatteryOptimizedIgnored;
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;
String get elapsedLabel {
final totalSeconds = status.elapsedMillis ~/ 1000;
final hours = (totalSeconds ~/ 3600).toString().padLeft(2, '0');
final minutes = ((totalSeconds % 3600) ~/ 60).toString().padLeft(2, '0');
final seconds = (totalSeconds % 60).toString().padLeft(2, '0');
return '$hours:$minutes:$seconds';
}
RecordingSessionState copyWith({
RecordingStatus? status,
bool? isTouchLocked,
bool? isPreviewReady,
bool? isStartingRecording,
bool? hasDndAccess,
bool? isBatteryOptimizedIgnored,
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,
isTouchLocked: isTouchLocked ?? this.isTouchLocked,
isPreviewReady: isPreviewReady ?? this.isPreviewReady,
isStartingRecording: isStartingRecording ?? this.isStartingRecording,
hasDndAccess: hasDndAccess ?? this.hasDndAccess,
isBatteryOptimizedIgnored:
isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored,
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,
);
}
}

View File

@@ -0,0 +1,479 @@
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/core/platform/app_platform_info.dart';
import 'package:recording_tool/core/platform/device_health_checker.dart';
import 'package:recording_tool/features/dialog/dialog-record.dart';
import 'package:recording_tool/features/recording/model/model_recording.dart';
import 'package:recording_tool/features/recording/platform/recording_platform.dart';
import 'package:recording_tool/features/recording/utils/recording_display_name.dart';
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
import 'package:recording_tool/features/recording/widgets/widget_camera_preview.dart';
import 'package:recording_tool/features/recording/widgets/widget_record_footer.dart';
import 'package:recording_tool/features/recording/widgets/widget_record_header.dart';
import 'package:recording_tool/features/recording/widgets/widget_record_timer.dart';
import 'package:recording_tool/features/recording/widgets/widget_recording_hud.dart';
import 'package:recording_tool/features/recording/widgets/widget_recording_loading_overlay.dart';
import 'package:recording_tool/features/recording/widgets/widget_recording_saved_dialog.dart';
import 'package:recording_tool/features/recording/widgets/widget_recording_touch_lock_overlay.dart';
import 'package:recording_tool/shared/widgets/widgets.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> _checkAndShowDeviceHealthAlerts() async {
final snapshot = await AppPlatformInfo.deviceHealth();
if (!mounted) return;
final lines = DeviceHealthChecker.warningLines(snapshot);
if (lines.isEmpty) return;
await RecordDialog.showSingle(
context,
title: lines.join('\n'),
buttonText: '确定',
);
}
/// 页面启动:健康检查、读剪贴板、进入录制模式、准备相机会话
Future<void> _bootstrap() async {
await _checkAndShowDeviceHealthAlerts();
if (!mounted) return;
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));
if (!mounted) return;
await ref.read(recordingViewModelProvider.notifier).prepareSession();
}
/// Android 进入沉浸式全屏
Future<void> _enterRecordingMode() async {
if (!Platform.isAndroid) return;
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
await RecordingPlatform.setImmersiveMode(enabled: true);
_immersiveApplied = true;
}
/// 解析保存成功弹窗的标题文案
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 '录制完成';
}
/// 从剪贴板粘贴赛事信息(与 header「粘贴选手信息」一致
Future<void> _pasteEventInfo() async {
final result = await ref
.read(recordingViewModelProvider.notifier)
.getClipboardContent();
if (!mounted) return;
if (result != ClipboardReadResult.success) {
AppToast.show('无选手信息');
}
}
/// 无选手信息时弹窗提示
Future<void> _showNoPlayerInfoDialog() {
return RecordDialog.showSingle(
context,
title: '无选手信息!',
buttonText: '粘贴',
onPressed: _pasteEventInfo,
);
}
/// 根据缺失权限生成弹窗文案。
String _recordingPermissionDialogTitle(RecordingRequiredPermissions result) {
if (!result.cameraGranted && !result.microphoneGranted) {
return '录制需要开启相机和录音权限,请在系统设置中授权后重试';
}
if (!result.cameraGranted) {
return '录制需要开启相机权限,请在系统设置中授权后重试';
}
return '录制需要开启录音权限,请在系统设置中授权后重试';
}
/// 开始录制前检测相机、录音权限,未授予则弹窗并跳转系统设置。
Future<bool> _ensureRecordingPermissions() async {
final result = await ref
.read(recordingViewModelProvider.notifier)
.ensureCameraAndMicrophonePermissions();
if (result.allGranted) {
final ready = ref.read(recordingViewModelProvider).session.isPreviewReady;
if (ready) return true;
if (!mounted) return false;
AppToast.show('相机预览启动失败,请重试');
return false;
}
if (!mounted) return false;
await RecordDialog.showSingle(
context,
title: _recordingPermissionDialogTitle(result),
buttonText: '确定',
onPressed: openAppSettings,
);
return false;
}
/// 点击开始录制:校验剪贴板、权限与健康状态
Future<void> _onStartRecording() async {
final recordingInfo = ref.read(recordingViewModelProvider);
if (!recordingInfo.hasClipboardFilename) {
await _showNoPlayerInfoDialog();
return;
}
if (!await _ensureRecordingPermissions()) return;
if (!mounted) return;
await _checkAndShowDeviceHealthAlerts();
if (!mounted) return;
await ref.read(recordingViewModelProvider.notifier).startRecording();
}
/// 停止录制并按结果显示保存提示。
Future<void> _stopRecordingAndShowResult() async {
await ref.read(recordingViewModelProvider.notifier).stopRecording();
if (!mounted) return;
final latest = ref.read(recordingViewModelProvider).session;
if (latest.gallerySaveFailed) {
AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限');
return;
}
await _showRecordingSavedDialogIfNeeded();
}
/// 清空剪贴板信息,准备新一轮录制
void _clearClipboardForNewRound() {
final notifier = ref.read(recordingViewModelProvider.notifier);
notifier.resetClipboardInfo();
notifier.clearSavedRecordingResult();
}
/// 保存成功后按需弹出完成对话框
Future<void> _showRecordingSavedDialogIfNeeded() async {
final recordingInfo = ref.read(recordingViewModelProvider);
final session = recordingInfo.session;
if (session.lastSavedDisplayName == null || session.gallerySaveFailed) {
return;
}
final sessionTitle = _savedDialogSessionTitle(
recordingInfo,
session.lastSavedDisplayName,
);
await showRecordingSavedDialog(
context,
sessionTitle: sessionTitle,
onContinueRound: () {
ref
.read(recordingViewModelProvider.notifier)
.clearSavedRecordingResult();
},
onRecordNewRound: _clearClipboardForNewRound,
);
}
/// 退出沉浸式并释放录制会话
Future<void> _exitRecordingMode() async {
if (!_immersiveApplied) return;
await ref.read(recordingViewModelProvider.notifier).teardown();
await SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
await RecordingPlatform.setImmersiveMode(enabled: false);
_immersiveApplied = false;
}
@override
/// 页面销毁时恢复系统 UI
void dispose() {
if (_immersiveApplied) {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: SystemUiOverlay.values,
);
RecordingPlatform.setImmersiveMode(enabled: false);
}
super.dispose();
}
@override
/// 构建录制页 UI
Widget build(BuildContext context) {
return _RecordingPopScope(
onExitRecordingMode: _exitRecordingMode,
child: Scaffold(
backgroundColor: Colors.black,
body: Column(
children: [
_RecordHeaderSection(
onPasteEventInfo: _pasteEventInfo,
onClearEventInfo: _clearClipboardForNewRound,
),
Expanded(
child: Stack(
children: [
const CameraPreviewWidget(),
const _PreviewLoadingLayer(),
const RecordTimerWidget(),
_RecordingHudLayer(
onStart: _onStartRecording,
onStop: _stopRecordingAndShowResult,
),
_TouchLockOverlayLayer(
onStopRecording: _stopRecordingAndShowResult,
),
const _StartingRecordingOverlay(),
],
),
),
const RecordFooter(),
],
),
),
);
}
}
class _RecordingPopScope extends ConsumerWidget {
const _RecordingPopScope({
required this.onExitRecordingMode,
required this.child,
});
final Future<void> Function() onExitRecordingMode;
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isRecording = ref.watch(
recordingViewModelProvider.select((m) => m.session.isRecording),
);
return PopScope(
canPop: !isRecording,
onPopInvokedWithResult: (didPop, result) async {
if (didPop) {
await onExitRecordingMode();
return;
}
if (isRecording) {
AppToast.show('录制中无法返回,请先停止录制');
}
},
child: child,
);
}
}
class _RecordHeaderSection extends ConsumerWidget {
const _RecordHeaderSection({
required this.onPasteEventInfo,
required this.onClearEventInfo,
});
final Future<void> Function() onPasteEventInfo;
final VoidCallback onClearEventInfo;
@override
Widget build(BuildContext context, WidgetRef ref) {
final headerState = ref.watch(
recordingViewModelProvider.select(
(m) => (
m.hasValidClipboardInfo,
m.hasValidClipboardInfo ? m.clipboardRecordingModel.title : null,
m.session.isRecording,
),
),
);
final (hasValidClipboardInfo, eventTitle, isRecording) = headerState;
return RecordHeaderWidget(
hasValidClipboardInfo: hasValidClipboardInfo,
eventTitle: eventTitle,
isRecording: isRecording,
onPasteEventInfo: onPasteEventInfo,
onClearEventInfo: onClearEventInfo,
);
}
}
class _PreviewLoadingLayer extends ConsumerWidget {
const _PreviewLoadingLayer();
@override
Widget build(BuildContext context, WidgetRef ref) {
final showLoading = ref.watch(
recordingViewModelProvider.select(
(m) => !m.session.isPreviewReady && m.session.errorMessage == null,
),
);
if (!showLoading) {
return const SizedBox.shrink();
}
return const RecordingLoadingOverlayWidget(message: '正在启动相机…');
}
}
class _RecordingHudLayer extends ConsumerWidget {
const _RecordingHudLayer({
required this.onStart,
required this.onStop,
});
final Future<void> Function() onStart;
final Future<void> Function() onStop;
@override
Widget build(BuildContext context, WidgetRef ref) {
final hudState = ref.watch(
recordingViewModelProvider.select(
(m) => (
m.session.errorMessage,
m.session.permissionWarning,
m.session.hasDndAccess,
m.session.isBatteryOptimizedIgnored,
m.session.notificationsGranted,
m.session.isRecording,
m.session.isStartingRecording,
m.session.isTouchLocked,
m.hasValidClipboardInfo,
m.clipboardRecordingModel.address.trim(),
),
),
);
final (
errorMessage,
permissionWarning,
hasDndAccess,
isBatteryOptimizedIgnored,
notificationsGranted,
isRecording,
isStartingRecording,
isTouchLocked,
showClipboardHint,
clipboardAddress,
) = hudState;
final viewModel = ref.read(recordingViewModelProvider.notifier);
return RecordingHudWidget(
errorMessage: errorMessage,
permissionWarning: permissionWarning,
hasDndAccess: hasDndAccess,
isBatteryOptimizedIgnored: isBatteryOptimizedIgnored,
notificationsGranted: notificationsGranted,
isRecording: isRecording,
isStartingRecording: isStartingRecording,
isTouchLocked: isTouchLocked,
showClipboardHint: showClipboardHint,
clipboardAddress: clipboardAddress,
onStart: onStart,
onStop: onStop,
onOpenDnd: () async {
await viewModel.openDndSettings();
await viewModel.refreshDndAccess();
},
onOpenBattery: () async {
await viewModel.openBatterySettings();
await viewModel.refreshBatteryOptimization();
},
onToggleTouchLock: () {
final locked = ref.read(recordingViewModelProvider).session.isTouchLocked;
viewModel.setTouchLocked(!locked);
},
);
}
}
class _TouchLockOverlayLayer extends ConsumerWidget {
const _TouchLockOverlayLayer({required this.onStopRecording});
final Future<void> Function() onStopRecording;
@override
Widget build(BuildContext context, WidgetRef ref) {
final overlayState = ref.watch(
recordingViewModelProvider.select(
(m) => (m.session.isTouchLocked, m.session.isRecording),
),
);
final (isTouchLocked, isRecording) = overlayState;
if (!isTouchLocked || !isRecording) {
return const SizedBox.shrink();
}
final viewModel = ref.read(recordingViewModelProvider.notifier);
return RecordingTouchLockOverlayWidget(
enabled: true,
onUnlocked: (intent) async {
viewModel.setTouchLocked(false);
if (intent == RecordingTouchLockUnlockIntent.stopRecording) {
await onStopRecording();
}
},
);
}
}
class _StartingRecordingOverlay extends ConsumerWidget {
const _StartingRecordingOverlay();
@override
Widget build(BuildContext context, WidgetRef ref) {
final isStartingRecording = ref.watch(
recordingViewModelProvider.select((m) => m.session.isStartingRecording),
);
if (!isStartingRecording) {
return const SizedBox.shrink();
}
return RecordingLoadingOverlayWidget(
message: '正在开始录制…',
backgroundColor: Colors.black.withValues(alpha: 0.24),
);
}
}

View File

@@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:recording_tool/features/recording/recording_channel_names.dart';
import 'package:recording_tool/features/recording/platform/recording_channel_names.dart';
enum RecordingState {
idle,

View File

@@ -1,676 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.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/platform/app_platform_info.dart';
import 'package:recording_tool/core/platform/device_health_checker.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';
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> _checkAndShowDeviceHealthAlerts() async {
final snapshot = await AppPlatformInfo.deviceHealth();
if (!mounted) return;
final lines = DeviceHealthChecker.warningLines(snapshot);
if (lines.isEmpty) return;
await AppDialog.deviceHealthAlert(context, lines: lines);
}
Future<void> _bootstrap() async {
await _checkAndShowDeviceHealthAlerts();
if (!mounted) return;
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));
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;
}
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> _showNoPlayerInfoDialog() {
return showDialog<void>(
context: context,
builder: (dialogContext) {
return AlertDialog(
content: const Text('无选手信息'),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('确定'),
),
],
);
},
);
}
Future<void> _onStartRecording() async {
final recordingInfo = ref.read(recordingViewModelProvider);
if (!recordingInfo.hasClipboardFilename) {
await _showNoPlayerInfoDialog();
return;
}
await _checkAndShowDeviceHealthAlerts();
if (!mounted) return;
await ref.read(recordingSessionControllerProvider.notifier).startRecording();
}
void _clearClipboardForNewRound() {
ref.read(recordingViewModelProvider.notifier).resetClipboardInfo();
ref
.read(recordingSessionControllerProvider.notifier)
.clearSavedRecordingResult();
}
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: _clearClipboardForNewRound,
);
}
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 recordingInfo = ref.watch(recordingViewModelProvider);
final controller = ref.read(recordingSessionControllerProvider.notifier);
final clipboard = recordingInfo.clipboardRecordingModel;
final showClipboardInfo = recordingInfo.hasValidClipboardInfo;
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.isPreviewReady && state.errorMessage == null)
const _RecordingLoadingOverlay(message: '正在启动相机…'),
if (state.isTouchLocked && state.isRecording)
RecordingTouchLockOverlay(
enabled: true,
onUnlocked: () => controller.setTouchLocked(false),
),
_RecordingHud(
state: state,
eventTitle: showClipboardInfo ? clipboard.title : null,
eventAddress: showClipboardInfo ? clipboard.address : null,
showClipboardHint: showClipboardInfo,
clipboardAddress: clipboard.address.trim(),
onClearEventInfo: _clearClipboardForNewRound,
onPasteEventInfo: () async {
final result = await ref
.read(recordingViewModelProvider.notifier)
.getClipboardContent();
if (!context.mounted) return;
if (result != ClipboardReadResult.success) {
AppToast.show('无赛事信息');
}
},
onStart: _onStartRecording,
onStop: () async {
await controller.stopRecording();
if (!context.mounted) return;
final latest = ref.read(recordingSessionControllerProvider);
if (latest.gallerySaveFailed) {
AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限');
return;
}
await _showRecordingSavedDialogIfNeeded();
},
onOpenDnd: () async {
await controller.openDndSettings();
await controller.refreshDndAccess();
},
onOpenBattery: () async {
await controller.openBatterySettings();
await controller.refreshBatteryOptimization();
},
onToggleTouchLock: () {
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),
),
],
),
),
);
}
}
class _RecordingHud extends StatelessWidget {
const _RecordingHud({
required this.state,
this.eventTitle,
this.eventAddress,
this.showClipboardHint = false,
this.clipboardAddress = '',
required this.onClearEventInfo,
required this.onPasteEventInfo,
required this.onStart,
required this.onStop,
required this.onOpenDnd,
required this.onOpenBattery,
required this.onToggleTouchLock,
});
final RecordingSessionState state;
final String? eventTitle;
final String? eventAddress;
final bool showClipboardHint;
final String clipboardAddress;
final VoidCallback onClearEventInfo;
final Future<void> Function() onPasteEventInfo;
final Future<void> Function() onStart;
final Future<void> Function() onStop;
final VoidCallback onOpenDnd;
final VoidCallback onOpenBattery;
final VoidCallback onToggleTouchLock;
static TextStyle get _overlayTextStyle => TextStyle(
color: Colors.white,
shadows: [Shadow(color: Colors.black54, blurRadius: 6.r)],
);
static double get _controlSlotWidth => 48.r;
@override
Widget build(BuildContext context) {
final showPasteEventInfo = eventTitle == null && !state.isRecording;
return SafeArea(
child: Stack(
children: [
Column(
children: [
SizedBox(
height:
eventTitle != null ||
state.isRecording ||
showPasteEventInfo
? 56.h
: 8.h,
),
const Spacer(),
if (state.errorMessage != null)
Padding(
padding: EdgeInsets.all(12.r),
child: Text(
state.errorMessage!,
style: const TextStyle(color: Colors.amber),
textAlign: TextAlign.center,
),
),
if (state.permissionWarning != null)
Padding(
padding: EdgeInsets.symmetric(
horizontal: 16.r,
vertical: 8.r,
),
child: Text(
state.permissionWarning!,
style: TextStyle(
color: Colors.orangeAccent,
fontSize: 12.sp,
),
textAlign: TextAlign.center,
),
),
_SetupHints(
hasDndAccess: state.hasDndAccess,
isBatteryIgnored: state.isBatteryOptimizedIgnored,
notificationsGranted: state.notificationsGranted,
showClipboardHint: showClipboardHint,
clipboardAddress: clipboardAddress,
onOpenDnd: onOpenDnd,
onOpenBattery: onOpenBattery,
onOpenNotificationSettings: openAppSettings,
),
Padding(
padding: EdgeInsets.fromLTRB(24.r, 8.r, 24.r, 24.r),
child: Row(
children: [
SizedBox(
width: _controlSlotWidth,
height: _controlSlotWidth,
child: state.isRecording
? IconButton(
onPressed: onToggleTouchLock,
icon: Icon(
state.isTouchLocked
? Icons.lock
: Icons.lock_open,
color: Colors.white,
size: 28.r,
),
)
: null,
),
Expanded(
child: Center(
child: GestureDetector(
onTap: state.isStartingRecording
? null
: () async {
if (state.isRecording) {
await onStop();
} else {
await onStart();
}
},
child: Container(
width: 76.w,
height: 76.h,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 4.r,
),
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.r,
),
),
),
),
),
SizedBox(
width: _controlSlotWidth,
height: _controlSlotWidth,
),
],
),
),
],
),
if (showPasteEventInfo)
Positioned(
top: 8.r,
left: 12.w,
right: 12.w,
child: Center(
child: TextButton.icon(
onPressed: onPasteEventInfo,
icon: Icon(Icons.content_paste, size: 18.r),
label: const Text('粘贴赛事信息'),
style: TextButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: Colors.black.withValues(alpha: 0.5),
padding: EdgeInsets.symmetric(
horizontal: 14.r,
vertical: 8.r,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20.r),
side: const BorderSide(color: Colors.white30),
),
),
),
),
),
if (eventTitle != null)
Positioned(
top: 8.r,
left: 12.w,
right: 12.w,
child: Padding(
padding: EdgeInsets.only(right: state.isRecording ? 96.w : 0),
child: Row(
children: [
Expanded(
child: Text(
eventTitle!,
style: _overlayTextStyle.copyWith(
fontSize: 16.sp,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
if (!state.isRecording)
IconButton(
onPressed: onClearEventInfo,
icon: Icon(
Icons.delete_outline,
color: Colors.white,
size: 22.r,
),
padding: EdgeInsets.zero,
constraints: BoxConstraints(
minWidth: 40.r,
minHeight: 40.r,
),
tooltip: '删除',
),
],
),
),
),
if (state.isRecording)
Positioned(
top: 8.r,
right: 12.w,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12.r, vertical: 6.r),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(20.r),
),
child: Text(
'REC ${state.elapsedLabel}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
// 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,
// ),
// ),
],
),
);
}
}
class _SetupHints extends StatelessWidget {
const _SetupHints({
required this.hasDndAccess,
required this.isBatteryIgnored,
required this.notificationsGranted,
this.showClipboardHint = false,
this.clipboardAddress = '',
required this.onOpenDnd,
required this.onOpenBattery,
required this.onOpenNotificationSettings,
});
final bool hasDndAccess;
final bool isBatteryIgnored;
final bool notificationsGranted;
final bool showClipboardHint;
final String clipboardAddress;
final VoidCallback onOpenDnd;
final VoidCallback onOpenBattery;
final VoidCallback onOpenNotificationSettings;
@override
Widget build(BuildContext context) {
final showPermissionHints =
!hasDndAccess || !isBatteryIgnored || !notificationsGranted;
final showClipboardHint = this.showClipboardHint;
if (!showPermissionHints && !showClipboardHint) {
return const SizedBox.shrink();
}
return Padding(
padding: EdgeInsets.symmetric(horizontal: 16.r, vertical: 8.r),
child: Column(
children: [
if (!notificationsGranted) ...[
_HintChip(
label: '开启通知权限以显示录制前台服务',
onTap: onOpenNotificationSettings,
),
SizedBox(height: 8.h),
],
if (!hasDndAccess)
_HintChip(label: '开启勿扰权限可减少录制中断', onTap: onOpenDnd),
if (!isBatteryIgnored) ...[
SizedBox(height: 8.h),
_HintChip(label: '关闭电池优化可提升息屏续录稳定性', onTap: onOpenBattery),
],
if (showClipboardHint) ...[
SizedBox(height: 8.h),
_ClipboardAddressClockChip(address: clipboardAddress),
],
],
),
);
}
}
class _ClipboardAddressClockChip extends StatefulWidget {
const _ClipboardAddressClockChip({required this.address});
final String address;
@override
State<_ClipboardAddressClockChip> createState() =>
_ClipboardAddressClockChipState();
}
class _ClipboardAddressClockChipState extends State<_ClipboardAddressClockChip> {
Timer? _clockTimer;
@override
void initState() {
super.initState();
_clockTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_clockTimer?.cancel();
_clockTimer = null;
super.dispose();
}
String _buildLabel() {
final nowText = DateTimeFormatter.format(
DateTime.now(),
pattern: 'yyyy-M-d-H:mm:ss',
);
if (widget.address.isEmpty) return nowText;
return '${widget.address}\n$nowText';
}
@override
Widget build(BuildContext context) {
return _HintChip(label: _buildLabel(), onTap: () {});
}
}
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.r),
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 12.r, vertical: 8.r),
child: Row(
children: [
Expanded(
child: Text(
label,
style: TextStyle(color: Colors.white70, fontSize: 12.sp),
),
),
Icon(Icons.chevron_right, color: Colors.white54, size: 18.r),
],
),
),
),
);
}
}

View File

@@ -1,330 +0,0 @@
import 'dart:async';
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/core/utils/rate_limiter.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 {
const 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,
this.isMicrophoneGranted = false,
this.lastOutputPath,
this.lastSavedDisplayName,
this.errorMessage,
this.permissionWarning,
this.gallerySaveFailed = false,
});
final RecordingStatus status;
final bool isTouchLocked;
final bool isPreviewReady;
final bool isStartingRecording;
final bool hasDndAccess;
final bool isBatteryOptimizedIgnored;
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;
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? isStartingRecording,
bool? hasDndAccess,
bool? isBatteryOptimizedIgnored,
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,
isTouchLocked: isTouchLocked ?? this.isTouchLocked,
isPreviewReady: isPreviewReady ?? this.isPreviewReady,
isStartingRecording: isStartingRecording ?? this.isStartingRecording,
hasDndAccess: hasDndAccess ?? this.hasDndAccess,
isBatteryOptimizedIgnored:
isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored,
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,
);
}
}
final recordingSessionControllerProvider =
NotifierProvider<RecordingSessionController, RecordingSessionState>(
RecordingSessionController.new,
);
class RecordingSessionController extends Notifier<RecordingSessionState> {
static const Duration _recordingActionInterval = Duration(milliseconds: 300);
static const Object _startRecordingThrottleKey = 'recording.session.start';
static const Object _stopRecordingThrottleKey = 'recording.session.stop';
StreamSubscription<RecordingStatus>? _statusSubscription;
final _rateLimit = RateLimitHub();
@override
RecordingSessionState build() {
ref.onDispose(_dispose);
return const RecordingSessionState();
}
Future<void> prepareSession() async {
if (!RecordingPlatform.isSupported) {
state = state.copyWith(errorMessage: '当前设备不支持录制');
return;
}
final permissions = await PermissionService.requestMissing([
Permission.camera,
Permission.microphone,
if (Platform.isAndroid) Permission.notification,
..._galleryPermissions(),
]);
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('未授予麦克风权限,当前将以静音模式录制');
}
if (!_isGalleryPermissionGranted(permissions)) {
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');
}
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;
}
bool _tryAcquireRecordingAction(Object key) {
var executed = false;
_rateLimit.throttle<void>(
key: key,
value: null,
duration: _recordingActionInterval,
options: const ThrottleOptions(leading: true, trailing: false),
onCallback: (_) => executed = true,
);
return executed;
}
Future<void> startRecording({bool enableDoNotDisturb = true}) async {
if (!_tryAcquireRecordingAction(_startRecordingThrottleKey)) 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,
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 ?? '开始录制失败');
} finally {
state = state.copyWith(isStartingRecording: false);
}
}
Future<void> stopRecording() async {
if (!_tryAcquireRecordingAction(_stopRecordingThrottleKey)) return;
if (!state.isRecording) return;
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,
lastSavedDisplayName: galleryFailed ? null : savedName,
errorMessage: galleryFailed
? (result.galleryErrorMessage ?? '保存到相册失败,请开启相册权限')
: null,
gallerySaveFailed: galleryFailed,
);
} on PlatformException catch (error) {
state = state.copyWith(errorMessage: error.message ?? '停止录制失败');
}
}
void setTouchLocked(bool locked) {
state = state.copyWith(isTouchLocked: locked);
}
void clearSavedRecordingResult() {
state = state.copyWith(clearLastSaved: true);
}
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 {
_rateLimit.clear();
await _statusSubscription?.cancel();
}
}

View File

@@ -1,16 +1,23 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/legacy.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:recording_tool/core/logging/app_logger.dart';
import 'package:recording_tool/core/permission/permission_service.dart';
import 'package:recording_tool/features/recording/model/model_clipboard.dart';
import 'package:recording_tool/features/recording/model/model_recording.dart';
import 'package:recording_tool/features/recording/model/model_recording_session.dart';
import 'package:recording_tool/features/recording/platform/recording_platform.dart';
import 'package:recording_tool/features/recording/utils/recording_display_name.dart';
/// 录制页状态 Provider。
final recordingViewModelProvider =
StateNotifierProvider<RecordingViewModel, RecordingModel>((ref) {
return RecordingViewModel(ref);
});
NotifierProvider<RecordingViewModel, RecordingModel>(
RecordingViewModel.new,
);
/// 剪切板读取结果,供 UI 决定是否提示用户。
enum ClipboardReadResult {
@@ -24,24 +31,56 @@ enum ClipboardReadResult {
invalid,
}
class RecordingViewModel extends StateNotifier<RecordingModel> {
RecordingViewModel(this.ref)
: super(
RecordingModel(
clipboardRecordingModel: ClipboardRecordingModel(
title: '',
address: '',
),
),
);
final Ref ref;
List<Permission> recordingGalleryPermissionsForHost({
required bool isIOS,
required bool isAndroid,
}) {
if (isIOS) {
return [Permission.photosAddOnly];
}
if (isAndroid) {
return [Permission.videos, Permission.storage];
}
return const [];
}
/// 开始录制所需的相机/麦克风权限检测结果。
class RecordingRequiredPermissions {
const RecordingRequiredPermissions({
required this.cameraGranted,
required this.microphoneGranted,
});
final bool cameraGranted;
final bool microphoneGranted;
bool get allGranted => cameraGranted && microphoneGranted;
}
/// 录制页 ViewModel剪贴板、权限、相机预览与录制流程。
class RecordingViewModel extends Notifier<RecordingModel> {
static final _defaultClipboard = ClipboardRecordingModel(
title: '',
address: '',
);
/// 从剪切板获取小程序复制的录制信息。
StreamSubscription<RecordingStatus>? _statusSubscription;
/// 初始化状态并注册销毁回调。
@override
RecordingModel build() {
ref.onDispose(_dispose);
return RecordingModel(clipboardRecordingModel: _defaultClipboard);
}
/// 局部更新 session 子状态。
void _updateSession(
RecordingSessionState Function(RecordingSessionState session) update,
) {
state = state.copyWith(session: update(state.session));
}
/// 读取并解析剪贴板中的小程序录制信息。
Future<ClipboardReadResult> getClipboardContent() async {
try {
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
@@ -85,14 +124,313 @@ class RecordingViewModel extends StateNotifier<RecordingModel> {
}
}
/// 清空剪贴板赛事信息(供 UI 调用)。
void resetClipboardInfo() {
_resetClipboardInfo();
}
/// 重置剪贴板赛事信息为默认空值。
void _resetClipboardInfo() {
state = state.copyWith(
clipboardRecordingModel: _defaultClipboard,
hasValidClipboardInfo: false,
);
}
/// 申请权限、检查系统设置并初始化相机预览。
Future<void> prepareSession() async {
if (!RecordingPlatform.isSupported) {
_updateSession((s) => s.copyWith(errorMessage: '当前设备不支持录制'));
return;
}
final permissions = await PermissionService.requestMissing([
Permission.camera,
Permission.microphone,
if (Platform.isAndroid) Permission.notification,
..._galleryPermissions(),
]);
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
if (!cameraGranted) {
_updateSession((s) => s.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('未授予麦克风权限,当前将以静音模式录制');
}
if (!_isGalleryPermissionGranted(permissions)) {
warnings.add('未授予相册权限,录制结束后可能无法保存到相册');
}
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
final batteryIgnored =
await RecordingPlatform.isIgnoringBatteryOptimizations();
_updateSession(
(s) => s.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();
_updateSession(
(s) => s.copyWith(
status: status,
isPreviewReady: status.state == RecordingState.previewing,
errorMessage: status.state == RecordingState.previewing
? null
: (status.message ?? '相机预览初始化失败'),
),
);
} on PlatformException catch (error) {
_updateSession(
(s) => s.copyWith(
isPreviewReady: false,
errorMessage: error.message ?? '相机预览初始化失败',
),
);
}
}
/// 初始化相机预览PlatformView 未就绪时自动重试。
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> restorePreview() async {
if (!RecordingPlatform.isSupported) return;
_updateSession(
(s) => s.copyWith(isPreviewReady: false, errorMessage: null),
);
try {
final status = await _initializePreviewWithRetry();
_updateSession(
(s) => s.copyWith(
status: status,
isPreviewReady: status.state == RecordingState.previewing,
errorMessage: status.state == RecordingState.previewing
? null
: (status.message ?? '相机预览初始化失败'),
),
);
} on PlatformException catch (error) {
_updateSession(
(s) => s.copyWith(
isPreviewReady: false,
errorMessage: error.message ?? '相机预览初始化失败',
),
);
}
}
/// 当前平台所需的相册/视频保存权限列表。
List<Permission> _galleryPermissions() {
return recordingGalleryPermissionsForHost(
isIOS: Platform.isIOS,
isAndroid: Platform.isAndroid,
);
}
/// 判断相册相关权限是否至少有一项已授予。
bool _isGalleryPermissionGranted(
Map<Permission, PermissionStatus> permissions,
) {
for (final permission in _galleryPermissions()) {
if (permissions[permission]?.isGranted ?? false) {
return true;
}
}
return _galleryPermissions().isEmpty;
}
/// 检测并尝试申请相机、麦克风权限,同步更新 session 中的 isMicrophoneGranted。
Future<RecordingRequiredPermissions>
ensureCameraAndMicrophonePermissions() async {
final permissions = await PermissionService.requestMissing([
Permission.camera,
Permission.microphone,
]);
final cameraGranted = _isPermissionGranted(permissions[Permission.camera]);
final microphoneGranted = _isPermissionGranted(
permissions[Permission.microphone],
);
_updateSession((s) => s.copyWith(isMicrophoneGranted: microphoneGranted));
if (cameraGranted && !state.session.isPreviewReady) {
_updateSession((s) => s.copyWith(errorMessage: null));
await _listenStatus();
await restorePreview();
}
return RecordingRequiredPermissions(
cameraGranted: cameraGranted,
microphoneGranted: microphoneGranted,
);
}
bool _isPermissionGranted(PermissionStatus? status) {
return status?.isGranted == true || status?.isLimited == true;
}
/// 开始录制,可选开启勿扰模式。
Future<void> startRecording({bool enableDoNotDisturb = true}) async {
final session = state.session;
if (session.isRecording || session.isStartingRecording) {
return;
}
if (!session.isPreviewReady) {
_updateSession((s) => s.copyWith(errorMessage: '相机预览未就绪,请稍后重试'));
return;
}
final displayName = recordingFileNameForPlatform(
state.clipboardRecordingModel.filename,
);
_updateSession(
(s) => s.copyWith(isStartingRecording: true, errorMessage: null),
);
try {
final result = await RecordingPlatform.startRecording(
enableDoNotDisturb: enableDoNotDisturb && state.session.hasDndAccess,
displayName: displayName,
);
_updateSession(
(s) => s.copyWith(
status: result.status,
lastOutputPath: result.outputPath,
isTouchLocked: true,
errorMessage: null,
gallerySaveFailed: false,
clearLastSaved: true,
),
);
} on PlatformException catch (error) {
_updateSession(
(s) => s.copyWith(errorMessage: error.message ?? '开始录制失败'),
);
} finally {
_updateSession((s) => s.copyWith(isStartingRecording: false));
}
}
/// 停止录制、保存到相册,并恢复相机预览。
Future<void> stopRecording() async {
if (!state.session.isRecording) return;
try {
final result = await RecordingPlatform.stopRecording();
final galleryFailed = !result.gallerySaved;
final savedName = recordingFileNameForPlatform(
state.clipboardRecordingModel.filename,
);
_updateSession(
(s) => s.copyWith(
status: result.status,
lastOutputPath: result.outputPath ?? s.lastOutputPath,
lastSavedDisplayName: galleryFailed ? null : savedName,
errorMessage: galleryFailed
? (result.galleryErrorMessage ?? '保存到相册失败,请开启相册权限')
: null,
gallerySaveFailed: galleryFailed,
),
);
} on PlatformException catch (error) {
_updateSession(
(s) => s.copyWith(errorMessage: error.message ?? '停止录制失败'),
);
} finally {
await restorePreview();
}
}
/// 切换录制中触屏锁定状态。
void setTouchLocked(bool locked) {
_updateSession((s) => s.copyWith(isTouchLocked: locked));
}
/// 清除上次保存成功的录制结果标记。
void clearSavedRecordingResult() {
_updateSession((s) => s.copyWith(clearLastSaved: true));
}
/// 跳转系统勿扰/通知策略设置页。
Future<void> openDndSettings() =>
RecordingPlatform.openNotificationPolicySettings();
/// 重新检测勿扰模式权限并更新状态。
Future<void> refreshDndAccess() async {
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
_updateSession((s) => s.copyWith(hasDndAccess: hasDnd));
}
/// 跳转电池优化白名单设置页。
Future<void> openBatterySettings() =>
RecordingPlatform.openBatteryOptimizationSettings();
/// 重新检测是否已忽略电池优化并更新状态。
Future<void> refreshBatteryOptimization() async {
final ignored = await RecordingPlatform.isIgnoringBatteryOptimizations();
_updateSession((s) => s.copyWith(isBatteryOptimizedIgnored: ignored));
}
/// 退出录制页时释放相机、勿扰和状态订阅。
Future<void> teardown() async {
await RecordingPlatform.setImmersiveMode(enabled: false);
await RecordingPlatform.disableDoNotDisturb();
await RecordingPlatform.disposePreview();
await _statusSubscription?.cancel();
_statusSubscription = null;
state = state.copyWith(session: const RecordingSessionState());
}
/// 订阅原生层录制状态流并同步到 session。
Future<void> _listenStatus() async {
await _statusSubscription?.cancel();
_statusSubscription = RecordingPlatform.statusStream().listen((status) {
_updateSession((s) => s.copyWith(status: status));
});
}
/// Provider 销毁时取消状态流订阅。
Future<void> _dispose() async {
await _statusSubscription?.cancel();
}
}

View File

@@ -0,0 +1,49 @@
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, ?currentChild],
);
}
static Widget bottomStackLayoutBuilder(
Widget? currentChild,
List<Widget> previousChildren,
) {
return Stack(
alignment: Alignment.bottomLeft,
clipBehavior: Clip.none,
children: [...previousChildren, ?currentChild],
);
}
}

View File

@@ -1,120 +0,0 @@
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)),
);
}
}

View File

@@ -1,101 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.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: EdgeInsets.only(top: 48.r),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(24.r),
),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 16.r,
vertical: 8.r,
),
child: Text(
_isHolding
? '保持按住 ${widget.unlockHoldDuration.inSeconds}s 解锁…'
: '防误触已开启,按住 ${widget.unlockHoldDuration.inSeconds}s 解锁',
style: TextStyle(color: Colors.white, fontSize: 13.sp),
),
),
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,80 @@
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 {
const ClipboardAddressClockChipWidget({super.key, required this.address});
final String address;
@override
State<ClipboardAddressClockChipWidget> createState() =>
_ClipboardAddressClockChipWidgetState();
}
class _ClipboardAddressClockChipWidgetState
extends State<ClipboardAddressClockChipWidget> {
Timer? _clockTimer;
static TextStyle get _textStyle => TextStyle(
color: Colors.white,
fontSize: 12.sp,
height: 1.4,
shadows: [Shadow(color: Colors.black54, blurRadius: 6.r)],
);
@override
void initState() {
super.initState();
_clockTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_clockTimer?.cancel();
_clockTimer = null;
super.dispose();
}
String get _nowText => DateTimeFormatter.format(
DateTime.now(),
pattern: 'yyyy-M-d-H:mm:ss',
);
@override
Widget build(BuildContext context) {
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')),
),
],
),
);
}
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
class RecordFooter extends StatefulWidget {
const RecordFooter({super.key});
@override
State<RecordFooter> createState() => _RecordFooterState();
}
class _RecordFooterState extends State<RecordFooter> {
@override
Widget build(BuildContext context) {
return SizedBox(height: 65.r, width: double.infinity);
}
}

View File

@@ -0,0 +1,221 @@
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';
/// 录制页顶部Logo、粘贴赛事、赛事标题
class RecordHeaderWidget extends StatelessWidget {
const RecordHeaderWidget({
super.key,
required this.hasValidClipboardInfo,
this.eventTitle,
required this.isRecording,
required this.onPasteEventInfo,
required this.onClearEventInfo,
});
final bool hasValidClipboardInfo;
final String? eventTitle;
final bool isRecording;
final Future<void> Function() onPasteEventInfo;
final VoidCallback onClearEventInfo;
bool get _showPasteButtons => !hasValidClipboardInfo && !isRecording;
bool get _showEventTitle => hasValidClipboardInfo;
Widget _buildAnimatedHeaderContent() {
if (_showEventTitle) {
return _HeaderEventTitleRow(
key: ValueKey('title-${eventTitle ?? ''}'),
title: eventTitle ?? '',
isRecording: isRecording,
onClearEventInfo: onClearEventInfo,
);
}
return const SizedBox.shrink(key: ValueKey('header-empty'));
}
void _mockCopyEventInfo() {
const strTemp =
'{"title":"蔡依婷vs夏志豪 空中格斗赛 初中组","address":"黑龙江省鹤岗市11111","filename":"蔡依婷_夏志豪_测试循环赛-7_空中格斗赛"}';
Clipboard.setData(const ClipboardData(text: strTemp));
AppToast.show('模拟复制赛事信息成功');
}
@override
Widget build(BuildContext context) {
return SafeArea(
bottom: false,
child: SizedBox(
height: 56.h,
width: double.maxFinite,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 12.w),
child: Row(
children: [
Image.asset(
Assets.images.imageLogo.path,
width: 24.r,
height: 24.r,
fit: BoxFit.contain,
),
Expanded(
child: Stack(
alignment: Alignment.center,
children: [
AnimatedSwitcher(
duration: RecordContentTransition.duration,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
layoutBuilder: RecordContentTransition.stackLayoutBuilder,
transitionBuilder: RecordContentTransition.builder,
child: _buildAnimatedHeaderContent(),
),
if (_showPasteButtons)
Align(
alignment: Alignment.centerRight,
child: _HeaderPasteActions(
onMockCopy: _mockCopyEventInfo,
onPasteEventInfo: onPasteEventInfo,
),
),
],
),
),
],
),
),
),
);
}
}
class _HeaderEventTitleRow extends StatelessWidget {
const _HeaderEventTitleRow({
super.key,
required this.title,
required this.isRecording,
required this.onClearEventInfo,
});
final String title;
final bool isRecording;
final VoidCallback onClearEventInfo;
static TextStyle get _overlayTextStyle => TextStyle(
color: Colors.white,
shadows: [Shadow(color: Colors.black54, blurRadius: 6.r)],
);
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
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,
),
),
),
!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),
alignment: Alignment.centerRight,
tooltip: '删除',
)
: const SizedBox.shrink(key: ValueKey('clear-event-info-hidden')),
],
);
}
}
class _HeaderPasteActions extends StatelessWidget {
const _HeaderPasteActions({
required this.onMockCopy,
required this.onPasteEventInfo,
});
final VoidCallback onMockCopy;
final Future<void> Function() onPasteEventInfo;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// _HeaderActionButton(label: 'mock', onPressed: onMockCopy),
_HeaderActionButton(
label: '粘贴选手信息',
onPressed: () => onPasteEventInfo(),
icon: Assets.images.imageCopy.image(
width: 10.r,
height: 10.r,
fit: BoxFit.contain,
excludeFromSemantics: true,
),
),
],
);
}
}
class _HeaderActionButton extends StatelessWidget {
const _HeaderActionButton({
required this.label,
required this.onPressed,
this.icon,
});
final String label;
final VoidCallback onPressed;
final Widget? icon;
@override
Widget build(BuildContext context) {
return TextButton.icon(
onPressed: onPressed,
icon: icon ?? Icon(Icons.content_paste, size: 10.r),
label: Text(label),
style: TextButton.styleFrom(
minimumSize: Size.zero, // 取消 40dp 最小高度
tapTargetSize: MaterialTapTargetSize.shrinkWrap, // 取消额外点击热区
foregroundColor: Colors.white,
backgroundColor: Colors.black.withValues(alpha: 0.5),
textStyle: TextStyle(fontSize: 10.sp),
padding: EdgeInsets.symmetric(horizontal: 7.r, vertical: 4.r),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(25.r),
side: const BorderSide(color: Colors.white30),
),
),
);
}
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
class RecordTimerWidget extends ConsumerStatefulWidget {
const RecordTimerWidget({super.key});
@override
ConsumerState<RecordTimerWidget> createState() => _RecordTimerWidgetState();
}
class _RecordTimerWidgetState extends ConsumerState<RecordTimerWidget> {
@override
Widget build(BuildContext context) {
final timerState = ref.watch(
recordingViewModelProvider.select(
(m) => (m.session.isRecording, m.session.elapsedLabel),
),
);
final (isRecording, elapsedLabel) = timerState;
final displayTime = isRecording ? elapsedLabel : '00:00:00';
return Positioned(
top: 13.r,
left: 0,
right: 0,
child: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 380),
curve: Curves.easeOutCubic,
padding: EdgeInsets.symmetric(horizontal: 5.r, vertical: 0.r),
decoration: BoxDecoration(
color: isRecording ? Colors.red : Colors.transparent,
borderRadius: BorderRadius.circular(6.r),
),
child: Text(
displayTime,
style: TextStyle(
color: Colors.white,
fontSize: 20.sp,
shadows: [Shadow(color: Colors.black54, blurRadius: 6.r)],
),
),
),
),
);
}
}

View File

@@ -0,0 +1,165 @@
import 'dart:ui' show lerpDouble;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// 录制控制按钮:白圈 + 红色内芯,录制中为圆角矩形。
class RecordingControlButton extends StatefulWidget {
const RecordingControlButton({
super.key,
required this.isRecording,
required this.onTap,
this.isStartingRecording = false,
this.enabled = true,
this.size,
});
final bool isRecording;
final bool isStartingRecording;
final VoidCallback? onTap;
final bool enabled;
final double? size;
@override
State<RecordingControlButton> createState() => _RecordingControlButtonState();
}
class _RecordingControlButtonState extends State<RecordingControlButton>
with TickerProviderStateMixin {
static const _morphDuration = Duration(milliseconds: 380);
static const _pressDownDuration = Duration(milliseconds: 120);
static const _pressUpDuration = Duration(milliseconds: 180);
late final AnimationController _morphController;
late final AnimationController _pressController;
late final CurvedAnimation _morphAnimation;
late final Animation<double> _pressScale;
bool get _targetIsRecording =>
widget.isRecording || widget.isStartingRecording;
@override
void initState() {
super.initState();
_morphController = AnimationController(
vsync: this,
duration: _morphDuration,
value: _targetIsRecording ? 1 : 0,
);
_morphAnimation = CurvedAnimation(
parent: _morphController,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
_pressController = AnimationController(
vsync: this,
duration: _pressDownDuration,
);
_pressScale = Tween<double>(begin: 1, end: 0.94).animate(
CurvedAnimation(
parent: _pressController,
curve: Curves.easeOut,
reverseCurve: Curves.easeOutBack,
),
);
}
@override
void didUpdateWidget(covariant RecordingControlButton oldWidget) {
super.didUpdateWidget(oldWidget);
final oldTarget =
oldWidget.isRecording || oldWidget.isStartingRecording;
final newTarget = _targetIsRecording;
if (oldTarget != newTarget) {
if (newTarget) {
_morphController.forward();
} else {
_morphController.reverse();
}
}
}
@override
void dispose() {
_morphAnimation.dispose();
_morphController.dispose();
_pressController.dispose();
super.dispose();
}
void _handlePressDown() {
if (!widget.enabled) return;
_pressController.duration = _pressDownDuration;
_pressController.forward();
}
void _handlePressUp() {
if (!widget.enabled) return;
_pressController.duration = _pressUpDuration;
_pressController.reverse();
}
@override
Widget build(BuildContext context) {
final buttonSize = widget.size ?? 70.r;
final borderWidth = 4.r;
final idleInnerSize = 62.r;
final recordingInnerSize = 22.r;
final idleCornerRadius = idleInnerSize / 2;
final recordingCornerRadius = 6.r;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (_) => _handlePressDown(),
onTapUp: (_) => _handlePressUp(),
onTapCancel: _handlePressUp,
onTap: widget.enabled ? widget.onTap : null,
child: AnimatedBuilder(
animation: Listenable.merge([_morphController, _pressController]),
builder: (context, child) {
final morph = _morphAnimation.value;
final innerSize = lerpDouble(
idleInnerSize,
recordingInnerSize,
morph,
)!;
final cornerRadius = lerpDouble(
idleCornerRadius,
recordingCornerRadius,
morph,
)!;
return Transform.scale(
scale: _pressScale.value,
child: SizedBox(
width: buttonSize,
height: buttonSize,
child: Stack(
alignment: Alignment.center,
children: [
Container(
width: buttonSize,
height: buttonSize,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: borderWidth),
),
),
Container(
width: innerSize,
height: innerSize,
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(cornerRadius),
),
),
],
),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// 可点击的提示条组件
class RecordingHintChipWidget extends StatelessWidget {
const RecordingHintChipWidget({
super.key,
required this.label,
required this.onTap,
});
final String label;
final VoidCallback onTap;
@override
/// 构建提示条 UI
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white12,
borderRadius: BorderRadius.circular(8.r),
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 12.r, vertical: 8.r),
child: Row(
children: [
Expanded(
child: Text(
label,
style: TextStyle(color: Colors.white70, fontSize: 12.sp),
),
),
Icon(Icons.chevron_right, color: Colors.white54, size: 18.r),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
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/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';
/// 录制页 HUD 层(状态提示、录制控制)
class RecordingHudWidget extends StatelessWidget {
const RecordingHudWidget({
super.key,
this.errorMessage,
this.permissionWarning,
required this.hasDndAccess,
required this.isBatteryOptimizedIgnored,
required this.notificationsGranted,
required this.isRecording,
required this.isStartingRecording,
required this.isTouchLocked,
this.showClipboardHint = false,
this.clipboardAddress = '',
required this.onStart,
required this.onStop,
required this.onOpenDnd,
required this.onOpenBattery,
required this.onToggleTouchLock,
});
final String? errorMessage;
final String? permissionWarning;
final bool hasDndAccess;
final bool isBatteryOptimizedIgnored;
final bool notificationsGranted;
final bool isRecording;
final bool isStartingRecording;
final bool isTouchLocked;
final bool showClipboardHint;
final String clipboardAddress;
final Future<void> Function() onStart;
final Future<void> Function() onStop;
final VoidCallback onOpenDnd;
final VoidCallback onOpenBattery;
final VoidCallback onToggleTouchLock;
static double get _recordButtonSize => 70.r;
static double get _recordButtonBottom => 63.r;
static double get _overlayInfoLeft => 13.r;
static double get _overlayInfoBottom => 10.r;
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
Positioned(
left: 0,
right: 0,
top: 0,
bottom: _recordButtonBottom + _recordButtonSize + 16.h,
child: Column(
children: [
SizedBox(height: 8.h),
const Spacer(),
if (errorMessage != null)
Padding(
padding: EdgeInsets.all(12.r),
child: Text(
errorMessage!,
style: const TextStyle(color: Colors.amber),
textAlign: TextAlign.center,
),
),
if (permissionWarning != null)
Padding(
padding: EdgeInsets.symmetric(
horizontal: 16.r,
vertical: 8.r,
),
child: Text(
permissionWarning!,
style: TextStyle(
color: Colors.orangeAccent,
fontSize: 12.sp,
),
textAlign: TextAlign.center,
),
),
RecordingSetupHintsWidget(
hasDndAccess: hasDndAccess,
isBatteryIgnored: isBatteryOptimizedIgnored,
notificationsGranted: notificationsGranted,
onOpenDnd: onOpenDnd,
onOpenBattery: onOpenBattery,
onOpenNotificationSettings: openAppSettings,
),
],
),
),
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 (isRecording)
Positioned(
left: 16.r,
bottom: _recordButtonBottom,
child: SizedBox(
height: _recordButtonSize,
child: Center(
child: IconButton(
onPressed: onToggleTouchLock,
icon: Icon(
isTouchLocked ? Icons.lock : Icons.lock_open,
color: Colors.white,
size: 28.r,
),
),
),
),
),
Positioned(
left: 0,
right: 0,
bottom: _recordButtonBottom,
child: Center(
child: RecordingControlButton(
isRecording: isRecording,
isStartingRecording: isStartingRecording,
enabled: !isStartingRecording,
size: _recordButtonSize,
onTap: () {
if (isRecording) {
RateLimit.instance.debounce<void>(
key: 'recording.session.stop',
value: null,
duration: Duration(milliseconds: 300),
onCallback: (_) async {
await onStop();
},
);
} else {
RateLimit.instance.debounce<void>(
key: 'recording.session.start',
value: null,
duration: Duration(milliseconds: 300),
onCallback: (_) async {
await onStart();
},
);
}
},
),
),
),
],
);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
/// 录制加载遮罩(相机启动/开始录制)
class RecordingLoadingOverlayWidget extends StatelessWidget {
const RecordingLoadingOverlayWidget({
super.key,
required this.message,
this.backgroundColor = Colors.black,
});
final String message;
final Color backgroundColor;
@override
/// 显示加载动画与提示文案
Widget build(BuildContext context) {
return ColoredBox(
color: backgroundColor,
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),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
import 'package:recording_tool/features/dialog/dialog-record.dart';
/// 录制结束并保存到相册后的后续操作弹窗。
Future<void> showRecordingSavedDialog(
BuildContext context, {
required String sessionTitle,
required VoidCallback onContinueRound,
required VoidCallback onRecordNewRound,
}) {
return RecordDialog.showDouble(
context,
title: '本轮比赛视频已保存到相册\n请选择后续录制信息',
leftText: '继续本轮',
rightText: '录制新轮',
onLeftPressed: onContinueRound,
onRightPressed: onRecordNewRound,
barrierDismissible: false,
);
}

View File

@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/features/recording/widgets/widget_recording_hint_chip.dart';
/// 权限相关设置提示条
class RecordingSetupHintsWidget extends StatelessWidget {
const RecordingSetupHintsWidget({
super.key,
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) {
final showPermissionHints =
!hasDndAccess || !isBatteryIgnored || !notificationsGranted;
if (!showPermissionHints) {
return const SizedBox.shrink();
}
return Padding(
padding: EdgeInsets.symmetric(horizontal: 16.r, vertical: 8.r),
child: Column(
children: [
if (!notificationsGranted) ...[
RecordingHintChipWidget(
label: '开启通知权限以显示录制前台服务',
onTap: onOpenNotificationSettings,
),
SizedBox(height: 8.h),
],
if (!hasDndAccess)
RecordingHintChipWidget(label: '开启勿扰权限可减少录制中断', onTap: onOpenDnd),
if (!isBatteryIgnored) ...[
SizedBox(height: 8.h),
RecordingHintChipWidget(
label: '关闭电池优化可提升息屏续录稳定性',
onTap: onOpenBattery,
),
],
],
),
);
}
}

View File

@@ -0,0 +1,218 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
enum RecordingTouchLockUnlockIntent { unlockOnly, stopRecording }
RecordingTouchLockUnlockIntent resolveRecordingTouchLockUnlockIntent({
required Offset position,
required Size size,
double stopZoneFraction = 0.3,
}) {
if (size.width <= 0 || size.height <= 0 || stopZoneFraction <= 0) {
return RecordingTouchLockUnlockIntent.unlockOnly;
}
final normalizedStopZone = stopZoneFraction.clamp(0.0, 1.0);
if (size.width <= size.height) {
final stopZoneTop = size.height * (1 - normalizedStopZone);
return position.dy >= stopZoneTop
? RecordingTouchLockUnlockIntent.stopRecording
: RecordingTouchLockUnlockIntent.unlockOnly;
}
final stopZoneLeft = size.width * (1 - normalizedStopZone);
return position.dx >= stopZoneLeft
? RecordingTouchLockUnlockIntent.stopRecording
: RecordingTouchLockUnlockIntent.unlockOnly;
}
class RecordingTouchLockOverlayWidget extends StatefulWidget {
const RecordingTouchLockOverlayWidget({
super.key,
required this.enabled,
required this.onUnlocked,
this.unlockHoldDuration = const Duration(seconds: 2),
});
final bool enabled;
final ValueChanged<RecordingTouchLockUnlockIntent> onUnlocked;
final Duration unlockHoldDuration;
@override
State<RecordingTouchLockOverlayWidget> createState() =>
_RecordingTouchLockOverlayWidgetState();
}
class _RecordingTouchLockOverlayWidgetState
extends State<RecordingTouchLockOverlayWidget> {
Timer? _holdTimer;
bool _isHolding = false;
int? _remainingSeconds;
Offset? _holdStartPosition;
Size? _holdStartSize;
@override
void didUpdateWidget(RecordingTouchLockOverlayWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (!widget.enabled) {
_cancelHold();
}
}
@override
void dispose() {
_holdTimer?.cancel();
_holdTimer = null;
super.dispose();
}
void _cancelHold() {
_holdTimer?.cancel();
_holdTimer = null;
if (!_isHolding && _remainingSeconds == null) return;
setState(() {
_isHolding = false;
_remainingSeconds = null;
_holdStartPosition = null;
_holdStartSize = null;
});
}
void _startHold(Offset position, Size size) {
if (!widget.enabled) return;
final totalSeconds = widget.unlockHoldDuration.inSeconds;
_holdTimer?.cancel();
setState(() {
_isHolding = true;
_remainingSeconds = totalSeconds;
_holdStartPosition = position;
_holdStartSize = size;
});
var elapsed = 0;
_holdTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
elapsed += 1;
if (!mounted) {
timer.cancel();
return;
}
if (elapsed >= totalSeconds) {
timer.cancel();
_holdTimer = null;
final intent = resolveRecordingTouchLockUnlockIntent(
position: _holdStartPosition ?? Offset.zero,
size: _holdStartSize ?? Size.zero,
);
setState(() {
_isHolding = false;
_remainingSeconds = null;
_holdStartPosition = null;
_holdStartSize = null;
});
widget.onUnlocked(intent);
return;
}
setState(() => _remainingSeconds = totalSeconds - elapsed);
});
}
@override
Widget build(BuildContext context) {
if (!widget.enabled) {
return const SizedBox.shrink();
}
return Positioned.fill(
child: LayoutBuilder(
builder: (context, constraints) {
final overlaySize = Size(constraints.maxWidth, constraints.maxHeight);
return Listener(
behavior: HitTestBehavior.opaque,
onPointerDown: (event) =>
_startHold(event.localPosition, overlaySize),
onPointerUp: (_) => _cancelHold(),
onPointerCancel: (_) => _cancelHold(),
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.01),
child: Align(
alignment: Alignment.topCenter,
child: Padding(
padding: EdgeInsets.only(top: 68.r),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(24.r),
),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 16.r,
vertical: 8.r,
),
child: _isHolding && _remainingSeconds != null
? Builder(
builder: (context) {
final remainingSeconds = _remainingSeconds!;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSwitcher(
duration: const Duration(
milliseconds: 280,
),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
transitionBuilder: (child, animation) {
return ScaleTransition(
scale: Tween<double>(
begin: 0.6,
end: 1,
).animate(animation),
child: FadeTransition(
opacity: animation,
child: child,
),
);
},
child: Text(
'${remainingSeconds}s',
key: ValueKey<int>(remainingSeconds),
style: TextStyle(
color: Colors.white,
fontSize: 18.sp,
fontWeight: FontWeight.w600,
height: 1.1,
),
),
),
SizedBox(height: 2.r),
Text(
'保持按住解锁',
style: TextStyle(
color: Colors.white70,
fontSize: 10.sp,
),
),
],
);
},
)
: Text(
'防误触已开启,按住 ${widget.unlockHoldDuration.inSeconds}s 解锁',
style: TextStyle(
color: Colors.white,
fontSize: 10.sp,
),
),
),
),
),
),
),
);
},
),
);
}
}

134
lib/gen/assets.gen.dart Normal file
View File

@@ -0,0 +1,134 @@
// dart format width=80
/// GENERATED CODE - DO NOT MODIFY BY HAND
/// *****************************************************
/// FlutterGen
/// *****************************************************
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: deprecated_member_use,directives_ordering,implicit_dynamic_list_literal,unnecessary_import
import 'package:flutter/widgets.dart';
class $AssetsImagesGen {
const $AssetsImagesGen();
/// File path: assets/images/image_copy.png
AssetGenImage get imageCopy =>
const AssetGenImage('assets/images/image_copy.png');
/// File path: assets/images/image_delete.png
AssetGenImage get imageDelete =>
const AssetGenImage('assets/images/image_delete.png');
/// File path: assets/images/image_dialog_bg.png
AssetGenImage get imageDialogBg =>
const AssetGenImage('assets/images/image_dialog_bg.png');
/// File path: assets/images/image_logo.png
AssetGenImage get imageLogo =>
const AssetGenImage('assets/images/image_logo.png');
/// List of all assets
List<AssetGenImage> get values => [
imageCopy,
imageDelete,
imageDialogBg,
imageLogo,
];
}
class Assets {
const Assets._();
static const $AssetsImagesGen images = $AssetsImagesGen();
}
class AssetGenImage {
const AssetGenImage(
this._assetName, {
this.size,
this.flavors = const {},
this.animation,
});
final String _assetName;
final Size? size;
final Set<String> flavors;
final AssetGenImageAnimation? animation;
Image image({
Key? key,
AssetBundle? bundle,
ImageFrameBuilder? frameBuilder,
ImageErrorWidgetBuilder? errorBuilder,
String? semanticLabel,
bool excludeFromSemantics = false,
double? scale,
double? width,
double? height,
Color? color,
Animation<double>? opacity,
BlendMode? colorBlendMode,
BoxFit? fit,
AlignmentGeometry alignment = Alignment.center,
ImageRepeat repeat = ImageRepeat.noRepeat,
Rect? centerSlice,
bool matchTextDirection = false,
bool gaplessPlayback = true,
bool isAntiAlias = false,
String? package,
FilterQuality filterQuality = FilterQuality.medium,
int? cacheWidth,
int? cacheHeight,
}) {
return Image.asset(
_assetName,
key: key,
bundle: bundle,
frameBuilder: frameBuilder,
errorBuilder: errorBuilder,
semanticLabel: semanticLabel,
excludeFromSemantics: excludeFromSemantics,
scale: scale,
width: width,
height: height,
color: color,
opacity: opacity,
colorBlendMode: colorBlendMode,
fit: fit,
alignment: alignment,
repeat: repeat,
centerSlice: centerSlice,
matchTextDirection: matchTextDirection,
gaplessPlayback: gaplessPlayback,
isAntiAlias: isAntiAlias,
package: package,
filterQuality: filterQuality,
cacheWidth: cacheWidth,
cacheHeight: cacheHeight,
);
}
ImageProvider provider({AssetBundle? bundle, String? package}) {
return AssetImage(_assetName, bundle: bundle, package: package);
}
String get path => _assetName;
String get keyName => _assetName;
}
class AssetGenImageAnimation {
const AssetGenImageAnimation({
required this.isAnimation,
required this.duration,
required this.frames,
});
final bool isAnimation;
final Duration duration;
final int frames;
}

View File

@@ -58,6 +58,8 @@ dev_dependencies:
# package. See that file for information about deactivating specific lint
# rules and activating additional ones.
flutter_lints: ^6.0.0
build_runner: ^2.15.0
flutter_gen_runner: ^5.14.1
# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec
@@ -68,7 +70,11 @@ flutter:
# included with your application, so that you can use the icons in
# the material Icons class.
uses-material-design: true
generate: true
# To add assets to your application, add an assets section, like this:
assets:
- assets/images/
# To add assets to your application, add an assets section, like this:
# assets:
# - images/a_dot_burr.jpeg
@@ -99,3 +105,8 @@ flutter:
#
# For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package
flutter_gen:
output: lib/gen/
integrations:
flutter_svg: true

View File

@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:recording_tool/features/dialog/dialog-record.dart';
import 'package:recording_tool/features/recording/widgets/widget_recording_saved_dialog.dart';
import 'package:recording_tool/gen/assets.gen.dart';
void main() {
Future<void> pumpDialogHost(WidgetTester tester, Widget child) async {
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(375, 812),
builder: (context, _) {
return MaterialApp(home: Scaffold(body: child));
},
),
);
}
testWidgets('single button dialog shows configured content and closes', (
tester,
) async {
var tapped = false;
await pumpDialogHost(
tester,
Builder(
builder: (context) {
return TextButton(
onPressed: () {
RecordDialog.showSingle(
context,
title: '无选手信息!',
buttonText: '粘贴',
onPressed: () => tapped = true,
);
},
child: const Text('show'),
);
},
),
);
await tester.tap(find.text('show'));
await tester.pumpAndSettle();
expect(find.byType(Image), findsOneWidget);
expect(find.image(AssetImage(Assets.images.imageDialogBg.path)), findsOne);
expect(find.text('无选手信息!'), findsOneWidget);
expect(find.text('粘贴'), findsOneWidget);
await tester.tap(find.text('粘贴'));
await tester.pumpAndSettle();
expect(tapped, isTrue);
expect(find.text('无选手信息!'), findsNothing);
});
testWidgets('double button dialog dispatches each action', (tester) async {
var leftTapped = false;
var rightTapped = false;
await pumpDialogHost(
tester,
Builder(
builder: (context) {
return TextButton(
onPressed: () {
RecordDialog.showDouble(
context,
title: '本轮比赛视频已保存到相册\n请选择后续录制信息',
leftText: '继续本轮',
rightText: '录制新轮',
onLeftPressed: () => leftTapped = true,
onRightPressed: () => rightTapped = true,
);
},
child: const Text('show'),
);
},
),
);
await tester.tap(find.text('show'));
await tester.pumpAndSettle();
await tester.tap(find.text('继续本轮'));
await tester.pumpAndSettle();
expect(leftTapped, isTrue);
expect(rightTapped, isFalse);
await tester.tap(find.text('show'));
await tester.pumpAndSettle();
await tester.tap(find.text('录制新轮'));
await tester.pumpAndSettle();
expect(rightTapped, isTrue);
});
testWidgets('recording saved dialog follows design title only', (
tester,
) async {
await pumpDialogHost(
tester,
Builder(
builder: (context) {
return TextButton(
onPressed: () {
showRecordingSavedDialog(
context,
sessionTitle: '王东方 丨李想 空中格斗赛',
onContinueRound: () {},
onRecordNewRound: () {},
);
},
child: const Text('show'),
);
},
),
);
await tester.tap(find.text('show'));
await tester.pumpAndSettle();
expect(find.text('王东方 丨李想 空中格斗赛'), findsNothing);
expect(find.text('本轮比赛视频已保存到相册\n请选择后续录制信息'), findsOneWidget);
expect(find.text('继续本轮'), findsOneWidget);
expect(find.text('录制新轮'), findsOneWidget);
});
}

View File

@@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:recording_tool/features/recording/recording_display_name.dart';
import 'package:recording_tool/features/recording/utils/recording_display_name.dart';
void main() {
group('sanitizeRecordingBaseName', () {

View File

@@ -1,5 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:recording_tool/features/recording/recording_platform.dart';
import 'package:recording_tool/features/recording/platform/recording_platform.dart';
void main() {
group('RecordingPlatform support', () {

View File

@@ -1,6 +1,7 @@
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
void main() {
@@ -25,6 +26,40 @@ void main() {
.setMockMethodCallHandler(SystemChannels.platform, null);
});
group('RecordingViewModel', () {
test('initializes with default clipboard and session state', () {
final container = ProviderContainer();
addTearDown(container.dispose);
final model = container.read(recordingViewModelProvider);
expect(model.hasValidClipboardInfo, isFalse);
expect(model.clipboardRecordingModel.title, defaultClipboardTitle);
expect(model.session.isPreviewReady, isFalse);
expect(model.session.isRecording, isFalse);
});
});
group('recordingGalleryPermissionsForHost', () {
test('requests only add-only photo permission on iOS', () {
final permissions = recordingGalleryPermissionsForHost(
isIOS: true,
isAndroid: false,
);
expect(permissions, <Permission>[Permission.photosAddOnly]);
expect(permissions, isNot(contains(Permission.photos)));
});
test('keeps Android gallery permissions unchanged', () {
final permissions = recordingGalleryPermissionsForHost(
isIOS: false,
isAndroid: true,
);
expect(permissions, <Permission>[Permission.videos, Permission.storage]);
});
});
group('RecordingViewModel.getClipboardContent', () {
test(
'updates state when clipboard contains valid mini program JSON',
@@ -43,14 +78,8 @@ void main() {
expect(model.clipboardRecordingModel.title, '王东方 丨李想 空中格斗赛');
expect(model.clipboardRecordingModel.startTimestamp, 1717334400);
expect(model.clipboardRecordingModel.endTimestamp, 1717334400);
expect(
model.clipboardRecordingModel.address,
'广州市番禺区·粤港澳大湾区青年人才双创小镇',
);
expect(
model.clipboardRecordingModel.filename,
'选手名称_选手ID_赛事名称_赛项',
);
expect(model.clipboardRecordingModel.address, '广州市番禺区·粤港澳大湾区青年人才双创小镇');
expect(model.clipboardRecordingModel.filename, '选手名称_选手ID_赛事名称_赛项');
},
);
@@ -80,7 +109,10 @@ void main() {
expect(result, ClipboardReadResult.invalid);
expect(
container.read(recordingViewModelProvider).clipboardRecordingModel.title,
container
.read(recordingViewModelProvider)
.clipboardRecordingModel
.title,
defaultClipboardTitle,
);
expect(
@@ -100,33 +132,18 @@ void main() {
expect(result, ClipboardReadResult.invalid);
expect(
container.read(recordingViewModelProvider).clipboardRecordingModel.title,
defaultClipboardTitle,
);
});
test('returns invalid when clipboard JSON misses required address', () async {
await setClipboardText('{"title":"王东方 丨李想 空中格斗赛"}');
final container = ProviderContainer();
addTearDown(container.dispose);
final result = await container
.read(recordingViewModelProvider.notifier)
.getClipboardContent();
expect(result, ClipboardReadResult.invalid);
expect(
container.read(recordingViewModelProvider).clipboardRecordingModel.title,
container
.read(recordingViewModelProvider)
.clipboardRecordingModel
.title,
defaultClipboardTitle,
);
});
test(
'updates state when clipboard omits optional timestamps',
'returns invalid when clipboard JSON misses required address',
() async {
await setClipboardText(
'{"title":"郑昌梦 丨黄伟依 空中格斗赛 小学组","address":"广东省汕头市番禺区青蓝街 111 号","filename":"郑昌梦_黄伟依_6月3日测试-1_空中格斗赛"}',
);
await setClipboardText('{"title":"王东方 丨李想 空中格斗赛"}');
final container = ProviderContainer();
addTearDown(container.dispose);
@@ -134,18 +151,36 @@ void main() {
.read(recordingViewModelProvider.notifier)
.getClipboardContent();
expect(result, ClipboardReadResult.success);
final model = container.read(recordingViewModelProvider);
expect(model.hasValidClipboardInfo, isTrue);
expect(model.clipboardRecordingModel.startTimestamp, isNull);
expect(model.clipboardRecordingModel.endTimestamp, isNull);
expect(result, ClipboardReadResult.invalid);
expect(
model.clipboardRecordingModel.filename,
'郑昌梦_黄伟依_6月3日测试-1_空中格斗赛',
container
.read(recordingViewModelProvider)
.clipboardRecordingModel
.title,
defaultClipboardTitle,
);
},
);
test('updates state when clipboard omits optional timestamps', () async {
await setClipboardText(
'{"title":"郑昌梦 丨黄伟依 空中格斗赛 小学组","address":"广东省汕头市番禺区青蓝街 111 号","filename":"郑昌梦_黄伟依_6月3日测试-1_空中格斗赛"}',
);
final container = ProviderContainer();
addTearDown(container.dispose);
final result = await container
.read(recordingViewModelProvider.notifier)
.getClipboardContent();
expect(result, ClipboardReadResult.success);
final model = container.read(recordingViewModelProvider);
expect(model.hasValidClipboardInfo, isTrue);
expect(model.clipboardRecordingModel.startTimestamp, isNull);
expect(model.clipboardRecordingModel.endTimestamp, isNull);
expect(model.clipboardRecordingModel.filename, '郑昌梦_黄伟依_6月3日测试-1_空中格斗赛');
});
test('returns invalid when clipboard JSON has wrong field type', () async {
await setClipboardText(
'{"title":"王东方 丨李想 空中格斗赛","startTimestamp":"1717334400","endTimestamp":1717334400,"address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}',
@@ -159,7 +194,10 @@ void main() {
expect(result, ClipboardReadResult.invalid);
expect(
container.read(recordingViewModelProvider).clipboardRecordingModel.title,
container
.read(recordingViewModelProvider)
.clipboardRecordingModel
.title,
defaultClipboardTitle,
);
});

View File

@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:recording_tool/features/recording/widgets/widget_record_header.dart';
import 'package:recording_tool/gen/assets.gen.dart';
void main() {
Future<void> pumpHeader(
WidgetTester tester, {
required bool hasValidClipboardInfo,
String? eventTitle,
}) async {
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(375, 812),
builder: (context, _) {
return MaterialApp(
home: Scaffold(
body: RecordHeaderWidget(
hasValidClipboardInfo: hasValidClipboardInfo,
eventTitle: eventTitle,
isRecording: false,
onPasteEventInfo: () async {},
onClearEventInfo: () {},
),
),
);
},
),
);
}
testWidgets('paste player info button uses copy image asset', (tester) async {
await pumpHeader(tester, hasValidClipboardInfo: false);
expect(find.text('粘贴选手信息'), findsOneWidget);
expect(find.image(AssetImage(Assets.images.imageCopy.path)), findsOne);
});
testWidgets('clear player info button uses delete image asset', (
tester,
) async {
await pumpHeader(
tester,
hasValidClipboardInfo: true,
eventTitle: '王东方 丨李想 空中格斗赛',
);
expect(find.text('王东方 丨李想 空中格斗赛'), findsOneWidget);
expect(find.image(AssetImage(Assets.images.imageDelete.path)), findsOne);
});
}

View File

@@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:recording_tool/features/recording/widgets/widget_recording_button.dart';
void main() {
const designSize = Size(375, 812);
const morphDuration = Duration(milliseconds: 380);
Future<void> pumpButton(
WidgetTester tester, {
required bool isRecording,
bool isStartingRecording = false,
}) async {
await tester.pumpWidget(
ScreenUtilInit(
designSize: designSize,
builder: (context, _) {
return MaterialApp(
home: Scaffold(
body: Center(
child: RecordingControlButton(
isRecording: isRecording,
isStartingRecording: isStartingRecording,
onTap: () {},
),
),
),
);
},
),
);
await tester.pump();
}
Size innerCoreSize(WidgetTester tester) {
final finder = find.byWidgetPredicate(
(widget) =>
widget is Container &&
widget.decoration is BoxDecoration &&
(widget.decoration! as BoxDecoration).color == Colors.red,
);
return tester.getSize(finder);
}
testWidgets('idle state uses large circular inner core', (tester) async {
await pumpButton(tester, isRecording: false);
final size = innerCoreSize(tester);
expect(size.width, closeTo(62.r, 0.5));
expect(size.height, closeTo(62.r, 0.5));
});
testWidgets('isStartingRecording morphs to stop square before isRecording', (
tester,
) async {
await pumpButton(
tester,
isRecording: false,
isStartingRecording: true,
);
await tester.pump(morphDuration);
await tester.pump();
final size = innerCoreSize(tester);
expect(size.width, closeTo(22.r, 0.5));
expect(size.height, closeTo(22.r, 0.5));
});
testWidgets('isRecording forward and reverse morph without errors', (
tester,
) async {
await pumpButton(tester, isRecording: false);
await tester.pumpWidget(
ScreenUtilInit(
designSize: designSize,
builder: (context, _) {
return MaterialApp(
home: Scaffold(
body: Center(
child: RecordingControlButton(
isRecording: true,
onTap: () {},
),
),
),
);
},
),
);
await tester.pump(morphDuration);
await tester.pump();
expect(innerCoreSize(tester).width, closeTo(22.r, 0.5));
await tester.pumpWidget(
ScreenUtilInit(
designSize: designSize,
builder: (context, _) {
return MaterialApp(
home: Scaffold(
body: Center(
child: RecordingControlButton(
isRecording: false,
onTap: () {},
),
),
),
);
},
),
);
await tester.pump(morphDuration);
await tester.pump();
expect(innerCoreSize(tester).width, closeTo(62.r, 0.5));
});
testWidgets('failed start rolls morph back to idle circle', (tester) async {
await pumpButton(
tester,
isRecording: false,
isStartingRecording: true,
);
await tester.pump(morphDuration);
await tester.pump();
expect(innerCoreSize(tester).width, closeTo(22.r, 0.5));
await pumpButton(tester, isRecording: false, isStartingRecording: false);
await tester.pump(morphDuration);
await tester.pump();
expect(innerCoreSize(tester).width, closeTo(62.r, 0.5));
});
}

View File

@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:recording_tool/features/recording/widgets/widget_recording_touch_lock_overlay.dart';
void main() {
group('resolveRecordingTouchLockUnlockIntent', () {
test('returns stopRecording for portrait bottom 30 percent', () {
final intent = resolveRecordingTouchLockUnlockIntent(
position: const Offset(120, 466.9),
size: const Size(375, 667),
);
expect(intent, RecordingTouchLockUnlockIntent.stopRecording);
});
test('returns unlockOnly for portrait area outside bottom 30 percent', () {
final intent = resolveRecordingTouchLockUnlockIntent(
position: const Offset(120, 320),
size: const Size(375, 667),
);
expect(intent, RecordingTouchLockUnlockIntent.unlockOnly);
});
test('returns stopRecording for landscape right 30 percent', () {
final intent = resolveRecordingTouchLockUnlockIntent(
position: const Offset(466.9, 120),
size: const Size(667, 375),
);
expect(intent, RecordingTouchLockUnlockIntent.stopRecording);
});
test('returns unlockOnly for landscape area outside right 30 percent', () {
final intent = resolveRecordingTouchLockUnlockIntent(
position: const Offset(320, 120),
size: const Size(667, 375),
);
expect(intent, RecordingTouchLockUnlockIntent.unlockOnly);
});
});
group('RecordingTouchLockOverlayWidget', () {
Future<void> pumpOverlay(
WidgetTester tester, {
required Size surfaceSize,
required ValueChanged<RecordingTouchLockUnlockIntent> onUnlocked,
}) async {
await tester.binding.setSurfaceSize(surfaceSize);
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(375, 812),
builder: (context, _) {
return MaterialApp(
home: Scaffold(
body: Stack(
children: [
RecordingTouchLockOverlayWidget(
enabled: true,
unlockHoldDuration: const Duration(seconds: 2),
onUnlocked: onUnlocked,
),
],
),
),
);
},
),
);
}
testWidgets('long press in portrait bottom 30 percent stops recording', (
tester,
) async {
RecordingTouchLockUnlockIntent? receivedIntent;
await pumpOverlay(
tester,
surfaceSize: const Size(375, 667),
onUnlocked: (intent) => receivedIntent = intent,
);
final gesture = await tester.startGesture(const Offset(120, 600));
await tester.pump(const Duration(seconds: 2));
await gesture.up();
expect(receivedIntent, RecordingTouchLockUnlockIntent.stopRecording);
});
testWidgets('long press outside stop area only unlocks', (tester) async {
RecordingTouchLockUnlockIntent? receivedIntent;
await pumpOverlay(
tester,
surfaceSize: const Size(375, 667),
onUnlocked: (intent) => receivedIntent = intent,
);
final gesture = await tester.startGesture(const Offset(120, 320));
await tester.pump(const Duration(seconds: 2));
await gesture.up();
expect(receivedIntent, RecordingTouchLockUnlockIntent.unlockOnly);
});
testWidgets('releasing before hold duration does not unlock', (
tester,
) async {
RecordingTouchLockUnlockIntent? receivedIntent;
await pumpOverlay(
tester,
surfaceSize: const Size(375, 667),
onUnlocked: (intent) => receivedIntent = intent,
);
final gesture = await tester.startGesture(const Offset(120, 600));
await tester.pump(const Duration(milliseconds: 1500));
await gesture.up();
await tester.pump(const Duration(seconds: 1));
expect(receivedIntent, isNull);
});
});
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:recording_tool/app/app.dart';
import 'package:recording_tool/features/recording/widgets/widget_recording_button.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -34,17 +35,17 @@ void main() {
await tester.pumpWidget(const ProviderScope(child: FlutterTemplateApp()));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
await tester.pumpAndSettle();
await tester.pump(const Duration(seconds: 1));
}
testWidgets('recording app renders recording page', (tester) async {
await pumpRecordingApp(tester);
final recordIcon = find.byIcon(Icons.fiber_manual_record);
final recordButton = find.byType(RecordingControlButton);
expect(recordIcon, findsOneWidget);
expect(recordButton, findsOneWidget);
expect(
tester.getCenter(recordIcon).dx,
tester.getCenter(recordButton).dx,
closeTo(tester.getCenter(find.byType(Scaffold)).dx, 0.5),
);
});
@@ -56,7 +57,7 @@ void main() {
await pumpRecordingApp(tester);
expect(find.text('粘贴赛事信息'), findsOneWidget);
expect(find.text('粘贴选手信息'), findsOneWidget);
});
testWidgets('pastes valid event info from clipboard', (tester) async {
@@ -65,11 +66,10 @@ void main() {
await pumpRecordingApp(tester);
clipboardText = validClipboardText;
await tester.tap(find.text('粘贴赛事信息'));
await tester.pumpAndSettle();
await tester.tap(find.text('粘贴选手信息'));
await tester.pump(const Duration(milliseconds: 700));
expect(find.text('王东方 丨李想 空中格斗赛'), findsOneWidget);
expect(find.text('粘贴赛事信息'), findsNothing);
});
testWidgets('shows no event info toast when pasted clipboard is invalid', (
@@ -80,11 +80,11 @@ void main() {
await pumpRecordingApp(tester);
clipboardText = 'hello';
await tester.tap(find.text('粘贴赛事信息'));
await tester.tap(find.text('粘贴选手信息'));
await tester.pump();
expect(find.text('王东方 丨李想 空中格斗赛'), findsNothing);
expect(find.text('赛事信息'), findsOneWidget);
expect(find.text('选手信息'), findsOneWidget);
await tester.pump(const Duration(seconds: 2));
});