Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c1fb24685 | ||
|
|
a849457172 | ||
|
|
d6a7e74809 | ||
|
|
df38ecee18 | ||
|
|
9dea31f76e | ||
|
|
7faa8e5c47 | ||
|
|
17799af2e7 | ||
|
|
7d65be0514 | ||
|
|
d4920e2b98 | ||
|
|
a19d9dfa6d | ||
|
|
40799f2d15 | ||
|
|
934b3ea2fa | ||
|
|
f7f7413b55 | ||
|
|
8e599f5a86 | ||
|
|
9fca76f679 | ||
|
|
a324825e8a | ||
|
|
6793ca53af | ||
|
|
9e72bc522e | ||
|
|
36253aacbe | ||
|
|
1096645e79 | ||
|
|
a56a42c7c8 | ||
|
|
d29b4ed5bf | ||
|
|
b9c0d60f2a | ||
|
|
43b967d2f8 | ||
|
|
58de72ac92 | ||
|
|
13ecb2fccc | ||
|
|
e3f9c8ebc0 | ||
|
|
6790ea41b2 | ||
|
|
b2dd593e93 | ||
|
|
5ffe524e20 | ||
|
|
356636c4b4 | ||
|
|
686db244fd | ||
|
|
20d501f673 | ||
|
|
ddc1c9bf98 | ||
|
|
30d317c0b9 | ||
|
|
10d9eee60e | ||
|
|
00937bf4b6 | ||
|
|
3c3baede85 | ||
|
|
8c127dd0e7 | ||
|
|
1264ebdd7c | ||
|
|
d6e80df5bf | ||
|
|
4ff93edaab | ||
|
|
1f0e0407ab | ||
|
|
da52dc9797 | ||
|
|
e5b6246458 | ||
|
|
4e0a675198 | ||
|
|
bfc76f469f | ||
|
|
744e768d3a | ||
|
|
25a07cbd3b | ||
|
|
a4333d3bd1 | ||
|
|
e08a8ed26b | ||
|
|
62d717c3ee | ||
|
|
1f9c5af51b | ||
|
|
831ca1059b | ||
|
|
613bd90bad | ||
|
|
9818a56fe1 | ||
|
|
16ad7b253c | ||
|
|
45000b4188 | ||
|
|
fab49a9750 | ||
|
|
1c909277ff | ||
|
|
6138eddc16 | ||
|
|
75c42ea168 | ||
|
|
c38f6d69c8 | ||
|
|
9766c43722 | ||
|
|
a1f6ea14cf | ||
|
|
98a3381716 | ||
|
|
bd5937f910 | ||
|
|
b21857f364 | ||
|
|
f6c477c9be | ||
|
|
1246e41e4b | ||
|
|
26574808df | ||
|
|
d4c7d7180e | ||
|
|
4a85b9f83e | ||
|
|
e4057c3d6a | ||
|
|
90e8966528 | ||
|
|
c8a7494344 | ||
|
|
02614c2817 | ||
|
|
9f49f73a31 | ||
|
|
da2e69b014 | ||
|
|
f6347e99cd | ||
|
|
d40c13d802 | ||
|
|
23319cfddb | ||
|
|
6d93c1c8dd | ||
|
|
c9323f26a7 | ||
|
|
7dafd61314 | ||
|
|
d056d28d83 | ||
|
|
8570486798 | ||
|
|
208920dfea | ||
|
|
88d8dfda04 | ||
|
|
d39d85cd99 | ||
|
|
c01ce1dca0 | ||
|
|
25ac9c4c35 | ||
|
|
a3a02e623f | ||
|
|
7a654d54f0 | ||
|
|
de2aacca90 | ||
|
|
cf1c2d7d0e | ||
|
|
13cb3bfd7b | ||
|
|
bcd2162cd7 | ||
|
|
41fcd730f0 | ||
|
|
7ab03dd912 | ||
|
|
29cfbdf8c4 | ||
|
|
7031765b4d | ||
|
|
942d15e54c | ||
|
|
6b168ccd62 | ||
|
|
551d10dec4 | ||
|
|
e1446337e9 | ||
|
|
26098114d2 | ||
|
|
1e08b70c39 | ||
|
|
e821bd68a7 | ||
|
|
9c21915bf7 | ||
|
|
1221b16c7f | ||
|
|
54738d53f9 | ||
|
|
4d83f38960 | ||
|
|
36da37c6c0 | ||
|
|
0183bd9a6d | ||
|
|
016aad49b7 | ||
|
|
a39fcdb929 | ||
|
|
0a2cfe27ac | ||
|
|
d598b36449 | ||
|
|
c0aa2db6db | ||
|
|
0d06975313 | ||
|
|
f6440ea8b7 | ||
|
|
1e936bfc12 | ||
|
|
4c5bf22638 | ||
|
|
e387dfad0a |
@@ -18,6 +18,8 @@ pubspec.lock
|
||||
*.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/
|
||||
|
||||
@@ -4,7 +4,7 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
val appPackageName = "com.qxy.dronex"
|
||||
val appPackageName = "com.dronex.rec"
|
||||
|
||||
android {
|
||||
namespace = appPackageName
|
||||
@@ -42,12 +42,6 @@ kotlin {
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -1,5 +1,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.qxy.dronex">
|
||||
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,9 @@
|
||||
<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.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
@@ -19,9 +22,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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.qxy.dronex
|
||||
package com.dronex.rec
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
@@ -6,9 +6,8 @@ import android.os.BatteryManager
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.StatFs
|
||||
import androidx.camera.view.PreviewView
|
||||
import com.qxy.dronex.recording.RecordingPlatformHandler
|
||||
import com.qxy.dronex.recording.RecordingPreviewFactory
|
||||
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
|
||||
@@ -17,15 +16,8 @@ class MainActivity : FlutterActivity() {
|
||||
private var platformHandler: RecordingPlatformHandler? = null
|
||||
private var platformInfoChannel: MethodChannel? = null
|
||||
|
||||
var recordingPreviewView: PreviewView? = null
|
||||
private set
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
flutterEngine.platformViewsController.registry.registerViewFactory(
|
||||
"recording-camera-preview",
|
||||
RecordingPreviewFactory(this),
|
||||
)
|
||||
|
||||
platformInfoChannel =
|
||||
MethodChannel(
|
||||
@@ -50,16 +42,6 @@ class MainActivity : FlutterActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
fun attachRecordingPreview(previewView: PreviewView) {
|
||||
recordingPreviewView = previewView
|
||||
}
|
||||
|
||||
fun detachRecordingPreview(previewView: PreviewView? = null) {
|
||||
if (previewView == null || recordingPreviewView === previewView) {
|
||||
recordingPreviewView = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
platformInfoChannel?.setMethodCallHandler(null)
|
||||
platformInfoChannel = null
|
||||
@@ -111,9 +93,11 @@ class MainActivity : FlutterActivity() {
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.qxy.dronex.recording
|
||||
package com.dronex.rec.recording
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.qxy.dronex.recording
|
||||
package com.dronex.rec.recording
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.qxy.dronex.recording
|
||||
package com.dronex.rec.recording
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
@@ -14,8 +14,8 @@ import android.os.PowerManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleService
|
||||
import com.qxy.dronex.AppConstants
|
||||
import com.qxy.dronex.MainActivity
|
||||
import com.dronex.rec.AppConstants
|
||||
import com.dronex.rec.MainActivity
|
||||
|
||||
class RecordingForegroundService : LifecycleService() {
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
@@ -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,10 +0,0 @@
|
||||
package com.qxy.dronex
|
||||
|
||||
object AppConstants {
|
||||
const val PACKAGE_NAME = "com.qxy.dronex"
|
||||
const val PLATFORM_INFO_CHANNEL = "$PACKAGE_NAME/platform_info"
|
||||
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,239 +0,0 @@
|
||||
package com.qxy.dronex.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,50 +0,0 @@
|
||||
package com.qxy.dronex.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,229 +0,0 @@
|
||||
package com.qxy.dronex.recording
|
||||
|
||||
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.qxy.dronex.AppConstants
|
||||
import com.qxy.dronex.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.qxy.dronex.recording
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import androidx.camera.view.PreviewView
|
||||
import com.qxy.dronex.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.qxy.dronex.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.qxy.dronex.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,
|
||||
)
|
||||
}
|
||||
|
After Width: | Height: | Size: 251 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>
|
||||
|
||||
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 9.9 KiB |
|
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>
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 509 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 833 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 293 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 633 B |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
@@ -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')"
|
||||
@@ -0,0 +1 @@
|
||||
flutter build apk --release
|
||||
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
flutter clean
|
||||
flutter pub get
|
||||
rm -rf ios/Pods
|
||||
rm -rf ios/Podfile.lock
|
||||
cd ios
|
||||
pod install
|
||||
cd ..
|
||||
@@ -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,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,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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,16 +1,101 @@
|
||||
PODS:
|
||||
- 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
|
||||
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: 5a82b772179df87e6518bddc6f9bb8b4053ce48b
|
||||
PODFILE CHECKSUM: 858401fbd980bedce6ecd2f9b429bf271f11f74b
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
64BC3B3C75FABAA128C4FE7A /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D84231833EC084C45F76D9A0 /* Pods_RunnerTests.framework */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
|
||||
87DA76CA5231F693C9392B80 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B6AA1C86CE710449CC3E8FCD /* Pods_Runner.framework */; };
|
||||
8D5D9D9E2C69000100F10001 /* RecordingPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */; };
|
||||
8E5D9D9E2C69000100F10002 /* PlatformInfoPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E5D9D9D2C69000100F10002 /* PlatformInfoPlugin.swift */; };
|
||||
@@ -54,7 +53,6 @@
|
||||
69561E7442E8CC939DB2B482 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
8D5D9D9D2C69000100F10001 /* RecordingPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordingPlugin.swift; sourceTree = "<group>"; };
|
||||
8E5D9D9D2C69000100F10002 /* PlatformInfoPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformInfoPlugin.swift; sourceTree = "<group>"; };
|
||||
@@ -86,7 +84,6 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
|
||||
87DA76CA5231F693C9392B80 /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -104,7 +101,6 @@
|
||||
DFA822DC4965C5AFAFF6BB41 /* Pods-RunnerTests.release.xcconfig */,
|
||||
99DBE6263F70650B13C33EAC /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -128,7 +124,6 @@
|
||||
9740EEB11CF90186004384FC /* Flutter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
|
||||
9740EEB21CF90195004384FC /* Debug.xcconfig */,
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
|
||||
@@ -208,15 +203,14 @@
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
7858230D5ADC7A99F778CB03 /* [CP] Embed Pods Frameworks */,
|
||||
99E9790F23C2D0C0B51A6C19 /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = Runner;
|
||||
packageProductDependencies = (
|
||||
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
|
||||
);
|
||||
productName = Runner;
|
||||
productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
@@ -250,9 +244,6 @@
|
||||
Base,
|
||||
);
|
||||
mainGroup = 97C146E51CF9000F007C117D;
|
||||
packageReferences = (
|
||||
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
|
||||
);
|
||||
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
@@ -345,6 +336,23 @@
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
7858230D5ADC7A99F778CB03 /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
@@ -360,6 +368,23 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
99E9790F23C2D0C0B51A6C19 /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -470,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.qxy.dronex;
|
||||
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";
|
||||
@@ -653,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.qxy.dronex;
|
||||
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;
|
||||
@@ -676,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.qxy.dronex;
|
||||
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";
|
||||
@@ -726,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"
|
||||
|
||||
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 622 B After Width: | Height: | Size: 976 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 945 B After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 17 KiB |
|
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
|
||||
}
|
||||
}
|
||||
|
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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,7 +4,7 @@ import UIKit
|
||||
final class PlatformInfoPlugin: NSObject, FlutterPlugin {
|
||||
static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let channel = FlutterMethodChannel(
|
||||
name: "com.qxy.dronex/platform_info",
|
||||
name: "app.record_tool/platform_info",
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
let plugin = PlatformInfoPlugin()
|
||||
@@ -62,6 +62,7 @@ final class PlatformInfoPlugin: NSObject, FlutterPlugin {
|
||||
let device = UIDevice.current
|
||||
return [
|
||||
"platform": "ios",
|
||||
"deviceCode": "",
|
||||
"brand": device.systemName,
|
||||
"model": machineIdentifier(),
|
||||
"systemVersion": device.systemVersion,
|
||||
|
||||
@@ -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,14 +129,30 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
var statusListener: (([String: Any]) -> Void)?
|
||||
|
||||
func attach(previewView: RecordingPreviewView) {
|
||||
self.previewView = previewView
|
||||
previewView.previewLayer.session = session
|
||||
let bindPreview = { [weak self, weak previewView] in
|
||||
guard let self, let previewView else { return }
|
||||
self.previewView = previewView
|
||||
previewView.previewLayer.session = self.session
|
||||
}
|
||||
if Thread.isMainThread {
|
||||
bindPreview()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: bindPreview)
|
||||
}
|
||||
}
|
||||
|
||||
func detach(previewView: RecordingPreviewView) {
|
||||
if self.previewView === previewView {
|
||||
self.previewView?.previewLayer.session = nil
|
||||
self.previewView = nil
|
||||
let unbindPreview = { [weak self, weak previewView] in
|
||||
guard let self, let previewView else { return }
|
||||
if self.previewView === previewView {
|
||||
previewView.previewLayer.session = nil
|
||||
self.previewView = nil
|
||||
}
|
||||
}
|
||||
if Thread.isMainThread {
|
||||
unbindPreview()
|
||||
} else {
|
||||
DispatchQueue.main.async(execute: unbindPreview)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,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)
|
||||
@@ -238,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)
|
||||
}
|
||||
@@ -275,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))
|
||||
|
||||
@@ -296,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,
|
||||
@@ -306,8 +371,8 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
pendingStopResult = nil
|
||||
|
||||
if let error {
|
||||
latestGallerySaved = false
|
||||
latestGalleryErrorMessage = error.localizedDescription
|
||||
latestFileSaved = false
|
||||
latestFileErrorMessage = error.localizedDescription
|
||||
updateStatus(
|
||||
RecordingStatus(
|
||||
state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
|
||||
@@ -315,29 +380,30 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
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?) {
|
||||
@@ -347,79 +413,23 @@ 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 {
|
||||
guard let videoDevice = Self.preferredVideoDevice() else {
|
||||
throw NSError(
|
||||
domain: "RecordingCamera", code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "No camera device available"])
|
||||
@@ -449,9 +459,74 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
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() }
|
||||
@@ -486,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 {
|
||||
@@ -504,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
|
||||
}
|
||||
@@ -528,9 +633,9 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
}
|
||||
|
||||
private enum RecordingChannelNames {
|
||||
static let packageName = "com.qxy.dronex"
|
||||
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 {
|
||||
@@ -571,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":
|
||||
|
||||
@@ -1,46 +1,16 @@
|
||||
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(
|
||||
@@ -74,7 +44,7 @@ class _FlutterTemplateAppState extends ConsumerState<FlutterTemplateApp>
|
||||
home: RefreshConfiguration(
|
||||
enableLoadingWhenNoData: false,
|
||||
headerTriggerDistance: 80.h,
|
||||
child: const RecordingPage(),
|
||||
child: const AuthPageWidget(),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -3,10 +3,14 @@ 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:recording_tool/core/network/app_dio.dart';
|
||||
import 'package:recording_tool/core/platform/app_platform_info.dart';
|
||||
|
||||
class AppBootstrapper {
|
||||
@@ -25,7 +29,21 @@ class AppBootstrapper {
|
||||
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -8,11 +8,13 @@ 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 {
|
||||
@@ -21,9 +23,11 @@ class AppConfig {
|
||||
static late EnvironmentValues current;
|
||||
static AppPackageInfo? packageInfo;
|
||||
|
||||
static const appName = '飞行极控录像工作台';
|
||||
static const appName = 'SportsX裁判工作台';
|
||||
static const designSize = Size(375, 812);
|
||||
|
||||
/// 主裁判计分 H5 链接
|
||||
|
||||
static void configure({
|
||||
required AppEnvironment environment,
|
||||
AppPackageInfo? packageInfo,
|
||||
@@ -32,17 +36,24 @@ class AppConfig {
|
||||
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,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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}) {
|
||||
|
||||
@@ -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 => '证书校验失败',
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -31,6 +31,7 @@ class AppDeviceInfo {
|
||||
required this.platform,
|
||||
required this.isPhysicalDevice,
|
||||
required this.values,
|
||||
required this.deviceCode,
|
||||
});
|
||||
|
||||
factory AppDeviceInfo.fromMap(Map<Object?, Object?> map) {
|
||||
@@ -48,19 +49,21 @@ class 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(
|
||||
'com.qxy.dronex/platform_info',
|
||||
'app.record_tool/platform_info',
|
||||
);
|
||||
|
||||
static Future<AppPackageInfo> packageInfo() async {
|
||||
|
||||
@@ -28,4 +28,12 @@ class DeviceUtils {
|
||||
}
|
||||
return (await AppPlatformInfo.deviceInfo()).values;
|
||||
}
|
||||
|
||||
static Future<String> deviceCode() async {
|
||||
try {
|
||||
return (await AppPlatformInfo.deviceInfo()).deviceCode.trim();
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 由本机 Wi‑Fi 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');
|
||||
//
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import 'dart:async';
|
||||
|
||||
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/core/utils/util_search_nasIp.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 {
|
||||
// 静默探测 NAS,不阻塞登录 / 自动跳转
|
||||
unawaited(UtilSearchNasIp.discover());
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/legacy.dart';
|
||||
import 'package:recording_tool/core/cache/app_storage.dart';
|
||||
import 'package:recording_tool/core/cache/storage_keys.dart';
|
||||
import 'package:recording_tool/core/network/api_exception.dart';
|
||||
import 'package:recording_tool/features/auth/model/model_auth.dart';
|
||||
import 'package:recording_tool/features/auth/server/server_auth.dart';
|
||||
import 'package:recording_tool/features/auth/state/state_auth.dart';
|
||||
|
||||
final authProvider =
|
||||
StateNotifierProvider.autoDispose<AuthViewModel, AuthState>((ref) {
|
||||
return AuthViewModel(ref);
|
||||
});
|
||||
|
||||
class AuthViewModel extends StateNotifier<AuthState> {
|
||||
AuthViewModel(this._ref) : super(const AuthState());
|
||||
final Ref _ref;
|
||||
|
||||
/// 鉴权获取 token
|
||||
Future<bool> auth(String code) async {
|
||||
final passCode = code.trim();
|
||||
if (passCode.isEmpty) {
|
||||
state = const AuthState(errorMessage: '请输入执裁口令');
|
||||
return false;
|
||||
}
|
||||
|
||||
state = const AuthState(isLoading: true);
|
||||
try {
|
||||
final data = await AuthServer.login(passCode, _ref);
|
||||
await AppStorage.setString(StorageKeys.authToken, data.deviceAccessToken);
|
||||
state = const AuthState();
|
||||
if (data.deviceAccessToken.isNotEmpty) {
|
||||
final ok = await parseTokenSetState();
|
||||
if (!ok) {
|
||||
state = const AuthState(errorMessage: '获取赛事信息失败,请重试');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} on FormatException catch (error) {
|
||||
state = AuthState(errorMessage: error.message);
|
||||
return false;
|
||||
} on ApiException catch (error) {
|
||||
state = AuthState(errorMessage: error.message);
|
||||
return false;
|
||||
} catch (_) {
|
||||
state = const AuthState(errorMessage: '认证失败,请重试');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 TOKEN,并更新状态
|
||||
Future<bool> parseTokenSetState() async {
|
||||
final data = await AuthServer.getLoginDeviceInfo(_ref);
|
||||
if (data == null) return false;
|
||||
state = state.copyWith(jwtDecodedData: data);
|
||||
return true;
|
||||
// final decoded = JwtDecoder.decode(token);
|
||||
// final rawData = decoded['data'];
|
||||
// if (rawData is! Map) return false;
|
||||
// try {
|
||||
// final data = JwtDecodedData.fromJson(Map<String, dynamic>.from(rawData));
|
||||
// state = state.copyWith(jwtDecodedData: data);
|
||||
// return true;
|
||||
// } catch (error) {
|
||||
// debugPrint('认证失败,请重试: $error');
|
||||
// return false;
|
||||
// }
|
||||
}
|
||||
|
||||
/// 获取赛事列表
|
||||
Future<bool> getRecordList(String eventName) async {
|
||||
if (eventName.isEmpty) return false;
|
||||
final nasIpAddr = state.jwtDecodedData?.nasIpAddr?.trim() ?? '';
|
||||
if (nasIpAddr.isEmpty) return false;
|
||||
final data = await AuthServer.getRecordList(
|
||||
_ref,
|
||||
path: eventName,
|
||||
nasIpAddr: nasIpAddr,
|
||||
);
|
||||
if (data == null) return false;
|
||||
if (data.items == null) return false;
|
||||
state = state.copyWith(recordList: data.items);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 获取指定目录的录像列表(不更新 state,用于目录下钻)
|
||||
Future<List<RecordListItem>?> fetchRecordList(String path) async {
|
||||
if (path.isEmpty) return null;
|
||||
final nasIpAddr = state.jwtDecodedData?.nasIpAddr?.trim() ?? '';
|
||||
if (nasIpAddr.isEmpty) return null;
|
||||
final data = await AuthServer.getRecordList(
|
||||
_ref,
|
||||
path: path,
|
||||
nasIpAddr: nasIpAddr,
|
||||
);
|
||||
return data?.items;
|
||||
}
|
||||
|
||||
/// 清空授权信息(本地 token + 内存状态)
|
||||
Future<void> clearAuth() async {
|
||||
await AppStorage.remove(StorageKeys.authToken);
|
||||
state = const AuthState();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
class CompetitionPlayer {
|
||||
const CompetitionPlayer({required this.id, required this.name});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
}
|
||||
|
||||
class CompetitionTeam {
|
||||
const CompetitionTeam({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.players,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final List<CompetitionPlayer> players;
|
||||
|
||||
String get playerNames => players.map((player) => player.name).join('、');
|
||||
}
|
||||
|
||||
class CompetitionMatchup {
|
||||
const CompetitionMatchup({
|
||||
required this.id,
|
||||
required this.teamA,
|
||||
required this.teamB,
|
||||
this.winnerTeamId,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final CompetitionTeam teamA;
|
||||
final CompetitionTeam teamB;
|
||||
final String? winnerTeamId;
|
||||
|
||||
CompetitionMatchup copyWith({String? winnerTeamId}) {
|
||||
return CompetitionMatchup(
|
||||
id: id,
|
||||
teamA: teamA,
|
||||
teamB: teamB,
|
||||
winnerTeamId: winnerTeamId ?? this.winnerTeamId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 参赛项目/组别列表项(来自 /api/events/device/item/group)
|
||||
class CompetitionTeamListItem {
|
||||
const CompetitionTeamListItem({
|
||||
required this.eventId,
|
||||
required this.itemId,
|
||||
required this.name,
|
||||
required this.groupName,
|
||||
this.matchStartTime = '',
|
||||
this.matchEndTime = '',
|
||||
this.matchups = const [],
|
||||
});
|
||||
|
||||
final String eventId;
|
||||
final String itemId;
|
||||
final String name;
|
||||
final String groupName;
|
||||
final String matchStartTime;
|
||||
final String matchEndTime;
|
||||
|
||||
/// 对阵明细暂未由列表接口返回,默认空
|
||||
final List<CompetitionMatchup> matchups;
|
||||
|
||||
String get title => groupName.isEmpty ? name : '$name ($groupName)';
|
||||
|
||||
String get scheduleTime {
|
||||
if (matchStartTime.isEmpty && matchEndTime.isEmpty) return '';
|
||||
if (matchStartTime.isNotEmpty && matchEndTime.isNotEmpty) {
|
||||
return '$matchStartTime-$matchEndTime';
|
||||
}
|
||||
if (matchStartTime.isNotEmpty) return matchStartTime;
|
||||
return matchEndTime;
|
||||
}
|
||||
|
||||
factory CompetitionTeamListItem.fromJson(Map<String, dynamic> json) {
|
||||
return CompetitionTeamListItem(
|
||||
eventId: (json['eventId'] ?? '').toString(),
|
||||
itemId: (json['itemId'] ?? '').toString(),
|
||||
name: (json['name'] ?? '').toString(),
|
||||
groupName: (json['groupName'] ?? '').toString(),
|
||||
matchStartTime: (json['matchStartTime'] ?? '').toString(),
|
||||
matchEndTime: (json['matchEndTime'] ?? '').toString(),
|
||||
);
|
||||
}
|
||||
|
||||
CompetitionTeamListItem copyWith({List<CompetitionMatchup>? matchups}) {
|
||||
return CompetitionTeamListItem(
|
||||
eventId: eventId,
|
||||
itemId: itemId,
|
||||
name: name,
|
||||
groupName: groupName,
|
||||
matchStartTime: matchStartTime,
|
||||
matchEndTime: matchEndTime,
|
||||
matchups: matchups ?? this.matchups,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CompetitionTeamPageResult {
|
||||
const CompetitionTeamPageResult({
|
||||
required this.items,
|
||||
required this.total,
|
||||
required this.page,
|
||||
required this.pageSize,
|
||||
});
|
||||
|
||||
final List<CompetitionTeamListItem> items;
|
||||
final int total;
|
||||
final int page;
|
||||
final int pageSize;
|
||||
|
||||
bool get hasMore => page * pageSize < total;
|
||||
|
||||
factory CompetitionTeamPageResult.fromJson(
|
||||
dynamic json, {
|
||||
required int page,
|
||||
required int pageSize,
|
||||
}) {
|
||||
if (json is List) {
|
||||
// 整表无分页:本页即全部,hasMore = false
|
||||
final items = json
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(item) => CompetitionTeamListItem.fromJson(
|
||||
Map<String, dynamic>.from(item),
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
final effectivePageSize = items.isEmpty ? pageSize : items.length;
|
||||
return CompetitionTeamPageResult(
|
||||
items: items,
|
||||
total: items.length,
|
||||
page: page,
|
||||
pageSize: effectivePageSize,
|
||||
);
|
||||
}
|
||||
|
||||
if (json is Map) {
|
||||
final map = Map<String, dynamic>.from(json);
|
||||
final rawItems =
|
||||
map['items'] ?? map['rows'] ?? map['list'] ?? map['records'];
|
||||
final items = rawItems is List
|
||||
? rawItems
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(item) => CompetitionTeamListItem.fromJson(
|
||||
Map<String, dynamic>.from(item),
|
||||
),
|
||||
)
|
||||
.toList(growable: false)
|
||||
: const <CompetitionTeamListItem>[];
|
||||
final total = (map['total'] as num?)?.toInt() ?? items.length;
|
||||
return CompetitionTeamPageResult(
|
||||
items: items,
|
||||
total: total,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
);
|
||||
}
|
||||
|
||||
return CompetitionTeamPageResult(
|
||||
items: const [],
|
||||
total: 0,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/// 参赛队伍详情
|
||||
class CompetitionTeamDetail {
|
||||
String? scheduleId;
|
||||
String? itemId;
|
||||
String? eventId;
|
||||
String? itemName;
|
||||
String? groupName;
|
||||
String? matchPlace;
|
||||
String? matchStartTime;
|
||||
String? matchEndTime;
|
||||
List<Matchup>? matchups;
|
||||
|
||||
CompetitionTeamDetail({
|
||||
this.scheduleId,
|
||||
this.itemId,
|
||||
this.eventId,
|
||||
this.itemName,
|
||||
this.groupName,
|
||||
this.matchPlace,
|
||||
this.matchStartTime,
|
||||
this.matchEndTime,
|
||||
this.matchups,
|
||||
});
|
||||
|
||||
factory CompetitionTeamDetail.fromJson(Map<String, dynamic> json) =>
|
||||
CompetitionTeamDetail(
|
||||
scheduleId: json['scheduleId']?.toString(),
|
||||
itemId: json['itemId']?.toString(),
|
||||
eventId: json['eventId']?.toString(),
|
||||
itemName: json['itemName']?.toString(),
|
||||
groupName: json['groupName']?.toString(),
|
||||
matchPlace: json['matchPlace']?.toString(),
|
||||
matchStartTime: json['matchStartTime']?.toString(),
|
||||
matchEndTime: json['matchEndTime']?.toString(),
|
||||
matchups: json['matchups'] == null
|
||||
? const []
|
||||
: List<Matchup>.from(
|
||||
(json['matchups'] as List).whereType<Map>().map(
|
||||
(x) => Matchup.fromJson(Map<String, dynamic>.from(x)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// 兼容 data 为对象或数组(取首项)
|
||||
factory CompetitionTeamDetail.fromResponse(dynamic json) {
|
||||
if (json is List) {
|
||||
if (json.isEmpty) return CompetitionTeamDetail(matchups: const []);
|
||||
final first = json.first;
|
||||
if (first is Map) {
|
||||
return CompetitionTeamDetail.fromJson(Map<String, dynamic>.from(first));
|
||||
}
|
||||
return CompetitionTeamDetail(matchups: const []);
|
||||
}
|
||||
if (json is Map) {
|
||||
return CompetitionTeamDetail.fromJson(Map<String, dynamic>.from(json));
|
||||
}
|
||||
return CompetitionTeamDetail(matchups: const []);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'scheduleId': scheduleId,
|
||||
'itemId': itemId,
|
||||
'eventId': eventId,
|
||||
'itemName': itemName,
|
||||
'groupName': groupName,
|
||||
'matchPlace': matchPlace,
|
||||
'matchStartTime': matchStartTime,
|
||||
'matchEndTime': matchEndTime,
|
||||
'matchups': matchups == null
|
||||
? []
|
||||
: List<dynamic>.from(matchups!.map((x) => x.toJson())),
|
||||
};
|
||||
}
|
||||
|
||||
class Matchup {
|
||||
String? matchTitle;
|
||||
String? opponentId;
|
||||
List<Team>? teamA;
|
||||
List<Team>? teamB;
|
||||
|
||||
Matchup({this.matchTitle, this.opponentId, this.teamA, this.teamB});
|
||||
|
||||
factory Matchup.fromJson(Map<String, dynamic> json) => Matchup(
|
||||
matchTitle: json['matchTitle']?.toString(),
|
||||
opponentId: json['opponentId']?.toString(),
|
||||
teamA: json['teamA'] == null
|
||||
? const []
|
||||
: List<Team>.from(
|
||||
(json['teamA'] as List).whereType<Map>().map(
|
||||
(x) => Team.fromJson(Map<String, dynamic>.from(x)),
|
||||
),
|
||||
),
|
||||
teamB: json['teamB'] == null
|
||||
? const []
|
||||
: List<Team>.from(
|
||||
(json['teamB'] as List).whereType<Map>().map(
|
||||
(x) => Team.fromJson(Map<String, dynamic>.from(x)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'matchTitle': matchTitle,
|
||||
'opponentId': opponentId,
|
||||
'teamA': teamA == null
|
||||
? []
|
||||
: List<dynamic>.from(teamA!.map((x) => x.toJson())),
|
||||
'teamB': teamB == null
|
||||
? []
|
||||
: List<dynamic>.from(teamB!.map((x) => x.toJson())),
|
||||
};
|
||||
}
|
||||
|
||||
class Team {
|
||||
String? teamName;
|
||||
List<Player>? players;
|
||||
|
||||
Team({this.teamName, this.players});
|
||||
|
||||
String get playerNames => (players ?? const <Player>[])
|
||||
.map((player) => player.name?.trim() ?? '')
|
||||
.where((name) => name.isNotEmpty)
|
||||
.join('、');
|
||||
|
||||
/// 队长 Player.id;无队长则取首个选手
|
||||
String? get leaderUserId {
|
||||
final list = players ?? const <Player>[];
|
||||
for (final player in list) {
|
||||
final id = player.id?.trim() ?? '';
|
||||
if (player.isLeader == true && id.isNotEmpty) return id;
|
||||
}
|
||||
if (list.isEmpty) return null;
|
||||
final firstId = list.first.id?.trim() ?? '';
|
||||
return firstId.isEmpty ? null : firstId;
|
||||
}
|
||||
|
||||
factory Team.fromJson(Map<String, dynamic> json) => Team(
|
||||
teamName: json['teamName']?.toString(),
|
||||
players: json['players'] == null
|
||||
? const []
|
||||
: List<Player>.from(
|
||||
(json['players'] as List).whereType<Map>().map(
|
||||
(x) => Player.fromJson(Map<String, dynamic>.from(x)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'teamName': teamName,
|
||||
'players': players == null
|
||||
? []
|
||||
: List<dynamic>.from(players!.map((x) => x.toJson())),
|
||||
};
|
||||
}
|
||||
|
||||
class Player {
|
||||
String? id;
|
||||
String? name;
|
||||
bool? isLeader;
|
||||
|
||||
Player({this.id, this.name, this.isLeader});
|
||||
|
||||
factory Player.fromJson(Map<String, dynamic> json) => Player(
|
||||
id: json['id']?.toString(),
|
||||
name: json['name']?.toString(),
|
||||
isLeader: json['isLeader'] as bool?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'isLeader': isLeader,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:recording_tool/core/network/api_exception.dart';
|
||||
import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart';
|
||||
import 'package:recording_tool/features/competition_teams/model/model_competition_team_detail.dart';
|
||||
import 'package:recording_tool/features/competition_teams/widgets/widget_manual_winner_dialog.dart';
|
||||
import 'package:recording_tool/features/events/request_model/request_model_event.dart';
|
||||
import 'package:recording_tool/features/events/server/server_events.dart';
|
||||
import 'package:recording_tool/gen/assets.gen.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_bar.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_empty_view.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_toast.dart';
|
||||
|
||||
class CompetitionTeamDetailPage extends ConsumerStatefulWidget {
|
||||
const CompetitionTeamDetailPage({super.key, required this.detail});
|
||||
|
||||
final CompetitionTeamDetail detail;
|
||||
|
||||
@override
|
||||
ConsumerState<CompetitionTeamDetailPage> createState() =>
|
||||
_CompetitionTeamDetailPageState();
|
||||
}
|
||||
|
||||
class _CompetitionTeamDetailPageState
|
||||
extends ConsumerState<CompetitionTeamDetailPage> {
|
||||
/// matchupIndex -> winner side index(0=teamA,1=teamB)
|
||||
final Map<int, int> _winners = {};
|
||||
|
||||
CompetitionTeamDetail get _detail => widget.detail;
|
||||
|
||||
String get _title {
|
||||
final name = _detail.itemName?.trim() ?? '';
|
||||
final group = _detail.groupName?.trim() ?? '';
|
||||
if (name.isEmpty) return '参赛队伍';
|
||||
return group.isEmpty ? name : '$name ($group)';
|
||||
}
|
||||
|
||||
List<Matchup> get _matchups => _detail.matchups ?? const <Matchup>[];
|
||||
|
||||
CompetitionTeam _toCompetitionTeam(Team team, {required String fallbackId}) {
|
||||
return CompetitionTeam(
|
||||
id: team.leaderUserId ?? fallbackId,
|
||||
name: team.teamName?.trim().isNotEmpty == true ? team.teamName! : '未命名队伍',
|
||||
players: (team.players ?? const <Player>[])
|
||||
.map(
|
||||
(player) => CompetitionPlayer(
|
||||
id: player.id?.trim() ?? '',
|
||||
name: player.name?.trim().isNotEmpty == true
|
||||
? player.name!
|
||||
: '选手',
|
||||
),
|
||||
)
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
CompetitionMatchup? _toCompetitionMatchup(Matchup matchup, int index) {
|
||||
final teamA = matchup.teamA;
|
||||
final teamB = matchup.teamB;
|
||||
if (teamA == null || teamA.isEmpty || teamB == null || teamB.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
final winnerSide = _winners[index];
|
||||
final convertedA = _toCompetitionTeam(teamA.first, fallbackId: 'team-a');
|
||||
final convertedB = _toCompetitionTeam(teamB.first, fallbackId: 'team-b');
|
||||
return CompetitionMatchup(
|
||||
id: '${_detail.scheduleId ?? _detail.itemId ?? 'match'}-$index',
|
||||
teamA: convertedA,
|
||||
teamB: convertedB,
|
||||
winnerTeamId: winnerSide == 0
|
||||
? convertedA.id
|
||||
: winnerSide == 1
|
||||
? convertedB.id
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final matchups = _matchups;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF2F4F7),
|
||||
appBar: AppPageBar(title: _title),
|
||||
body: matchups.isEmpty
|
||||
? const AppEmptyView(message: '暂无对阵信息')
|
||||
: ListView.separated(
|
||||
padding: EdgeInsets.only(bottom: 36.h),
|
||||
itemCount: matchups.length,
|
||||
separatorBuilder: (_, _) => SizedBox(height: 12.h),
|
||||
itemBuilder: (context, index) {
|
||||
final raw = matchups[index];
|
||||
final matchup = _toCompetitionMatchup(raw, index);
|
||||
if (matchup == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return _MatchupCard(
|
||||
matchup: matchup,
|
||||
onManualProcess: () => _handleManualProcess(raw, index),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Team? _sideTeam(Matchup matchup, {required bool isTeamA}) {
|
||||
final list = isTeamA ? matchup.teamA : matchup.teamB;
|
||||
if (list == null || list.isEmpty) return null;
|
||||
return list.first;
|
||||
}
|
||||
|
||||
Future<void> _handleManualProcess(Matchup raw, int index) async {
|
||||
final winnerSide = await ManualWinnerDialog.show(
|
||||
context,
|
||||
matchup: raw,
|
||||
initialWinnerSideIndex: _winners[index],
|
||||
);
|
||||
if (winnerSide == null || !mounted) return;
|
||||
|
||||
final teamA = _sideTeam(raw, isTeamA: true);
|
||||
final teamB = _sideTeam(raw, isTeamA: false);
|
||||
final winnerTeam = winnerSide == 0 ? teamA : teamB;
|
||||
final loserTeam = winnerSide == 0 ? teamB : teamA;
|
||||
|
||||
final winnerLeaderId = winnerTeam?.leaderUserId;
|
||||
final loserLeaderId = loserTeam?.leaderUserId;
|
||||
if (winnerLeaderId == null ||
|
||||
winnerLeaderId.isEmpty ||
|
||||
loserLeaderId == null ||
|
||||
loserLeaderId.isEmpty) {
|
||||
AppToast.show('无法识别双方队长');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ref
|
||||
.read(eventsServerProvider)
|
||||
.setTeamStatus(
|
||||
req: SetTeamStatusReq(
|
||||
scheduleId: _detail.scheduleId ?? '',
|
||||
opponentId: raw.opponentId ?? '',
|
||||
teamScore: [
|
||||
TeamScore(
|
||||
userId: winnerLeaderId,
|
||||
firstHalfScore: 0,
|
||||
secondHalfScore: 0,
|
||||
decisiveScore: 0,
|
||||
ifWithdraw: true,
|
||||
),
|
||||
TeamScore(
|
||||
userId: loserLeaderId,
|
||||
firstHalfScore: 0,
|
||||
secondHalfScore: 0,
|
||||
decisiveScore: 0,
|
||||
ifWithdraw: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _winners[index] = winnerSide);
|
||||
final winnerName = winnerTeam?.teamName?.trim().isNotEmpty == true
|
||||
? winnerTeam!.teamName!
|
||||
: '胜方';
|
||||
AppToast.show('已设置$winnerName直接获胜');
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
final message = error is ApiException && error.message.isNotEmpty
|
||||
? error.message
|
||||
: '设置失败,请重试';
|
||||
AppToast.show(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MatchupCard extends StatelessWidget {
|
||||
const _MatchupCard({required this.matchup, required this.onManualProcess});
|
||||
|
||||
/// 背景图 image_team_vs_bg.png 的原始宽高比(1920 x 318)。
|
||||
static const double _vsBgAspectRatio = 1920 / 318;
|
||||
|
||||
final CompetitionMatchup matchup;
|
||||
final VoidCallback onManualProcess;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
key: ValueKey('competition-matchup-${matchup.id}'),
|
||||
color: Colors.white,
|
||||
padding: EdgeInsets.fromLTRB(12.w, 4.h, 12.w, 24.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
key: ValueKey('manual-process-${matchup.id}'),
|
||||
onPressed: onManualProcess,
|
||||
style: TextButton.styleFrom(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Text(
|
||||
'人工处理',
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: const Color(0xFF078AF2),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
AspectRatio(
|
||||
aspectRatio: _vsBgAspectRatio,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: AssetImage(Assets.images.imageTeamVsBg.path),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TeamPanel(
|
||||
team: matchup.teamA,
|
||||
isLeft: true,
|
||||
winner: matchup.winnerTeamId == matchup.teamA.id,
|
||||
accentColor: const Color(0xFFFF6B75),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _TeamPanel(
|
||||
team: matchup.teamB,
|
||||
isLeft: false,
|
||||
winner: matchup.winnerTeamId == matchup.teamB.id,
|
||||
accentColor: const Color(0xFF12A6C8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TeamPanel extends StatelessWidget {
|
||||
const _TeamPanel({
|
||||
required this.team,
|
||||
required this.isLeft,
|
||||
required this.winner,
|
||||
required this.accentColor,
|
||||
});
|
||||
|
||||
final CompetitionTeam team;
|
||||
|
||||
/// 左半区(红色面板,右对齐、略偏上);右半区(蓝色面板,左对齐、略偏下)。
|
||||
final bool isLeft;
|
||||
final bool winner;
|
||||
final Color accentColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textAlign = isLeft ? TextAlign.end : TextAlign.start;
|
||||
|
||||
final nameRow = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
team.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: textAlign,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF282E37),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (winner) ...[
|
||||
SizedBox(width: 4.w),
|
||||
Icon(
|
||||
Icons.emoji_events,
|
||||
key: ValueKey('winner-${team.id}'),
|
||||
size: 14.r,
|
||||
color: accentColor,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return Padding(
|
||||
// 中线两侧留出 VS 图案空间,外侧避开斜切边缘。
|
||||
padding: isLeft
|
||||
? EdgeInsets.only(left: 16.w, right: 30.w, bottom: 10.h)
|
||||
: EdgeInsets.only(left: 30.w, right: 16.w, top: 10.h),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: isLeft
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
nameRow,
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
team.playerNames,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: textAlign,
|
||||
style: TextStyle(fontSize: 13.sp, color: const Color(0xFF4E5662)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import 'package:flutter/material.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/features/competition_teams/model/model_competition_team.dart';
|
||||
import 'package:recording_tool/features/competition_teams/pages/page_competition_team_detail.dart';
|
||||
import 'package:recording_tool/features/competition_teams/server/server_competition_teams.dart';
|
||||
import 'package:recording_tool/features/competition_teams/view_model/view_model_competition_teams.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_bar.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_empty_view.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_error_view.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_loading_view.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_refresh_list.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_toast.dart';
|
||||
|
||||
class CompetitionTeamListPage extends ConsumerStatefulWidget {
|
||||
const CompetitionTeamListPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<CompetitionTeamListPage> createState() =>
|
||||
_CompetitionTeamListPageState();
|
||||
}
|
||||
|
||||
class _CompetitionTeamListPageState
|
||||
extends ConsumerState<CompetitionTeamListPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(competitionTeamsProvider.notifier).loadInitial();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(competitionTeamsProvider);
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F6F8),
|
||||
appBar: AppPageBar(title: '参赛队伍'),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
if (state.isInitialLoading && state.items.isEmpty) {
|
||||
return const AppLoadingView(message: '正在加载参赛队伍...');
|
||||
}
|
||||
if (state.errorMessage != null && state.items.isEmpty) {
|
||||
return AppErrorView(
|
||||
message: state.errorMessage!,
|
||||
onRetry: () =>
|
||||
ref.read(competitionTeamsProvider.notifier).loadInitial(),
|
||||
);
|
||||
}
|
||||
|
||||
return AppRefreshList<CompetitionTeamListItem>(
|
||||
items: state.items,
|
||||
onRefresh: ref.read(competitionTeamsProvider.notifier).refresh,
|
||||
onLoadMore: ref.read(competitionTeamsProvider.notifier).loadMore,
|
||||
enablePullUp: state.hasMore,
|
||||
padding: EdgeInsets.fromLTRB(10.w, 10.h, 10.w, 24.h),
|
||||
separator: SizedBox(height: 8.h),
|
||||
empty: const AppEmptyView(message: '暂无参赛队伍'),
|
||||
itemBuilder: (context, item, index) {
|
||||
return _CompetitionScheduleCard(
|
||||
item: item,
|
||||
onTap: () async {
|
||||
try {
|
||||
final detail = await ref
|
||||
.read(competitionTeamsServerProvider)
|
||||
.fetchTeamDetail(
|
||||
itemId: item.itemId,
|
||||
eventId: item.eventId,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
AppNavigator.push(
|
||||
CompetitionTeamDetailPage(detail: detail),
|
||||
context: context,
|
||||
);
|
||||
} catch (error) {
|
||||
AppToast.show('对阵详情加载失败,请重试');
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CompetitionScheduleCard extends StatelessWidget {
|
||||
const _CompetitionScheduleCard({required this.item, required this.onTap});
|
||||
|
||||
final CompetitionTeamListItem item;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
key: ValueKey('competition-team-item-${item.itemId}'),
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
child: Container(
|
||||
constraints: BoxConstraints(minHeight: 86.h),
|
||||
padding: EdgeInsets.fromLTRB(12.w, 10.h, 14.w, 10.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(6.r),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
height: 1.2,
|
||||
color: const Color(0xFF30343A),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
_MetaText(_formatScheduleTime(item)),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
size: 20.r,
|
||||
color: const Color(0xFF9AA3AF),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetaText extends StatelessWidget {
|
||||
const _MetaText(this.text);
|
||||
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 10.sp,
|
||||
height: 1.2,
|
||||
color: const Color(0xFF6E747D),
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _formatScheduleTime(CompetitionTeamListItem item) {
|
||||
final start = DateTime.tryParse(item.matchStartTime);
|
||||
final end = DateTime.tryParse(item.matchEndTime);
|
||||
if (start == null && end == null) {
|
||||
return item.scheduleTime.isEmpty ? '时间待定' : item.scheduleTime;
|
||||
}
|
||||
if (start != null && end != null) {
|
||||
return '${start.month}月${start.day}日 ${_formatClock(start)}-${_formatClock(end)}';
|
||||
}
|
||||
final value = start ?? end!;
|
||||
return '${value.month}月${value.day}日 ${_formatClock(value)}';
|
||||
}
|
||||
|
||||
String _formatClock(DateTime value) {
|
||||
return '${value.hour}:${value.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||