Author SHA1 Message Date
linfeng 299f44fe57 如果是直接晋级不进入记分页面,也不提示 2026-08-06 18:28:17 +08:00
linfeng d137a46e9b 如果是团队赛且 赛项直接晋级不进入记分页面 2026-08-06 18:18:51 +08:00
linfeng 43373bec54 更新主裁判 APP 的上传描述,添加构建时间戳 2026-07-29 18:47:23 +08:00
linfeng ac304b10f8 更新主裁判 APP 的启动页 2026-07-29 18:39:59 +08:00
linfeng df38ecee18 登录后清空输入框 2026-07-29 18:06:53 +08:00
linfeng 7faa8e5c47 更新获取赛事列表功能,增加 NAS IP 地址参数以支持动态请求 2026-07-29 18:00:45 +08:00
linfeng 7d65be0514 选手赛项列表增加 选手与该赛项的选手编号属性 2026-07-29 11:51:11 +08:00
linfeng 40799f2d15 扫码页支持返回上一页 2026-07-29 10:13:47 +08:00
linfeng f7f7413b55 优化扫码页 UI 2026-07-28 18:50:14 +08:00
linfeng 8e599f5a86 完成视频播放UI 调整 2026-07-28 18:02:40 +08:00
linfeng 9fca76f679 1.完成查看录像模块功能
2.完成录像页面播放视频功能
2026-07-28 16:36:32 +08:00
linfeng a324825e8a update 2026-07-28 15:46:52 +08:00
linfeng 6793ca53af 还原参赛队伍列表页、详情页 2026-07-28 15:46:23 +08:00
linfeng 9e72bc522e 解决查看录像页 UI 2026-07-28 14:59:34 +08:00
linfeng 1096645e79 update git 湖绿 2026-07-28 12:04:28 +08:00
linfeng 13ecb2fccc 优化赛事信息页 UI 2026-07-28 10:12:13 +08:00
linfeng e3f9c8ebc0 优化团队赛页面 UI 2026-07-28 10:00:02 +08:00
linfeng 6790ea41b2 优化人工处理弹窗 UI 2026-07-28 09:30:24 +08:00
linfeng b2dd593e93 完成团队赛、 UI 开发 2026-07-27 18:41:20 +08:00
linfeng 5ffe524e20 完成选手信息页 2026-07-27 17:57:09 +08:00
linfeng 356636c4b4 解决解析 TOKEN 类型报错问题 2026-07-27 16:57:05 +08:00
linfeng 686db244fd 1.还原授权页 UI
2.还原扫码页 UI
2026-07-27 16:52:58 +08:00
linfeng 20d501f673 修改启动页 2026-07-27 15:45:06 +08:00
linfeng ddc1c9bf98 更换 APP 图标 2026-07-27 15:43:26 +08:00
linfeng 30d317c0b9 修改名字 2026-07-27 15:30:17 +08:00
linfeng 10d9eee60e 新增人工设置晋级/淘汰功能,更新相关数据模型和API接口;重构比赛页面以支持新逻辑,优化用户交互体验。 2026-07-23 15:35:31 +08:00
linfeng 00937bf4b6 更新比赛页面以传递新的事件注册项,简化数据处理逻辑并优化构建脚本。 2026-07-23 11:55:26 +08:00
linfeng 3c3baede85 移除比赛页面中的备注文本,优化UI布局以提升用户体验。 2026-07-23 11:48:07 +08:00
linfeng 8c127dd0e7 1.重构参赛队伍详情页面,更新数据模型以支持新结构;
2.优化比赛信息展示逻辑,增强用户交互体验;
3.修复API请求以获取队伍详情并处理异常情况。
2026-07-23 11:44:47 +08:00
linfeng 1264ebdd7c 重构参赛队伍相关模型,更新API接口以支持队伍详情获取;
调整UI逻辑以使用新的数据结构,优化比赛信息展示;
更新构建脚本以支持新功能。
2026-07-22 13:35:53 +08:00
linfeng d6e80df5bf 新增网络可视化请求工具 2026-07-22 09:24:48 +08:00
linfeng 4ff93edaab 授权页增加获取设备码 2026-07-21 22:45:41 +08:00
linfeng 1f0e0407ab 修复口令过期处理并增加参赛队伍接口 2026-07-20 10:52:19 +08:00
linfeng da52dc9797 Handle zero opponent IDs and hide video button 2026-07-20 10:23:44 +08:00
linfeng e5b6246458 更新应用配置以添加主裁判计分 H5 链接;
重构事件注册模型以支持团队成员信息,并简化事件信息页面的导航逻辑。
2026-07-20 10:10:51 +08:00
linfeng 4e0a675198 1.新增参赛队伍页面交互
2.新增人工处理功能交互
2026-07-20 09:29:53 +08:00
linfeng a4333d3bd1 解决华为 webview Chrome 78 内核版本渲染白屏问题 2026-07-17 15:22:57 +08:00
linfeng e08a8ed26b 重构事件信息页面,移除不必要的加载状态和错误消息字段,简化状态管理逻辑;在扫码页面中增强选手信息查询功能,添加加载提示和错误处理。 2026-07-17 15:17:08 +08:00
linfeng 62d717c3ee 更新build-apk-split.sh脚本以添加错误处理和构建完成时间输出;在ScanQrCodePage中增强玩家ID验证,添加无效凭证提示。 2026-07-17 12:19:57 +08:00
linfeng 1f9c5af51b 重构事件注册模型并更新应用配置。更改不同环境的基本url,增强事件注册项结构,并改进ScanQrCodePage中的导航。在AuthViewModel中添加clearAuth方法,以便更好地进行状态管理。 2026-07-17 11:55:13 +08:00
linfeng 831ca1059b Merge branch 'linfeng/drone_video/dev_1.1.0/20260707' into linfeng/drone_scoring/dev_1.1.0/2026630 2026-07-16 11:59:22 +08:00
linfeng 613bd90bad update 2026-07-16 11:58:58 +08:00
linfeng 9818a56fe1 删除单例测试文件 2026-07-16 11:58:43 +08:00
linfeng 16ad7b253c 添加 jwt_decoder 依赖;更新 AuthApi 枚举以支持获取录像列表;新增录像响应模型和相关状态管理;重构 AuthViewModel 以解析和存储 JWT 数据;更新 ScanQrCodePage 以处理用户鉴权和获取录像列表。 2026-07-14 10:16:31 +08:00
linfeng 45000b4188 update 2026-07-14 09:10:27 +08:00
linfeng fab49a9750 通过在扫描仪对话框中启用自动缩放和改进错误处理来重构QR码扫描功能。更新ScanQrCodePage以利用AppQrScannerDialog获取玩家ID。 2026-07-09 17:56:17 +08:00
linfeng 1c909277ff 更新 devtools_options.yaml 以启用 riverpod 和 shared_preferences 扩展;更新 AuthApi 枚举以添加 playerRegistrationList API;重构 Auth 页面以在用户已登录时直接导航到扫码页面;重构 EventInfoPage 以使用新的状态管理和加载赛事报名信息;更新扫码页面以导航到 EventInfoPage。 2026-07-09 17:38:56 +08:00
linfeng 6138eddc16 删除未使用的相机和记录类来重构记录功能;更新pubspec中的依赖项。Yaml为本地插件路径;简化MainActivity和相关类,以提高性能和可维护性。 2026-07-09 16:17:34 +08:00
linfeng 75c42ea168 新增赛事信息页 2026-07-09 14:57:09 +08:00
linfeng c38f6d69c8 更新 AuthApi 枚举以添加获取推流地址的 API 路径;重构 Auth 页面以优化输入控件和按钮布局;更新扫码页面以导航到录制页面。 2026-07-09 14:32:39 +08:00
linfeng 9766c43722 更新 .gitignore 文件以排除 AGENTS.md 和 test/ 目录;在 AGENTS.md 中添加页面级 Feature 目录规范;删除不再使用的 dialog-record.dart 文件,并更新相关引用路径。 2026-07-09 14:01:37 +08:00
linfeng a1f6ea14cf 添加设备代码信息到 Android 和 iOS 平台信息;更新 Auth 页面以使用新的 TextEditingController 管理输入;重构推流测试页面以支持动态 RTMP URL 解析;删除不再需要的测试文件。 2026-07-09 12:26:41 +08:00
linfeng 98a3381716 更新 AndroidManifest.xml 以启用明文流量;在 index.html 中替换 token 值
增强 WebviewPage 的清理和导航功能。
2026-07-09 10:10:53 +08:00
linfeng bd5937f910 添加 HTML 资源路径到 pubspec.yaml;更新 Auth 页面以使用新的 Webview 组件加载本地 HTML 文件;重构 WebviewPage 以支持加载本地资产和注入 JavaScript 通信桥接;清理不必要的导入和注释。 2026-07-08 12:03:25 +08:00
linfeng b21857f364 添加 apivideo_live_stream 依赖;
修改 Auth 页面文本为“输入执裁口令”;
添加公用 AppBar 组件
模拟核心流程交互
2026-07-08 09:37:38 +08:00
linfeng f6c477c9be 添加webview_flutter及相关依赖
添加公共的 webview 组件
2026-07-07 17:40:24 +08:00
linfeng 1246e41e4b 添加mobile_scanner包,
添加AuthPageWidget
添加全局扫码组件
2026-07-07 17:16:51 +08:00
linfeng 26574808df Merge branch 'linfeng/run/dev/20260630' into linfeng/drone/dev/2026630 2026-07-01 12:18:58 +08:00
linfeng d4c7d7180e 删除AndroidManifest.xml中的READ_MEDIA_VIDEO权限,并更新view_model_recording中的权限逻辑。dart确保文件保存权限仅适用于Android SDK版本28及以下。添加测试以验证Android 13和未知SDK版本的权限行为。 2026-07-01 12:18:12 +08:00
linfeng 4a85b9f83e Merge run recording updates into drone branch 2026-07-01 11:54:06 +08:00
linfeng e4057c3d6a 添加 CLAUDE.md 到 .gitignore 文件 2026-07-01 11:32:22 +08:00
linfeng 90e8966528 重构通道名称,在Android和iOS实现中使用统一的命名空间‘app.record_tool’。相应地更新相关的常量和方法通道。增强在录制HUD中的缩放比例显示格式。 2026-07-01 11:19:30 +08:00
linfeng c8a7494344 1.广角/主摄切换按钮已上移:
2.新增 isSwitchingLens 状态、避免切换镜头    stop 按钮禁用,不会触发 stopRecording
倍距按钮禁用,避免连续切换
ViewModel 层也兜底:isSwitchingLens=true 时直接拒绝 stop
3.合并失败兜底提示
2026-07-01 10:59:28 +08:00
linfeng 02614c2817 更新 .gitignore 文件以排除 CLAUDE.md,并注释掉录制头部小部件中的 mock 按钮 2026-07-01 10:38:50 +08:00
linfeng 9f49f73a31 删除 agent 说明书 2026-07-01 10:37:09 +08:00
linfeng da2e69b014 支持录制中切换广角/主摄 2026-07-01 09:59:21 +08:00
linfeng f6347e99cd 兼容 IOS 2026-06-30 17:28:03 +08:00
linfeng d40c13d802 兼容 IOS 2026-06-30 17:27:54 +08:00
linfeng 23319cfddb 修复部分机型无法切换广角和主摄功能 2026-06-30 17:06:35 +08:00
linfeng 6d93c1c8dd 解决安卓端广角无法读取问题 2026-06-30 16:30:24 +08:00
linfeng c9323f26a7 启动图修改 2026-06-30 14:19:02 +08:00
linfeng 7dafd61314 修改图标 2026-06-30 13:57:18 +08:00
linfeng d056d28d83 1.更换包名
2.更换 APP 名字
2026-06-30 13:38:16 +08:00
linfeng 8570486798 兼容 IOS 2026-06-13 19:00:37 +08:00
linfeng 208920dfea Merge branch 'linfeng/dev/20260603' into linfeng/dev/2026612 2026-06-13 16:09:20 +08:00
linfeng 88d8dfda04 更新超广角相机的变焦比例,确保在相机能力允许的情况下使用0.6x的缩放比例,优化相关UI和测试用例。 2026-06-12 19:04:00 +08:00
linfeng d39d85cd99 增强变焦功能 2026-06-12 18:35:18 +08:00
linfeng c01ce1dca0 Merge branch 'linfeng/dev/compatibility/20260609' into linfeng/dev/2026612 2026-06-12 17:10:24 +08:00
linfeng 25ac9c4c35 关闭测试按钮 2026-06-12 16:39:41 +08:00
linfeng a3a02e623f 实现缩放功能:增加缩放功能检索和设置方法,更新UI以支持缩放调整,增强缩放比例的状态管理。 2026-06-12 16:38:31 +08:00
linfeng 7a654d54f0 新增 一键清除项目缓存 sh 工具 2026-06-09 15:28:39 +08:00
linfeng de2aacca90 兼容 IOS 2026-06-09 12:29:27 +08:00
linfeng cf1c2d7d0e 完成 IOS 端启动页 2026-06-09 10:46:01 +08:00
linfeng 13cb3bfd7b 更换包名:com.dronex.rec 2026-06-09 10:25:34 +08:00
linfeng bcd2162cd7 兼容 IOS 端 ,IOS 端包名修改为 com.dronex.dronex 2026-06-09 09:18:25 +08:00
linfeng 41fcd730f0 粘贴删除按钮不参与过渡 2026-06-08 15:51:05 +08:00
linfeng 7ab03dd912 重构录制页面,优化 UI 组件,简化状态管理,移除不必要的参数,提升代码可读性和维护性。 2026-06-08 11:23:45 +08:00
linfeng 29cfbdf8c4 录制按钮增加动画 2026-06-08 11:21:13 +08:00
linfeng 7031765b4d 优化录制页面功能,修正剪贴板信息提示,新增停止录制后的结果提示,改进触摸锁定解锁逻辑,提升用户交互体验。 2026-06-08 11:10:22 +08:00
linfeng 942d15e54c 优化交互体验增加动画效果 2026-06-08 10:58:10 +08:00
linfeng 6b168ccd62 1.UI 优化
2.新增打包构建脚本
2026-06-08 10:19:19 +08:00
linfeng 551d10dec4 兼容 IOS 端 2026-06-08 08:53:49 +08:00
linfeng e1446337e9 优化了 启动录制效果 2026-06-05 18:52:47 +08:00
linfeng 26098114d2 更换 录制按钮 UI 2026-06-05 18:29:49 +08:00
linfeng 1e08b70c39 更换 APP 图标 2026-06-05 18:08:25 +08:00
linfeng e821bd68a7 更换 APP 图标 2026-06-05 18:08:19 +08:00
linfeng 9c21915bf7 取消 mock 按钮 2026-06-05 17:59:24 +08:00
linfeng 1221b16c7f 优化录制页面的相机预览逻辑,增加预览未就绪时的错误提示,确保用户体验更流畅。 2026-06-05 16:26:37 +08:00
linfeng 54738d53f9 用户开始录制,拒绝录音录像权限,增加弹窗引导用户前往系统页设置 2026-06-05 16:11:54 +08:00
linfeng 4d83f38960 状态管理 函数增加注释 2026-06-05 16:02:08 +08:00
linfeng 36da37c6c0 调整对话框背景图适应方式,简化赛事信息复制按钮标签 2026-06-05 16:00:53 +08:00
linfeng 0183bd9a6d 重构录制页面,更新对话框逻辑,优化赛事信息粘贴功能,调整相关文本标签。 2026-06-05 15:46:16 +08:00
linfeng 016aad49b7 解决录制结束后,无法重新预览相机问题 2026-06-05 15:10:03 +08:00
linfeng a39fcdb929 更新录制会话的时间格式,添加小时显示;在录制页面中引入计时器组件,并调整触摸锁定覆盖层的样式。 2026-06-05 15:03:53 +08:00
linfeng 0a2cfe27ac 打开防误触模式逻辑 2026-06-05 14:41:12 +08:00
linfeng d598b36449 优化录制、停止录制逻辑 2026-06-05 14:30:56 +08:00
linfeng c0aa2db6db 1.还原 UI 稿,地址和时间与录制按钮排版优化 2026-06-05 14:09:25 +08:00
linfeng 0d06975313 重构录制页面,优化HUD布局,添加头部和底部组件,移除触摸锁定功能,简化事件信息处理。 2026-06-05 14:02:01 +08:00
linfeng f6440ea8b7 规范化代码结构 2026-06-05 12:07:29 +08:00
linfeng 1e936bfc12 更新pubspec。修改启动屏幕的启动后台XML,并通过删除过时的文件和增强会话管理来重构记录功能。 2026-06-05 11:44:51 +08:00
linfeng 4c5bf22638 更新 gitignore 2026-06-05 11:07:48 +08:00
linfeng e387dfad0a 删除无用文件 2026-06-05 10:13:24 +08:00
linfeng 846c6a8edb Stop tracking pubspec.lock 2026-06-05 10:07:50 +08:00
linfeng f49d208042 1.开始录制、结束录制增加
2. 增加电量检测、内存检查,是否低于 10%
2026-06-04 18:25:58 +08:00
linfeng 124b4c1882 新增删除剪切板内容功能 2026-06-04 17:55:18 +08:00
linfeng 7c342c4477 重构ClipboardRecordingModel以支持可选的时间戳,并更新相关测试以改进JSON解析和验证。 2026-06-04 17:32:54 +08:00
linfeng dfbdbbdb66 开始录制增加 剪切板参数校验 2026-06-04 17:20:59 +08:00
linfeng 1b404525d2 1.更换包名
2.调整录制页地址下方时间为当前时间读秒
2026-06-04 16:58:34 +08:00
linfeng 77d9c35592 1.确定 APP 包名
2.录制结束增加弹窗提示
3.完成读取剪切板内容、新增粘贴剪切板内容
2026-06-04 16:25:26 +08:00
linfeng 5ddcb95358 屏幕适配 2026-06-04 14:34:46 +08:00
linfeng 02c1c87b46 1. 升级依赖版本
2. 解决运行项目警告日志问题
3. 优化代码
2026-06-04 13:55:33 +08:00
449 changed files with 106041 additions and 3887 deletions
+9 -1
View File
@@ -12,12 +12,14 @@
.swiftpm/
migrate_working_dir/
.vscode
pubspec.lock
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
.cursor
Podfile.lock
# 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 +46,9 @@ app.*.map.json
/android/app/debug
/android/app/profile
/android/app/release
/android/.kotlin
CLAUDE.md
AGENTS.md
test/
script
+8 -13
View File
@@ -1,11 +1,10 @@
plugins {
id("com.android.application")
id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
// The Flutter Gradle Plugin must be applied after the Android Gradle plugin.
id("dev.flutter.flutter-gradle-plugin")
}
val appPackageName = "com.gdfw.fxjk"
val appPackageName = "com.dronex.rec"
android {
namespace = appPackageName
@@ -17,10 +16,6 @@ android {
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString()
}
defaultConfig {
applicationId = appPackageName
// You can update the following values to match your application needs.
@@ -40,13 +35,13 @@ android {
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11
}
}
dependencies {
val cameraxVersion = "1.4.1"
implementation("androidx.camera:camera-core:$cameraxVersion")
implementation("androidx.camera:camera-camera2:$cameraxVersion")
implementation("androidx.camera:camera-lifecycle:$cameraxVersion")
implementation("androidx.camera:camera-video:$cameraxVersion")
implementation("androidx.camera:camera-view:$cameraxVersion")
implementation("androidx.lifecycle:lifecycle-service:2.8.7")
implementation("androidx.core:core-ktx:1.15.0")
}
@@ -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>
+12 -11
View File
@@ -1,5 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.gdfw.fxjk">
package="com.dronex.rec">
<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" />
@@ -9,7 +10,6 @@
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
@@ -19,9 +19,10 @@
android:required="true" />
<application
android:label="飞行极控"
android:label="SportsX裁判工作台"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -32,12 +33,12 @@
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
@@ -52,8 +53,8 @@
</application>
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>
@@ -0,0 +1,10 @@
package com.dronex.rec
object AppConstants {
private const val CHANNEL_NAMESPACE = "app.record_tool"
const val PLATFORM_INFO_CHANNEL = "$CHANNEL_NAMESPACE/platform_info"
const val RECORDING_METHOD_CHANNEL = "$CHANNEL_NAMESPACE/recording"
const val RECORDING_EVENT_CHANNEL = "$CHANNEL_NAMESPACE/recording_events"
const val RECORDING_ACTION_START = "$CHANNEL_NAMESPACE.recording.START"
const val RECORDING_ACTION_STOP = "$CHANNEL_NAMESPACE.recording.STOP"
}
@@ -0,0 +1,129 @@
package com.dronex.rec
import android.content.Context
import android.content.pm.ApplicationInfo
import android.os.BatteryManager
import android.os.Build
import android.os.Environment
import android.os.StatFs
import android.provider.Settings
import com.dronex.rec.recording.RecordingPlatformHandler
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
private var platformHandler: RecordingPlatformHandler? = null
private var platformInfoChannel: MethodChannel? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
platformInfoChannel =
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
AppConstants.PLATFORM_INFO_CHANNEL,
)
.also { channel ->
channel.setMethodCallHandler { call, result ->
when (call.method) {
"packageInfo" -> result.success(packageInfoMap())
"deviceInfo" -> result.success(deviceInfoMap())
"deviceHealth" -> result.success(deviceHealthMap())
else -> result.notImplemented()
}
}
}
platformHandler =
RecordingPlatformHandler(
this,
flutterEngine.dartExecutor.binaryMessenger,
)
}
override fun onDestroy() {
platformInfoChannel?.setMethodCallHandler(null)
platformInfoChannel = null
platformHandler?.dispose()
platformHandler = null
super.onDestroy()
}
private fun packageInfoMap(): Map<String, String> {
val packageInfo =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackageInfo(
packageName,
android.content.pm.PackageManager.PackageInfoFlags.of(0),
)
} else {
@Suppress("DEPRECATION") packageManager.getPackageInfo(packageName, 0)
}
val appName = applicationInfo.loadLabel(packageManager)?.toString().orEmpty()
val versionCode =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageInfo.longVersionCode.toString()
} else {
@Suppress("DEPRECATION") packageInfo.versionCode.toString()
}
return mapOf(
"appName" to appName,
"packageName" to packageName,
"version" to packageInfo.versionName.orEmpty(),
"buildNumber" to versionCode,
)
}
private fun deviceInfoMap(): Map<String, Any> {
val flags = applicationInfo.flags
val isEmulator =
Build.FINGERPRINT.startsWith("generic") ||
Build.FINGERPRINT.startsWith("unknown") ||
Build.MODEL.contains("google_sdk") ||
Build.MODEL.contains("Emulator") ||
Build.MODEL.contains("Android SDK built for x86") ||
Build.MANUFACTURER.contains("Genymotion") ||
Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic") ||
Build.PRODUCT == "google_sdk" ||
flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 &&
Build.HARDWARE.contains("ranchu")
return mapOf(
"platform" to "android",
"deviceCode" to Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID).orEmpty(),
"brand" to Build.BRAND,
"model" to Build.MODEL,
"systemVersion" to Build.VERSION.RELEASE,
"sdkInt" to Build.VERSION.SDK_INT,
"isPhysicalDevice" to !isEmulator,
)
}
private fun deviceHealthMap(): Map<String, Any?> {
val batteryLevelPercent = readBatteryLevelPercent()
val storageAvailablePercent = readStorageAvailablePercent()
return mapOf(
"batteryLevelPercent" to batteryLevelPercent,
"storageAvailablePercent" to storageAvailablePercent,
)
}
private fun readBatteryLevelPercent(): Int? {
val batteryManager = getSystemService(Context.BATTERY_SERVICE) as? BatteryManager
?: return null
val level =
batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
return if (level in 0..100) level else null
}
private fun readStorageAvailablePercent(): Double {
val stat = StatFs(Environment.getDataDirectory().path)
val totalBytes = stat.totalBytes
if (totalBytes <= 0L) return 100.0
val availableBytes = stat.availableBytes
return availableBytes.toDouble() / totalBytes.toDouble() * 100.0
}
}
@@ -1,4 +1,4 @@
package com.gdfw.fxjk.recording
package com.dronex.rec.recording
import android.content.Context
import android.content.Intent
@@ -18,10 +18,10 @@ object BatteryOptimizationHelper {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return
val intent =
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
if (intent.resolveActivity(context.packageManager) != null) {
context.startActivity(intent)
@@ -29,9 +29,9 @@ object BatteryOptimizationHelper {
}
val fallback =
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(fallback)
}
}
@@ -1,9 +1,8 @@
package com.gdfw.fxjk.recording
package com.dronex.rec.recording
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.core.app.NotificationManagerCompat
@@ -16,9 +15,10 @@ object DoNotDisturbHelper {
}
fun openAccessSettings(context: Context) {
val intent = Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
val intent =
Intent(Settings.ACTION_NOTIFICATION_POLICY_ACCESS_SETTINGS).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
@@ -1,22 +1,21 @@
package com.gdfw.fxjk.recording
package com.dronex.rec.recording
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Build
import androidx.core.content.ContextCompat
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleService
import com.gdfw.fxjk.AppConstants
import com.gdfw.fxjk.MainActivity
import com.dronex.rec.AppConstants
import com.dronex.rec.MainActivity
class RecordingForegroundService : LifecycleService() {
private var wakeLock: PowerManager.WakeLock? = null
@@ -35,9 +34,9 @@ class RecordingForegroundService : LifecycleService() {
val notification = buildNotification("正在录制")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
foregroundServiceTypes(),
NOTIFICATION_ID,
notification,
foregroundServiceTypes(),
)
} else {
startForeground(NOTIFICATION_ID, notification)
@@ -72,10 +71,10 @@ class RecordingForegroundService : LifecycleService() {
if (wakeLock?.isHeld == true) return
val manager = getSystemService(PowerManager::class.java) ?: return
wakeLock =
manager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
setReferenceCounted(false)
acquire(4 * 60 * 60 * 1000L)
}
manager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, WAKE_LOCK_TAG).apply {
setReferenceCounted(false)
acquire(4 * 60 * 60 * 1000L)
}
}
private fun releaseWakeLock() {
@@ -90,14 +89,15 @@ class RecordingForegroundService : LifecycleService() {
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel =
NotificationChannel(
CHANNEL_ID,
"录制服务",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "保持相机录制在后台与息屏时继续运行"
setShowBadge(false)
}
NotificationChannel(
CHANNEL_ID,
"录制服务",
NotificationManager.IMPORTANCE_LOW,
)
.apply {
description = "保持相机录制在后台与息屏时继续运行"
setShowBadge(false)
}
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(channel)
}
@@ -112,33 +112,33 @@ class RecordingForegroundService : LifecycleService() {
private fun hasRecordAudioPermission(): Boolean {
return ContextCompat.checkSelfPermission(
this,
android.Manifest.permission.RECORD_AUDIO,
this,
android.Manifest.permission.RECORD_AUDIO,
) == PackageManager.PERMISSION_GRANTED
}
private fun buildNotification(content: String): Notification {
val launchIntent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pendingIntent =
PendingIntent.getActivity(
this,
0,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
PendingIntent.getActivity(
this,
0,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("录制进行中")
.setContentText(content)
.setSmallIcon(android.R.drawable.presence_video_online)
.setOngoing(true)
.setContentIntent(pendingIntent)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
.setContentTitle("录制进行中")
.setContentText(content)
.setSmallIcon(android.R.drawable.presence_video_online)
.setOngoing(true)
.setContentIntent(pendingIntent)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
companion object {
@@ -146,25 +146,23 @@ class RecordingForegroundService : LifecycleService() {
const val NOTIFICATION_ID = 1001
private const val WAKE_LOCK_TAG = "record_tool:recording_wake_lock"
@Volatile
var isRunning: Boolean = false
@Volatile var isRunning: Boolean = false
@Volatile
var instance: RecordingForegroundService? = null
@Volatile var instance: RecordingForegroundService? = null
fun start(context: Context) {
val intent =
Intent(context, RecordingForegroundService::class.java).apply {
action = AppConstants.RECORDING_ACTION_START
}
Intent(context, RecordingForegroundService::class.java).apply {
action = AppConstants.RECORDING_ACTION_START
}
ContextCompatStart.startForegroundService(context, intent)
}
fun stop(context: Context) {
val intent =
Intent(context, RecordingForegroundService::class.java).apply {
action = AppConstants.RECORDING_ACTION_STOP
}
Intent(context, RecordingForegroundService::class.java).apply {
action = AppConstants.RECORDING_ACTION_STOP
}
context.startService(intent)
}
}
@@ -0,0 +1,66 @@
package com.dronex.rec.recording
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.dronex.rec.AppConstants
import com.dronex.rec.MainActivity
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class RecordingPlatformHandler(
private val activity: MainActivity,
messenger: BinaryMessenger,
) : MethodChannel.MethodCallHandler {
private val methodChannel = MethodChannel(messenger, AppConstants.RECORDING_METHOD_CHANNEL)
init {
methodChannel.setMethodCallHandler(this)
}
fun dispose() {
methodChannel.setMethodCallHandler(null)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"hasNotificationPolicyAccess" -> result.success(DoNotDisturbHelper.hasAccess(activity))
"openNotificationPolicySettings" -> {
DoNotDisturbHelper.openAccessSettings(activity)
result.success(null)
}
"enableDoNotDisturb" -> result.success(DoNotDisturbHelper.enable(activity))
"disableDoNotDisturb" -> {
DoNotDisturbHelper.disable(activity)
result.success(null)
}
"isIgnoringBatteryOptimizations" ->
result.success(BatteryOptimizationHelper.isIgnoringOptimizations(activity))
"openBatteryOptimizationSettings" -> {
BatteryOptimizationHelper.openSettings(activity)
result.success(null)
}
"setImmersiveMode" -> {
val enabled = call.argument<Boolean>("enabled") ?: false
setImmersiveMode(enabled)
result.success(null)
}
"isForegroundServiceRunning" -> result.success(RecordingForegroundService.isRunning)
else -> result.notImplemented()
}
}
private fun setImmersiveMode(enabled: Boolean) {
val window = activity.window
WindowCompat.setDecorFitsSystemWindows(window, !enabled)
val insetsController = WindowInsetsControllerCompat(window, window.decorView)
if (enabled) {
insetsController.hide(WindowInsetsCompat.Type.systemBars())
insetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
} else {
insetsController.show(WindowInsetsCompat.Type.systemBars())
}
}
}
@@ -1,9 +0,0 @@
package com.gdfw.fxjk
object AppConstants {
const val PACKAGE_NAME = "com.gdfw.fxjk"
const val RECORDING_METHOD_CHANNEL = "$PACKAGE_NAME/recording"
const val RECORDING_EVENT_CHANNEL = "$PACKAGE_NAME/recording_events"
const val RECORDING_ACTION_START = "$PACKAGE_NAME.recording.START"
const val RECORDING_ACTION_STOP = "$PACKAGE_NAME.recording.STOP"
}
@@ -1,47 +0,0 @@
package com.gdfw.fxjk
import androidx.camera.view.PreviewView
import com.gdfw.fxjk.recording.RecordingPlatformHandler
import com.gdfw.fxjk.recording.RecordingPreviewFactory
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterActivity() {
private var platformHandler: RecordingPlatformHandler? = null
var recordingPreviewView: PreviewView? = null
private set
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine
.platformViewsController
.registry
.registerViewFactory(
"recording-camera-preview",
RecordingPreviewFactory(this),
)
platformHandler =
RecordingPlatformHandler(
this,
flutterEngine.dartExecutor.binaryMessenger,
)
}
fun attachRecordingPreview(previewView: PreviewView) {
recordingPreviewView = previewView
}
fun detachRecordingPreview(previewView: PreviewView? = null) {
if (previewView == null || recordingPreviewView === previewView) {
recordingPreviewView = null
}
}
override fun onDestroy() {
platformHandler?.dispose()
platformHandler = null
super.onDestroy()
}
}
@@ -1,236 +0,0 @@
package com.gdfw.fxjk.recording
import android.content.Context
import android.util.Log
import androidx.camera.core.CameraSelector
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector
import androidx.camera.video.Recorder
import androidx.camera.video.Recording
import androidx.camera.video.VideoCapture
import androidx.camera.video.VideoRecordEvent
import androidx.camera.view.PreviewView
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleOwner
import java.util.concurrent.Executor
class RecordingCameraController(
private val appContext: Context,
) {
private val mainExecutor: Executor = ContextCompat.getMainExecutor(appContext)
private var cameraProvider: ProcessCameraProvider? = null
private var preview: Preview? = null
private var videoCapture: VideoCapture<Recorder>? = null
private var activeRecording: Recording? = null
private var boundLifecycleOwner: LifecycleOwner? = null
var status: RecordingStatus = RecordingStatus(RecordingState.IDLE)
private set
var statusListener: ((RecordingStatus) -> Unit)? = null
private var recordingStartedAt: Long = 0L
private var latestOutputPath: String? = null
private var pendingStopCallback: ((String?) -> Unit)? = null
fun bindPreview(
lifecycleOwner: LifecycleOwner,
previewView: PreviewView,
onReady: (Boolean) -> Unit,
) {
val future = ProcessCameraProvider.getInstance(appContext)
future.addListener(
{
try {
val provider = future.get()
cameraProvider = provider
boundLifecycleOwner = lifecycleOwner
preview =
Preview.Builder().build().also {
it.surfaceProvider = previewView.surfaceProvider
}
val recorder =
Recorder.Builder()
.setQualitySelector(QualitySelector.from(Quality.HD))
.build()
videoCapture = VideoCapture.withOutput(recorder)
provider.unbindAll()
provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
videoCapture,
)
updateStatus(RecordingStatus(RecordingState.PREVIEWING))
onReady(true)
} catch (error: Exception) {
Log.e(TAG, "bindPreview failed", error)
updateStatus(
RecordingStatus(
RecordingState.ERROR,
message = error.message,
),
)
onReady(false)
}
},
mainExecutor,
)
}
fun rebindForRecording(
lifecycleOwner: LifecycleOwner,
previewView: PreviewView,
onReady: (Boolean) -> Unit,
) {
val provider = cameraProvider
if (provider == null) {
bindPreview(lifecycleOwner, previewView, onReady)
return
}
try {
boundLifecycleOwner = lifecycleOwner
provider.unbindAll()
provider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
videoCapture,
)
onReady(true)
} catch (error: Exception) {
Log.e(TAG, "rebindForRecording failed", error)
onReady(false)
}
}
fun startRecording(
withAudio: Boolean,
displayName: String?,
onStarted: (Boolean, String?) -> Unit,
) {
val capture = videoCapture
if (capture == null || boundLifecycleOwner == null) {
onStarted(false, "Camera not ready")
return
}
if (activeRecording != null) {
onStarted(false, "Already recording")
return
}
val outputOptions =
RecordingOutputFactory.buildMediaStoreOutputOptions(
appContext,
displayName,
)
latestOutputPath = null
val pending =
capture.output.prepareRecording(appContext, outputOptions).apply {
if (withAudio) {
val granted =
ContextCompat.checkSelfPermission(
appContext,
android.Manifest.permission.RECORD_AUDIO,
) == android.content.pm.PackageManager.PERMISSION_GRANTED
if (granted) {
withAudioEnabled()
}
}
}
recordingStartedAt = System.currentTimeMillis()
updateStatus(
RecordingStatus(
RecordingState.RECORDING,
outputPath = latestOutputPath,
),
)
activeRecording =
pending.start(mainExecutor) { event ->
when (event) {
is VideoRecordEvent.Start -> Unit
is VideoRecordEvent.Finalize -> {
activeRecording = null
if (event.hasError()) {
updateStatus(
RecordingStatus(
RecordingState.ERROR,
message = event.cause?.message ?: "Recording failed",
),
)
} else {
latestOutputPath = event.outputResults.outputUri.toString()
updateStatus(
RecordingStatus(
RecordingState.PREVIEWING,
outputPath = latestOutputPath,
elapsedMillis = System.currentTimeMillis() - recordingStartedAt,
),
)
}
val stopCallback = pendingStopCallback
pendingStopCallback = null
stopCallback?.invoke(latestOutputPath)
}
}
}
onStarted(true, latestOutputPath ?: "recording")
}
fun stopRecording(onStopped: (String?) -> Unit) {
val recording = activeRecording
if (recording == null) {
onStopped(latestOutputPath)
return
}
pendingStopCallback = onStopped
updateStatus(
RecordingStatus(
RecordingState.STOPPING,
outputPath = latestOutputPath,
),
)
recording.stop()
activeRecording = null
}
fun unbind() {
activeRecording?.stop()
activeRecording = null
cameraProvider?.unbindAll()
cameraProvider = null
preview = null
videoCapture = null
boundLifecycleOwner = null
updateStatus(RecordingStatus(RecordingState.IDLE))
}
fun elapsedMillis(): Long {
if (status.state != RecordingState.RECORDING) return 0L
return System.currentTimeMillis() - recordingStartedAt
}
private fun updateStatus(next: RecordingStatus) {
status = next
statusListener?.invoke(next)
}
companion object {
private const val TAG = "RecordingCamera"
}
}
@@ -1,51 +0,0 @@
package com.gdfw.fxjk.recording
import android.content.ContentValues
import android.content.Context
import android.os.Build
import android.provider.MediaStore
import androidx.camera.video.MediaStoreOutputOptions
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object RecordingOutputFactory {
private const val RELATIVE_PATH = "Movies/飞行极控"
private const val MIME_TYPE = "video/mp4"
fun buildMediaStoreOutputOptions(
context: Context,
displayName: String?,
): MediaStoreOutputOptions {
val fileName = resolveFileName(displayName)
val contentValues =
ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
put(MediaStore.MediaColumns.MIME_TYPE, MIME_TYPE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(MediaStore.Video.Media.RELATIVE_PATH, RELATIVE_PATH)
}
}
return MediaStoreOutputOptions.Builder(
context.contentResolver,
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
)
.setContentValues(contentValues)
.build()
}
fun resolveFileName(displayName: String?): String {
val trimmed = displayName?.trim().orEmpty()
if (trimmed.isNotEmpty()) {
return if (trimmed.lowercase(Locale.US).endsWith(".mp4")) {
trimmed
} else {
"$trimmed.mp4"
}
}
val timestamp =
SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date())
return "REC_$timestamp.mp4"
}
}
@@ -1,238 +0,0 @@
package com.gdfw.fxjk.recording
import android.app.Activity
import android.os.Build
import android.os.Handler
import android.os.Looper
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.gdfw.fxjk.AppConstants
import com.gdfw.fxjk.MainActivity
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class RecordingPlatformHandler(
private val activity: MainActivity,
messenger: BinaryMessenger,
) : MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
private val methodChannel =
MethodChannel(messenger, AppConstants.RECORDING_METHOD_CHANNEL)
private val eventChannel =
EventChannel(messenger, AppConstants.RECORDING_EVENT_CHANNEL)
private val mainHandler = Handler(Looper.getMainLooper())
private var eventSink: EventChannel.EventSink? = null
private var elapsedTicker: Runnable? = null
private val controller by lazy { RecordingSession.controller(activity.applicationContext) }
init {
methodChannel.setMethodCallHandler(this)
eventChannel.setStreamHandler(this)
controller.statusListener = { status ->
mainHandler.post {
eventSink?.success(status.toMap())
}
}
}
fun dispose() {
stopElapsedTicker()
methodChannel.setMethodCallHandler(null)
eventChannel.setStreamHandler(null)
controller.statusListener = null
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"initializePreview" -> initializePreview(result)
"startRecording" -> {
val withAudio = call.argument<Boolean>("withAudio") ?: true
val enableDnd = call.argument<Boolean>("enableDoNotDisturb") ?: true
val displayName = call.argument<String>("displayName")
startRecording(withAudio, enableDnd, displayName, result)
}
"stopRecording" -> stopRecording(result)
"disposePreview" -> {
controller.unbind()
result.success(null)
}
"hasNotificationPolicyAccess" ->
result.success(DoNotDisturbHelper.hasAccess(activity))
"openNotificationPolicySettings" -> {
DoNotDisturbHelper.openAccessSettings(activity)
result.success(null)
}
"enableDoNotDisturb" ->
result.success(DoNotDisturbHelper.enable(activity))
"disableDoNotDisturb" -> {
DoNotDisturbHelper.disable(activity)
result.success(null)
}
"isIgnoringBatteryOptimizations" ->
result.success(BatteryOptimizationHelper.isIgnoringOptimizations(activity))
"openBatteryOptimizationSettings" -> {
BatteryOptimizationHelper.openSettings(activity)
result.success(null)
}
"setImmersiveMode" -> {
val enabled = call.argument<Boolean>("enabled") ?: false
setImmersiveMode(enabled)
result.success(null)
}
"getStatus" -> result.success(controller.status.toMap())
"isForegroundServiceRunning" ->
result.success(RecordingForegroundService.isRunning)
else -> result.notImplemented()
}
}
private fun initializePreview(result: MethodChannel.Result) {
val previewView = activity.recordingPreviewView
if (previewView == null) {
result.error("NO_PREVIEW", "Camera preview is not attached", null)
return
}
controller.bindPreview(activity, previewView) { ready ->
mainHandler.post {
if (ready) {
result.success(controller.status.toMap())
} else {
result.error("PREVIEW_FAILED", "Failed to bind camera preview", null)
}
}
}
}
private fun startRecording(
withAudio: Boolean,
enableDnd: Boolean,
displayName: String?,
result: MethodChannel.Result,
) {
val previewView = activity.recordingPreviewView
if (previewView == null) {
result.error("NO_PREVIEW", "Camera preview is not attached", null)
return
}
RecordingSession.startForeground(activity)
fun beginCapture() {
if (enableDnd && DoNotDisturbHelper.hasAccess(activity)) {
DoNotDisturbHelper.enable(activity)
}
controller.startRecording(withAudio, displayName) { started, message ->
mainHandler.post {
if (started) {
startElapsedTicker()
result.success(
mapOf(
"outputPath" to message,
"status" to controller.status.toMap(),
),
)
} else {
RecordingSession.stopForeground(activity)
DoNotDisturbHelper.disable(activity)
result.error("START_FAILED", message, null)
}
}
}
}
fun rebindAndCapture() {
val lifecycleOwner =
RecordingForegroundService.instance ?: activity
controller.rebindForRecording(lifecycleOwner, previewView) { ready ->
if (ready) {
beginCapture()
} else {
RecordingSession.stopForeground(activity)
result.error("REBIND_FAILED", "Failed to bind camera for recording", null)
}
}
}
if (RecordingForegroundService.instance != null) {
rebindAndCapture()
} else {
mainHandler.post { rebindAndCapture() }
}
}
private fun stopRecording(result: MethodChannel.Result) {
stopElapsedTicker()
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)
}
}
}
private fun setImmersiveMode(enabled: Boolean) {
val window = activity.window
WindowCompat.setDecorFitsSystemWindows(window, !enabled)
val insetsController = WindowInsetsControllerCompat(window, window.decorView)
if (enabled) {
insetsController.hide(WindowInsetsCompat.Type.systemBars())
insetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
} else {
insetsController.show(WindowInsetsCompat.Type.systemBars())
}
}
private fun startElapsedTicker() {
stopElapsedTicker()
elapsedTicker =
object : Runnable {
override fun run() {
if (controller.status.state == RecordingState.RECORDING) {
eventSink?.success(
controller.status.copy(
elapsedMillis = controller.elapsedMillis(),
).toMap(),
)
mainHandler.postDelayed(this, 1000L)
}
}
}.also {
mainHandler.post(it)
}
}
private fun stopElapsedTicker() {
elapsedTicker?.let { mainHandler.removeCallbacks(it) }
elapsedTicker = null
}
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
events?.success(controller.status.toMap())
}
override fun onCancel(arguments: Any?) {
eventSink = null
stopElapsedTicker()
}
}
@@ -1,37 +0,0 @@
package com.gdfw.fxjk.recording
import android.content.Context
import android.view.View
import androidx.camera.view.PreviewView
import com.gdfw.fxjk.MainActivity
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory
class RecordingPreviewFactory(
private val activity: MainActivity,
) : PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context, viewId: Int, args: Any?): PlatformView {
return RecordingPreviewPlatformView(activity)
}
}
class RecordingPreviewPlatformView(
private val activity: MainActivity,
) : PlatformView {
val previewView: PreviewView =
PreviewView(activity).apply {
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
scaleType = PreviewView.ScaleType.FILL_CENTER
}
init {
activity.attachRecordingPreview(previewView)
}
override fun getView(): View = previewView
override fun dispose() {
activity.detachRecordingPreview(previewView)
}
}
@@ -1,30 +0,0 @@
package com.gdfw.fxjk.recording
import android.content.Context
import androidx.lifecycle.LifecycleService
object RecordingSession {
private var cameraController: RecordingCameraController? = null
fun controller(context: Context): RecordingCameraController {
return cameraController
?: RecordingCameraController(context.applicationContext).also {
cameraController = it
}
}
fun release() {
cameraController?.unbind()
cameraController = null
}
fun startForeground(context: Context) {
RecordingForegroundService.start(context)
}
fun stopForeground(context: Context) {
RecordingForegroundService.stop(context)
}
fun recordingLifecycleOwner(): LifecycleService? = RecordingForegroundService.instance
}
@@ -1,24 +0,0 @@
package com.gdfw.fxjk.recording
enum class RecordingState {
IDLE,
PREVIEWING,
RECORDING,
STOPPING,
ERROR,
}
data class RecordingStatus(
val state: RecordingState,
val outputPath: String? = null,
val elapsedMillis: Long = 0L,
val message: String? = null,
) {
fun toMap(): Map<String, Any?> =
mapOf(
"state" to state.name.lowercase(),
"outputPath" to outputPath,
"elapsedMillis" to elapsedMillis,
"message" to message,
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

@@ -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>
@@ -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: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

@@ -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>
File diff suppressed because one or more lines are too long
+47
View File
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
.aa {
margin: 100px;
width: 200px;
height: 100px;
border-radius: 20px;
}
</style>
</head>
<body>
<button class="aa" onclick="postMsgToAPP()">向 APP 发送数据</button>
</body>
<script>
(function () {
if (window.__appInstallData) {
return;
}
window.__appInstallData = true;
window.AppBridge.token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjgxNDI4NTczNTY2NjY4ODAwLCJkYXRhIjp7fSwic3ViIjoiODE0Mjg1NzM1NjY2Njg4MDAiLCJleHAiOjE3ODM5MzM1ODQsIm5iZiI6MTc4MzMyODc4NCwiaWF0IjoxNzgzMzI4Nzg0fQ.r1wYfvpV-5HIgwUuvq1Or3jPgjc3AhfJmoV1PNP23-Y';
///TODO 后续业务
})();
function postMsgToAPP() {
window.AppBridge.postMessage(JSON.stringify({ type: 'navigator_pop', value: null }))
console.log(window.AppBridge.token)
console.log(window.AppBridge.playId)
}
</script>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 633 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
set -e
flutter build apk --release --split-per-abi
# echo "构建完成时间: $(date '+%Y-%m-%d %H:%M:%S')"
pgyer upload build/app/outputs/flutter-apk/app-arm64-v8a-release.apk --build-update-description "主裁判 APP $(date '+%Y-%m-%d %H:%M:%S')"
+1
View File
@@ -0,0 +1 @@
flutter build apk --release
+19
View File
@@ -0,0 +1,19 @@
{
"name": "xcode build server",
"version": "1.3.0",
"bspVersion": "2.2.0",
"languages": [
"c",
"cpp",
"objective-c",
"objective-cpp",
"swift"
],
"argv": [
"/opt/homebrew/bin/xcode-build-server"
],
"workspace": "/Users/ZhuanZ/Documents/gdfw/record-tool/ios/Runner.xcworkspace",
"build_root": "/Users/ZhuanZ/Library/Developer/Xcode/DerivedData/Runner-ckjfuyjdkgumnpbnnftroxddsppq",
"scheme": "Runner",
"kind": "xcode"
}
+7
View File
@@ -0,0 +1,7 @@
flutter clean
flutter pub get
rm -rf ios/Pods
rm -rf ios/Podfile.lock
cd ios
pod install
cd ..
+5
View File
@@ -0,0 +1,5 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
- riverpod: true
- shared_preferences: true
+1
View File
@@ -1,2 +1,3 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}"
+1
View File
@@ -1,2 +1,3 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}"
+27 -2
View File
@@ -45,9 +45,34 @@ post_install do |installer|
'$(inherited)',
'PERMISSION_CAMERA=1',
'PERMISSION_MICROPHONE=1',
'PERMISSION_PHOTOS=1',
'PERMISSION_PHOTOS_ADD_ONLY=1',
]
end
end
pods_runner_dir = File.join(
installer.sandbox.root,
'Target Support Files',
'Pods-Runner'
)
Dir.glob(File.join(pods_runner_dir, 'Pods-Runner.*.xcconfig')).each do |config_path|
config = File.read(config_path)
config.gsub!(
'FRAMEWORK_SEARCH_PATHS = $(inherited)',
'FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}"'
)
File.write(config_path, config)
end
Dir.glob(File.join(pods_runner_dir, 'Pods-Runner-frameworks-*input-files.xcfilelist')).each do |file_list_path|
file_list = File.read(file_list_path)
file_list.gsub!('${BUILT_PRODUCTS_DIR}/', '${PODS_CONFIGURATION_BUILD_DIR}/')
File.write(file_list_path, file_list)
end
frameworks_script = File.join(pods_runner_dir, 'Pods-Runner-frameworks.sh')
if File.exist?(frameworks_script)
script = File.read(frameworks_script)
script.gsub!('${BUILT_PRODUCTS_DIR}/', '${PODS_CONFIGURATION_BUILD_DIR}/')
File.write(frameworks_script, script)
end
end
+83 -4
View File
@@ -1,22 +1,101 @@
PODS:
- Flutter (1.0.0)
- permission_handler_apple (9.3.0):
- apivideo_live_stream (0.0.1):
- ApiVideoLiveStream (= 1.4.1)
- Flutter
- ApiVideoLiveStream (1.4.1):
- HaishinKit (= 1.7.3)
- connectivity_plus (0.0.1):
- Flutter
- device_info_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
- HaishinKit (1.7.3):
- Logboard (~> 2.4.1)
- Logboard (2.4.2)
- mobile_scanner (7.0.0):
- Flutter
- FlutterMacOS
- native_device_orientation (0.0.1):
- Flutter
- permission_handler_apple (9.4.8):
- Flutter
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
- webview_flutter_wkwebview (0.0.1):
- Flutter
- FlutterMacOS
DEPENDENCIES:
- apivideo_live_stream (from `.symlinks/plugins/apivideo_live_stream/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`)
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`)
- native_device_orientation (from `.symlinks/plugins/native_device_orientation/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- share_plus (from `.symlinks/plugins/share_plus/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`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
SPEC REPOS:
trunk:
- ApiVideoLiveStream
- HaishinKit
- Logboard
EXTERNAL SOURCES:
apivideo_live_stream:
:path: ".symlinks/plugins/apivideo_live_stream/ios"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
Flutter:
:path: Flutter
mobile_scanner:
:path: ".symlinks/plugins/mobile_scanner/darwin"
native_device_orientation:
:path: ".symlinks/plugins/native_device_orientation/ios"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/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"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS:
apivideo_live_stream: caab45dd35fb3b140d423c099d0f6672378e7abe
ApiVideoLiveStream: 8f9dce7f6d15d5e4bb3c7a25e406bf2a36337a5a
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
HaishinKit: 326e27c4d06427ba53bffc68e516a92033293051
Logboard: 759d82599c439945d430d5a0958455b5a1974a0c
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
native_device_orientation: d6a4dc6887cd8a5ce1049962367aec60139ea0f1
permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
PODFILE CHECKSUM: 6f58d96c1fb09d1d57dbcbf38a5d856d7302054a
PODFILE CHECKSUM: 858401fbd980bedce6ecd2f9b429bf271f11f74b
COCOAPODS: 1.16.2
+57 -42
View File
@@ -12,9 +12,9 @@
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 */; };
8D5D9D9E2C69000100F10001 /* RecordingPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */; };
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 */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@@ -53,9 +53,9 @@
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>"; };
8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingPlugin.swift; 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>"; };
95C6AC1536EA3DBA2D369C55 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
@@ -84,7 +84,6 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
87DA76CA5231F693C9392B80 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -102,7 +101,6 @@
DFA822DC4965C5AFAFF6BB41 /* Pods-RunnerTests.release.xcconfig */,
99DBE6263F70650B13C33EAC /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
@@ -126,7 +124,6 @@
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
9740EEB21CF90195004384FC /* Debug.xcconfig */,
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
@@ -167,6 +164,7 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */,
8E5D9D9D2C69000100F10002 /* PlatformInfoPlugin.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
@@ -205,16 +203,14 @@
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
62D3774184B6C76B8E34C8C2 /* [CP] Copy Pods Resources */,
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";
@@ -248,9 +244,6 @@
Base,
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
projectRoot = "";
@@ -343,21 +336,21 @@
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;
};
62D3774184B6C76B8E34C8C2 /* [CP] Copy Pods Resources */ = {
7858230D5ADC7A99F778CB03 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
"${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-resources.sh\"\n";
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
@@ -375,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 */
@@ -392,6 +402,7 @@
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
8D5D9D9E2C69000100F10001 /* RecordingPlugin.swift in Sources */,
8E5D9D9E2C69000100F10002 /* PlatformInfoPlugin.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -484,16 +495,22 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 35634V629S;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MT26BPCKF6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterTemplate;
PRODUCT_BUNDLE_IDENTIFIER = com.dronex.rec;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "dev-profile-dronex";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
@@ -509,7 +526,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterTemplate.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.qxy.dronex.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -527,7 +544,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterTemplate.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.qxy.dronex.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -543,7 +560,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterTemplate.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.qxy.dronex.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -667,16 +684,22 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 35634V629S;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MT26BPCKF6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterTemplate;
PRODUCT_BUNDLE_IDENTIFIER = com.dronex.rec;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "dev-profile-dronex";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@@ -690,16 +713,22 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 35634V629S;
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MT26BPCKF6;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.example.flutterTemplate;
PRODUCT_BUNDLE_IDENTIFIER = com.dronex.rec;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "dev-profile-dronex";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic";
@@ -740,20 +769,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 */;
}
@@ -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"
+3
View File
@@ -15,5 +15,8 @@ import UIKit
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "RecordingPlugin") {
RecordingPlugin.register(with: registrar)
}
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "PlatformInfoPlugin") {
PlatformInfoPlugin.register(with: registrar)
}
}
}
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

@@ -0,0 +1,13 @@
{
"images" : [
{
"filename" : "startup_background.png",
"idiom" : "universal",
"scale" : "1x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

@@ -16,13 +16,15 @@
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" image="StartupBackground" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="4X2-HB-R7a"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="E8f-e3-JEx"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="trailing" secondItem="Ze5-6b-2t3" secondAttribute="trailing" id="rwG-Bh-0uU"/>
</constraints>
</view>
</viewController>
@@ -32,6 +34,6 @@
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
<image name="StartupBackground" width="750" height="1624"/>
</resources>
</document>
+22 -5
View File
@@ -1,8 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="24412" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24405"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Flutter View Controller-->
@@ -14,13 +16,28 @@
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
<subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" image="StartupBackground" translatesAutoresizingMaskIntoConstraints="NO" id="YQm-Ov-qw7">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YQm-Ov-qw7" firstAttribute="top" secondItem="8bC-Xf-vdC" secondAttribute="top" id="7pp-OK-Dgk"/>
<constraint firstItem="YQm-Ov-qw7" firstAttribute="bottom" secondItem="8bC-Xf-vdC" secondAttribute="bottom" id="Hbf-gY-rbf"/>
<constraint firstItem="YQm-Ov-qw7" firstAttribute="leading" secondItem="8bC-Xf-vdC" secondAttribute="leading" id="WPc-7k-20p"/>
<constraint firstItem="YQm-Ov-qw7" firstAttribute="trailing" secondItem="8bC-Xf-vdC" secondAttribute="trailing" id="w1G-WA-3QR"/>
</constraints>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects>
<point key="canvasLocation" x="139" y="122"/>
</scene>
</scenes>
<resources>
<image name="StartupBackground" width="750" height="1624"/>
</resources>
</document>
+6 -4
View File
@@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>飞行极控</string>
<string>飞行极控录像工作台</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>飞行极控</string>
<string>飞行极控录像工作台</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
@@ -30,8 +30,10 @@
<string>需要访问相机以显示预览并录制视频。</string>
<key>NSMicrophoneUsageDescription</key>
<string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>需要将录制的视频保存到相册。</string>
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
+98
View File
@@ -0,0 +1,98 @@
import Flutter
import UIKit
final class PlatformInfoPlugin: NSObject, FlutterPlugin {
static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "app.record_tool/platform_info",
binaryMessenger: registrar.messenger()
)
let plugin = PlatformInfoPlugin()
registrar.addMethodCallDelegate(plugin, channel: channel)
}
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "packageInfo":
result(packageInfoMap())
case "deviceInfo":
result(deviceInfoMap())
case "deviceHealth":
result(deviceHealthMap())
default:
result(FlutterMethodNotImplemented)
}
}
private func packageInfoMap() -> [String: String] {
let bundle = Bundle.main
return [
"appName": displayName(bundle: bundle),
"packageName": bundle.bundleIdentifier ?? "",
"version": bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "",
"buildNumber": bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "",
]
}
private func deviceHealthMap() -> [String: Any?] {
let device = UIDevice.current
device.isBatteryMonitoringEnabled = true
var batteryLevelPercent: Int?
let batteryLevel = device.batteryLevel
if batteryLevel >= 0 {
batteryLevelPercent = Int((batteryLevel * 100).rounded())
}
var storageAvailablePercent = 100.0
if let attrs = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()),
let free = attrs[.systemFreeSize] as? NSNumber,
let total = attrs[.systemSize] as? NSNumber,
total.doubleValue > 0 {
storageAvailablePercent = free.doubleValue / total.doubleValue * 100.0
}
return [
"batteryLevelPercent": batteryLevelPercent,
"storageAvailablePercent": storageAvailablePercent,
]
}
private func deviceInfoMap() -> [String: Any] {
let device = UIDevice.current
return [
"platform": "ios",
"deviceCode": "",
"brand": device.systemName,
"model": machineIdentifier(),
"systemVersion": device.systemVersion,
"isPhysicalDevice": !isSimulator(),
]
}
private func displayName(bundle: Bundle) -> String {
if let displayName = bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String {
return displayName
}
return bundle.object(forInfoDictionaryKey: "CFBundleName") as? String ?? ""
}
private func isSimulator() -> Bool {
#if targetEnvironment(simulator)
return true
#else
return false
#endif
}
private func machineIdentifier() -> String {
var systemInfo = utsname()
uname(&systemInfo)
let mirror = Mirror(reflecting: systemInfo.machine)
let identifier = mirror.children.reduce(into: "") { value, element in
guard let byte = element.value as? Int8, byte != 0 else { return }
value.append(String(UnicodeScalar(UInt8(byte))))
}
return identifier
}
}
+233 -108
View File
@@ -1,6 +1,5 @@
import AVFoundation
import Flutter
import Photos
import UIKit
private enum RecordingState: String {
@@ -110,12 +109,13 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
private var audioInput: AVCaptureDeviceInput?
private var configured = false
private var latestOutputPath: String?
private var latestGallerySaved = true
private var latestGalleryErrorMessage: String?
private var latestFileSaved = true
private var latestFileErrorMessage: String?
private var pendingDisplayName: String?
private var recordingStartedAt: Date?
private var elapsedTimer: Timer?
private var pendingStopResult: FlutterResult?
private var currentZoomRatio: CGFloat = 1.0
private(set) var status = RecordingStatus(state: .idle) {
didSet {
@@ -129,20 +129,37 @@ 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)
}
}
func initializePreview(result: @escaping FlutterResult) {
guard let previewView else {
result(FlutterError(code: "NO_PREVIEW", message: "Camera preview is not attached", details: nil))
result(
FlutterError(code: "NO_PREVIEW", message: "Camera preview is not attached", details: nil))
return
}
@@ -176,7 +193,8 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
func startRecording(withAudio: Bool, displayName: String?, result: @escaping FlutterResult) {
guard previewView != nil else {
result(FlutterError(code: "NO_PREVIEW", message: "Camera preview is not attached", details: nil))
result(
FlutterError(code: "NO_PREVIEW", message: "Camera preview is not attached", details: nil))
return
}
@@ -197,10 +215,10 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
}
self.pendingDisplayName = displayName
self.latestGallerySaved = true
self.latestGalleryErrorMessage = nil
self.latestFileSaved = true
self.latestFileErrorMessage = nil
let outputURL = try self.createOutputURL(displayName: displayName)
self.latestOutputPath = outputURL.lastPathComponent
self.latestOutputPath = outputURL.path
self.recordingStartedAt = Date()
self.updateStatus(RecordingStatus(state: .recording, outputPath: outputURL.path))
self.movieOutput.startRecording(to: outputURL, recordingDelegate: self)
@@ -236,11 +254,11 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
var payload: [String: Any] = [
"outputPath": self.latestOutputPath as Any,
"status": self.currentStatusMap(),
"gallerySaved": self.latestGallerySaved,
"fileSaved": self.latestFileSaved,
]
if !self.latestGallerySaved {
payload["galleryErrorMessage"] =
self.latestGalleryErrorMessage ?? "保存到相册失败"
if !self.latestFileSaved {
payload["fileErrorMessage"] =
self.latestFileErrorMessage ?? "保存到文件夹失败"
}
result(payload)
}
@@ -273,6 +291,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
self.session.commitConfiguration()
self.videoInput = nil
self.audioInput = nil
self.currentZoomRatio = 1.0
self.configured = false
self.updateStatus(RecordingStatus(state: .idle))
@@ -294,6 +313,54 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
return status.toMap()
}
func zoomCapabilities(result: @escaping FlutterResult) {
sessionQueue.async { [weak self] in
guard let self else { return }
let capabilities = self.currentZoomCapabilitiesMap()
DispatchQueue.main.async {
result(capabilities)
}
}
}
func setZoomRatio(_ ratio: CGFloat, result: @escaping FlutterResult) {
sessionQueue.async { [weak self] in
guard let self else { return }
guard let device = self.videoInput?.device else {
self.currentZoomRatio = max(1.0, ratio)
let capabilities = self.currentZoomCapabilitiesMap()
DispatchQueue.main.async {
result(capabilities)
}
return
}
do {
// (1.0x = ) S zoomFactor
let baseline = self.mainBaselineFactor(for: device)
let nextZoom = self.clampedZoomRatio(ratio * baseline, for: device)
try device.lockForConfiguration()
device.videoZoomFactor = nextZoom
device.unlockForConfiguration()
self.currentZoomRatio = nextZoom
let capabilities = self.currentZoomCapabilitiesMap()
DispatchQueue.main.async {
result(capabilities)
}
} catch {
DispatchQueue.main.async {
result(
FlutterError(
code: "ZOOM_FAILED",
message: error.localizedDescription,
details: nil
)
)
}
}
}
}
func fileOutput(
_ output: AVCaptureFileOutput,
didFinishRecordingTo outputFileURL: URL,
@@ -304,36 +371,39 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
pendingStopResult = nil
if let error {
latestGallerySaved = false
latestGalleryErrorMessage = error.localizedDescription
updateStatus(RecordingStatus(state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
latestFileSaved = false
latestFileErrorMessage = error.localizedDescription
updateStatus(
RecordingStatus(
state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
finishStopRecording(stopResult: stopResult)
return
}
saveVideoToPhotoLibrary(fileURL: outputFileURL) { [weak self] success, message in
guard let self else { return }
self.latestGallerySaved = success
self.latestGalleryErrorMessage = message
if success {
self.updateStatus(
RecordingStatus(
state: .previewing,
outputPath: self.latestOutputPath,
elapsedMillis: self.elapsedMillis()
)
latestFileSaved = true
latestFileErrorMessage = nil
latestOutputPath = outputFileURL.path
guard FileManager.default.fileExists(atPath: outputFileURL.path) else {
latestFileSaved = false
latestFileErrorMessage = "录制文件未生成"
updateStatus(
RecordingStatus(
state: .error,
outputPath: latestOutputPath,
message: latestFileErrorMessage
)
} else {
self.updateStatus(
RecordingStatus(
state: .error,
outputPath: self.latestOutputPath,
message: message ?? "保存到相册失败"
)
)
}
self.finishStopRecording(stopResult: stopResult)
)
finishStopRecording(stopResult: stopResult)
return
}
updateStatus(
RecordingStatus(
state: .previewing,
outputPath: latestOutputPath,
elapsedMillis: elapsedMillis()
)
)
finishStopRecording(stopResult: stopResult)
}
private func finishStopRecording(stopResult: FlutterResult?) {
@@ -343,78 +413,26 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
var payload: [String: Any] = [
"outputPath": self.latestOutputPath as Any,
"status": self.currentStatusMap(),
"gallerySaved": self.latestGallerySaved,
"fileSaved": self.latestFileSaved,
]
if !self.latestGallerySaved {
payload["galleryErrorMessage"] =
self.latestGalleryErrorMessage ?? "保存到相册失败,请开启相册权限"
if !self.latestFileSaved {
payload["fileErrorMessage"] =
self.latestFileErrorMessage ?? "保存到文件夹失败,请检查文件保存权限"
}
stopResult?(payload)
}
}
private func saveVideoToPhotoLibrary(
fileURL: URL,
completion: @escaping (Bool, String?) -> Void
) {
let performSave = {
PHPhotoLibrary.shared().performChanges({
PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: fileURL, options: nil)
}) { success, error in
if success {
try? FileManager.default.removeItem(at: fileURL)
completion(true, nil)
} else {
completion(false, error?.localizedDescription ?? "保存到相册失败")
}
}
}
if #available(iOS 14, *) {
let status = PHPhotoLibrary.authorizationStatus(for: .addOnly)
switch status {
case .authorized, .limited:
performSave()
case .notDetermined:
PHPhotoLibrary.requestAuthorization(for: .addOnly) { newStatus in
if newStatus == .authorized || newStatus == .limited {
performSave()
} else {
completion(false, "未授予相册权限")
}
}
default:
completion(false, "未授予相册权限")
}
} else {
let status = PHPhotoLibrary.authorizationStatus()
switch status {
case .authorized:
performSave()
case .notDetermined:
PHPhotoLibrary.requestAuthorization { newStatus in
if newStatus == .authorized {
performSave()
} else {
completion(false, "未授予相册权限")
}
}
default:
completion(false, "未授予相册权限")
}
}
}
private func configureSession(withAudio: Bool) throws {
if configured {
try configureAudioInput(enabled: withAudio)
return
}
guard let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back)
?? AVCaptureDevice.default(for: .video)
else {
throw NSError(domain: "RecordingCamera", code: 1, userInfo: [NSLocalizedDescriptionKey: "No camera device available"])
guard let videoDevice = Self.preferredVideoDevice() else {
throw NSError(
domain: "RecordingCamera", code: 1,
userInfo: [NSLocalizedDescriptionKey: "No camera device available"])
}
let nextVideoInput = try AVCaptureDeviceInput(device: videoDevice)
@@ -424,22 +442,91 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
guard session.canAddInput(nextVideoInput) else {
session.commitConfiguration()
throw NSError(domain: "RecordingCamera", code: 2, userInfo: [NSLocalizedDescriptionKey: "Cannot add camera input"])
throw NSError(
domain: "RecordingCamera", code: 2,
userInfo: [NSLocalizedDescriptionKey: "Cannot add camera input"])
}
session.addInput(nextVideoInput)
videoInput = nextVideoInput
guard session.canAddOutput(movieOutput) else {
session.commitConfiguration()
throw NSError(domain: "RecordingCamera", code: 3, userInfo: [NSLocalizedDescriptionKey: "Cannot add movie output"])
throw NSError(
domain: "RecordingCamera", code: 3,
userInfo: [NSLocalizedDescriptionKey: "Cannot add movie output"])
}
session.addOutput(movieOutput)
session.commitConfiguration()
configured = true
// ( 1.0x) zoomFactor S
currentZoomRatio = mainBaselineFactor(for: videoDevice)
try applyCurrentZoom()
try configureAudioInput(enabled: withAudio)
}
/// 广使 minAvailableVideoZoomFactor ( 0.6x)
private static func preferredVideoDevice() -> AVCaptureDevice? {
let preferredTypes: [AVCaptureDevice.DeviceType] = [
.builtInTripleCamera,
.builtInDualWideCamera,
.builtInWideAngleCamera,
]
for type in preferredTypes {
if let device = AVCaptureDevice.default(type, for: .video, position: .back) {
return device
}
}
return AVCaptureDevice.default(for: .video)
}
/// ( 1.0x) zoomFactor S
/// ultra-wide wide 1.0()
private func mainBaselineFactor(for device: AVCaptureDevice) -> CGFloat {
if let first = device.virtualDeviceSwitchOverVideoZoomFactors.first {
let value = CGFloat(truncating: first)
if value > 0 {
return value
}
}
return 1.0
}
private func currentZoomCapabilitiesMap() -> [String: Any] {
guard let device = videoInput?.device else {
return [
"zoomRatio": Double(currentZoomRatio),
"minZoomRatio": 1.0,
"maxZoomRatio": 3.0,
]
}
// zoomFactor S App 使(1.0x = )
let baseline = mainBaselineFactor(for: device)
let minZoom = device.minAvailableVideoZoomFactor
let maxZoom = device.maxAvailableVideoZoomFactor
let zoom = clampedZoomRatio(device.videoZoomFactor, for: device)
currentZoomRatio = zoom
return [
"zoomRatio": Double(zoom / baseline),
"minZoomRatio": Double(minZoom / baseline),
"maxZoomRatio": Double(maxZoom / baseline),
]
}
private func applyCurrentZoom() throws {
guard let device = videoInput?.device else { return }
let nextZoom = clampedZoomRatio(currentZoomRatio, for: device)
try device.lockForConfiguration()
device.videoZoomFactor = nextZoom
device.unlockForConfiguration()
currentZoomRatio = nextZoom
}
private func clampedZoomRatio(_ ratio: CGFloat, for device: AVCaptureDevice) -> CGFloat {
min(max(ratio, device.minAvailableVideoZoomFactor), device.maxAvailableVideoZoomFactor)
}
private func configureAudioInput(enabled: Bool) throws {
session.beginConfiguration()
defer { session.commitConfiguration() }
@@ -474,7 +561,30 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
try FileManager.default.createDirectory(at: recordingsURL, withIntermediateDirectories: true)
let fileName = Self.resolveFileName(displayName: displayName)
return recordingsURL.appendingPathComponent(fileName)
return uniqueOutputURL(in: recordingsURL, preferredFileName: fileName)
}
private func uniqueOutputURL(in directoryURL: URL, preferredFileName: String) -> URL {
let preferredURL = directoryURL.appendingPathComponent(preferredFileName)
guard !FileManager.default.fileExists(atPath: preferredURL.path) else {
let fileExtension = preferredURL.pathExtension
let baseName = preferredURL.deletingPathExtension().lastPathComponent
let timestamp = Self.fileNameDateFormatter.string(from: Date())
var index = 0
while true {
let suffix = index == 0 ? timestamp : "\(timestamp)_\(index)"
let nextName = fileExtension.isEmpty
? "\(baseName)_\(suffix)"
: "\(baseName)_\(suffix).\(fileExtension)"
let nextURL = directoryURL.appendingPathComponent(nextName)
if !FileManager.default.fileExists(atPath: nextURL.path) {
return nextURL
}
index += 1
}
}
return preferredURL
}
private static func resolveFileName(displayName: String?) -> String {
@@ -492,6 +602,13 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
return "REC_\(formatter.string(from: Date())).mov"
}
private static let fileNameDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyyMMdd_HHmmss"
return formatter
}()
private func updateStatus(_ next: RecordingStatus) {
status = next
}
@@ -516,9 +633,9 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
}
private enum RecordingChannelNames {
static let packageName = "com.gdfw.fxjk"
static let method = "\(packageName)/recording"
static let events = "\(packageName)/recording_events"
static let namespace = "app.record_tool"
static let method = "\(namespace)/recording"
static let events = "\(namespace)/recording_events"
}
final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
@@ -559,6 +676,12 @@ final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
controller.startRecording(withAudio: withAudio, displayName: displayName, result: result)
case "stopRecording":
controller.stopRecording(result: result)
case "getZoomCapabilities":
controller.zoomCapabilities(result: result)
case "setZoomRatio":
let args = call.arguments as? [String: Any]
let ratio = args?["zoomRatio"] as? Double ?? 1.0
controller.setZoomRatio(CGFloat(ratio), result: result)
case "disposePreview":
controller.disposePreview(result: result)
case "getStatus":
@@ -584,7 +707,9 @@ final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
}
}
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
-> FlutterError?
{
eventSink = events
events(controller.currentStatusMap())
return nil
+6 -36
View File
@@ -1,50 +1,20 @@
import 'package:flutter/material.dart';
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/view-model/view_model_recording.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:recording_tool/features/auth/pages/page_auth.dart';
class FlutterTemplateApp extends ConsumerStatefulWidget {
class FlutterTemplateApp extends StatelessWidget {
const FlutterTemplateApp({super.key});
@override
ConsumerState<FlutterTemplateApp> createState() => _FlutterTemplateAppState();
}
class _FlutterTemplateAppState extends ConsumerState<FlutterTemplateApp>
with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(recordingViewModelProvider.notifier).getClipboardContent();
});
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
ref.read(recordingViewModelProvider.notifier).getClipboardContent();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
Widget build(BuildContext context) {
return ScreenUtilInit(
designSize: const Size(375, 812),
designSize: AppConfig.designSize,
minTextAdapt: true,
splitScreenMode: true,
builder: (context, child) {
@@ -73,8 +43,8 @@ class _FlutterTemplateAppState extends ConsumerState<FlutterTemplateApp>
),
home: RefreshConfiguration(
enableLoadingWhenNoData: false,
headerTriggerDistance: 80,
child: const RecordingPage(),
headerTriggerDistance: 80.h,
child: const AuthPageWidget(),
),
);
},
+43 -4
View File
@@ -1,11 +1,17 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_ume/flutter_ume.dart';
import 'package:flutter_ume_kit_device/components/device_info/device_info_panel.dart';
import 'package:flutter_ume_kit_dio/flutter_ume_kit_dio.dart';
import 'package:recording_tool/app/app.dart';
import 'package:recording_tool/app/config/app_config.dart';
import 'package:recording_tool/core/cache/app_storage.dart';
import 'package:recording_tool/core/logging/app_logger.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:recording_tool/core/network/app_dio.dart';
import 'package:recording_tool/core/platform/app_platform_info.dart';
class AppBootstrapper {
AppBootstrapper._();
@@ -18,12 +24,45 @@ class AppBootstrapper {
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
await AppStorage.init();
final packageInfo = await PackageInfo.fromPlatform();
AppConfig.configure(environment: environment, packageInfo: packageInfo);
AppConfig.configure(environment: environment);
AppLogger.debug('App started in ${AppConfig.current.environment.name}');
runApp(const ProviderScope(child: FlutterTemplateApp()));
// 注册 UME 调试插件(DioInspector 需与业务共用同一 Dio 实例)
PluginManager.instance
..register(DioInspector(dio: AppDio.instance))
..register(DeviceInfoPanel());
runApp(
UMEWidget(
enable: true,
child: Stack(
alignment: Alignment.center,
children: [const ProviderScope(child: FlutterTemplateApp())],
),
),
);
// runApp(const ProviderScope(child: FlutterTemplateApp()));
// Load native package metadata after the first frame can render.
// Awaiting MethodChannel calls before runApp() can stall the Android
// splash screen on some devices.
unawaited(_loadPackageInfo(environment));
}
static Future<void> _loadPackageInfo(AppEnvironment environment) async {
try {
final packageInfo = await AppPlatformInfo.packageInfo().timeout(
const Duration(seconds: 8),
);
AppConfig.configure(environment: environment, packageInfo: packageInfo);
} catch (error, stackTrace) {
AppLogger.debug(
'Native packageInfo unavailable',
error: error,
stackTrace: stackTrace,
);
}
}
}
+28
View File
@@ -0,0 +1,28 @@
enum AuthApi {
/// 获取 token
getToken('/api/events/device/token'),
/// 获取推流地址
getStreamKey('/api/events/device/stream/key'),
/// 根据赛事目录获取视频列表
getRecordList('/api/files'),
/// 获取参赛队伍列表
getTeamList('/api/events/device/item/group'),
/// 参赛队伍详情
getTeamDetail('/api/events/device/schedule/pending'),
/// 人工设置晋级/淘汰
setTeamStatus('/api/events/device/schedule/team/score'),
/// 获取登录设备信息
getLoginDeviceInfo('/api/events/device/nas/info'),
/// 获取选手的赛事信息
playerRegistrationList('/api/events/device/player/registration/list');
final String path;
const AuthApi(this.path);
}
+20 -7
View File
@@ -1,4 +1,5 @@
import 'package:package_info_plus/package_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:recording_tool/core/platform/app_platform_info.dart';
enum AppEnvironment { dev, staging, prod }
@@ -7,40 +8,52 @@ class EnvironmentValues {
required this.environment,
required this.baseUrl,
required this.enableNetworkLog,
this.mainRefereeScoreH5Url = '',
});
final AppEnvironment environment;
final String baseUrl;
final bool enableNetworkLog;
final String mainRefereeScoreH5Url;
}
class AppConfig {
AppConfig._();
static late EnvironmentValues current;
static PackageInfo? packageInfo;
static AppPackageInfo? packageInfo;
static const appName = '飞行极控';
static const appName = 'SportsX裁判工作台';
static const designSize = Size(640, 1024);
/// 主裁判计分 H5 链接
static void configure({
required AppEnvironment environment,
PackageInfo? packageInfo,
AppPackageInfo? packageInfo,
}) {
AppConfig.packageInfo = packageInfo;
current = switch (environment) {
AppEnvironment.dev => const EnvironmentValues(
environment: AppEnvironment.dev,
baseUrl: 'https://example.com/api',
// baseUrl: 'http://192.168.1.104:8000',
baseUrl: 'https://apitest.dronex.cc',
mainRefereeScoreH5Url:
'https://drone.apptest.sportsx.cc/#/pages/h5/score-entry',
enableNetworkLog: true,
),
AppEnvironment.staging => const EnvironmentValues(
environment: AppEnvironment.staging,
baseUrl: 'https://staging.example.com/api',
baseUrl: 'https://apitest.dronex.cc',
mainRefereeScoreH5Url:
'https://drone.apptest.sportsx.cc/#/pages/h5/score-entry',
enableNetworkLog: true,
),
AppEnvironment.prod => const EnvironmentValues(
environment: AppEnvironment.prod,
baseUrl: 'https://api.example.com',
baseUrl: 'https://api.dronex.cc',
mainRefereeScoreH5Url:
'https://drone.sportsx.cc/#/pages/h5/score-entry',
enableNetworkLog: false,
),
};
+3 -6
View File
@@ -94,8 +94,8 @@ class AppNavigator {
barrierDismissible: dismissible,
transitionDuration: duration,
reverseTransitionDuration: duration,
pageBuilder: (_, __, ___) => page,
transitionsBuilder: (_, animation, __, child) {
pageBuilder: (_, _, _) => page,
transitionsBuilder: (_, animation, _, child) {
return FadeTransition(opacity: animation, child: child);
},
),
@@ -103,10 +103,7 @@ class AppNavigator {
}
static void pop<T extends Object?>({BuildContext? context, T? result}) {
Navigator.of(
context ?? AppNavigator.context!,
rootNavigator: true,
).pop<T>(result);
Navigator.maybePop(context ?? AppNavigator.context!);
}
static void popTimes({BuildContext? context, int count = 1}) {
+12 -11
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
class AppTheme {
AppTheme._();
@@ -32,20 +33,20 @@ class AppTheme {
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
minimumSize: const Size(88, 44),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
minimumSize: Size(88.w, 44.h),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.r)),
),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.r)),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
minimumSize: const Size(88, 44),
minimumSize: Size(88.w, 44.h),
side: const BorderSide(color: border),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.r)),
),
),
);
@@ -71,10 +72,10 @@ class AppTheme {
class AppSpacing {
AppSpacing._();
static const double xs = 4;
static const double sm = 8;
static const double md = 12;
static const double lg = 16;
static const double xl = 24;
static const double xxl = 32;
static double get xs => 4.r;
static double get sm => 8.r;
static double get md => 12.r;
static double get lg => 16.r;
static double get xl => 24.r;
static double get xxl => 32.r;
}
+18 -3
View File
@@ -38,11 +38,15 @@ class ApiClient {
return _parseData<T>(raw, parser);
}
if (raw is! Map<String, dynamic>) {
final payload = raw is Map ? Map<String, dynamic>.from(raw) : null;
if (payload == null ||
!(payload.containsKey('code') || payload.containsKey('message'))) {
return _parseData<T>(raw, parser);
}
final wrapped = ApiResponse<T>.fromJson(raw, fromJsonT: parser);
// 先按 {code,message,data} 解包,再用业务 parser 解析 data
// 避免把 Map 直接强转成业务模型。
final wrapped = ApiResponse<dynamic>.fromJson(payload);
if (!wrapped.isSuccess) {
throw ApiException(
code: wrapped.code,
@@ -52,7 +56,17 @@ class ApiClient {
);
}
return wrapped.data as T;
if (wrapped.data == null) {
if (null is T) return null as T;
throw ApiException(
code: wrapped.code,
statusCode: response.statusCode,
message: '响应 data 为空',
details: raw,
);
}
return _parseData<T>(wrapped.data, parser);
} on DioException catch (error) {
throw _mapDioException(error);
}
@@ -108,6 +122,7 @@ class ApiClient {
final statusCode = error.response?.statusCode;
final message = switch (error.type) {
DioExceptionType.connectionTimeout => '网络连接超时',
DioExceptionType.transformTimeout => '网络请求处理超时',
DioExceptionType.sendTimeout => '请求发送超时',
DioExceptionType.receiveTimeout => '响应接收超时',
DioExceptionType.badCertificate => '证书校验失败',
+18 -2
View File
@@ -5,16 +5,32 @@ class ApiResponse<T> {
final String message;
final T? data;
bool get isSuccess => code >= 200 && code < 300;
bool get isSuccess => code == 0 || code >= 200 && code < 300;
factory ApiResponse.fromJson(
Map<String, dynamic> json, {
T Function(dynamic json)? fromJsonT,
}) {
final rawData = json['data'];
T? data;
if (rawData != null && fromJsonT != null) {
data = fromJsonT(rawData);
} else if (rawData != null && fromJsonT == null) {
// 无 parser 时仅在类型已匹配时接收,避免 Map 被强转为业务模型。
if (rawData is T) {
data = rawData;
} else {
throw FormatException(
'ApiResponse data 类型不匹配,且未提供 fromJsonT;'
'期望 $T,实际 ${rawData.runtimeType}',
);
}
}
return ApiResponse<T>(
code: (json['code'] as num?)?.toInt() ?? 200,
message: (json['message'] ?? json['msg'] ?? '').toString(),
data: fromJsonT == null ? json['data'] as T? : fromJsonT(json['data']),
data: data,
);
}
}
+21
View File
@@ -0,0 +1,21 @@
import 'package:dio/dio.dart';
import 'package:recording_tool/app/config/app_config.dart';
/// 应用全局 Dio 单例,供业务请求与调试插件共用。
class AppDio {
AppDio._();
static Dio? _instance;
static Dio get instance {
return _instance ??= Dio(
BaseOptions(
baseUrl: AppConfig.current.baseUrl,
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
sendTimeout: const Duration(seconds: 30),
responseType: ResponseType.json,
),
);
}
}
+16
View File
@@ -2,9 +2,12 @@ import 'dart:io';
import 'package:dio/dio.dart';
import 'package:recording_tool/app/config/app_config.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/core/cache/app_storage.dart';
import 'package:recording_tool/core/cache/storage_keys.dart';
import 'package:recording_tool/core/utils/device_utils.dart';
import 'package:recording_tool/features/auth/pages/page_auth.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
class HeaderInterceptor extends Interceptor {
@override
@@ -28,4 +31,17 @@ class HeaderInterceptor extends Interceptor {
handler.next(options);
}
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
if (err.response?.statusCode == 401) {
await AppStorage.remove(StorageKeys.authToken);
AppToast.show('口令已过期,请重新输入执裁口令');
AppNavigator.pushAndRemoveUntil(AuthPageWidget());
}
handler.next(err);
}
}
+21 -21
View File
@@ -2,36 +2,36 @@ import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:recording_tool/app/config/app_config.dart';
import 'package:recording_tool/core/network/api_client.dart';
import 'package:recording_tool/core/network/app_dio.dart';
import 'package:recording_tool/core/network/header_interceptor.dart';
import 'package:recording_tool/core/network/offline_queue/offline_queue_interceptor.dart';
import 'package:recording_tool/core/network/providers/network_providers.dart';
import 'package:recording_tool/core/network/providers/offline_queue_providers.dart';
bool _dioConfigured = false;
final dioProvider = Provider<Dio>((ref) {
final dio = Dio(
BaseOptions(
baseUrl: AppConfig.current.baseUrl,
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
sendTimeout: const Duration(seconds: 30),
responseType: ResponseType.json,
),
);
final dio = AppDio.instance;
dio.interceptors.add(HeaderInterceptor());
if (!_dioConfigured) {
_dioConfigured = true;
dio.interceptors.add(HeaderInterceptor());
final monitor = ref.watch(networkMonitorProvider);
final queueManager = ref.watch(offlineQueueManagerProvider);
dio.interceptors.add(
OfflineQueueInterceptor(
monitor: monitor,
manager: queueManager,
enabled: false,
),
);
final monitor = ref.read(networkMonitorProvider);
final queueManager = ref.read(offlineQueueManagerProvider);
dio.interceptors.add(
OfflineQueueInterceptor(
monitor: monitor,
manager: queueManager,
enabled: false,
),
);
if (AppConfig.current.enableNetworkLog) {
dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true));
if (AppConfig.current.enableNetworkLog) {
dio.interceptors.add(
LogInterceptor(requestBody: true, responseBody: true),
);
}
}
return dio;
+89
View File
@@ -0,0 +1,89 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:recording_tool/core/platform/device_health_snapshot.dart';
class AppPackageInfo {
const AppPackageInfo({
required this.appName,
required this.packageName,
required this.version,
required this.buildNumber,
});
factory AppPackageInfo.fromMap(Map<Object?, Object?> map) {
return AppPackageInfo(
appName: map['appName'] as String? ?? '',
packageName: map['packageName'] as String? ?? '',
version: map['version'] as String? ?? '',
buildNumber: map['buildNumber'] as String? ?? '',
);
}
final String appName;
final String packageName;
final String version;
final String buildNumber;
}
class AppDeviceInfo {
const AppDeviceInfo({
required this.platform,
required this.isPhysicalDevice,
required this.values,
required this.deviceCode,
});
factory AppDeviceInfo.fromMap(Map<Object?, Object?> map) {
final values = <String, String>{};
for (final entry in map.entries) {
final key = entry.key;
final value = entry.value;
if (key is String && value != null) {
values[key] = value.toString();
}
}
final isPhysicalDevice = map['isPhysicalDevice'];
return AppDeviceInfo(
platform: map['platform'] as String? ?? Platform.operatingSystem,
isPhysicalDevice: isPhysicalDevice is bool ? isPhysicalDevice : true,
values: values,
deviceCode: values['deviceCode'] ?? '',
);
}
final String platform;
final bool isPhysicalDevice;
final Map<String, String> values;
final String deviceCode;
}
class AppPlatformInfo {
AppPlatformInfo._();
static const MethodChannel _channel = MethodChannel(
'app.record_tool/platform_info',
);
static Future<AppPackageInfo> packageInfo() async {
final result = await _channel.invokeMapMethod<Object?, Object?>(
'packageInfo',
);
return AppPackageInfo.fromMap(result ?? const <Object?, Object?>{});
}
static Future<AppDeviceInfo> deviceInfo() async {
final result = await _channel.invokeMapMethod<Object?, Object?>(
'deviceInfo',
);
return AppDeviceInfo.fromMap(result ?? const <Object?, Object?>{});
}
static Future<DeviceHealthSnapshot> deviceHealth() async {
final result = await _channel.invokeMapMethod<Object?, Object?>(
'deviceHealth',
);
return DeviceHealthSnapshot.fromMap(result ?? const <Object?, Object?>{});
}
}
@@ -0,0 +1,25 @@
import 'package:recording_tool/core/platform/device_health_snapshot.dart';
class DeviceHealthChecker {
DeviceHealthChecker._();
static const int thresholdPercent = 10;
static const String lowBatteryMessage = '电量低于10%,请充电';
static const String lowStorageMessage = '内存低于10%,请清理内存';
static List<String> warningLines(DeviceHealthSnapshot snapshot) {
final lines = <String>[];
final battery = snapshot.batteryLevelPercent;
if (battery != null && battery < thresholdPercent) {
lines.add(lowBatteryMessage);
}
if (snapshot.storageAvailablePercent < thresholdPercent) {
lines.add(lowStorageMessage);
}
return lines;
}
}
@@ -0,0 +1,30 @@
class DeviceHealthSnapshot {
const DeviceHealthSnapshot({
this.batteryLevelPercent,
required this.storageAvailablePercent,
});
factory DeviceHealthSnapshot.fromMap(Map<Object?, Object?> map) {
final batteryRaw = map['batteryLevelPercent'];
int? batteryLevelPercent;
if (batteryRaw is int) {
batteryLevelPercent = batteryRaw;
} else if (batteryRaw is num) {
batteryLevelPercent = batteryRaw.round();
}
final storageRaw = map['storageAvailablePercent'];
final storageAvailablePercent = switch (storageRaw) {
final num value => value.toDouble(),
_ => 100.0,
};
return DeviceHealthSnapshot(
batteryLevelPercent: batteryLevelPercent,
storageAvailablePercent: storageAvailablePercent,
);
}
final int? batteryLevelPercent;
final double storageAvailablePercent;
}
+12 -27
View File
@@ -1,7 +1,7 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/material.dart';
import 'package:recording_tool/core/platform/app_platform_info.dart';
class DeviceUtils {
DeviceUtils._();
@@ -19,36 +19,21 @@ class DeviceUtils {
MediaQuery.paddingOf(context).bottom;
static Future<bool> isPhysicalDevice() async {
final plugin = DeviceInfoPlugin();
if (Platform.isAndroid) {
return (await plugin.androidInfo).isPhysicalDevice;
}
if (Platform.isIOS) {
return (await plugin.iosInfo).isPhysicalDevice;
}
return true;
return (await AppPlatformInfo.deviceInfo()).isPhysicalDevice;
}
static Future<Map<String, String>> deviceInfo() async {
final plugin = DeviceInfoPlugin();
if (Platform.isAndroid) {
final info = await plugin.androidInfo;
return {
'platform': 'android',
'brand': info.brand,
'model': info.model,
'systemVersion': info.version.release,
};
if (!Platform.isAndroid && !Platform.isIOS) {
return {'platform': Platform.operatingSystem};
}
if (Platform.isIOS) {
final info = await plugin.iosInfo;
return {
'platform': 'ios',
'brand': info.systemName,
'model': info.utsname.machine,
'systemVersion': info.systemVersion,
};
return (await AppPlatformInfo.deviceInfo()).values;
}
static Future<String> deviceCode() async {
try {
return (await AppPlatformInfo.deviceInfo()).deviceCode.trim();
} catch (_) {
return '';
}
return {'platform': Platform.operatingSystem};
}
}
+142
View File
@@ -0,0 +1,142 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:network_info_plus/network_info_plus.dart';
/// 局域网 NAS IP 探测(结果仅存内存静态字段)。
class UtilSearchNasIp {
UtilSearchNasIp._();
static const String _defaultPreUrl = 'http://192.168.1.';
static const String _probePath =
':5666/sac/rpcproxy/v1/new-user-guide/status';
static const Duration _timeout = Duration(seconds: 1);
/// 命中的 NAS IPv4(仅内存,进程内有效)。
static String? nasIp;
/// 并发扫同网段 1~255,超时 1s;首个有 HTTP 响应者写入 [nasIp] 并返回。
static Future<String?> discover() async {
if (nasIp != null && nasIp!.isNotEmpty) {
return nasIp;
}
final startedAt = DateTime.now();
final preUrl = await _resolvePreUrl();
final dio = Dio(
BaseOptions(
connectTimeout: _timeout,
receiveTimeout: _timeout,
sendTimeout: _timeout,
validateStatus: (_) => true,
),
);
final cancelToken = CancelToken();
try {
await Future.wait([
for (var host = 1; host <= 255; host++)
_probeHost(
dio: dio,
cancelToken: cancelToken,
preUrl: preUrl,
host: host,
startedAt: startedAt,
),
]);
if (nasIp == null) {
final elapsedMs = DateTime.now().difference(startedAt).inMilliseconds;
debugPrint('NAS 探测未命中,总耗时: ${elapsedMs}ms');
}
return nasIp;
} finally {
dio.close(force: true);
}
}
/// 由本机 WiFi IPv4 推导 `http://a.b.c.`,失败则用默认网段。
static Future<String> _resolvePreUrl() async {
try {
final wifiIp = await NetworkInfo().getWifiIP();
if (wifiIp == null || wifiIp.isEmpty) return _defaultPreUrl;
final parts = wifiIp.split('.');
if (parts.length != 4) return _defaultPreUrl;
return 'http://${parts[0]}.${parts[1]}.${parts[2]}.';
} catch (_) {
return _defaultPreUrl;
}
}
/// 探测单个主机;任意 HTTP 响应视为命中。
static Future<void> _probeHost({
required Dio dio,
required CancelToken cancelToken,
required String preUrl,
required int host,
required DateTime startedAt,
}) async {
if (cancelToken.isCancelled || nasIp != null) return;
final target = '$preUrl$host$_probePath';
try {
await dio.get<dynamic>(target, cancelToken: cancelToken);
_onHit(preUrl, host, cancelToken, startedAt);
} on DioException catch (error) {
if (error.type == DioExceptionType.cancel) return;
// 带 response 的 DioException 也表示已连通
if (error.response != null) {
_onHit(preUrl, host, cancelToken, startedAt);
}
} catch (_) {
// 超时 / 连接失败:未命中
}
}
/// 记录命中:写 [nasIp]、打印 IP 与总耗时,并取消其余请求。
static void _onHit(
String preUrl,
int host,
CancelToken cancelToken,
DateTime startedAt,
) {
if (nasIp != null) return;
final ip = '${_hostPrefix(preUrl)}$host';
nasIp = ip;
final elapsedMs = DateTime.now().difference(startedAt).inMilliseconds;
debugPrint('NAS IP: $ip,探测总耗时: ${elapsedMs}ms');
if (!cancelToken.isCancelled) {
cancelToken.cancel('nas-found');
}
}
/// `http://192.168.1.` → `192.168.1.`
static String _hostPrefix(String preUrl) {
return preUrl.replaceFirst(RegExp(r'^https?://'), '');
}
}
// ---------------------------------------------------------------------------
// 调用案例
// ---------------------------------------------------------------------------
//
// 1) Auth 页静默探测(不阻塞登录):
//
// import 'dart:async';
// import 'package:recording_tool/core/utils/util_search_nasIp.dart';
//
// WidgetsBinding.instance.addPostFrameCallback((_) {
// unawaited(UtilSearchNasIp.discover());
// });
//
// 2) 业务侧读取已命中 IP
//
// final ip = UtilSearchNasIp.nasIp;
// if (ip != null) {
// final base = 'http://$ip:5666';
// // 使用 base 访问 NAS...
// }
//
// 3) 主动等待探测结果(少用,会阻塞当前异步流程):
//
// final ip = await UtilSearchNasIp.discover();
// debugPrint('result: $ip');
//
+94
View File
@@ -0,0 +1,94 @@
/// 获取 TOKEN 响应模型
class GetTokenResModel {
GetTokenResModel({required this.deviceAccessToken});
final String deviceAccessToken;
factory GetTokenResModel.fromJson(Map<String, dynamic> json) {
return GetTokenResModel(
deviceAccessToken: (json['deviceAccessToken'] ?? '').toString(),
);
}
Map<String, dynamic> toJson() {
return {'deviceAccessToken': deviceAccessToken};
}
}
/// 查看录像响应模型
class GetRecordListResModel {
String? path;
List<RecordListItem>? items;
GetRecordListResModel({this.path, this.items});
factory GetRecordListResModel.fromJson(Map<String, dynamic> json) {
final rawItems = json['items'];
return GetRecordListResModel(
path: json['path']?.toString(),
items: rawItems is! List
? []
: rawItems
.whereType<Map>()
.map(
(x) => RecordListItem.fromJson(Map<String, dynamic>.from(x)),
)
.toList(),
);
}
Map<String, dynamic> toJson() => {
'path': path,
'items': items == null
? []
: List<dynamic>.from(items!.map((x) => x.toJson())),
};
}
enum RecordItemType {
file('file'),
directory('dir');
final String value;
const RecordItemType(this.value);
}
class RecordListItem {
String? name;
String? path;
String? type;
int? size;
DateTime? modTime;
String? url;
String? extension;
RecordListItem({
this.name,
this.path,
this.type,
this.size,
this.modTime,
this.url,
this.extension,
});
factory RecordListItem.fromJson(Map<String, dynamic> json) => RecordListItem(
name: json['name'] ?? '',
path: json['path'] ?? '',
type: json['type'] ?? '',
size: json['size'] ?? 0,
modTime: json['modTime'] == null ? null : DateTime.parse(json['modTime']),
url: json['url'] ?? '',
extension: json['extension'] ?? '',
);
Map<String, dynamic> toJson() => {
'name': name,
'path': path,
'type': type,
'size': size,
'modTime': modTime?.toIso8601String(),
'url': url,
'extension': extension,
};
}
+67
View File
@@ -0,0 +1,67 @@
// To parse this JSON data, do
//
// final jwtDecodedData = jwtDecodedDataFromJson(jsonString);
import 'dart:convert';
JwtDecodedData jwtDecodedDataFromJson(String str) =>
JwtDecodedData.fromJson(json.decode(str));
String jwtDecodedDataToJson(JwtDecodedData data) => json.encode(data.toJson());
class JwtDecodedData {
String? authType;
String? deviceCode;
int? deviceId;
String? deviceRole;
String? eventName;
String? oId;
List<String>? oIds;
String? organizerId;
String? nasIpAddr;
JwtDecodedData({
this.authType,
this.deviceCode,
this.deviceId,
this.deviceRole,
this.eventName,
this.oId,
this.oIds,
this.organizerId,
this.nasIpAddr,
});
factory JwtDecodedData.fromJson(Map<String, dynamic> json) => JwtDecodedData(
authType: json['authType']?.toString(),
deviceCode: json['deviceCode']?.toString(),
deviceId: _readInt(json['deviceId']),
deviceRole: json['deviceRole']?.toString(),
eventName: json['eventName']?.toString(),
oId: json['oId']?.toString(),
oIds: json['oIds'] == null
? []
: List<String>.from(json['oIds']!.map((x) => x?.toString())),
organizerId: json['organizerId']?.toString(),
nasIpAddr: json['nasIpAddr']?.toString(),
);
Map<String, dynamic> toJson() => {
'authType': authType,
'deviceCode': deviceCode,
'deviceId': deviceId,
'deviceRole': deviceRole,
'eventName': eventName,
'oId': oId,
'oIds': oIds == null ? [] : List<dynamic>.from(oIds!.map((x) => x)),
'organizerId': organizerId,
'nasIpAddr': nasIpAddr,
};
}
int? _readInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
if (value is num) return value.toInt();
return int.tryParse(value.toString());
}
+269
View File
@@ -0,0 +1,269 @@
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:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/core/cache/app_storage.dart';
import 'package:recording_tool/core/cache/storage_keys.dart';
import 'package:recording_tool/core/utils/device_utils.dart';
import 'package:recording_tool/features/auth/view_model_auth/view_model_auth.dart';
import 'package:recording_tool/features/scan_qrcode/pages/page_scan_qrcode.dart';
import 'package:recording_tool/gen/assets.gen.dart';
import 'package:recording_tool/shared/widgets/app_dialog.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
class AuthPageWidget extends ConsumerStatefulWidget {
const AuthPageWidget({super.key});
@override
ConsumerState<AuthPageWidget> createState() => _AuthPageWidgetState();
}
class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
late final TextEditingController _controller;
/// 记录点击次数
int _clickCount = 0;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: '');
WidgetsBinding.instance.addPostFrameCallback((_) async {
final token = AppStorage.getString(StorageKeys.authToken);
if (token?.isNotEmpty ?? false) {
final success = await ref
.read(authProvider.notifier)
.parseTokenSetState();
if (!success) {
return;
}
AppNavigator.push(const ScanQrCodePage());
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _showDeviceCodeDialog() async {
final deviceCode = await DeviceUtils.deviceCode();
if (!mounted) return;
AppDialog.confirm(context, title: '设备码:$deviceCode');
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.white,
),
child: Scaffold(
backgroundColor: Colors.white,
body: Stack(
children: [
Positioned.fill(
child: Image.asset(
_AuthAssets.pageBg,
width: double.infinity,
fit: BoxFit.fill,
),
),
SafeArea(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 32.w),
child: SingleChildScrollView(
child: Column(
children: [
SizedBox(height: 190.h),
ClipRRect(
borderRadius: BorderRadius.circular(24.r),
child: GestureDetector(
onTap: () {
_clickCount++;
if (_clickCount >= 5) {
_showDeviceCodeDialog();
_clickCount = 0;
}
},
child: Image.asset(
_AuthAssets.appIcon,
width: 82.w,
height: 82.w,
fit: BoxFit.cover,
),
),
),
SizedBox(height: 18.h),
Image.asset(
_AuthAssets.appNameText,
width: 132.w,
height: 28.w,
fit: BoxFit.cover,
),
SizedBox(height: 78.h),
_PassCodeInput(controller: _controller),
SizedBox(height: 20.h),
_GradientConfirmButton(
isLoading: authState.isLoading,
onPressed: _handleSubmit,
),
// SizedBox(height: 20.h),
// AppButton(
// onPressed: () async {
// final deviceCode = await DeviceUtils.deviceCode();
// if (!mounted) return;
// AppDialog.confirm(context, title: '设备码:$deviceCode');
// },
// label: '获取设备码',
// ),
],
),
),
),
),
],
),
),
);
}
Future<void> _handleSubmit() async {
final success = await ref
.read(authProvider.notifier)
.auth(_controller.text);
if (!mounted) return;
if (success) {
_controller.clear();
AppNavigator.push(const ScanQrCodePage());
return;
}
final message = ref.read(authProvider).errorMessage;
if (message != null && message.isNotEmpty) {
AppToast.show(message);
}
}
}
class _PassCodeInput extends StatelessWidget {
const _PassCodeInput({required this.controller});
final TextEditingController controller;
@override
Widget build(BuildContext context) {
return Container(
height: 50.h,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12.r),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 10.r,
offset: Offset(0, 2.h),
),
],
),
child: TextField(
controller: controller,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
maxLength: 6,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
style: TextStyle(
color: const Color(0xFF2F3338),
fontSize: 16.sp,
fontWeight: FontWeight.w500,
),
decoration: InputDecoration(
hintText: '请输入执裁口令',
hintStyle: TextStyle(
color: const Color(0xFFB6B9BF),
fontSize: 16.sp,
fontWeight: FontWeight.w400,
),
counterText: '',
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(
horizontal: 16.w,
vertical: 14.h,
),
),
),
);
}
}
class _GradientConfirmButton extends StatelessWidget {
const _GradientConfirmButton({
required this.isLoading,
required this.onPressed,
});
final bool isLoading;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Opacity(
opacity: isLoading ? 0.72 : 1,
child: GestureDetector(
onTap: isLoading ? null : onPressed,
child: Container(
height: 50.h,
width: double.infinity,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(25.r),
gradient: const LinearGradient(
colors: [Color(0xFF268DFF), Color(0xFF5DD4F6)],
),
boxShadow: [
BoxShadow(
color: const Color(0xFF2196F3).withValues(alpha: 0.28),
blurRadius: 14.r,
offset: Offset(0, 6.h),
),
],
),
child: isLoading
? SizedBox.square(
dimension: 18.r,
child: CircularProgressIndicator(
strokeWidth: 2.r,
valueColor: const AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
: Text(
'确定',
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
fontWeight: FontWeight.w500,
),
),
),
),
);
}
}
class _AuthAssets {
const _AuthAssets._();
static String pageBg = Assets.images.imagePageBg.path;
static String appIcon = Assets.images.imageAppIcon.path;
static String appNameText = Assets.images.imageAppNameText.path;
}
+84
View File
@@ -0,0 +1,84 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:recording_tool/app/config/api_common.dart';
import 'package:recording_tool/core/network/providers/dio_providers.dart';
import 'package:recording_tool/core/utils/device_utils.dart';
import 'package:recording_tool/features/auth/model/model_auth.dart';
import 'package:recording_tool/features/auth/model/model_jwt.dart';
class AuthServer {
/// [passCode] 口令码
static Future<GetTokenResModel> login(String passCode, Ref ref) async {
final deviceCode = await DeviceUtils.deviceCode();
if (deviceCode.isEmpty) {
throw const FormatException('无法获取设备标识');
}
final apiClient = ref.read(apiClientProvider);
final data = await apiClient.post<GetTokenResModel>(
AuthApi.getToken.path,
data: {'deviceCode': deviceCode, 'passCode': passCode},
parser: (json) => GetTokenResModel.fromJson(json as Map<String, dynamic>),
);
if (data.deviceAccessToken.isEmpty) {
throw const FormatException('登录响应缺少 TOKEN');
}
return data;
}
/// 获取赛事列表
/// [path] 赛事目录
/// [nasIpAddr] NAS IP,由调用方传入,避免在 authProvider 内部再 read(authProvider)。
static Future<GetRecordListResModel?> getRecordList(
Ref ref, {
required String path,
required String nasIpAddr,
}) async {
try {
if (nasIpAddr.isEmpty) {
throw const FormatException('无法获取NAS IP地址');
}
final apiClient = ref.read(apiClientProvider);
// NAS /api/files 直接返回 {path, items},不是业务网关的 {code,message,data} 包装。
final data = await apiClient.get<GetRecordListResModel>(
'http://$nasIpAddr:9001/${AuthApi.getRecordList.path}',
queryParameters: {'path': path},
wrapResponse: false,
parser: (json) {
if (json is! Map) {
throw const FormatException('录像列表响应格式错误');
}
return GetRecordListResModel.fromJson(
Map<String, dynamic>.from(json),
);
},
);
return data;
} catch (error) {
debugPrint('getRecordList failed: $error');
return null;
}
}
/// 获取登录设备信息
static Future<JwtDecodedData?> getLoginDeviceInfo(Ref ref) async {
try {
final apiClient = ref.read(apiClientProvider);
final data = await apiClient.get<JwtDecodedData>(
AuthApi.getLoginDeviceInfo.path,
parser: (json) {
if (json is! Map) {
throw const FormatException('登录设备信息响应格式错误');
}
return JwtDecodedData.fromJson(Map<String, dynamic>.from(json));
},
);
return data;
} catch (error) {
debugPrint('getLoginDeviceInfo failed: $error');
return null;
}
}
}
+34
View File
@@ -0,0 +1,34 @@
import 'package:recording_tool/features/auth/model/model_auth.dart';
import 'package:recording_tool/features/auth/model/model_jwt.dart';
class AuthState {
const AuthState({
this.isLoading = false,
this.errorMessage,
this.jwtDecodedData,
this.recordList,
});
final bool isLoading;
final String? errorMessage;
/// 解析后的 TOKEN 数据
final JwtDecodedData? jwtDecodedData;
/// 赛事列表
final List<RecordListItem>? recordList;
AuthState copyWith({
bool? isLoading,
String? errorMessage,
JwtDecodedData? jwtDecodedData,
List<RecordListItem>? recordList,
}) {
return AuthState(
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage ?? this.errorMessage,
jwtDecodedData: jwtDecodedData ?? this.jwtDecodedData,
recordList: recordList ?? this.recordList,
);
}
}

Some files were not shown because too many files have changed in this diff Show More