Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fca76f679 | ||
|
|
a324825e8a | ||
|
|
6793ca53af | ||
|
|
9e72bc522e | ||
|
|
1096645e79 | ||
|
|
13ecb2fccc | ||
|
|
e3f9c8ebc0 | ||
|
|
6790ea41b2 | ||
|
|
b2dd593e93 | ||
|
|
5ffe524e20 | ||
|
|
356636c4b4 | ||
|
|
686db244fd | ||
|
|
20d501f673 | ||
|
|
ddc1c9bf98 | ||
|
|
30d317c0b9 | ||
|
|
10d9eee60e | ||
|
|
00937bf4b6 | ||
|
|
3c3baede85 | ||
|
|
8c127dd0e7 | ||
|
|
1264ebdd7c | ||
|
|
d6e80df5bf | ||
|
|
4ff93edaab | ||
|
|
1f0e0407ab | ||
|
|
da52dc9797 | ||
|
|
e5b6246458 | ||
|
|
4e0a675198 | ||
|
|
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 |
@@ -19,6 +19,7 @@ pubspec.lock
|
|||||||
*.iws
|
*.iws
|
||||||
.idea/
|
.idea/
|
||||||
.cursor
|
.cursor
|
||||||
|
Podfile.lock
|
||||||
|
|
||||||
# The .vscode folder contains launch configuration and tasks you configure in
|
# 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
|
# VS Code which you may wish to be included in version control, so this line
|
||||||
@@ -47,3 +48,7 @@ app.*.map.json
|
|||||||
/android/app/release
|
/android/app/release
|
||||||
/android/.kotlin
|
/android/.kotlin
|
||||||
|
|
||||||
|
CLAUDE.md
|
||||||
|
AGENTS.md
|
||||||
|
test/
|
||||||
|
script
|
||||||
@@ -4,7 +4,7 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
val appPackageName = "com.qxy.dronex"
|
val appPackageName = "com.dronex.rec"
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = appPackageName
|
namespace = appPackageName
|
||||||
@@ -42,12 +42,6 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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.lifecycle:lifecycle-service:2.8.7")
|
||||||
implementation("androidx.core:core-ktx:1.15.0")
|
implementation("androidx.core:core-ktx:1.15.0")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<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.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
@@ -10,7 +10,6 @@
|
|||||||
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
|
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
|
||||||
<uses-permission
|
<uses-permission
|
||||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
android:maxSdkVersion="28" />
|
android:maxSdkVersion="28" />
|
||||||
@@ -20,9 +19,10 @@
|
|||||||
android:required="true" />
|
android:required="true" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="飞行极控录像工作台"
|
android:label="SportsX裁判工作台"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:usesCleartextTraffic="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -57,4 +57,4 @@
|
|||||||
<data android:mimeType="text/plain" />
|
<data android:mimeType="text/plain" />
|
||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.dronex.rec
|
||||||
|
|
||||||
|
object AppConstants {
|
||||||
|
private const val CHANNEL_NAMESPACE = "app.record_tool"
|
||||||
|
const val PLATFORM_INFO_CHANNEL = "$CHANNEL_NAMESPACE/platform_info"
|
||||||
|
const val RECORDING_METHOD_CHANNEL = "$CHANNEL_NAMESPACE/recording"
|
||||||
|
const val RECORDING_EVENT_CHANNEL = "$CHANNEL_NAMESPACE/recording_events"
|
||||||
|
const val RECORDING_ACTION_START = "$CHANNEL_NAMESPACE.recording.START"
|
||||||
|
const val RECORDING_ACTION_STOP = "$CHANNEL_NAMESPACE.recording.STOP"
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.qxy.dronex
|
package com.dronex.rec
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.pm.ApplicationInfo
|
import android.content.pm.ApplicationInfo
|
||||||
@@ -6,9 +6,8 @@ import android.os.BatteryManager
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.os.StatFs
|
import android.os.StatFs
|
||||||
import androidx.camera.view.PreviewView
|
import android.provider.Settings
|
||||||
import com.qxy.dronex.recording.RecordingPlatformHandler
|
import com.dronex.rec.recording.RecordingPlatformHandler
|
||||||
import com.qxy.dronex.recording.RecordingPreviewFactory
|
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
@@ -17,15 +16,8 @@ class MainActivity : FlutterActivity() {
|
|||||||
private var platformHandler: RecordingPlatformHandler? = null
|
private var platformHandler: RecordingPlatformHandler? = null
|
||||||
private var platformInfoChannel: MethodChannel? = null
|
private var platformInfoChannel: MethodChannel? = null
|
||||||
|
|
||||||
var recordingPreviewView: PreviewView? = null
|
|
||||||
private set
|
|
||||||
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
super.configureFlutterEngine(flutterEngine)
|
super.configureFlutterEngine(flutterEngine)
|
||||||
flutterEngine.platformViewsController.registry.registerViewFactory(
|
|
||||||
"recording-camera-preview",
|
|
||||||
RecordingPreviewFactory(this),
|
|
||||||
)
|
|
||||||
|
|
||||||
platformInfoChannel =
|
platformInfoChannel =
|
||||||
MethodChannel(
|
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() {
|
override fun onDestroy() {
|
||||||
platformInfoChannel?.setMethodCallHandler(null)
|
platformInfoChannel?.setMethodCallHandler(null)
|
||||||
platformInfoChannel = null
|
platformInfoChannel = null
|
||||||
@@ -111,9 +93,11 @@ class MainActivity : FlutterActivity() {
|
|||||||
|
|
||||||
return mapOf(
|
return mapOf(
|
||||||
"platform" to "android",
|
"platform" to "android",
|
||||||
|
"deviceCode" to Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID).orEmpty(),
|
||||||
"brand" to Build.BRAND,
|
"brand" to Build.BRAND,
|
||||||
"model" to Build.MODEL,
|
"model" to Build.MODEL,
|
||||||
"systemVersion" to Build.VERSION.RELEASE,
|
"systemVersion" to Build.VERSION.RELEASE,
|
||||||
|
"sdkInt" to Build.VERSION.SDK_INT,
|
||||||
"isPhysicalDevice" to !isEmulator,
|
"isPhysicalDevice" to !isEmulator,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.qxy.dronex.recording
|
package com.dronex.rec.recording
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.qxy.dronex.recording
|
package com.dronex.rec.recording
|
||||||
|
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.content.Context
|
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.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
@@ -14,8 +14,8 @@ import android.os.PowerManager
|
|||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.lifecycle.LifecycleService
|
import androidx.lifecycle.LifecycleService
|
||||||
import com.qxy.dronex.AppConstants
|
import com.dronex.rec.AppConstants
|
||||||
import com.qxy.dronex.MainActivity
|
import com.dronex.rec.MainActivity
|
||||||
|
|
||||||
class RecordingForegroundService : LifecycleService() {
|
class RecordingForegroundService : LifecycleService() {
|
||||||
private var wakeLock: PowerManager.WakeLock? = null
|
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,248 +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
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
boundLifecycleOwner === lifecycleOwner &&
|
|
||||||
preview != null &&
|
|
||||||
videoCapture != null
|
|
||||||
) {
|
|
||||||
onReady(true)
|
|
||||||
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,238 +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)
|
|
||||||
val previewView = activity.recordingPreviewView
|
|
||||||
if (previewView == null) {
|
|
||||||
mainHandler.post { deliverStopResult(result, path) }
|
|
||||||
return@stopRecording
|
|
||||||
}
|
|
||||||
controller.rebindForRecording(activity, previewView) { _ ->
|
|
||||||
mainHandler.post { deliverStopResult(result, path) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun deliverStopResult(result: MethodChannel.Result, path: String?) {
|
|
||||||
val gallerySaved = path != null && controller.status.state != RecordingState.ERROR
|
|
||||||
val payload =
|
|
||||||
mutableMapOf<String, Any?>(
|
|
||||||
"outputPath" to path,
|
|
||||||
"status" to controller.status.toMap(),
|
|
||||||
"gallerySaved" to gallerySaved,
|
|
||||||
)
|
|
||||||
if (!gallerySaved) {
|
|
||||||
payload["galleryErrorMessage"] = controller.status.message ?: "保存到相册失败"
|
|
||||||
}
|
|
||||||
result.success(payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setImmersiveMode(enabled: Boolean) {
|
|
||||||
val window = activity.window
|
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, !enabled)
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 9.9 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 12 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 795 B |
|
Before Width: | Height: | Size: 1011 B |
|
After Width: | Height: | Size: 833 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 |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 5.1 KiB |
@@ -1 +1,8 @@
|
|||||||
flutter build apk --release --split-per-abi
|
#!/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 "新增可视化请求插件"
|
||||||
@@ -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 ..
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
description: This file stores settings for Dart & Flutter DevTools.
|
description: This file stores settings for Dart & Flutter DevTools.
|
||||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
extensions:
|
extensions:
|
||||||
|
- riverpod: true
|
||||||
|
- shared_preferences: true
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||||
#include "Generated.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? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||||
#include "Generated.xcconfig"
|
#include "Generated.xcconfig"
|
||||||
|
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}"
|
||||||
|
|||||||
@@ -45,9 +45,34 @@ post_install do |installer|
|
|||||||
'$(inherited)',
|
'$(inherited)',
|
||||||
'PERMISSION_CAMERA=1',
|
'PERMISSION_CAMERA=1',
|
||||||
'PERMISSION_MICROPHONE=1',
|
'PERMISSION_MICROPHONE=1',
|
||||||
'PERMISSION_PHOTOS=1',
|
|
||||||
'PERMISSION_PHOTOS_ADD_ONLY=1',
|
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
PODS:
|
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):
|
- connectivity_plus (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- device_info_plus (0.0.1):
|
||||||
|
- Flutter
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
- path_provider_foundation (0.0.1):
|
- HaishinKit (1.7.3):
|
||||||
|
- Logboard (~> 2.4.1)
|
||||||
|
- Logboard (2.4.2)
|
||||||
|
- mobile_scanner (7.0.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
- native_device_orientation (0.0.1):
|
||||||
|
- Flutter
|
||||||
- permission_handler_apple (9.4.8):
|
- permission_handler_apple (9.4.8):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- share_plus (0.0.1):
|
||||||
|
- Flutter
|
||||||
- shared_preferences_foundation (0.0.1):
|
- shared_preferences_foundation (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
@@ -15,41 +29,73 @@ PODS:
|
|||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
- url_launcher_ios (0.0.1):
|
- url_launcher_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- webview_flutter_wkwebview (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
|
- apivideo_live_stream (from `.symlinks/plugins/apivideo_live_stream/ios`)
|
||||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||||
|
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
- 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`)
|
- 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`)
|
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
- 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:
|
EXTERNAL SOURCES:
|
||||||
|
apivideo_live_stream:
|
||||||
|
:path: ".symlinks/plugins/apivideo_live_stream/ios"
|
||||||
connectivity_plus:
|
connectivity_plus:
|
||||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||||
|
device_info_plus:
|
||||||
|
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
path_provider_foundation:
|
mobile_scanner:
|
||||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
:path: ".symlinks/plugins/mobile_scanner/darwin"
|
||||||
|
native_device_orientation:
|
||||||
|
:path: ".symlinks/plugins/native_device_orientation/ios"
|
||||||
permission_handler_apple:
|
permission_handler_apple:
|
||||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||||
|
share_plus:
|
||||||
|
:path: ".symlinks/plugins/share_plus/ios"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||||
sqflite_darwin:
|
sqflite_darwin:
|
||||||
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||||
|
webview_flutter_wkwebview:
|
||||||
|
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
|
apivideo_live_stream: caab45dd35fb3b140d423c099d0f6672378e7abe
|
||||||
|
ApiVideoLiveStream: 8f9dce7f6d15d5e4bb3c7a25e406bf2a36337a5a
|
||||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||||
|
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
HaishinKit: 326e27c4d06427ba53bffc68e516a92033293051
|
||||||
|
Logboard: 759d82599c439945d430d5a0958455b5a1974a0c
|
||||||
|
mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93
|
||||||
|
native_device_orientation: d6a4dc6887cd8a5ce1049962367aec60139ea0f1
|
||||||
permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e
|
permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e
|
||||||
|
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
|
||||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||||
|
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
|
||||||
|
|
||||||
PODFILE CHECKSUM: 5a82b772179df87e6518bddc6f9bb8b4053ce48b
|
PODFILE CHECKSUM: 858401fbd980bedce6ecd2f9b429bf271f11f74b
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -495,16 +495,22 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
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)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = 35634V629S;
|
DEVELOPMENT_TEAM = "";
|
||||||
|
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MT26BPCKF6;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.qxy.dronex;
|
PRODUCT_BUNDLE_IDENTIFIER = com.dronex.rec;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
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_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
@@ -678,16 +684,22 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
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)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = 35634V629S;
|
DEVELOPMENT_TEAM = "";
|
||||||
|
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MT26BPCKF6;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.qxy.dronex;
|
PRODUCT_BUNDLE_IDENTIFIER = com.dronex.rec;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
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_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
@@ -701,16 +713,22 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
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)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = 35634V629S;
|
DEVELOPMENT_TEAM = "";
|
||||||
|
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MT26BPCKF6;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.qxy.dronex;
|
PRODUCT_BUNDLE_IDENTIFIER = com.dronex.rec;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
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_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
|||||||
@@ -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">
|
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
<subviews>
|
<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>
|
</imageView>
|
||||||
</subviews>
|
</subviews>
|
||||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
<constraints>
|
<constraints>
|
||||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" 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="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>
|
</constraints>
|
||||||
</view>
|
</view>
|
||||||
</viewController>
|
</viewController>
|
||||||
@@ -32,6 +34,6 @@
|
|||||||
</scene>
|
</scene>
|
||||||
</scenes>
|
</scenes>
|
||||||
<resources>
|
<resources>
|
||||||
<image name="LaunchImage" width="168" height="185"/>
|
<image name="StartupBackground" width="750" height="1624"/>
|
||||||
</resources>
|
</resources>
|
||||||
</document>
|
</document>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<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">
|
<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>
|
<dependencies>
|
||||||
<deployment identifier="iOS"/>
|
<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>
|
</dependencies>
|
||||||
<scenes>
|
<scenes>
|
||||||
<!--Flutter View Controller-->
|
<!--Flutter View Controller-->
|
||||||
@@ -14,13 +16,28 @@
|
|||||||
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||||
</layoutGuides>
|
</layoutGuides>
|
||||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
<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"/>
|
<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>
|
</view>
|
||||||
</viewController>
|
</viewController>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||||
</objects>
|
</objects>
|
||||||
|
<point key="canvasLocation" x="139" y="122"/>
|
||||||
</scene>
|
</scene>
|
||||||
</scenes>
|
</scenes>
|
||||||
|
<resources>
|
||||||
|
<image name="StartupBackground" width="750" height="1624"/>
|
||||||
|
</resources>
|
||||||
</document>
|
</document>
|
||||||
|
|||||||
@@ -30,8 +30,10 @@
|
|||||||
<string>需要访问相机以显示预览并录制视频。</string>
|
<string>需要访问相机以显示预览并录制视频。</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string>
|
<string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string>
|
||||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
<key>UIFileSharingEnabled</key>
|
||||||
<string>需要将录制的视频保存到相册。</string>
|
<true/>
|
||||||
|
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||||
|
<true/>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import UIKit
|
|||||||
final class PlatformInfoPlugin: NSObject, FlutterPlugin {
|
final class PlatformInfoPlugin: NSObject, FlutterPlugin {
|
||||||
static func register(with registrar: FlutterPluginRegistrar) {
|
static func register(with registrar: FlutterPluginRegistrar) {
|
||||||
let channel = FlutterMethodChannel(
|
let channel = FlutterMethodChannel(
|
||||||
name: "com.qxy.dronex/platform_info",
|
name: "app.record_tool/platform_info",
|
||||||
binaryMessenger: registrar.messenger()
|
binaryMessenger: registrar.messenger()
|
||||||
)
|
)
|
||||||
let plugin = PlatformInfoPlugin()
|
let plugin = PlatformInfoPlugin()
|
||||||
@@ -62,6 +62,7 @@ final class PlatformInfoPlugin: NSObject, FlutterPlugin {
|
|||||||
let device = UIDevice.current
|
let device = UIDevice.current
|
||||||
return [
|
return [
|
||||||
"platform": "ios",
|
"platform": "ios",
|
||||||
|
"deviceCode": "",
|
||||||
"brand": device.systemName,
|
"brand": device.systemName,
|
||||||
"model": machineIdentifier(),
|
"model": machineIdentifier(),
|
||||||
"systemVersion": device.systemVersion,
|
"systemVersion": device.systemVersion,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Flutter
|
import Flutter
|
||||||
import Photos
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
private enum RecordingState: String {
|
private enum RecordingState: String {
|
||||||
@@ -110,12 +109,13 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
private var audioInput: AVCaptureDeviceInput?
|
private var audioInput: AVCaptureDeviceInput?
|
||||||
private var configured = false
|
private var configured = false
|
||||||
private var latestOutputPath: String?
|
private var latestOutputPath: String?
|
||||||
private var latestGallerySaved = true
|
private var latestFileSaved = true
|
||||||
private var latestGalleryErrorMessage: String?
|
private var latestFileErrorMessage: String?
|
||||||
private var pendingDisplayName: String?
|
private var pendingDisplayName: String?
|
||||||
private var recordingStartedAt: Date?
|
private var recordingStartedAt: Date?
|
||||||
private var elapsedTimer: Timer?
|
private var elapsedTimer: Timer?
|
||||||
private var pendingStopResult: FlutterResult?
|
private var pendingStopResult: FlutterResult?
|
||||||
|
private var currentZoomRatio: CGFloat = 1.0
|
||||||
|
|
||||||
private(set) var status = RecordingStatus(state: .idle) {
|
private(set) var status = RecordingStatus(state: .idle) {
|
||||||
didSet {
|
didSet {
|
||||||
@@ -215,10 +215,10 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.pendingDisplayName = displayName
|
self.pendingDisplayName = displayName
|
||||||
self.latestGallerySaved = true
|
self.latestFileSaved = true
|
||||||
self.latestGalleryErrorMessage = nil
|
self.latestFileErrorMessage = nil
|
||||||
let outputURL = try self.createOutputURL(displayName: displayName)
|
let outputURL = try self.createOutputURL(displayName: displayName)
|
||||||
self.latestOutputPath = outputURL.lastPathComponent
|
self.latestOutputPath = outputURL.path
|
||||||
self.recordingStartedAt = Date()
|
self.recordingStartedAt = Date()
|
||||||
self.updateStatus(RecordingStatus(state: .recording, outputPath: outputURL.path))
|
self.updateStatus(RecordingStatus(state: .recording, outputPath: outputURL.path))
|
||||||
self.movieOutput.startRecording(to: outputURL, recordingDelegate: self)
|
self.movieOutput.startRecording(to: outputURL, recordingDelegate: self)
|
||||||
@@ -254,11 +254,11 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
var payload: [String: Any] = [
|
var payload: [String: Any] = [
|
||||||
"outputPath": self.latestOutputPath as Any,
|
"outputPath": self.latestOutputPath as Any,
|
||||||
"status": self.currentStatusMap(),
|
"status": self.currentStatusMap(),
|
||||||
"gallerySaved": self.latestGallerySaved,
|
"fileSaved": self.latestFileSaved,
|
||||||
]
|
]
|
||||||
if !self.latestGallerySaved {
|
if !self.latestFileSaved {
|
||||||
payload["galleryErrorMessage"] =
|
payload["fileErrorMessage"] =
|
||||||
self.latestGalleryErrorMessage ?? "保存到相册失败"
|
self.latestFileErrorMessage ?? "保存到文件夹失败"
|
||||||
}
|
}
|
||||||
result(payload)
|
result(payload)
|
||||||
}
|
}
|
||||||
@@ -291,6 +291,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
self.session.commitConfiguration()
|
self.session.commitConfiguration()
|
||||||
self.videoInput = nil
|
self.videoInput = nil
|
||||||
self.audioInput = nil
|
self.audioInput = nil
|
||||||
|
self.currentZoomRatio = 1.0
|
||||||
self.configured = false
|
self.configured = false
|
||||||
self.updateStatus(RecordingStatus(state: .idle))
|
self.updateStatus(RecordingStatus(state: .idle))
|
||||||
|
|
||||||
@@ -312,6 +313,54 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
return status.toMap()
|
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(
|
func fileOutput(
|
||||||
_ output: AVCaptureFileOutput,
|
_ output: AVCaptureFileOutput,
|
||||||
didFinishRecordingTo outputFileURL: URL,
|
didFinishRecordingTo outputFileURL: URL,
|
||||||
@@ -322,8 +371,8 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
pendingStopResult = nil
|
pendingStopResult = nil
|
||||||
|
|
||||||
if let error {
|
if let error {
|
||||||
latestGallerySaved = false
|
latestFileSaved = false
|
||||||
latestGalleryErrorMessage = error.localizedDescription
|
latestFileErrorMessage = error.localizedDescription
|
||||||
updateStatus(
|
updateStatus(
|
||||||
RecordingStatus(
|
RecordingStatus(
|
||||||
state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
|
state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
|
||||||
@@ -331,29 +380,30 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
saveVideoToPhotoLibrary(fileURL: outputFileURL) { [weak self] success, message in
|
latestFileSaved = true
|
||||||
guard let self else { return }
|
latestFileErrorMessage = nil
|
||||||
self.latestGallerySaved = success
|
latestOutputPath = outputFileURL.path
|
||||||
self.latestGalleryErrorMessage = message
|
guard FileManager.default.fileExists(atPath: outputFileURL.path) else {
|
||||||
if success {
|
latestFileSaved = false
|
||||||
self.updateStatus(
|
latestFileErrorMessage = "录制文件未生成"
|
||||||
RecordingStatus(
|
updateStatus(
|
||||||
state: .previewing,
|
RecordingStatus(
|
||||||
outputPath: self.latestOutputPath,
|
state: .error,
|
||||||
elapsedMillis: self.elapsedMillis()
|
outputPath: latestOutputPath,
|
||||||
)
|
message: latestFileErrorMessage
|
||||||
)
|
)
|
||||||
} else {
|
)
|
||||||
self.updateStatus(
|
finishStopRecording(stopResult: stopResult)
|
||||||
RecordingStatus(
|
return
|
||||||
state: .error,
|
|
||||||
outputPath: self.latestOutputPath,
|
|
||||||
message: message ?? "保存到相册失败"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
self.finishStopRecording(stopResult: stopResult)
|
|
||||||
}
|
}
|
||||||
|
updateStatus(
|
||||||
|
RecordingStatus(
|
||||||
|
state: .previewing,
|
||||||
|
outputPath: latestOutputPath,
|
||||||
|
elapsedMillis: elapsedMillis()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finishStopRecording(stopResult: stopResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func finishStopRecording(stopResult: FlutterResult?) {
|
private func finishStopRecording(stopResult: FlutterResult?) {
|
||||||
@@ -363,79 +413,23 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
var payload: [String: Any] = [
|
var payload: [String: Any] = [
|
||||||
"outputPath": self.latestOutputPath as Any,
|
"outputPath": self.latestOutputPath as Any,
|
||||||
"status": self.currentStatusMap(),
|
"status": self.currentStatusMap(),
|
||||||
"gallerySaved": self.latestGallerySaved,
|
"fileSaved": self.latestFileSaved,
|
||||||
]
|
]
|
||||||
if !self.latestGallerySaved {
|
if !self.latestFileSaved {
|
||||||
payload["galleryErrorMessage"] =
|
payload["fileErrorMessage"] =
|
||||||
self.latestGalleryErrorMessage ?? "保存到相册失败,请开启相册权限"
|
self.latestFileErrorMessage ?? "保存到文件夹失败,请检查文件保存权限"
|
||||||
}
|
}
|
||||||
stopResult?(payload)
|
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 {
|
private func configureSession(withAudio: Bool) throws {
|
||||||
if configured {
|
if configured {
|
||||||
try configureAudioInput(enabled: withAudio)
|
try configureAudioInput(enabled: withAudio)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard
|
guard let videoDevice = Self.preferredVideoDevice() else {
|
||||||
let videoDevice = AVCaptureDevice.default(
|
|
||||||
.builtInWideAngleCamera, for: .video, position: .back)
|
|
||||||
?? AVCaptureDevice.default(for: .video)
|
|
||||||
else {
|
|
||||||
throw NSError(
|
throw NSError(
|
||||||
domain: "RecordingCamera", code: 1,
|
domain: "RecordingCamera", code: 1,
|
||||||
userInfo: [NSLocalizedDescriptionKey: "No camera device available"])
|
userInfo: [NSLocalizedDescriptionKey: "No camera device available"])
|
||||||
@@ -465,9 +459,74 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
session.commitConfiguration()
|
session.commitConfiguration()
|
||||||
|
|
||||||
configured = true
|
configured = true
|
||||||
|
// 默认以主摄(显示 1.0x)开场:虚拟多摄设备里主摄对应的 zoomFactor 是 S。
|
||||||
|
currentZoomRatio = mainBaselineFactor(for: videoDevice)
|
||||||
|
try applyCurrentZoom()
|
||||||
try configureAudioInput(enabled: withAudio)
|
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 {
|
private func configureAudioInput(enabled: Bool) throws {
|
||||||
session.beginConfiguration()
|
session.beginConfiguration()
|
||||||
defer { session.commitConfiguration() }
|
defer { session.commitConfiguration() }
|
||||||
@@ -502,7 +561,30 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
try FileManager.default.createDirectory(at: recordingsURL, withIntermediateDirectories: true)
|
try FileManager.default.createDirectory(at: recordingsURL, withIntermediateDirectories: true)
|
||||||
|
|
||||||
let fileName = Self.resolveFileName(displayName: displayName)
|
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 {
|
private static func resolveFileName(displayName: String?) -> String {
|
||||||
@@ -520,6 +602,13 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
return "REC_\(formatter.string(from: Date())).mov"
|
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) {
|
private func updateStatus(_ next: RecordingStatus) {
|
||||||
status = next
|
status = next
|
||||||
}
|
}
|
||||||
@@ -544,9 +633,9 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
|||||||
}
|
}
|
||||||
|
|
||||||
private enum RecordingChannelNames {
|
private enum RecordingChannelNames {
|
||||||
static let packageName = "com.qxy.dronex"
|
static let namespace = "app.record_tool"
|
||||||
static let method = "\(packageName)/recording"
|
static let method = "\(namespace)/recording"
|
||||||
static let events = "\(packageName)/recording_events"
|
static let events = "\(namespace)/recording_events"
|
||||||
}
|
}
|
||||||
|
|
||||||
final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
|
final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
|
||||||
@@ -587,6 +676,12 @@ final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
|
|||||||
controller.startRecording(withAudio: withAudio, displayName: displayName, result: result)
|
controller.startRecording(withAudio: withAudio, displayName: displayName, result: result)
|
||||||
case "stopRecording":
|
case "stopRecording":
|
||||||
controller.stopRecording(result: result)
|
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":
|
case "disposePreview":
|
||||||
controller.disposePreview(result: result)
|
controller.disposePreview(result: result)
|
||||||
case "getStatus":
|
case "getStatus":
|
||||||
|
|||||||
@@ -1,46 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||||
import 'package:recording_tool/app/config/app_config.dart';
|
import 'package:recording_tool/app/config/app_config.dart';
|
||||||
import 'package:recording_tool/app/router/app_navigator.dart';
|
import 'package:recording_tool/app/router/app_navigator.dart';
|
||||||
import 'package:recording_tool/app/theme/app_theme.dart';
|
import 'package:recording_tool/app/theme/app_theme.dart';
|
||||||
import 'package:recording_tool/features/recording/pages/page_record.dart';
|
import 'package:recording_tool/features/auth/pages/page_auth.dart';
|
||||||
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
|
|
||||||
|
|
||||||
class FlutterTemplateApp extends ConsumerStatefulWidget {
|
class FlutterTemplateApp extends StatelessWidget {
|
||||||
const FlutterTemplateApp({super.key});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ScreenUtilInit(
|
return ScreenUtilInit(
|
||||||
@@ -74,7 +44,7 @@ class _FlutterTemplateAppState extends ConsumerState<FlutterTemplateApp>
|
|||||||
home: RefreshConfiguration(
|
home: RefreshConfiguration(
|
||||||
enableLoadingWhenNoData: false,
|
enableLoadingWhenNoData: false,
|
||||||
headerTriggerDistance: 80.h,
|
headerTriggerDistance: 80.h,
|
||||||
child: const RecordingPage(),
|
child: const AuthPageWidget(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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/app.dart';
|
||||||
import 'package:recording_tool/app/config/app_config.dart';
|
import 'package:recording_tool/app/config/app_config.dart';
|
||||||
import 'package:recording_tool/core/cache/app_storage.dart';
|
import 'package:recording_tool/core/cache/app_storage.dart';
|
||||||
import 'package:recording_tool/core/logging/app_logger.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';
|
import 'package:recording_tool/core/platform/app_platform_info.dart';
|
||||||
|
|
||||||
class AppBootstrapper {
|
class AppBootstrapper {
|
||||||
@@ -25,7 +29,21 @@ class AppBootstrapper {
|
|||||||
|
|
||||||
AppLogger.debug('App started in ${AppConfig.current.environment.name}');
|
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.
|
// Load native package metadata after the first frame can render.
|
||||||
// Awaiting MethodChannel calls before runApp() can stall the Android
|
// Awaiting MethodChannel calls before runApp() can stall the Android
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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'),
|
||||||
|
|
||||||
|
/// 获取选手的赛事信息
|
||||||
|
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.environment,
|
||||||
required this.baseUrl,
|
required this.baseUrl,
|
||||||
required this.enableNetworkLog,
|
required this.enableNetworkLog,
|
||||||
|
this.mainRefereeScoreH5Url = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
final AppEnvironment environment;
|
final AppEnvironment environment;
|
||||||
final String baseUrl;
|
final String baseUrl;
|
||||||
final bool enableNetworkLog;
|
final bool enableNetworkLog;
|
||||||
|
final String mainRefereeScoreH5Url;
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppConfig {
|
class AppConfig {
|
||||||
@@ -21,8 +23,10 @@ class AppConfig {
|
|||||||
static late EnvironmentValues current;
|
static late EnvironmentValues current;
|
||||||
static AppPackageInfo? packageInfo;
|
static AppPackageInfo? packageInfo;
|
||||||
|
|
||||||
static const appName = '飞行极控录像工作台';
|
static const appName = 'SportsX裁判工作台';
|
||||||
static const designSize = Size(375, 812);
|
static const designSize = Size(640, 1024);
|
||||||
|
|
||||||
|
/// 主裁判计分 H5 链接
|
||||||
|
|
||||||
static void configure({
|
static void configure({
|
||||||
required AppEnvironment environment,
|
required AppEnvironment environment,
|
||||||
@@ -32,17 +36,24 @@ class AppConfig {
|
|||||||
current = switch (environment) {
|
current = switch (environment) {
|
||||||
AppEnvironment.dev => const EnvironmentValues(
|
AppEnvironment.dev => const EnvironmentValues(
|
||||||
environment: AppEnvironment.dev,
|
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,
|
enableNetworkLog: true,
|
||||||
),
|
),
|
||||||
AppEnvironment.staging => const EnvironmentValues(
|
AppEnvironment.staging => const EnvironmentValues(
|
||||||
environment: AppEnvironment.staging,
|
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,
|
enableNetworkLog: true,
|
||||||
),
|
),
|
||||||
AppEnvironment.prod => const EnvironmentValues(
|
AppEnvironment.prod => const EnvironmentValues(
|
||||||
environment: AppEnvironment.prod,
|
environment: AppEnvironment.prod,
|
||||||
baseUrl: 'https://api.example.com',
|
baseUrl: 'https://api.dronex.cc',
|
||||||
|
mainRefereeScoreH5Url:
|
||||||
|
'https://drone.sportsx.cc/#/pages/h5/score-entry',
|
||||||
enableNetworkLog: false,
|
enableNetworkLog: false,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -103,10 +103,7 @@ class AppNavigator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void pop<T extends Object?>({BuildContext? context, T? result}) {
|
static void pop<T extends Object?>({BuildContext? context, T? result}) {
|
||||||
Navigator.of(
|
Navigator.maybePop(context ?? AppNavigator.context!);
|
||||||
context ?? AppNavigator.context!,
|
|
||||||
rootNavigator: true,
|
|
||||||
).pop<T>(result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void popTimes({BuildContext? context, int count = 1}) {
|
static void popTimes({BuildContext? context, int count = 1}) {
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ class ApiClient {
|
|||||||
final statusCode = error.response?.statusCode;
|
final statusCode = error.response?.statusCode;
|
||||||
final message = switch (error.type) {
|
final message = switch (error.type) {
|
||||||
DioExceptionType.connectionTimeout => '网络连接超时',
|
DioExceptionType.connectionTimeout => '网络连接超时',
|
||||||
|
DioExceptionType.transformTimeout => '网络请求处理超时',
|
||||||
DioExceptionType.sendTimeout => '请求发送超时',
|
DioExceptionType.sendTimeout => '请求发送超时',
|
||||||
DioExceptionType.receiveTimeout => '响应接收超时',
|
DioExceptionType.receiveTimeout => '响应接收超时',
|
||||||
DioExceptionType.badCertificate => '证书校验失败',
|
DioExceptionType.badCertificate => '证书校验失败',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ class ApiResponse<T> {
|
|||||||
final String message;
|
final String message;
|
||||||
final T? data;
|
final T? data;
|
||||||
|
|
||||||
bool get isSuccess => code >= 200 && code < 300;
|
bool get isSuccess => code == 0 || code >= 200 && code < 300;
|
||||||
|
|
||||||
factory ApiResponse.fromJson(
|
factory ApiResponse.fromJson(
|
||||||
Map<String, dynamic> json, {
|
Map<String, dynamic> json, {
|
||||||
|
|||||||
@@ -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:dio/dio.dart';
|
||||||
import 'package:recording_tool/app/config/app_config.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/app_storage.dart';
|
||||||
import 'package:recording_tool/core/cache/storage_keys.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/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 {
|
class HeaderInterceptor extends Interceptor {
|
||||||
@override
|
@override
|
||||||
@@ -28,4 +31,17 @@ class HeaderInterceptor extends Interceptor {
|
|||||||
|
|
||||||
handler.next(options);
|
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:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:recording_tool/app/config/app_config.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/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/header_interceptor.dart';
|
||||||
import 'package:recording_tool/core/network/offline_queue/offline_queue_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/network_providers.dart';
|
||||||
import 'package:recording_tool/core/network/providers/offline_queue_providers.dart';
|
import 'package:recording_tool/core/network/providers/offline_queue_providers.dart';
|
||||||
|
|
||||||
|
bool _dioConfigured = false;
|
||||||
|
|
||||||
final dioProvider = Provider<Dio>((ref) {
|
final dioProvider = Provider<Dio>((ref) {
|
||||||
final dio = Dio(
|
final dio = AppDio.instance;
|
||||||
BaseOptions(
|
|
||||||
baseUrl: AppConfig.current.baseUrl,
|
|
||||||
connectTimeout: const Duration(seconds: 30),
|
|
||||||
receiveTimeout: const Duration(seconds: 30),
|
|
||||||
sendTimeout: const Duration(seconds: 30),
|
|
||||||
responseType: ResponseType.json,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
dio.interceptors.add(HeaderInterceptor());
|
if (!_dioConfigured) {
|
||||||
|
_dioConfigured = true;
|
||||||
|
dio.interceptors.add(HeaderInterceptor());
|
||||||
|
|
||||||
final monitor = ref.watch(networkMonitorProvider);
|
final monitor = ref.read(networkMonitorProvider);
|
||||||
final queueManager = ref.watch(offlineQueueManagerProvider);
|
final queueManager = ref.read(offlineQueueManagerProvider);
|
||||||
dio.interceptors.add(
|
dio.interceptors.add(
|
||||||
OfflineQueueInterceptor(
|
OfflineQueueInterceptor(
|
||||||
monitor: monitor,
|
monitor: monitor,
|
||||||
manager: queueManager,
|
manager: queueManager,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (AppConfig.current.enableNetworkLog) {
|
if (AppConfig.current.enableNetworkLog) {
|
||||||
dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true));
|
dio.interceptors.add(
|
||||||
|
LogInterceptor(requestBody: true, responseBody: true),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return dio;
|
return dio;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class AppDeviceInfo {
|
|||||||
required this.platform,
|
required this.platform,
|
||||||
required this.isPhysicalDevice,
|
required this.isPhysicalDevice,
|
||||||
required this.values,
|
required this.values,
|
||||||
|
required this.deviceCode,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory AppDeviceInfo.fromMap(Map<Object?, Object?> map) {
|
factory AppDeviceInfo.fromMap(Map<Object?, Object?> map) {
|
||||||
@@ -48,19 +49,21 @@ class AppDeviceInfo {
|
|||||||
platform: map['platform'] as String? ?? Platform.operatingSystem,
|
platform: map['platform'] as String? ?? Platform.operatingSystem,
|
||||||
isPhysicalDevice: isPhysicalDevice is bool ? isPhysicalDevice : true,
|
isPhysicalDevice: isPhysicalDevice is bool ? isPhysicalDevice : true,
|
||||||
values: values,
|
values: values,
|
||||||
|
deviceCode: values['deviceCode'] ?? '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final String platform;
|
final String platform;
|
||||||
final bool isPhysicalDevice;
|
final bool isPhysicalDevice;
|
||||||
final Map<String, String> values;
|
final Map<String, String> values;
|
||||||
|
final String deviceCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppPlatformInfo {
|
class AppPlatformInfo {
|
||||||
AppPlatformInfo._();
|
AppPlatformInfo._();
|
||||||
|
|
||||||
static const MethodChannel _channel = MethodChannel(
|
static const MethodChannel _channel = MethodChannel(
|
||||||
'com.qxy.dronex/platform_info',
|
'app.record_tool/platform_info',
|
||||||
);
|
);
|
||||||
|
|
||||||
static Future<AppPackageInfo> packageInfo() async {
|
static Future<AppPackageInfo> packageInfo() async {
|
||||||
|
|||||||
@@ -28,4 +28,12 @@ class DeviceUtils {
|
|||||||
}
|
}
|
||||||
return (await AppPlatformInfo.deviceInfo()).values;
|
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,63 @@
|
|||||||
|
// 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;
|
||||||
|
|
||||||
|
JwtDecodedData({
|
||||||
|
this.authType,
|
||||||
|
this.deviceCode,
|
||||||
|
this.deviceId,
|
||||||
|
this.deviceRole,
|
||||||
|
this.eventName,
|
||||||
|
this.oId,
|
||||||
|
this.oIds,
|
||||||
|
this.organizerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
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(),
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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,249 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
import 'package:recording_tool/app/router/app_navigator.dart';
|
||||||
|
import 'package:recording_tool/core/cache/app_storage.dart';
|
||||||
|
import 'package:recording_tool/core/cache/storage_keys.dart';
|
||||||
|
import 'package:recording_tool/core/utils/device_utils.dart';
|
||||||
|
import 'package:recording_tool/features/auth/view_model_auth/view_model_auth.dart';
|
||||||
|
import 'package:recording_tool/features/scan_qrcode/pages/page_scan_qrcode.dart';
|
||||||
|
import 'package:recording_tool/gen/assets.gen.dart';
|
||||||
|
import 'package:recording_tool/shared/widgets/app_button.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;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = TextEditingController(text: '905758');
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
final token = AppStorage.getString(StorageKeys.authToken);
|
||||||
|
if (token?.isNotEmpty ?? false) {
|
||||||
|
AppNavigator.push(const ScanQrCodePage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@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(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Image.asset(
|
||||||
|
_AuthAssets.pageBg,
|
||||||
|
width: double.infinity,
|
||||||
|
fit: BoxFit.fitWidth,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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: 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) {
|
||||||
|
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,58 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
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] 赛事目录
|
||||||
|
static Future<GetRecordListResModel?> getRecordList(
|
||||||
|
Ref ref,
|
||||||
|
String path,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final apiClient = ref.read(apiClientProvider);
|
||||||
|
// NAS /api/files 直接返回 {path, items},不是业务网关的 {code,message,data} 包装。
|
||||||
|
final data = await apiClient.get<GetRecordListResModel>(
|
||||||
|
'http://sheling.local: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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,88 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_riverpod/legacy.dart';
|
||||||
|
import 'package:jwt_decoder/jwt_decoder.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/model/model_jwt.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) {
|
||||||
|
parseTokenSetState(data.deviceAccessToken);
|
||||||
|
}
|
||||||
|
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(String token) async {
|
||||||
|
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 data = await AuthServer.getRecordList(_ref, eventName);
|
||||||
|
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 data = await AuthServer.getRecordList(_ref, path);
|
||||||
|
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')}';
|
||||||
|
}
|
||||||
@@ -0,0 +1,717 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
import 'package:recording_tool/app/config/app_config.dart';
|
||||||
|
import 'package:recording_tool/app/router/app_navigator.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/model/model_event_info.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_qr_scanner_dialog.dart';
|
||||||
|
import 'package:recording_tool/shared/widgets/app_toast.dart';
|
||||||
|
import 'package:recording_tool/shared/widgets/app_webview.dart';
|
||||||
|
|
||||||
|
typedef TeamQrScanner = Future<String?> Function(BuildContext context);
|
||||||
|
|
||||||
|
class EventTeamMatchPage extends ConsumerStatefulWidget {
|
||||||
|
const EventTeamMatchPage({
|
||||||
|
super.key,
|
||||||
|
required this.playerId,
|
||||||
|
this.qrScanner,
|
||||||
|
required this.detail,
|
||||||
|
required this.item,
|
||||||
|
});
|
||||||
|
|
||||||
|
final CompetitionTeamDetail detail;
|
||||||
|
final String playerId;
|
||||||
|
final TeamQrScanner? qrScanner;
|
||||||
|
final EventRegistrationItem item;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<EventTeamMatchPage> createState() => _EventTeamMatchPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EventTeamMatchPageState extends ConsumerState<EventTeamMatchPage> {
|
||||||
|
final Set<String> _verifiedUserIds = <String>{};
|
||||||
|
|
||||||
|
/// 0 = 红队(teamA),1 = 蓝队(teamB)
|
||||||
|
int? _winnerSideIndex;
|
||||||
|
|
||||||
|
CompetitionTeamDetail get _detail => widget.detail;
|
||||||
|
|
||||||
|
/// 备注:teamA 为红队,teamB 为蓝队。
|
||||||
|
Matchup? get _firstMatchup {
|
||||||
|
final matchups = _detail.matchups;
|
||||||
|
if (matchups == null || matchups.isEmpty) return null;
|
||||||
|
return matchups.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 红队(teamA)
|
||||||
|
Team? get _redRawTeam {
|
||||||
|
final teams = _firstMatchup?.teamA;
|
||||||
|
if (teams == null || teams.isEmpty) return null;
|
||||||
|
return teams.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 蓝队(teamB)
|
||||||
|
Team? get _blueRawTeam {
|
||||||
|
final teams = _firstMatchup?.teamB;
|
||||||
|
if (teams == null || teams.isEmpty) return null;
|
||||||
|
return teams.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
CompetitionTeam get _redTeam => _toCompetitionTeam(
|
||||||
|
_redRawTeam,
|
||||||
|
fallbackId: 'red-team',
|
||||||
|
fallbackName: '红队',
|
||||||
|
);
|
||||||
|
|
||||||
|
CompetitionTeam get _blueTeam => _toCompetitionTeam(
|
||||||
|
_blueRawTeam,
|
||||||
|
fallbackId: 'blue-team',
|
||||||
|
fallbackName: '蓝队',
|
||||||
|
);
|
||||||
|
|
||||||
|
List<EventTeamMember> get _redMembers =>
|
||||||
|
_toEventMembers(_redRawTeam?.players);
|
||||||
|
|
||||||
|
List<EventTeamMember> get _blueMembers =>
|
||||||
|
_toEventMembers(_blueRawTeam?.players);
|
||||||
|
|
||||||
|
CompetitionTeam _toCompetitionTeam(
|
||||||
|
Team? team, {
|
||||||
|
required String fallbackId,
|
||||||
|
required String fallbackName,
|
||||||
|
}) {
|
||||||
|
final id = team?.leaderUserId ?? '';
|
||||||
|
final name = team?.teamName?.trim() ?? '';
|
||||||
|
final players = (team?.players ?? const <Player>[])
|
||||||
|
.map(
|
||||||
|
(player) => CompetitionPlayer(
|
||||||
|
id: player.id?.trim() ?? '',
|
||||||
|
name: player.name?.trim() ?? '',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(growable: false);
|
||||||
|
return CompetitionTeam(
|
||||||
|
id: id.isNotEmpty ? id : fallbackId,
|
||||||
|
name: name.isNotEmpty ? name : fallbackName,
|
||||||
|
players: players,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<EventTeamMember> _toEventMembers(List<Player>? players) {
|
||||||
|
if (players == null || players.isEmpty) return const [];
|
||||||
|
return players
|
||||||
|
.map(
|
||||||
|
(player) => EventTeamMember(
|
||||||
|
userId: player.id?.trim() ?? '',
|
||||||
|
name: player.name?.trim() ?? '',
|
||||||
|
isLeader: player.isLeader ?? false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final initialPlayerId = widget.playerId.trim();
|
||||||
|
if (_isKnownMember(initialPlayerId)) {
|
||||||
|
_verifiedUserIds.add(initialPlayerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isKnownMember(String userId) {
|
||||||
|
if (userId.isEmpty) return false;
|
||||||
|
return _redMembers.any((member) => member.userId == userId) ||
|
||||||
|
_blueMembers.any((member) => member.userId == userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _memberNameOf(String userId) {
|
||||||
|
for (final member in [..._redMembers, ..._blueMembers]) {
|
||||||
|
if (member.userId == userId) {
|
||||||
|
return member.name.isEmpty ? '参赛成员' : member.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _continueScan() async {
|
||||||
|
final scan = widget.qrScanner ?? AppQrScannerDialog.show;
|
||||||
|
final result = (await scan(context))?.trim();
|
||||||
|
if (!mounted || result == null || result.isEmpty) return;
|
||||||
|
if (!_isKnownMember(result)) {
|
||||||
|
AppToast.show('未找到对应参赛成员');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_verifiedUserIds.contains(result)) {
|
||||||
|
AppToast.show('${_memberNameOf(result)}已完成核验');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _verifiedUserIds.add(result));
|
||||||
|
AppToast.show('${_memberNameOf(result)}核验成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _manualProcess() async {
|
||||||
|
final matchup = _firstMatchup;
|
||||||
|
if (matchup == null) {
|
||||||
|
AppToast.show('暂无对阵信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final winnerSide = await ManualWinnerDialog.show(
|
||||||
|
context,
|
||||||
|
matchup: matchup,
|
||||||
|
initialWinnerSideIndex: _winnerSideIndex,
|
||||||
|
);
|
||||||
|
if (!mounted || winnerSide == null) return;
|
||||||
|
|
||||||
|
final winnerTeam = winnerSide == 0 ? _redRawTeam : _blueRawTeam;
|
||||||
|
final loserTeam = winnerSide == 0 ? _blueRawTeam : _redRawTeam;
|
||||||
|
|
||||||
|
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: widget.item.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(() => _winnerSideIndex = winnerSide);
|
||||||
|
final winnerName = winnerTeam?.teamName?.trim().isNotEmpty == true
|
||||||
|
? winnerTeam!.teamName!
|
||||||
|
: (winnerSide == 0 ? _redTeam.name : _blueTeam.name);
|
||||||
|
AppToast.show('已设置$winnerName直接获胜');
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final message = error is ApiException && error.message.isNotEmpty
|
||||||
|
? error.message
|
||||||
|
: '设置失败,请重试';
|
||||||
|
AppToast.show(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startDirectly() {
|
||||||
|
AppNavigator.push(
|
||||||
|
buildTeamScorePage(item: widget.item, playerId: widget.playerId),
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final redTeam = _redTeam;
|
||||||
|
final blueTeam = _blueTeam;
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF6F7F9),
|
||||||
|
appBar: myAppBar(
|
||||||
|
context: context,
|
||||||
|
titleWidget: Text(
|
||||||
|
'选手检录',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.white,
|
||||||
|
fontFamily: 'PingFang SC',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
key: const ValueKey('event-team-manual-process'),
|
||||||
|
onPressed: _manualProcess,
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 14.w),
|
||||||
|
minimumSize: Size(72.w, kToolbarHeight),
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'人工处理',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14.sp,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.white.withValues(alpha: 0.8),
|
||||||
|
fontFamily: 'PingFang SC',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// SizedBox(height: 10.h),
|
||||||
|
_MatchMetadata(detail: _detail),
|
||||||
|
// SizedBox(height: 12.h),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 0.w, vertical: 20.h),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_TeamCard(
|
||||||
|
sideLabel: '红方',
|
||||||
|
team: redTeam,
|
||||||
|
members: _redMembers,
|
||||||
|
backgroundImage: Assets.images.imageRedTeam.path,
|
||||||
|
accentColor: const Color(0xFFE84B5B),
|
||||||
|
verifiedUserIds: _verifiedUserIds,
|
||||||
|
winner: _winnerSideIndex == 0,
|
||||||
|
),
|
||||||
|
SizedBox(height: 8.h),
|
||||||
|
_TeamCard(
|
||||||
|
sideLabel: '蓝方',
|
||||||
|
team: blueTeam,
|
||||||
|
members: _blueMembers,
|
||||||
|
backgroundImage: Assets.images.imageBlueTeam.path,
|
||||||
|
accentColor: const Color(0xFF287FDD),
|
||||||
|
verifiedUserIds: _verifiedUserIds,
|
||||||
|
winner: _winnerSideIndex == 1,
|
||||||
|
emptyMessage: '暂无蓝队成员数据',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(16.w, 16.h, 16.w, 24.h),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_TeamActionButton(
|
||||||
|
label: '继续扫码',
|
||||||
|
iconPath: Assets.images.imageScan.path,
|
||||||
|
onPressed: _continueScan,
|
||||||
|
filled: true,
|
||||||
|
),
|
||||||
|
SizedBox(height: 12.h),
|
||||||
|
_TeamActionButton(
|
||||||
|
label: '直接开赛',
|
||||||
|
iconPath: 'assets/images/image_start.png',
|
||||||
|
onPressed: _startDirectly,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
WebviewPage buildTeamScorePage({
|
||||||
|
required EventRegistrationItem item,
|
||||||
|
required String playerId,
|
||||||
|
}) {
|
||||||
|
return WebviewPage(
|
||||||
|
url: AppConfig.current.mainRefereeScoreH5Url,
|
||||||
|
eventRegistrationItem: item,
|
||||||
|
playerId: playerId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MatchMetadata extends StatelessWidget {
|
||||||
|
const _MatchMetadata({required this.detail});
|
||||||
|
|
||||||
|
final CompetitionTeamDetail detail;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final itemName = detail.itemName?.trim() ?? '';
|
||||||
|
final matchPlace = detail.matchPlace?.trim() ?? '';
|
||||||
|
final groupName = detail.groupName?.trim() ?? '';
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 12.h),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.04),
|
||||||
|
blurRadius: 12.r,
|
||||||
|
offset: Offset(0, 4.h),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _MetadataText(label: '场地', value: matchPlace),
|
||||||
|
),
|
||||||
|
SizedBox(width: 6.w),
|
||||||
|
Expanded(
|
||||||
|
child: _MetadataText(label: '赛项', value: itemName),
|
||||||
|
),
|
||||||
|
SizedBox(width: 6.w),
|
||||||
|
Expanded(
|
||||||
|
child: _MetadataText(label: '组别', value: groupName),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MetadataText extends StatelessWidget {
|
||||||
|
const _MetadataText({required this.label, required this.value});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Text(
|
||||||
|
'$label:${value.isEmpty ? '暂无' : value}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13.sp,
|
||||||
|
height: 1.35,
|
||||||
|
color: const Color(0xFF858B95),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontFamily: 'PingFang SC',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TeamCard extends StatelessWidget {
|
||||||
|
const _TeamCard({
|
||||||
|
required this.sideLabel,
|
||||||
|
required this.team,
|
||||||
|
required this.members,
|
||||||
|
required this.backgroundImage,
|
||||||
|
required this.accentColor,
|
||||||
|
required this.verifiedUserIds,
|
||||||
|
required this.winner,
|
||||||
|
this.emptyMessage = '暂无成员数据',
|
||||||
|
});
|
||||||
|
|
||||||
|
final String sideLabel;
|
||||||
|
final CompetitionTeam team;
|
||||||
|
final List<EventTeamMember> members;
|
||||||
|
final String backgroundImage;
|
||||||
|
final Color accentColor;
|
||||||
|
final Set<String> verifiedUserIds;
|
||||||
|
final bool winner;
|
||||||
|
final String emptyMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final visibleMembers = members
|
||||||
|
.where((member) => member.userId.isNotEmpty || member.name.isNotEmpty)
|
||||||
|
.toList(growable: false);
|
||||||
|
final leader = _leaderOf(visibleMembers);
|
||||||
|
final players = visibleMembers
|
||||||
|
.where((member) => !identical(member, leader))
|
||||||
|
.toList(growable: false);
|
||||||
|
final leaderVerified =
|
||||||
|
leader != null && verifiedUserIds.contains(leader.userId);
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
constraints: BoxConstraints(minHeight: 106.h),
|
||||||
|
padding: EdgeInsets.fromLTRB(12.w, 16.h, 16.w, 12.h),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
image: DecorationImage(
|
||||||
|
image: AssetImage(backgroundImage),
|
||||||
|
fit: BoxFit.fill,
|
||||||
|
),
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'$sideLabel队伍名称:${team.name}队伍',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16.sp,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: const Color(0xFF30343B),
|
||||||
|
fontFamily: 'PingFang SC',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (winner)
|
||||||
|
Container(
|
||||||
|
key: ValueKey('event-team-winner-${team.id}'),
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 10.w,
|
||||||
|
vertical: 4.h,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accentColor,
|
||||||
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'胜方',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 11.sp,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontFamily: 'PingFang SC',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: 6.h),
|
||||||
|
if (visibleMembers.isEmpty)
|
||||||
|
Text(
|
||||||
|
emptyMessage,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13.sp,
|
||||||
|
color: const Color(0xFF747D89),
|
||||||
|
fontFamily: 'PingFang SC',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
|
if (leader != null)
|
||||||
|
_TeamTextLine(
|
||||||
|
label: '队长',
|
||||||
|
text: _formatMember(leader),
|
||||||
|
showOk: verifiedUserIds.contains(leader.userId),
|
||||||
|
),
|
||||||
|
if (players.isNotEmpty)
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'选手:',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13.sp,
|
||||||
|
height: 1.35,
|
||||||
|
color: const Color(0xFF747B86),
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontFamily: 'PingFang SC',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...players.map(
|
||||||
|
(member) => Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_formatMember(member),
|
||||||
|
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13.sp,
|
||||||
|
height: 1.35,
|
||||||
|
color: const Color(0xFF4D545F),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (verifiedUserIds.contains(member.userId))
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(left: 6.w),
|
||||||
|
child: Image.asset(
|
||||||
|
Assets.images.imageOk.path,
|
||||||
|
width: 18.w,
|
||||||
|
height: 14.h,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
(member != players.last ? '、' : ''),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13.sp,
|
||||||
|
height: 1.35,
|
||||||
|
color: const Color(0xFF4D545F),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
EventTeamMember? _leaderOf(List<EventTeamMember> members) {
|
||||||
|
for (final member in members) {
|
||||||
|
if (member.isLeader) return member;
|
||||||
|
}
|
||||||
|
return members.isEmpty ? null : members.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatMember(EventTeamMember member) {
|
||||||
|
final name = member.name.trim().isEmpty ? '暂无' : member.name.trim();
|
||||||
|
// final id = member.userId.trim();
|
||||||
|
// return id.isEmpty ? name : '$name($id)';
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TeamTextLine extends StatelessWidget {
|
||||||
|
const _TeamTextLine({
|
||||||
|
required this.label,
|
||||||
|
required this.text,
|
||||||
|
required this.showOk,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String text;
|
||||||
|
final bool showOk;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(top: 4.h),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$label:',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13.sp,
|
||||||
|
height: 1.35,
|
||||||
|
color: const Color(0xFF747B86),
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontFamily: 'PingFang SC',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
text.isEmpty ? '暂无' : text,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13.sp,
|
||||||
|
height: 1.35,
|
||||||
|
color: const Color(0xFF4D545F),
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontFamily: 'PingFang SC',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (showOk)
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(left: 6.w),
|
||||||
|
child: Image.asset(
|
||||||
|
Assets.images.imageOk.path,
|
||||||
|
width: 18.w,
|
||||||
|
height: 14.h,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TeamActionButton extends StatelessWidget {
|
||||||
|
const _TeamActionButton({
|
||||||
|
required this.label,
|
||||||
|
required this.iconPath,
|
||||||
|
required this.onPressed,
|
||||||
|
this.filled = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String iconPath;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
final bool filled;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final radius = BorderRadius.circular(28.r);
|
||||||
|
final content = Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Image.asset(
|
||||||
|
iconPath,
|
||||||
|
width: filled ? 18.w : 18.w,
|
||||||
|
height: filled ? 18.h : 18.h,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
SizedBox(width: 4.w),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: filled ? Colors.white : const Color(0xFF359FED),
|
||||||
|
fontSize: 16.sp,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontFamily: 'PingFang SC',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 45.h,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: filled
|
||||||
|
? const LinearGradient(
|
||||||
|
colors: [Color(0xFF2F8DFF), Color(0xFF57D2EE)],
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
color: filled ? null : Colors.white,
|
||||||
|
borderRadius: radius,
|
||||||
|
border: filled
|
||||||
|
? null
|
||||||
|
: Border.all(color: const Color(0xFF9FD0F5), width: 1.w),
|
||||||
|
boxShadow: filled
|
||||||
|
? [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF2F8DFF).withValues(alpha: 0.24),
|
||||||
|
blurRadius: 10.r,
|
||||||
|
offset: Offset(0, 5.h),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onPressed,
|
||||||
|
borderRadius: radius,
|
||||||
|
child: Center(child: content),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:recording_tool/app/config/api_common.dart';
|
||||||
|
import 'package:recording_tool/core/network/api_client.dart';
|
||||||
|
import 'package:recording_tool/core/network/providers/dio_providers.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';
|
||||||
|
|
||||||
|
final competitionTeamsServerProvider = Provider<CompetitionTeamsServer>((ref) {
|
||||||
|
return CompetitionTeamsServer(ref.watch(apiClientProvider));
|
||||||
|
});
|
||||||
|
|
||||||
|
class CompetitionTeamsServer {
|
||||||
|
CompetitionTeamsServer(this._apiClient);
|
||||||
|
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
|
||||||
|
Future<CompetitionTeamPageResult> fetchPage({
|
||||||
|
required int page,
|
||||||
|
required int pageSize,
|
||||||
|
}) {
|
||||||
|
return _apiClient.get(
|
||||||
|
AuthApi.getTeamList.path,
|
||||||
|
queryParameters: {'page': page, 'pageSize': pageSize},
|
||||||
|
parser: (json) => CompetitionTeamPageResult.fromJson(
|
||||||
|
json,
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<CompetitionTeamDetail> fetchTeamDetail({
|
||||||
|
required String itemId,
|
||||||
|
required String eventId,
|
||||||
|
String? scheduleId,
|
||||||
|
String? opponentId,
|
||||||
|
}) {
|
||||||
|
return _apiClient.post(
|
||||||
|
AuthApi.getTeamDetail.path,
|
||||||
|
data: {
|
||||||
|
'itemId': itemId,
|
||||||
|
'eventId': eventId,
|
||||||
|
'scheduleId': scheduleId,
|
||||||
|
'opponentId': opponentId,
|
||||||
|
},
|
||||||
|
parser: (json) => CompetitionTeamDetail.fromResponse(json),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart';
|
||||||
|
|
||||||
|
class CompetitionTeamsState {
|
||||||
|
const CompetitionTeamsState({
|
||||||
|
this.items = const [],
|
||||||
|
this.page = 0,
|
||||||
|
this.hasMore = true,
|
||||||
|
this.isInitialLoading = false,
|
||||||
|
this.isRefreshing = false,
|
||||||
|
this.isLoadingMore = false,
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<CompetitionTeamListItem> items;
|
||||||
|
final int page;
|
||||||
|
final bool hasMore;
|
||||||
|
final bool isInitialLoading;
|
||||||
|
final bool isRefreshing;
|
||||||
|
final bool isLoadingMore;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
CompetitionTeamsState copyWith({
|
||||||
|
List<CompetitionTeamListItem>? items,
|
||||||
|
int? page,
|
||||||
|
bool? hasMore,
|
||||||
|
bool? isInitialLoading,
|
||||||
|
bool? isRefreshing,
|
||||||
|
bool? isLoadingMore,
|
||||||
|
String? errorMessage,
|
||||||
|
bool clearError = false,
|
||||||
|
}) {
|
||||||
|
return CompetitionTeamsState(
|
||||||
|
items: items ?? this.items,
|
||||||
|
page: page ?? this.page,
|
||||||
|
hasMore: hasMore ?? this.hasMore,
|
||||||
|
isInitialLoading: isInitialLoading ?? this.isInitialLoading,
|
||||||
|
isRefreshing: isRefreshing ?? this.isRefreshing,
|
||||||
|
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
|
||||||
|
errorMessage: clearError ? null : errorMessage ?? this.errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_riverpod/legacy.dart';
|
||||||
|
import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart';
|
||||||
|
import 'package:recording_tool/features/competition_teams/server/server_competition_teams.dart';
|
||||||
|
import 'package:recording_tool/features/competition_teams/state/state_competition_teams.dart';
|
||||||
|
|
||||||
|
final competitionTeamsProvider =
|
||||||
|
StateNotifierProvider<CompetitionTeamsViewModel, CompetitionTeamsState>((
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
return CompetitionTeamsViewModel(ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
class CompetitionTeamsViewModel extends StateNotifier<CompetitionTeamsState> {
|
||||||
|
CompetitionTeamsViewModel(this._ref) : super(const CompetitionTeamsState());
|
||||||
|
|
||||||
|
static const pageSize = 5;
|
||||||
|
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
Future<void> loadInitial() async {
|
||||||
|
state = state.copyWith(
|
||||||
|
isInitialLoading: true,
|
||||||
|
isLoadingMore: false,
|
||||||
|
isRefreshing: false,
|
||||||
|
clearError: true,
|
||||||
|
);
|
||||||
|
await _loadPage(page: 1, replace: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
if (state.isRefreshing) return;
|
||||||
|
state = state.copyWith(isRefreshing: true, clearError: true);
|
||||||
|
await _loadPage(page: 1, replace: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadMore() async {
|
||||||
|
if (!state.hasMore || state.isLoadingMore || state.isInitialLoading) return;
|
||||||
|
state = state.copyWith(isLoadingMore: true, clearError: true);
|
||||||
|
await _loadPage(page: state.page + 1, replace: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void selectWinner({
|
||||||
|
required String itemId,
|
||||||
|
required String matchupId,
|
||||||
|
required String winnerTeamId,
|
||||||
|
}) {
|
||||||
|
final updatedItems = state.items
|
||||||
|
.map((item) {
|
||||||
|
if (item.itemId != itemId) return item;
|
||||||
|
final updatedMatchups = item.matchups
|
||||||
|
.map((matchup) {
|
||||||
|
if (matchup.id != matchupId) return matchup;
|
||||||
|
final validWinner =
|
||||||
|
winnerTeamId == matchup.teamA.id ||
|
||||||
|
winnerTeamId == matchup.teamB.id;
|
||||||
|
return validWinner
|
||||||
|
? matchup.copyWith(winnerTeamId: winnerTeamId)
|
||||||
|
: matchup;
|
||||||
|
})
|
||||||
|
.toList(growable: false);
|
||||||
|
return item.copyWith(matchups: updatedMatchups);
|
||||||
|
})
|
||||||
|
.toList(growable: false);
|
||||||
|
state = state.copyWith(items: updatedItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
CompetitionTeamListItem? findItem(String itemId) {
|
||||||
|
for (final item in state.items) {
|
||||||
|
if (item.itemId == itemId) return item;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadPage({required int page, required bool replace}) async {
|
||||||
|
try {
|
||||||
|
final result = await _ref
|
||||||
|
.read(competitionTeamsServerProvider)
|
||||||
|
.fetchPage(page: page, pageSize: pageSize);
|
||||||
|
state = state.copyWith(
|
||||||
|
items: replace ? result.items : [...state.items, ...result.items],
|
||||||
|
page: result.page,
|
||||||
|
hasMore: result.hasMore,
|
||||||
|
isInitialLoading: false,
|
||||||
|
isRefreshing: false,
|
||||||
|
isLoadingMore: false,
|
||||||
|
clearError: true,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
state = state.copyWith(
|
||||||
|
isInitialLoading: false,
|
||||||
|
isRefreshing: false,
|
||||||
|
isLoadingMore: false,
|
||||||
|
errorMessage: '参赛队伍加载失败,请重试',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||||
|
import 'package:recording_tool/features/competition_teams/model/model_competition_team_detail.dart';
|
||||||
|
import 'package:recording_tool/gen/assets.gen.dart';
|
||||||
|
|
||||||
|
/// 弹窗回传选中侧索引:0 = teamA(红队),1 = teamB(蓝队)
|
||||||
|
class ManualWinnerDialog extends StatefulWidget {
|
||||||
|
const ManualWinnerDialog({
|
||||||
|
super.key,
|
||||||
|
required this.matchup,
|
||||||
|
this.initialWinnerSideIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Matchup matchup;
|
||||||
|
final int? initialWinnerSideIndex;
|
||||||
|
|
||||||
|
static Future<int?> show(
|
||||||
|
BuildContext context, {
|
||||||
|
required Matchup matchup,
|
||||||
|
int? initialWinnerSideIndex,
|
||||||
|
}) {
|
||||||
|
return showDialog<int>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (_) => ManualWinnerDialog(
|
||||||
|
matchup: matchup,
|
||||||
|
initialWinnerSideIndex: initialWinnerSideIndex,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ManualWinnerDialog> createState() => _ManualWinnerDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ManualWinnerDialogState extends State<ManualWinnerDialog> {
|
||||||
|
/// 0 = teamA,1 = teamB
|
||||||
|
int? _selectedSideIndex;
|
||||||
|
|
||||||
|
Team? get _teamA {
|
||||||
|
final teams = widget.matchup.teamA;
|
||||||
|
if (teams == null || teams.isEmpty) return null;
|
||||||
|
return teams.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
Team? get _teamB {
|
||||||
|
final teams = widget.matchup.teamB;
|
||||||
|
if (teams == null || teams.isEmpty) return null;
|
||||||
|
return teams.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
final initial = widget.initialWinnerSideIndex;
|
||||||
|
if (initial == 0 || initial == 1) {
|
||||||
|
_selectedSideIndex = initial;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final teamA = _teamA;
|
||||||
|
final teamB = _teamB;
|
||||||
|
final maxHeight = MediaQuery.sizeOf(context).height * 0.78;
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
insetPadding: EdgeInsets.symmetric(horizontal: 48.w),
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(maxWidth: 380.w, maxHeight: maxHeight),
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(16.r),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.white,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(18.w, 18.h, 18.w, 18.h),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
SizedBox(height: 26.h),
|
||||||
|
Text(
|
||||||
|
'请在比赛结束前处理',
|
||||||
|
key: const ValueKey('manual-winner-dialog-title'),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18.sp,
|
||||||
|
height: 1.3,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: const Color(0xFF20242B),
|
||||||
|
fontFamily: 'PingFang SC',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 16.h),
|
||||||
|
if (teamA != null) ...[
|
||||||
|
_TeamChoice(
|
||||||
|
team: teamA,
|
||||||
|
sideIndex: 0,
|
||||||
|
color: const Color(0xFFFF6575),
|
||||||
|
selected: _selectedSideIndex == 0,
|
||||||
|
onTap: () =>
|
||||||
|
setState(() => _selectedSideIndex = 0),
|
||||||
|
),
|
||||||
|
SizedBox(height: 10.h),
|
||||||
|
],
|
||||||
|
if (teamB != null)
|
||||||
|
_TeamChoice(
|
||||||
|
team: teamB,
|
||||||
|
sideIndex: 1,
|
||||||
|
color: const Color(0xFF21C5AC),
|
||||||
|
selected: _selectedSideIndex == 1,
|
||||||
|
onTap: () =>
|
||||||
|
setState(() => _selectedSideIndex = 1),
|
||||||
|
),
|
||||||
|
SizedBox(height: 18.h),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _DialogActionButton(
|
||||||
|
label: '取消',
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 14.w),
|
||||||
|
Expanded(
|
||||||
|
child: _DialogActionButton(
|
||||||
|
label: '确定',
|
||||||
|
filled: true,
|
||||||
|
onPressed: _selectedSideIndex == null
|
||||||
|
? null
|
||||||
|
: () => Navigator.of(
|
||||||
|
context,
|
||||||
|
).pop(_selectedSideIndex),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: -90.h,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: Image.asset(
|
||||||
|
Assets.images.imageDialogBg.path,
|
||||||
|
width: double.infinity,
|
||||||
|
height: 112.h,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TeamChoice extends StatelessWidget {
|
||||||
|
const _TeamChoice({
|
||||||
|
required this.team,
|
||||||
|
required this.sideIndex,
|
||||||
|
required this.color,
|
||||||
|
required this.selected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final Team team;
|
||||||
|
final int sideIndex;
|
||||||
|
final Color color;
|
||||||
|
final bool selected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final name = team.teamName?.trim().isNotEmpty == true
|
||||||
|
? team.teamName!
|
||||||
|
: '未命名队伍';
|
||||||
|
final players = team.playerNames;
|
||||||
|
final backgroundColor = selected
|
||||||
|
? color.withValues(alpha: 0.13)
|
||||||
|
: color.withValues(alpha: 0.07);
|
||||||
|
return Semantics(
|
||||||
|
selected: selected,
|
||||||
|
button: true,
|
||||||
|
label: '选择$name直接获胜',
|
||||||
|
child: Material(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
|
child: InkWell(
|
||||||
|
key: ValueKey('winner-choice-side-$sideIndex'),
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 12.h),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(12.r),
|
||||||
|
border: Border.all(
|
||||||
|
color: selected ? color : color.withValues(alpha: 0.32),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 160),
|
||||||
|
width: 22.r,
|
||||||
|
height: 22.r,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: selected ? color : const Color(0xFFB0B4BB),
|
||||||
|
width: 1.2.r,
|
||||||
|
),
|
||||||
|
color: Colors.transparent,
|
||||||
|
),
|
||||||
|
child: selected
|
||||||
|
? Center(
|
||||||
|
child: Container(
|
||||||
|
width: 10.r,
|
||||||
|
height: 10.r,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
SizedBox(width: 12.w),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 17.sp,
|
||||||
|
height: 1.25,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: const Color(0xFF20242B),
|
||||||
|
fontFamily: 'PingFang SC',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (players.isNotEmpty) ...[
|
||||||
|
SizedBox(height: 4.h),
|
||||||
|
Text(
|
||||||
|
players,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15.sp,
|
||||||
|
height: 1.25,
|
||||||
|
color: const Color(0xFF69717E),
|
||||||
|
fontFamily: 'PingFang SC',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 8.w),
|
||||||
|
Text(
|
||||||
|
'直接获胜',
|
||||||
|
maxLines: 1,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15.sp,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: color,
|
||||||
|
fontFamily: 'PingFang SC',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DialogActionButton extends StatelessWidget {
|
||||||
|
const _DialogActionButton({
|
||||||
|
required this.label,
|
||||||
|
required this.onPressed,
|
||||||
|
this.filled = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final bool filled;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final enabled = onPressed != null;
|
||||||
|
final radius = BorderRadius.circular(24.r);
|
||||||
|
final backgroundColor = filled
|
||||||
|
? (enabled ? null : const Color(0xFFD2D3D8))
|
||||||
|
: const Color(0xFFF1F1F1);
|
||||||
|
final gradient = filled && enabled
|
||||||
|
? const LinearGradient(colors: [Color(0xFF2F8DFF), Color(0xFF58D1EE)])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
height: 40.h,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
gradient: gradient,
|
||||||
|
borderRadius: radius,
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onPressed,
|
||||||
|
borderRadius: radius,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15.sp,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: filled
|
||||||
|
? (enabled ? Colors.white : const Color(0xFF8D9098))
|
||||||
|
: const Color(0xFF343941),
|
||||||
|
fontFamily: 'PingFang SC',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import 'package:recording_tool/features/recording/model/model_recording_context.dart';
|
||||||
|
|
||||||
|
class PlayerRegistrationListReq {
|
||||||
|
const PlayerRegistrationListReq({required this.userId, this.status = ''});
|
||||||
|
|
||||||
|
final String userId;
|
||||||
|
final String status;
|
||||||
|
|
||||||
|
Map<String, dynamic> toFormDataMap() {
|
||||||
|
return {'userId': userId, 'status': status};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StreamKeyReq {
|
||||||
|
const StreamKeyReq({
|
||||||
|
required this.eventId,
|
||||||
|
required this.itemId,
|
||||||
|
required this.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String eventId;
|
||||||
|
final String itemId;
|
||||||
|
final String userId;
|
||||||
|
|
||||||
|
Map<String, dynamic> toFormDataMap() {
|
||||||
|
return {'eventId': eventId, 'itemId': itemId, 'userId': userId};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EventProfile {
|
||||||
|
const EventProfile({
|
||||||
|
required this.name,
|
||||||
|
required this.phone,
|
||||||
|
required this.eventTitle,
|
||||||
|
this.avatarUrl = '',
|
||||||
|
this.avatarLabel = '头像',
|
||||||
|
});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final String phone;
|
||||||
|
final String eventTitle;
|
||||||
|
final String avatarUrl;
|
||||||
|
final String avatarLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
class EventTeamMember {
|
||||||
|
const EventTeamMember({
|
||||||
|
required this.userId,
|
||||||
|
required this.name,
|
||||||
|
required this.isLeader,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String userId;
|
||||||
|
final String name;
|
||||||
|
final bool isLeader;
|
||||||
|
|
||||||
|
factory EventTeamMember.fromJson(Map<dynamic, dynamic> json) {
|
||||||
|
final map = Map<String, dynamic>.from(json);
|
||||||
|
return EventTeamMember(
|
||||||
|
userId: _readString(map, const ['userId']),
|
||||||
|
name: _readString(map, const ['name']),
|
||||||
|
isLeader: _readBool(map, const ['isLeader']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EventRegistrationList {
|
||||||
|
const EventRegistrationList({
|
||||||
|
required this.userId,
|
||||||
|
required this.name,
|
||||||
|
required this.avatar,
|
||||||
|
required this.total,
|
||||||
|
required this.items,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String userId;
|
||||||
|
final String name;
|
||||||
|
final String avatar;
|
||||||
|
final int total;
|
||||||
|
final List<EventRegistrationItem> items;
|
||||||
|
|
||||||
|
factory EventRegistrationList.fromJson(dynamic json) {
|
||||||
|
final map = _extractObject(json);
|
||||||
|
final rawItems = _extractList(json);
|
||||||
|
final userId = _readString(map, const ['userId']);
|
||||||
|
final name = _readString(map, const [
|
||||||
|
'name',
|
||||||
|
'playerName',
|
||||||
|
'userName',
|
||||||
|
'realName',
|
||||||
|
]);
|
||||||
|
final avatar = _readString(map, const ['avatar', 'avatarUrl']);
|
||||||
|
return EventRegistrationList(
|
||||||
|
userId: userId,
|
||||||
|
name: name,
|
||||||
|
avatar: avatar,
|
||||||
|
total: _readInt(map, const ['total'], fallback: rawItems.length),
|
||||||
|
items: rawItems
|
||||||
|
.whereType<Map>()
|
||||||
|
.map(
|
||||||
|
(item) => EventRegistrationItem.fromJson(
|
||||||
|
item,
|
||||||
|
userId: userId,
|
||||||
|
playerName: name,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
EventProfile toProfile() {
|
||||||
|
return EventProfile(
|
||||||
|
name: name,
|
||||||
|
phone: '',
|
||||||
|
eventTitle: items.isEmpty ? '赛事信息' : items.first.eventName,
|
||||||
|
avatarUrl: avatar,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class EventRegistrationItem {
|
||||||
|
const EventRegistrationItem({
|
||||||
|
required this.eventId,
|
||||||
|
required this.itemId,
|
||||||
|
required this.scheduleId,
|
||||||
|
required this.eventName,
|
||||||
|
required this.itemName,
|
||||||
|
required this.groupName,
|
||||||
|
required this.matchPlace,
|
||||||
|
this.matchStartTime = '',
|
||||||
|
this.matchEndTime = '',
|
||||||
|
this.completed = false,
|
||||||
|
this.opponentId = '',
|
||||||
|
this.opponentName = '',
|
||||||
|
this.teamMembers = const [],
|
||||||
|
this.userId = '',
|
||||||
|
this.playerName = '',
|
||||||
|
this.playerPhone = '',
|
||||||
|
});
|
||||||
|
|
||||||
|
final String eventId;
|
||||||
|
final String itemId;
|
||||||
|
final String scheduleId;
|
||||||
|
final String eventName;
|
||||||
|
final String itemName;
|
||||||
|
final String groupName;
|
||||||
|
final String matchPlace;
|
||||||
|
final String matchStartTime;
|
||||||
|
final String matchEndTime;
|
||||||
|
final bool completed;
|
||||||
|
final String opponentId;
|
||||||
|
final String opponentName;
|
||||||
|
final List<EventTeamMember> teamMembers;
|
||||||
|
|
||||||
|
/// 来自报名列表父级,不在 item JSON 内
|
||||||
|
final String userId;
|
||||||
|
final String playerName;
|
||||||
|
final String playerPhone;
|
||||||
|
|
||||||
|
String get scheduleTime => _formatScheduleTime(matchStartTime, matchEndTime);
|
||||||
|
|
||||||
|
String? get statusLabel => completed ? '已完成' : null;
|
||||||
|
|
||||||
|
EventTeamMember? get teamLeader {
|
||||||
|
for (final member in teamMembers) {
|
||||||
|
if (member.isLeader) return member;
|
||||||
|
}
|
||||||
|
return teamMembers.isEmpty ? null : teamMembers.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
factory EventRegistrationItem.fromJson(
|
||||||
|
Map<dynamic, dynamic> json, {
|
||||||
|
String userId = '',
|
||||||
|
String playerName = '',
|
||||||
|
}) {
|
||||||
|
final map = Map<String, dynamic>.from(json);
|
||||||
|
final startTime = _readString(map, const ['matchStartTime']);
|
||||||
|
final endTime = _readString(map, const ['matchEndTime']);
|
||||||
|
final teamMembersRaw = map['teamMembers'];
|
||||||
|
return EventRegistrationItem(
|
||||||
|
eventId: _readString(map, const ['eventId']),
|
||||||
|
itemId: _readString(map, const ['itemId']),
|
||||||
|
scheduleId: _readString(map, const ['scheduleId']),
|
||||||
|
eventName: _readString(map, const ['eventName'], fallback: '赛事信息'),
|
||||||
|
itemName: _readString(map, const ['itemName'], fallback: '未命名项目'),
|
||||||
|
groupName: _readString(map, const ['groupName']),
|
||||||
|
matchPlace: _readString(map, const ['matchPlace']),
|
||||||
|
matchStartTime: startTime,
|
||||||
|
matchEndTime: endTime,
|
||||||
|
completed: _readBool(map, const ['completed']),
|
||||||
|
opponentId: _readString(map, const ['opponentId']),
|
||||||
|
opponentName: _readString(map, const ['opponentName']),
|
||||||
|
teamMembers: teamMembersRaw is List
|
||||||
|
? teamMembersRaw
|
||||||
|
.whereType<Map>()
|
||||||
|
.map(EventTeamMember.fromJson)
|
||||||
|
.toList(growable: false)
|
||||||
|
: const [],
|
||||||
|
userId: userId,
|
||||||
|
playerName: playerName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
EventProfile toProfile() {
|
||||||
|
return EventProfile(
|
||||||
|
name: playerName,
|
||||||
|
phone: playerPhone,
|
||||||
|
eventTitle: eventName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
RecordingContext toRecordingContext({EventProfile? profile}) {
|
||||||
|
return RecordingContext(
|
||||||
|
eventTitle: eventName,
|
||||||
|
matchName: itemName,
|
||||||
|
group: groupName,
|
||||||
|
venue: matchPlace,
|
||||||
|
time: scheduleTime,
|
||||||
|
playerName: profile?.name ?? playerName,
|
||||||
|
playerPhone: profile?.phone ?? playerPhone,
|
||||||
|
status: statusLabel,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StreamKeyResponse {
|
||||||
|
const StreamKeyResponse({required this.rawData});
|
||||||
|
|
||||||
|
final Map<String, dynamic> rawData;
|
||||||
|
|
||||||
|
factory StreamKeyResponse.fromJson(dynamic json) {
|
||||||
|
if (json is Map) {
|
||||||
|
return StreamKeyResponse(rawData: Map<String, dynamic>.from(json));
|
||||||
|
}
|
||||||
|
return StreamKeyResponse(rawData: {'value': json});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => rawData.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<dynamic> _extractList(dynamic json) {
|
||||||
|
if (json is List) return json;
|
||||||
|
if (json is Map) {
|
||||||
|
for (final key in const ['records', 'items', 'rows', 'list', 'data']) {
|
||||||
|
final value = json[key];
|
||||||
|
if (value is List) return value;
|
||||||
|
if (value is Map) {
|
||||||
|
final nested = _extractList(value);
|
||||||
|
if (nested.isNotEmpty) return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _extractObject(dynamic json) {
|
||||||
|
if (json is Map) {
|
||||||
|
final map = Map<String, dynamic>.from(json);
|
||||||
|
final data = map['data'];
|
||||||
|
if (data is Map) return Map<String, dynamic>.from(data);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
String _readString(
|
||||||
|
Map<String, dynamic> map,
|
||||||
|
List<String> keys, {
|
||||||
|
String fallback = '',
|
||||||
|
}) {
|
||||||
|
return _readNullableString(map, keys) ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatScheduleTime(String startTime, String endTime) {
|
||||||
|
if (startTime.isNotEmpty && endTime.isNotEmpty) {
|
||||||
|
return '$startTime-$endTime';
|
||||||
|
}
|
||||||
|
if (startTime.isNotEmpty) return startTime;
|
||||||
|
if (endTime.isNotEmpty) return endTime;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
int _readInt(Map<String, dynamic> map, List<String> keys, {int fallback = 0}) {
|
||||||
|
for (final key in keys) {
|
||||||
|
final value = _findValue(map, key);
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is num) return value.toInt();
|
||||||
|
if (value is String) {
|
||||||
|
final parsed = int.tryParse(value.trim());
|
||||||
|
if (parsed != null) return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _readBool(Map<String, dynamic> map, List<String> keys) {
|
||||||
|
for (final key in keys) {
|
||||||
|
final value = _findValue(map, key);
|
||||||
|
if (value is bool) return value;
|
||||||
|
if (value is num) return value != 0;
|
||||||
|
if (value is String) {
|
||||||
|
final text = value.trim().toLowerCase();
|
||||||
|
if (text == 'true' || text == '1') return true;
|
||||||
|
if (text == 'false' || text == '0') return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _readNullableString(Map<String, dynamic> map, List<String> keys) {
|
||||||
|
for (final key in keys) {
|
||||||
|
final value = _findValue(map, key);
|
||||||
|
if (value == null) continue;
|
||||||
|
final text = value.toString().trim();
|
||||||
|
if (text.isNotEmpty) return text;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic _findValue(dynamic value, String key) {
|
||||||
|
if (value is Map) {
|
||||||
|
if (value.containsKey(key)) return value[key];
|
||||||
|
for (final child in value.values) {
|
||||||
|
final found = _findValue(child, key);
|
||||||
|
if (found != null) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value is List) {
|
||||||
|
for (final child in value) {
|
||||||
|
final found = _findValue(child, key);
|
||||||
|
if (found != null) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
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/config/app_config.dart';
|
||||||
|
import 'package:recording_tool/app/router/app_navigator.dart';
|
||||||
|
import 'package:recording_tool/features/competition_teams/model/model_competition_team_detail.dart';
|
||||||
|
import 'package:recording_tool/features/competition_teams/pages/page_event_team_match.dart';
|
||||||
|
import 'package:recording_tool/features/competition_teams/server/server_competition_teams.dart';
|
||||||
|
import 'package:recording_tool/features/events/model/model_event_info.dart';
|
||||||
|
import 'package:recording_tool/features/events/state/state_event_info.dart';
|
||||||
|
import 'package:recording_tool/features/events/view_model/view_model_event_info.dart';
|
||||||
|
import 'package:recording_tool/gen/assets.gen.dart';
|
||||||
|
import 'package:recording_tool/shared/widgets/app_webview.dart';
|
||||||
|
import 'package:recording_tool/shared/widgets/widgets.dart';
|
||||||
|
|
||||||
|
class EventInfoPage extends ConsumerStatefulWidget {
|
||||||
|
const EventInfoPage({super.key, required this.playerId});
|
||||||
|
final String playerId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<EventInfoPage> createState() => _EventInfoPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EventInfoPageState extends ConsumerState<EventInfoPage> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> onItemTap(EventRegistrationItem item) async {
|
||||||
|
if (!mounted) return;
|
||||||
|
CompetitionTeamDetail? detail;
|
||||||
|
if (item.opponentId.isNotEmpty && item.opponentId != '0') {
|
||||||
|
detail = await ref
|
||||||
|
.read(competitionTeamsServerProvider)
|
||||||
|
.fetchTeamDetail(
|
||||||
|
itemId: item.itemId,
|
||||||
|
eventId: item.eventId,
|
||||||
|
scheduleId: item.scheduleId,
|
||||||
|
opponentId: item.opponentId,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
AppNavigator.push(
|
||||||
|
buildEventRegistrationDestination(
|
||||||
|
item: item,
|
||||||
|
playerId: widget.playerId,
|
||||||
|
detail: detail,
|
||||||
|
),
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
AppNavigator.push(
|
||||||
|
buildEventRegistrationDestination(
|
||||||
|
item: item,
|
||||||
|
playerId: widget.playerId,
|
||||||
|
detail: null,
|
||||||
|
),
|
||||||
|
context: context,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final state = ref.watch(eventInfoProvider);
|
||||||
|
final profile = state.profile;
|
||||||
|
|
||||||
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||||
|
value: SystemUiOverlayStyle.light.copyWith(
|
||||||
|
statusBarColor: Colors.transparent,
|
||||||
|
systemNavigationBarColor: const Color(0xFFF5F6F8),
|
||||||
|
),
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF5F6F8),
|
||||||
|
appBar: myAppBar(context: context),
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
const _TopGradientHeader(),
|
||||||
|
SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// _Header(onBack: () => AppNavigator.pop(context: context)),
|
||||||
|
SizedBox(height: 10.h),
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 10.w),
|
||||||
|
child: _ProfileSection(
|
||||||
|
profile: profile,
|
||||||
|
eventTitle: state.eventTitle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 8.h),
|
||||||
|
Expanded(
|
||||||
|
child: _ScheduleList(
|
||||||
|
state: state,
|
||||||
|
onRetry: () => ref
|
||||||
|
.read(eventInfoProvider.notifier)
|
||||||
|
.loadRegistrationList(),
|
||||||
|
onItemTap: onItemTap,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TopGradientHeader extends StatelessWidget {
|
||||||
|
const _TopGradientHeader();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Positioned(
|
||||||
|
top: -10.h,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: SizedBox(
|
||||||
|
height: 219.h,
|
||||||
|
width: double.infinity,
|
||||||
|
child: Image.asset(Assets.images.imageEventInfoBarBg.path),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildEventRegistrationDestination({
|
||||||
|
required EventRegistrationItem item,
|
||||||
|
required String playerId,
|
||||||
|
required CompetitionTeamDetail? detail,
|
||||||
|
}) {
|
||||||
|
if (detail != null) {
|
||||||
|
return EventTeamMatchPage(playerId: playerId, detail: detail, item: item);
|
||||||
|
}
|
||||||
|
return WebviewPage(
|
||||||
|
url: AppConfig.current.mainRefereeScoreH5Url,
|
||||||
|
eventRegistrationItem: item,
|
||||||
|
playerId: playerId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Header extends StatelessWidget {
|
||||||
|
const _Header({required this.onBack});
|
||||||
|
|
||||||
|
final VoidCallback onBack;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 44.h,
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: onBack,
|
||||||
|
icon: Icon(Icons.chevron_left_rounded, size: 28.r),
|
||||||
|
color: Colors.white,
|
||||||
|
tooltip: '返回',
|
||||||
|
padding: EdgeInsets.only(left: 10.w),
|
||||||
|
constraints: BoxConstraints(minWidth: 44.w, minHeight: 44.h),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProfileSection extends StatelessWidget {
|
||||||
|
const _ProfileSection({required this.profile, required this.eventTitle});
|
||||||
|
|
||||||
|
final EventProfile profile;
|
||||||
|
final String eventTitle;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final title = eventTitle.trim().isEmpty ? '赛事信息' : eventTitle.trim();
|
||||||
|
return Container(
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
padding: EdgeInsets.fromLTRB(12.w, 10.h, 12.w, 12.h),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(6.r),
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
profile.avatarUrl.isEmpty
|
||||||
|
? AppAvatar(
|
||||||
|
size: 52.r,
|
||||||
|
initials: profile.name.isEmpty ? 'S' : profile.name,
|
||||||
|
)
|
||||||
|
: AppAvatar(size: 52.r, imageUrl: profile.avatarUrl),
|
||||||
|
SizedBox(width: 12.w),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
profile.name.isEmpty ? '参赛选手' : profile.name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18.sp,
|
||||||
|
height: 1.2,
|
||||||
|
color: const Color(0xFF33363B),
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 5.h),
|
||||||
|
Text(
|
||||||
|
profile.phone,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10.sp,
|
||||||
|
height: 1.2,
|
||||||
|
color: const Color(0xFF969CA6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SizedBox(height: 10.h),
|
||||||
|
Divider(
|
||||||
|
height: 1.h,
|
||||||
|
thickness: 1.h,
|
||||||
|
color: const Color(0xFFE8EAED),
|
||||||
|
),
|
||||||
|
SizedBox(height: 10.h),
|
||||||
|
_EventTitleHighlight(title: title),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EventTitleHighlight extends StatelessWidget {
|
||||||
|
const _EventTitleHighlight({required this.title});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: IntrinsicWidth(
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.bottomLeft,
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 2.h,
|
||||||
|
child: Container(
|
||||||
|
height: 8.h,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFB8F5E8),
|
||||||
|
borderRadius: BorderRadius.circular(4.r),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16.sp,
|
||||||
|
height: 1.25,
|
||||||
|
color: const Color(0xFF33363B),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScheduleList extends StatelessWidget {
|
||||||
|
const _ScheduleList({
|
||||||
|
required this.state,
|
||||||
|
required this.onRetry,
|
||||||
|
required this.onItemTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final EventInfoState state;
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
final ValueChanged<EventRegistrationItem> onItemTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (state.items.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'暂无赛事报名信息',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 16.sp, color: const Color(0xFF3A3D42)),
|
||||||
|
),
|
||||||
|
SizedBox(height: 14.h),
|
||||||
|
TextButton(onPressed: onRetry, child: const Text('重新加载')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
padding: EdgeInsets.fromLTRB(10.w, 0, 10.w, 24.h),
|
||||||
|
itemCount: state.items.length,
|
||||||
|
separatorBuilder: (_, _) => SizedBox(height: 8.h),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = state.items[index];
|
||||||
|
return _ScheduleCard(
|
||||||
|
item: item,
|
||||||
|
scheduleIndex: index + 1,
|
||||||
|
onTap: () => onItemTap(item),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScheduleCard extends StatelessWidget {
|
||||||
|
const _ScheduleCard({
|
||||||
|
required this.item,
|
||||||
|
required this.scheduleIndex,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final EventRegistrationItem item;
|
||||||
|
final int scheduleIndex;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Material(
|
||||||
|
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: _ScheduleDetails(
|
||||||
|
item: item,
|
||||||
|
scheduleIndex: scheduleIndex,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 12.w),
|
||||||
|
_ScheduleStatus(item: item),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScheduleDetails extends StatelessWidget {
|
||||||
|
const _ScheduleDetails({required this.item, required this.scheduleIndex});
|
||||||
|
|
||||||
|
final EventRegistrationItem item;
|
||||||
|
final int scheduleIndex;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final memberLine = _formatMemberLine(item);
|
||||||
|
final opponentLine = _formatOpponentLine(item);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_formatTitle(item),
|
||||||
|
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(_formatVenue(item, scheduleIndex)),
|
||||||
|
SizedBox(height: 3.h),
|
||||||
|
_MetaText(_formatScheduleTime(item)),
|
||||||
|
if (memberLine.isNotEmpty) ...[
|
||||||
|
SizedBox(height: 3.h),
|
||||||
|
_MetaText(memberLine),
|
||||||
|
],
|
||||||
|
if (opponentLine.isNotEmpty) ...[
|
||||||
|
SizedBox(height: 3.h),
|
||||||
|
_MetaText(opponentLine),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScheduleStatus extends StatelessWidget {
|
||||||
|
const _ScheduleStatus({required this.item});
|
||||||
|
|
||||||
|
final EventRegistrationItem item;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final status = item.statusLabel;
|
||||||
|
if (status != null) {
|
||||||
|
return Text(
|
||||||
|
status,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10.sp,
|
||||||
|
height: 1.2,
|
||||||
|
color: const Color(0xFF1DBF73),
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Text(
|
||||||
|
_formatNumberBadge(item),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20.sp,
|
||||||
|
height: 1,
|
||||||
|
color: const Color(0xFFF27D69),
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTitle(EventRegistrationItem item) {
|
||||||
|
if (item.groupName.isEmpty) return item.itemName;
|
||||||
|
return '${item.itemName} (${item.groupName})';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatVenue(EventRegistrationItem item, int scheduleIndex) {
|
||||||
|
final place = item.matchPlace.trim();
|
||||||
|
if (place.isEmpty) return '$scheduleIndex号场馆$scheduleIndex区';
|
||||||
|
return '$place号场馆$scheduleIndex区';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatScheduleTime(EventRegistrationItem item) {
|
||||||
|
final start = DateTime.tryParse(item.matchStartTime);
|
||||||
|
final end = DateTime.tryParse(item.matchEndTime);
|
||||||
|
if (start == null && end == null) return 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')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatMemberLine(EventRegistrationItem item) {
|
||||||
|
final names = item.teamMembers
|
||||||
|
.map((member) => member.name.trim())
|
||||||
|
.where((name) => name.isNotEmpty)
|
||||||
|
.toList(growable: false);
|
||||||
|
return names.join('、');
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatOpponentLine(EventRegistrationItem item) {
|
||||||
|
final opponent = item.opponentName.trim();
|
||||||
|
if (opponent.isEmpty) return '';
|
||||||
|
return '对手:$opponent';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatNumberBadge(EventRegistrationItem item) {
|
||||||
|
final place = item.matchPlace.trim();
|
||||||
|
if (place.isEmpty) return '';
|
||||||
|
final match = RegExp(r'\d+').firstMatch(place);
|
||||||
|
if (match != null) return '${match.group(0)}号';
|
||||||
|
return place;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
class SetTeamStatusReq {
|
||||||
|
final String scheduleId;
|
||||||
|
final String opponentId;
|
||||||
|
final List<TeamScore> teamScore;
|
||||||
|
|
||||||
|
SetTeamStatusReq({
|
||||||
|
required this.scheduleId,
|
||||||
|
required this.opponentId,
|
||||||
|
required this.teamScore,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SetTeamStatusReq.fromJson(Map<String, dynamic> json) =>
|
||||||
|
SetTeamStatusReq(
|
||||||
|
scheduleId: json['scheduleId'],
|
||||||
|
opponentId: json['opponentId'],
|
||||||
|
teamScore: List<TeamScore>.from(
|
||||||
|
json['teamScore'].map((x) => TeamScore.fromJson(x)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'scheduleId': scheduleId,
|
||||||
|
'opponentId': opponentId,
|
||||||
|
'teamScore': List<dynamic>.from(teamScore.map((x) => x.toJson())),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class TeamScore {
|
||||||
|
final String userId;
|
||||||
|
final int firstHalfScore;
|
||||||
|
final int secondHalfScore;
|
||||||
|
final int decisiveScore;
|
||||||
|
final bool ifWithdraw;
|
||||||
|
|
||||||
|
TeamScore({
|
||||||
|
required this.userId,
|
||||||
|
required this.firstHalfScore,
|
||||||
|
required this.secondHalfScore,
|
||||||
|
required this.decisiveScore,
|
||||||
|
required this.ifWithdraw,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory TeamScore.fromJson(Map<String, dynamic> json) => TeamScore(
|
||||||
|
userId: json['userId'],
|
||||||
|
firstHalfScore: json['firstHalfScore'],
|
||||||
|
secondHalfScore: json['secondHalfScore'],
|
||||||
|
decisiveScore: json['decisiveScore'],
|
||||||
|
ifWithdraw: json['ifWithdraw'],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'userId': userId,
|
||||||
|
'firstHalfScore': firstHalfScore,
|
||||||
|
'secondHalfScore': secondHalfScore,
|
||||||
|
'decisiveScore': decisiveScore,
|
||||||
|
'ifWithdraw': ifWithdraw,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:recording_tool/app/config/api_common.dart';
|
||||||
|
import 'package:recording_tool/core/network/api_client.dart';
|
||||||
|
import 'package:recording_tool/core/network/http_method.dart';
|
||||||
|
import 'package:recording_tool/core/network/providers/dio_providers.dart';
|
||||||
|
import 'package:recording_tool/features/events/model/model_event_info.dart';
|
||||||
|
import 'package:recording_tool/features/events/request_model/request_model_event.dart';
|
||||||
|
|
||||||
|
final eventsServerProvider = Provider<EventsServer>((ref) {
|
||||||
|
return EventsServer(ref.watch(apiClientProvider));
|
||||||
|
});
|
||||||
|
|
||||||
|
class EventsServer {
|
||||||
|
const EventsServer(this._apiClient);
|
||||||
|
|
||||||
|
final ApiClient _apiClient;
|
||||||
|
|
||||||
|
Future<EventRegistrationList> fetchPlayerRegistrationList(
|
||||||
|
PlayerRegistrationListReq req,
|
||||||
|
) {
|
||||||
|
return _apiClient.request<EventRegistrationList>(
|
||||||
|
AuthApi.playerRegistrationList.path,
|
||||||
|
method: HttpMethod.get,
|
||||||
|
data: FormData.fromMap(req.toFormDataMap()),
|
||||||
|
parser: EventRegistrationList.fromJson,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<StreamKeyResponse> fetchStreamKey(StreamKeyReq req) {
|
||||||
|
return _apiClient.request<StreamKeyResponse>(
|
||||||
|
AuthApi.getStreamKey.path,
|
||||||
|
method: HttpMethod.get,
|
||||||
|
data: FormData.fromMap(req.toFormDataMap()),
|
||||||
|
parser: StreamKeyResponse.fromJson,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 人工设置晋级/淘汰
|
||||||
|
Future<void> setTeamStatus({required SetTeamStatusReq req}) {
|
||||||
|
print('req: ${req.toJson()}');
|
||||||
|
return _apiClient.post(
|
||||||
|
AuthApi.setTeamStatus.path,
|
||||||
|
data: req.toJson(),
|
||||||
|
parser: (json) => json,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:recording_tool/features/events/model/model_event_info.dart';
|
||||||
|
|
||||||
|
class EventInfoState {
|
||||||
|
const EventInfoState({
|
||||||
|
this.isRequestingStreamKey = false,
|
||||||
|
this.profile = const EventProfile(name: '', phone: '', eventTitle: '赛事信息'),
|
||||||
|
this.total = 0,
|
||||||
|
this.items = const [],
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool isRequestingStreamKey;
|
||||||
|
final EventProfile profile;
|
||||||
|
final int total;
|
||||||
|
final List<EventRegistrationItem> items;
|
||||||
|
|
||||||
|
String get eventTitle =>
|
||||||
|
items.isEmpty ? profile.eventTitle : items.first.eventName;
|
||||||
|
|
||||||
|
EventInfoState copyWith({
|
||||||
|
bool? isRequestingStreamKey,
|
||||||
|
EventProfile? profile,
|
||||||
|
int? total,
|
||||||
|
List<EventRegistrationItem>? items,
|
||||||
|
}) {
|
||||||
|
return EventInfoState(
|
||||||
|
isRequestingStreamKey:
|
||||||
|
isRequestingStreamKey ?? this.isRequestingStreamKey,
|
||||||
|
profile: profile ?? this.profile,
|
||||||
|
total: total ?? this.total,
|
||||||
|
items: items ?? this.items,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_riverpod/legacy.dart';
|
||||||
|
import 'package:recording_tool/core/network/api_exception.dart';
|
||||||
|
import 'package:recording_tool/features/events/model/model_event_info.dart';
|
||||||
|
import 'package:recording_tool/features/events/server/server_events.dart';
|
||||||
|
import 'package:recording_tool/features/events/state/state_event_info.dart';
|
||||||
|
import 'package:recording_tool/shared/widgets/app_toast.dart';
|
||||||
|
|
||||||
|
final eventInfoProvider =
|
||||||
|
StateNotifierProvider<EventInfoViewModel, EventInfoState>((ref) {
|
||||||
|
return EventInfoViewModel(ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
class EventInfoViewModel extends StateNotifier<EventInfoState> {
|
||||||
|
EventInfoViewModel(this._ref) : super(const EventInfoState());
|
||||||
|
|
||||||
|
static const defaultRegistrationRequest = PlayerRegistrationListReq(
|
||||||
|
userId: '',
|
||||||
|
status: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
static const _fallbackEventId = '';
|
||||||
|
static const _fallbackItemId = '';
|
||||||
|
static const _fallbackStreamUserId = '';
|
||||||
|
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
Future<bool> loadRegistrationList({
|
||||||
|
PlayerRegistrationListReq request = defaultRegistrationRequest,
|
||||||
|
}) async {
|
||||||
|
if (request.userId.isEmpty) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final result = await _ref
|
||||||
|
.read(eventsServerProvider)
|
||||||
|
.fetchPlayerRegistrationList(request);
|
||||||
|
debugPrint('报名列表请求成功: $result');
|
||||||
|
state = state.copyWith(
|
||||||
|
profile: result.toProfile(),
|
||||||
|
total: result.total,
|
||||||
|
items: result.items,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('报名列表请求失败: $error');
|
||||||
|
AppToast.show('报名列表加载失败');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> requestStreamKey(EventRegistrationItem item) async {
|
||||||
|
state = state.copyWith(isRequestingStreamKey: true);
|
||||||
|
final req = StreamKeyReq(
|
||||||
|
eventId: item.eventId.isNotEmpty ? item.eventId : _fallbackEventId,
|
||||||
|
itemId: item.itemId.isNotEmpty ? item.itemId : _fallbackItemId,
|
||||||
|
userId: item.userId.isNotEmpty ? item.userId : _fallbackStreamUserId,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _ref
|
||||||
|
.read(eventsServerProvider)
|
||||||
|
.fetchStreamKey(req);
|
||||||
|
debugPrint('推流 Key 接口响应: $response');
|
||||||
|
state = state.copyWith(isRequestingStreamKey: false);
|
||||||
|
} on ApiException catch (error) {
|
||||||
|
debugPrint('推流 Key 接口请求失败: ${error.message}');
|
||||||
|
state = state.copyWith(isRequestingStreamKey: false);
|
||||||
|
AppToast.show(error.message);
|
||||||
|
} catch (error) {
|
||||||
|
debugPrint('推流 Key 接口请求失败: $error');
|
||||||
|
state = state.copyWith(isRequestingStreamKey: false);
|
||||||
|
AppToast.show('推流 Key 获取失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
/// 小程序复制到剪切板的录制信息。
|
|
||||||
class ClipboardRecordingModel {
|
|
||||||
final String title;
|
|
||||||
int? startTimestamp;
|
|
||||||
int? endTimestamp;
|
|
||||||
final String address;
|
|
||||||
|
|
||||||
/// 录制文件名模板,如「选手名称_选手ID_赛事名称_赛项」。
|
|
||||||
final String? filename;
|
|
||||||
|
|
||||||
ClipboardRecordingModel({
|
|
||||||
required this.title,
|
|
||||||
this.startTimestamp,
|
|
||||||
this.endTimestamp,
|
|
||||||
required this.address,
|
|
||||||
this.filename,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory ClipboardRecordingModel.fromJson(Map<String, dynamic> json) {
|
|
||||||
return ClipboardRecordingModel(
|
|
||||||
title: _readString(json, 'title'),
|
|
||||||
startTimestamp: _readOptionalInt(json, 'startTimestamp'),
|
|
||||||
endTimestamp: _readOptionalInt(json, 'endTimestamp'),
|
|
||||||
address: _readString(json, 'address'),
|
|
||||||
filename: _readOptionalString(json, 'filename'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
return {
|
|
||||||
'title': title,
|
|
||||||
'startTimestamp': startTimestamp,
|
|
||||||
'endTimestamp': endTimestamp,
|
|
||||||
'address': address,
|
|
||||||
if (filename != null) 'filename': filename,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
static String? _readOptionalString(Map<String, dynamic> json, String key) {
|
|
||||||
final value = json[key];
|
|
||||||
if (value == null) return null;
|
|
||||||
if (value is String && value.isNotEmpty) return value;
|
|
||||||
if (value is! String) {
|
|
||||||
throw FormatException('Clipboard field "$key" must be a String.');
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _readString(Map<String, dynamic> json, String key) {
|
|
||||||
final value = json[key];
|
|
||||||
if (value is String) return value;
|
|
||||||
throw FormatException('Clipboard field "$key" must be a String.');
|
|
||||||
}
|
|
||||||
|
|
||||||
static int? _readOptionalInt(Map<String, dynamic> json, String key) {
|
|
||||||
final value = json[key];
|
|
||||||
if (value == null) return null;
|
|
||||||
if (value is int) return value;
|
|
||||||
throw FormatException('Clipboard field "$key" must be an int.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +1,26 @@
|
|||||||
import 'package:recording_tool/features/recording/model/model_clipboard.dart';
|
import 'package:recording_tool/features/recording/model/model_recording_context.dart';
|
||||||
import 'package:recording_tool/features/recording/model/model_recording_session.dart';
|
import 'package:recording_tool/features/recording/model/model_recording_session.dart';
|
||||||
|
|
||||||
class RecordingModel {
|
class RecordingModel {
|
||||||
/// 剪切板内容
|
/// 从赛事项进入录制页时传入的录制上下文。
|
||||||
final ClipboardRecordingModel clipboardRecordingModel;
|
final RecordingContext recordingContext;
|
||||||
|
|
||||||
/// 剪切板是否包含有效的小程序录制信息
|
|
||||||
final bool hasValidClipboardInfo;
|
|
||||||
|
|
||||||
/// 录制会话状态
|
/// 录制会话状态
|
||||||
final RecordingSessionState session;
|
final RecordingSessionState session;
|
||||||
|
|
||||||
RecordingModel({
|
RecordingModel({
|
||||||
required this.clipboardRecordingModel,
|
required this.recordingContext,
|
||||||
this.hasValidClipboardInfo = false,
|
|
||||||
this.session = const RecordingSessionState(),
|
this.session = const RecordingSessionState(),
|
||||||
});
|
});
|
||||||
|
|
||||||
bool get isRecording => session.isRecording;
|
bool get isRecording => session.isRecording;
|
||||||
|
|
||||||
factory RecordingModel.fromJson(Map<String, dynamic> json) {
|
|
||||||
return RecordingModel(
|
|
||||||
clipboardRecordingModel: ClipboardRecordingModel.fromJson(
|
|
||||||
json['clipboardRecordingModel'],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
return {'clipboardRecordingModel': clipboardRecordingModel.toJson()};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 剪切板是否包含可用于命名的 [ClipboardRecordingModel.filename]。
|
|
||||||
bool get hasClipboardFilename {
|
|
||||||
final name = clipboardRecordingModel.filename?.trim();
|
|
||||||
return hasValidClipboardInfo && name != null && name.isNotEmpty;
|
|
||||||
}
|
|
||||||
|
|
||||||
RecordingModel copyWith({
|
RecordingModel copyWith({
|
||||||
ClipboardRecordingModel? clipboardRecordingModel,
|
RecordingContext? recordingContext,
|
||||||
bool? hasValidClipboardInfo,
|
|
||||||
RecordingSessionState? session,
|
RecordingSessionState? session,
|
||||||
}) {
|
}) {
|
||||||
return RecordingModel(
|
return RecordingModel(
|
||||||
clipboardRecordingModel:
|
recordingContext: recordingContext ?? this.recordingContext,
|
||||||
clipboardRecordingModel ?? this.clipboardRecordingModel,
|
|
||||||
hasValidClipboardInfo:
|
|
||||||
hasValidClipboardInfo ?? this.hasValidClipboardInfo,
|
|
||||||
session: session ?? this.session,
|
session: session ?? this.session,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
class RecordingContext {
|
||||||
|
const RecordingContext({
|
||||||
|
required this.eventTitle,
|
||||||
|
required this.matchName,
|
||||||
|
required this.group,
|
||||||
|
required this.venue,
|
||||||
|
required this.time,
|
||||||
|
required this.playerName,
|
||||||
|
required this.playerPhone,
|
||||||
|
this.laneNo,
|
||||||
|
this.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
const RecordingContext.empty()
|
||||||
|
: eventTitle = '',
|
||||||
|
matchName = '',
|
||||||
|
group = '',
|
||||||
|
venue = '',
|
||||||
|
time = '',
|
||||||
|
playerName = '',
|
||||||
|
playerPhone = '',
|
||||||
|
laneNo = null,
|
||||||
|
status = null;
|
||||||
|
|
||||||
|
final String eventTitle;
|
||||||
|
final String matchName;
|
||||||
|
final String group;
|
||||||
|
final String venue;
|
||||||
|
final String time;
|
||||||
|
final String playerName;
|
||||||
|
final String playerPhone;
|
||||||
|
final String? laneNo;
|
||||||
|
final String? status;
|
||||||
|
|
||||||
|
String get title => '$matchName $group';
|
||||||
|
|
||||||
|
String get address => venue;
|
||||||
|
|
||||||
|
String get displayName {
|
||||||
|
final parts = [
|
||||||
|
playerName,
|
||||||
|
matchName,
|
||||||
|
group,
|
||||||
|
if (laneNo != null && laneNo!.trim().isNotEmpty) laneNo!,
|
||||||
|
];
|
||||||
|
return parts.map(_sanitize).where((part) => part.isNotEmpty).join('_');
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _sanitize(String value) {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_')
|
||||||
|
.replaceAll(RegExp(r'\s+'), '_');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,30 +7,38 @@ class RecordingSessionState {
|
|||||||
this.isTouchLocked = true,
|
this.isTouchLocked = true,
|
||||||
this.isPreviewReady = false,
|
this.isPreviewReady = false,
|
||||||
this.isStartingRecording = false,
|
this.isStartingRecording = false,
|
||||||
|
this.isSwitchingLens = false,
|
||||||
this.hasDndAccess = false,
|
this.hasDndAccess = false,
|
||||||
this.isBatteryOptimizedIgnored = true,
|
this.isBatteryOptimizedIgnored = true,
|
||||||
this.notificationsGranted = true,
|
this.notificationsGranted = true,
|
||||||
this.isMicrophoneGranted = false,
|
this.isMicrophoneGranted = false,
|
||||||
this.lastOutputPath,
|
this.zoomRatio = 1.0,
|
||||||
this.lastSavedDisplayName,
|
this.minZoomRatio = 1.0,
|
||||||
|
this.maxZoomRatio = 3.0,
|
||||||
|
this.lastStreamUrl,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
this.permissionWarning,
|
this.permissionWarning,
|
||||||
this.gallerySaveFailed = false,
|
this.streamFinished = false,
|
||||||
|
this.streamFailed = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final RecordingStatus status;
|
final RecordingStatus status;
|
||||||
final bool isTouchLocked;
|
final bool isTouchLocked;
|
||||||
final bool isPreviewReady;
|
final bool isPreviewReady;
|
||||||
final bool isStartingRecording;
|
final bool isStartingRecording;
|
||||||
|
final bool isSwitchingLens;
|
||||||
final bool hasDndAccess;
|
final bool hasDndAccess;
|
||||||
final bool isBatteryOptimizedIgnored;
|
final bool isBatteryOptimizedIgnored;
|
||||||
final bool notificationsGranted;
|
final bool notificationsGranted;
|
||||||
final bool isMicrophoneGranted;
|
final bool isMicrophoneGranted;
|
||||||
final String? lastOutputPath;
|
final double zoomRatio;
|
||||||
final String? lastSavedDisplayName;
|
final double minZoomRatio;
|
||||||
|
final double maxZoomRatio;
|
||||||
|
final String? lastStreamUrl;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
final String? permissionWarning;
|
final String? permissionWarning;
|
||||||
final bool gallerySaveFailed;
|
final bool streamFinished;
|
||||||
|
final bool streamFailed;
|
||||||
|
|
||||||
bool get isRecording => status.isRecording;
|
bool get isRecording => status.isRecording;
|
||||||
|
|
||||||
@@ -47,37 +55,47 @@ class RecordingSessionState {
|
|||||||
bool? isTouchLocked,
|
bool? isTouchLocked,
|
||||||
bool? isPreviewReady,
|
bool? isPreviewReady,
|
||||||
bool? isStartingRecording,
|
bool? isStartingRecording,
|
||||||
|
bool? isSwitchingLens,
|
||||||
bool? hasDndAccess,
|
bool? hasDndAccess,
|
||||||
bool? isBatteryOptimizedIgnored,
|
bool? isBatteryOptimizedIgnored,
|
||||||
bool? notificationsGranted,
|
bool? notificationsGranted,
|
||||||
bool? isMicrophoneGranted,
|
bool? isMicrophoneGranted,
|
||||||
String? lastOutputPath,
|
double? zoomRatio,
|
||||||
String? lastSavedDisplayName,
|
double? minZoomRatio,
|
||||||
|
double? maxZoomRatio,
|
||||||
|
String? lastStreamUrl,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
String? permissionWarning,
|
String? permissionWarning,
|
||||||
bool? gallerySaveFailed,
|
bool? streamFinished,
|
||||||
|
bool? streamFailed,
|
||||||
bool clearPermissionWarning = false,
|
bool clearPermissionWarning = false,
|
||||||
bool clearLastSaved = false,
|
bool clearStreamResult = false,
|
||||||
}) {
|
}) {
|
||||||
return RecordingSessionState(
|
return RecordingSessionState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
isTouchLocked: isTouchLocked ?? this.isTouchLocked,
|
isTouchLocked: isTouchLocked ?? this.isTouchLocked,
|
||||||
isPreviewReady: isPreviewReady ?? this.isPreviewReady,
|
isPreviewReady: isPreviewReady ?? this.isPreviewReady,
|
||||||
isStartingRecording: isStartingRecording ?? this.isStartingRecording,
|
isStartingRecording: isStartingRecording ?? this.isStartingRecording,
|
||||||
|
isSwitchingLens: isSwitchingLens ?? this.isSwitchingLens,
|
||||||
hasDndAccess: hasDndAccess ?? this.hasDndAccess,
|
hasDndAccess: hasDndAccess ?? this.hasDndAccess,
|
||||||
isBatteryOptimizedIgnored:
|
isBatteryOptimizedIgnored:
|
||||||
isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored,
|
isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored,
|
||||||
notificationsGranted: notificationsGranted ?? this.notificationsGranted,
|
notificationsGranted: notificationsGranted ?? this.notificationsGranted,
|
||||||
isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted,
|
isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted,
|
||||||
lastOutputPath: lastOutputPath ?? this.lastOutputPath,
|
zoomRatio: zoomRatio ?? this.zoomRatio,
|
||||||
lastSavedDisplayName: clearLastSaved
|
minZoomRatio: minZoomRatio ?? this.minZoomRatio,
|
||||||
? null
|
maxZoomRatio: maxZoomRatio ?? this.maxZoomRatio,
|
||||||
: (lastSavedDisplayName ?? this.lastSavedDisplayName),
|
lastStreamUrl: lastStreamUrl ?? this.lastStreamUrl,
|
||||||
errorMessage: errorMessage,
|
errorMessage: errorMessage,
|
||||||
permissionWarning: clearPermissionWarning
|
permissionWarning: clearPermissionWarning
|
||||||
? null
|
? null
|
||||||
: (permissionWarning ?? this.permissionWarning),
|
: (permissionWarning ?? this.permissionWarning),
|
||||||
gallerySaveFailed: gallerySaveFailed ?? this.gallerySaveFailed,
|
streamFinished: clearStreamResult
|
||||||
|
? false
|
||||||
|
: (streamFinished ?? this.streamFinished),
|
||||||
|
streamFailed: clearStreamResult
|
||||||
|
? false
|
||||||
|
: (streamFailed ?? this.streamFailed),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:apivideo_live_stream/apivideo_live_stream.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
|
import 'package:recording_tool/app/router/app_navigator.dart';
|
||||||
import 'package:recording_tool/core/platform/app_platform_info.dart';
|
import 'package:recording_tool/core/platform/app_platform_info.dart';
|
||||||
import 'package:recording_tool/core/platform/device_health_checker.dart';
|
import 'package:recording_tool/core/platform/device_health_checker.dart';
|
||||||
import 'package:recording_tool/features/dialog/dialog-record.dart';
|
import 'package:recording_tool/features/recording/dialog/dialog-record.dart';
|
||||||
import 'package:recording_tool/features/recording/model/model_recording.dart';
|
import 'package:recording_tool/features/recording/model/model_recording_context.dart';
|
||||||
import 'package:recording_tool/features/recording/platform/recording_platform.dart';
|
import 'package:recording_tool/features/recording/platform/recording_platform.dart';
|
||||||
import 'package:recording_tool/features/recording/utils/recording_display_name.dart';
|
|
||||||
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
|
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
|
||||||
import 'package:recording_tool/features/recording/widgets/widget_camera_preview.dart';
|
import 'package:recording_tool/features/recording/widgets/widget_camera_preview.dart';
|
||||||
import 'package:recording_tool/features/recording/widgets/widget_record_footer.dart';
|
import 'package:recording_tool/features/recording/widgets/widget_record_footer.dart';
|
||||||
@@ -19,11 +20,20 @@ import 'package:recording_tool/features/recording/widgets/widget_recording_hud.d
|
|||||||
import 'package:recording_tool/features/recording/widgets/widget_recording_loading_overlay.dart';
|
import 'package:recording_tool/features/recording/widgets/widget_recording_loading_overlay.dart';
|
||||||
import 'package:recording_tool/features/recording/widgets/widget_recording_saved_dialog.dart';
|
import 'package:recording_tool/features/recording/widgets/widget_recording_saved_dialog.dart';
|
||||||
import 'package:recording_tool/features/recording/widgets/widget_recording_touch_lock_overlay.dart';
|
import 'package:recording_tool/features/recording/widgets/widget_recording_touch_lock_overlay.dart';
|
||||||
|
import 'package:recording_tool/features/scan_qrcode/pages/page_scan_qrcode.dart';
|
||||||
|
import 'package:recording_tool/features/scan_qrcode/utils/rtmp_stream_target.dart';
|
||||||
import 'package:recording_tool/shared/widgets/widgets.dart';
|
import 'package:recording_tool/shared/widgets/widgets.dart';
|
||||||
|
|
||||||
/// 录制页入口
|
/// 录制页入口
|
||||||
class RecordingPage extends ConsumerStatefulWidget {
|
class RecordingPage extends ConsumerStatefulWidget {
|
||||||
const RecordingPage({super.key});
|
const RecordingPage({
|
||||||
|
super.key,
|
||||||
|
required this.recordingContext,
|
||||||
|
required this.streamUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
final RecordingContext recordingContext;
|
||||||
|
final String streamUrl;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
/// 创建页面状态
|
/// 创建页面状态
|
||||||
@@ -31,13 +41,60 @@ class RecordingPage extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _RecordingPageState extends ConsumerState<RecordingPage> {
|
class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||||
|
late final ApiVideoLiveStreamController _streamController;
|
||||||
var _immersiveApplied = false;
|
var _immersiveApplied = false;
|
||||||
|
var _previewReady = false;
|
||||||
|
var _stoppingByUser = false;
|
||||||
|
var _controllerDisposed = false;
|
||||||
|
String? _mainCameraId;
|
||||||
|
String? _ultraWideCameraId;
|
||||||
|
double _ultraWideZoomRatio = 1.0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
/// 首帧后初始化录制流程
|
/// 首帧后初始化录制流程
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => _bootstrap());
|
_streamController = ApiVideoLiveStreamController(
|
||||||
|
initialAudioConfig: AudioConfig(bitrate: 128000),
|
||||||
|
initialVideoConfig: VideoConfig.withDefaultBitrate(
|
||||||
|
resolution: Resolution.RESOLUTION_1080,
|
||||||
|
fps: 30,
|
||||||
|
),
|
||||||
|
onConnectionSuccess: () => {debugPrint('推流成功')},
|
||||||
|
onConnectionFailed: (reason) {
|
||||||
|
debugPrint('推流失败');
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
ref
|
||||||
|
.read(recordingViewModelProvider.notifier)
|
||||||
|
.markRecordingStartFailed('推流失败: $reason');
|
||||||
|
},
|
||||||
|
onDisconnection: () {
|
||||||
|
debugPrint('推流连接已断开');
|
||||||
|
|
||||||
|
if (!mounted || _stoppingByUser) return;
|
||||||
|
final isRecording = ref.read(recordingViewModelProvider).isRecording;
|
||||||
|
if (isRecording) {
|
||||||
|
ref
|
||||||
|
.read(recordingViewModelProvider.notifier)
|
||||||
|
.markRecordingStopped(errorMessage: '推流连接已断开');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) {
|
||||||
|
debugPrint('推流失败:${error.toString()}');
|
||||||
|
if (!mounted) return;
|
||||||
|
ref
|
||||||
|
.read(recordingViewModelProvider.notifier)
|
||||||
|
.setError(error.toString());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
ref
|
||||||
|
.read(recordingViewModelProvider.notifier)
|
||||||
|
.setRecordingContext(widget.recordingContext);
|
||||||
|
_bootstrap();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检查设备健康状态并弹窗提示
|
/// 检查设备健康状态并弹窗提示
|
||||||
@@ -55,23 +112,81 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 页面启动:健康检查、读剪贴板、进入录制模式、准备相机会话
|
/// 页面启动:健康检查、进入录制模式、准备相机会话
|
||||||
Future<void> _bootstrap() async {
|
Future<void> _bootstrap() async {
|
||||||
await _checkAndShowDeviceHealthAlerts();
|
await _checkAndShowDeviceHealthAlerts();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
final clipboardResult = await ref
|
|
||||||
.read(recordingViewModelProvider.notifier)
|
|
||||||
.getClipboardContent();
|
|
||||||
if (!mounted) return;
|
|
||||||
if (clipboardResult == ClipboardReadResult.invalid) {
|
|
||||||
AppToast.show('无选手信息');
|
|
||||||
}
|
|
||||||
await _enterRecordingMode();
|
await _enterRecordingMode();
|
||||||
// Allow PlatformView to attach before binding CameraX preview.
|
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await ref.read(recordingViewModelProvider.notifier).prepareSession();
|
await ref.read(recordingViewModelProvider.notifier).prepareSession();
|
||||||
|
if (!mounted) return;
|
||||||
|
await _initializeLiveStreamPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initializeLiveStreamPreview() async {
|
||||||
|
try {
|
||||||
|
await _streamController.initialize();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _previewReady = true);
|
||||||
|
ref
|
||||||
|
.read(recordingViewModelProvider.notifier)
|
||||||
|
.setPreviewReady(ready: true);
|
||||||
|
await _loadBackCameraCapabilities();
|
||||||
|
} on PlatformException catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ref
|
||||||
|
.read(recordingViewModelProvider.notifier)
|
||||||
|
.setPreviewReady(
|
||||||
|
ready: false,
|
||||||
|
errorMessage: error.message ?? '相机预览初始化失败',
|
||||||
|
);
|
||||||
|
if (mounted) setState(() => _previewReady = false);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ref
|
||||||
|
.read(recordingViewModelProvider.notifier)
|
||||||
|
.setPreviewReady(ready: false, errorMessage: '相机预览初始化失败: $error');
|
||||||
|
setState(() => _previewReady = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadBackCameraCapabilities() async {
|
||||||
|
try {
|
||||||
|
final cameras = await _streamController.getBackCameras();
|
||||||
|
if (cameras.isEmpty) return;
|
||||||
|
final main = cameras.first;
|
||||||
|
final widest = cameras.reduce(
|
||||||
|
(current, next) =>
|
||||||
|
next.horizontalFov > current.horizontalFov ? next : current,
|
||||||
|
);
|
||||||
|
_mainCameraId = main.cameraId;
|
||||||
|
if (widest.cameraId != main.cameraId &&
|
||||||
|
widest.horizontalFov > main.horizontalFov * 1.08 &&
|
||||||
|
main.minFocalLength > 0) {
|
||||||
|
_ultraWideCameraId = widest.cameraId;
|
||||||
|
_ultraWideZoomRatio = (widest.minFocalLength / main.minFocalLength)
|
||||||
|
.clamp(0.4, 0.9)
|
||||||
|
.toDouble();
|
||||||
|
}
|
||||||
|
ref
|
||||||
|
.read(recordingViewModelProvider.notifier)
|
||||||
|
.updateZoomCapabilities(
|
||||||
|
zoomRatio: 1.0,
|
||||||
|
minZoomRatio: _ultraWideCameraId == null
|
||||||
|
? 1.0
|
||||||
|
: _ultraWideZoomRatio,
|
||||||
|
maxZoomRatio: 1.0,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
ref
|
||||||
|
.read(recordingViewModelProvider.notifier)
|
||||||
|
.updateZoomCapabilities(
|
||||||
|
zoomRatio: 1.0,
|
||||||
|
minZoomRatio: 1.0,
|
||||||
|
maxZoomRatio: 1.0,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Android 进入沉浸式全屏
|
/// Android 进入沉浸式全屏
|
||||||
@@ -82,43 +197,6 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
_immersiveApplied = true;
|
_immersiveApplied = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 解析保存成功弹窗的标题文案
|
|
||||||
String _savedDialogSessionTitle(
|
|
||||||
RecordingModel recordingInfo,
|
|
||||||
String? savedName,
|
|
||||||
) {
|
|
||||||
final clipboard = recordingInfo.clipboardRecordingModel;
|
|
||||||
if (recordingInfo.hasValidClipboardInfo &&
|
|
||||||
clipboard.title.trim().isNotEmpty) {
|
|
||||||
return clipboard.title.trim();
|
|
||||||
}
|
|
||||||
if (savedName != null && savedName.isNotEmpty) {
|
|
||||||
return resolveRecordingDisplayName(savedName);
|
|
||||||
}
|
|
||||||
return '录制完成';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 从剪贴板粘贴赛事信息(与 header「粘贴选手信息」一致)。
|
|
||||||
Future<void> _pasteEventInfo() async {
|
|
||||||
final result = await ref
|
|
||||||
.read(recordingViewModelProvider.notifier)
|
|
||||||
.getClipboardContent();
|
|
||||||
if (!mounted) return;
|
|
||||||
if (result != ClipboardReadResult.success) {
|
|
||||||
AppToast.show('无选手信息');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 无选手信息时弹窗提示
|
|
||||||
Future<void> _showNoPlayerInfoDialog() {
|
|
||||||
return RecordDialog.showSingle(
|
|
||||||
context,
|
|
||||||
title: '无选手信息!',
|
|
||||||
buttonText: '粘贴',
|
|
||||||
onPressed: _pasteEventInfo,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 根据缺失权限生成弹窗文案。
|
/// 根据缺失权限生成弹窗文案。
|
||||||
String _recordingPermissionDialogTitle(RecordingRequiredPermissions result) {
|
String _recordingPermissionDialogTitle(RecordingRequiredPermissions result) {
|
||||||
if (!result.cameraGranted && !result.microphoneGranted) {
|
if (!result.cameraGranted && !result.microphoneGranted) {
|
||||||
@@ -153,61 +231,109 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 点击开始录制:校验剪贴板、权限与健康状态
|
/// 点击开始录制:校验推流地址、权限与健康状态
|
||||||
Future<void> _onStartRecording() async {
|
Future<void> _onStartRecording() async {
|
||||||
final recordingInfo = ref.read(recordingViewModelProvider);
|
if (widget.streamUrl.trim().isEmpty) {
|
||||||
if (!recordingInfo.hasClipboardFilename) {
|
AppToast.show('推流地址为空,请重新进入录制页');
|
||||||
await _showNoPlayerInfoDialog();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!await _ensureRecordingPermissions()) return;
|
if (!await _ensureRecordingPermissions()) return;
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await _checkAndShowDeviceHealthAlerts();
|
await _checkAndShowDeviceHealthAlerts();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await ref.read(recordingViewModelProvider.notifier).startRecording();
|
final viewModel = ref.read(recordingViewModelProvider.notifier);
|
||||||
|
viewModel.markStartingRecording();
|
||||||
|
try {
|
||||||
|
final target = RtmpStreamTarget.parse(widget.streamUrl);
|
||||||
|
await _streamController.startStreaming(
|
||||||
|
streamKey: target.streamKey,
|
||||||
|
url: target.url,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
await viewModel.markRecordingStarted(target.fullUrl);
|
||||||
|
} on FormatException catch (error) {
|
||||||
|
viewModel.markRecordingStartFailed(error.message);
|
||||||
|
AppToast.show(error.message);
|
||||||
|
} on PlatformException catch (error) {
|
||||||
|
final message = error.message ?? error.code;
|
||||||
|
viewModel.markRecordingStartFailed('推流失败: $message');
|
||||||
|
AppToast.show('推流失败: $message');
|
||||||
|
} catch (error) {
|
||||||
|
viewModel.markRecordingStartFailed('推流失败: $error');
|
||||||
|
AppToast.showError(error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 停止录制并按结果显示保存提示。
|
/// 停止录制并按结果显示推流提示。
|
||||||
Future<void> _stopRecordingAndShowResult() async {
|
Future<void> _stopRecordingAndShowResult() async {
|
||||||
await ref.read(recordingViewModelProvider.notifier).stopRecording();
|
final viewModel = ref.read(recordingViewModelProvider.notifier);
|
||||||
if (!mounted) return;
|
_stoppingByUser = true;
|
||||||
final latest = ref.read(recordingViewModelProvider).session;
|
try {
|
||||||
if (latest.gallerySaveFailed) {
|
await _streamController.stopStreaming();
|
||||||
AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限');
|
if (!mounted) return;
|
||||||
|
await viewModel.markRecordingStopped();
|
||||||
|
} on PlatformException catch (error) {
|
||||||
|
final message = error.message ?? error.code;
|
||||||
|
await viewModel.markRecordingStopped(errorMessage: '停止推流失败: $message');
|
||||||
|
AppToast.show('停止推流失败: $message');
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
await viewModel.markRecordingStopped(errorMessage: '停止推流失败: $error');
|
||||||
|
AppToast.showError(error);
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
_stoppingByUser = false;
|
||||||
|
}
|
||||||
|
await _showRecordingFinishedDialogIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setCameraZoomRatio(double ratio) async {
|
||||||
|
final viewModel = ref.read(recordingViewModelProvider.notifier);
|
||||||
|
final targetCameraId = ratio < 1.0 ? _ultraWideCameraId : _mainCameraId;
|
||||||
|
if (targetCameraId == null) {
|
||||||
|
viewModel.setError('当前设备不支持该镜头');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _showRecordingSavedDialogIfNeeded();
|
viewModel.markLensSwitching(true);
|
||||||
|
try {
|
||||||
|
await _streamController.setCameraId(targetCameraId);
|
||||||
|
viewModel.setZoomRatioValue(ratio < 1.0 ? _ultraWideZoomRatio : 1.0);
|
||||||
|
} on PlatformException catch (error) {
|
||||||
|
final isRecording = ref.read(recordingViewModelProvider).isRecording;
|
||||||
|
final message = isRecording
|
||||||
|
? '推流中切换镜头失败,请停止后重试'
|
||||||
|
: (error.message ?? '切换镜头失败,请重试');
|
||||||
|
viewModel.setError(message);
|
||||||
|
} catch (_) {
|
||||||
|
final isRecording = ref.read(recordingViewModelProvider).isRecording;
|
||||||
|
viewModel.setError(isRecording ? '推流中切换镜头失败,请停止后重试' : '切换镜头失败,请重试');
|
||||||
|
} finally {
|
||||||
|
viewModel.markLensSwitching(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 清空剪贴板信息,准备新一轮录制
|
/// 返回扫码页,准备新一轮录制。
|
||||||
void _clearClipboardForNewRound() {
|
void _recordNewRound() {
|
||||||
final notifier = ref.read(recordingViewModelProvider.notifier);
|
AppNavigator.pushAndRemoveUntil(const ScanQrCodePage());
|
||||||
notifier.resetClipboardInfo();
|
|
||||||
notifier.clearSavedRecordingResult();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 保存成功后按需弹出完成对话框
|
/// 推流结束后按需弹出完成对话框
|
||||||
Future<void> _showRecordingSavedDialogIfNeeded() async {
|
Future<void> _showRecordingFinishedDialogIfNeeded() async {
|
||||||
final recordingInfo = ref.read(recordingViewModelProvider);
|
final recordingInfo = ref.read(recordingViewModelProvider);
|
||||||
final session = recordingInfo.session;
|
final session = recordingInfo.session;
|
||||||
if (session.lastSavedDisplayName == null || session.gallerySaveFailed) {
|
if (!session.streamFinished || session.streamFailed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final sessionTitle = _savedDialogSessionTitle(
|
|
||||||
recordingInfo,
|
|
||||||
session.lastSavedDisplayName,
|
|
||||||
);
|
|
||||||
|
|
||||||
await showRecordingSavedDialog(
|
await showRecordingSavedDialog(
|
||||||
context,
|
context,
|
||||||
sessionTitle: sessionTitle,
|
sessionTitle: recordingInfo.recordingContext.title,
|
||||||
onContinueRound: () {
|
onContinueRound: () {
|
||||||
ref
|
ref
|
||||||
.read(recordingViewModelProvider.notifier)
|
.read(recordingViewModelProvider.notifier)
|
||||||
.clearSavedRecordingResult();
|
.clearSavedRecordingResult();
|
||||||
},
|
},
|
||||||
onRecordNewRound: _clearClipboardForNewRound,
|
onRecordNewRound: _recordNewRound,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +341,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
Future<void> _exitRecordingMode() async {
|
Future<void> _exitRecordingMode() async {
|
||||||
if (!_immersiveApplied) return;
|
if (!_immersiveApplied) return;
|
||||||
await ref.read(recordingViewModelProvider.notifier).teardown();
|
await ref.read(recordingViewModelProvider.notifier).teardown();
|
||||||
|
await _disposeStreamController();
|
||||||
await SystemChrome.setEnabledSystemUIMode(
|
await SystemChrome.setEnabledSystemUIMode(
|
||||||
SystemUiMode.manual,
|
SystemUiMode.manual,
|
||||||
overlays: SystemUiOverlay.values,
|
overlays: SystemUiOverlay.values,
|
||||||
@@ -233,9 +360,17 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
);
|
);
|
||||||
RecordingPlatform.setImmersiveMode(enabled: false);
|
RecordingPlatform.setImmersiveMode(enabled: false);
|
||||||
}
|
}
|
||||||
|
_disposeStreamController();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _disposeStreamController() async {
|
||||||
|
if (_controllerDisposed) return;
|
||||||
|
_controllerDisposed = true;
|
||||||
|
await _streamController.stop();
|
||||||
|
await _streamController.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
/// 构建录制页 UI
|
/// 构建录制页 UI
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -245,19 +380,20 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
|||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
_RecordHeaderSection(
|
const _RecordHeaderSection(),
|
||||||
onPasteEventInfo: _pasteEventInfo,
|
|
||||||
onClearEventInfo: _clearClipboardForNewRound,
|
|
||||||
),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
const CameraPreviewWidget(),
|
CameraPreviewWidget(
|
||||||
|
controller: _streamController,
|
||||||
|
isReady: _previewReady,
|
||||||
|
),
|
||||||
const _PreviewLoadingLayer(),
|
const _PreviewLoadingLayer(),
|
||||||
const RecordTimerWidget(),
|
const RecordTimerWidget(),
|
||||||
_RecordingHudLayer(
|
_RecordingHudLayer(
|
||||||
onStart: _onStartRecording,
|
onStart: _onStartRecording,
|
||||||
onStop: _stopRecordingAndShowResult,
|
onStop: _stopRecordingAndShowResult,
|
||||||
|
onZoomSelected: _setCameraZoomRatio,
|
||||||
),
|
),
|
||||||
_TouchLockOverlayLayer(
|
_TouchLockOverlayLayer(
|
||||||
onStopRecording: _stopRecordingAndShowResult,
|
onStopRecording: _stopRecordingAndShowResult,
|
||||||
@@ -306,34 +442,18 @@ class _RecordingPopScope extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _RecordHeaderSection extends ConsumerWidget {
|
class _RecordHeaderSection extends ConsumerWidget {
|
||||||
const _RecordHeaderSection({
|
const _RecordHeaderSection();
|
||||||
required this.onPasteEventInfo,
|
|
||||||
required this.onClearEventInfo,
|
|
||||||
});
|
|
||||||
|
|
||||||
final Future<void> Function() onPasteEventInfo;
|
|
||||||
final VoidCallback onClearEventInfo;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final headerState = ref.watch(
|
final headerState = ref.watch(
|
||||||
recordingViewModelProvider.select(
|
recordingViewModelProvider.select(
|
||||||
(m) => (
|
(m) => (m.recordingContext.title, m.session.isRecording),
|
||||||
m.hasValidClipboardInfo,
|
|
||||||
m.hasValidClipboardInfo ? m.clipboardRecordingModel.title : null,
|
|
||||||
m.session.isRecording,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
final (hasValidClipboardInfo, eventTitle, isRecording) = headerState;
|
final (eventTitle, isRecording) = headerState;
|
||||||
|
|
||||||
return RecordHeaderWidget(
|
return RecordHeaderWidget(eventTitle: eventTitle, isRecording: isRecording);
|
||||||
hasValidClipboardInfo: hasValidClipboardInfo,
|
|
||||||
eventTitle: eventTitle,
|
|
||||||
isRecording: isRecording,
|
|
||||||
onPasteEventInfo: onPasteEventInfo,
|
|
||||||
onClearEventInfo: onClearEventInfo,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,10 +480,12 @@ class _RecordingHudLayer extends ConsumerWidget {
|
|||||||
const _RecordingHudLayer({
|
const _RecordingHudLayer({
|
||||||
required this.onStart,
|
required this.onStart,
|
||||||
required this.onStop,
|
required this.onStop,
|
||||||
|
required this.onZoomSelected,
|
||||||
});
|
});
|
||||||
|
|
||||||
final Future<void> Function() onStart;
|
final Future<void> Function() onStart;
|
||||||
final Future<void> Function() onStop;
|
final Future<void> Function() onStop;
|
||||||
|
final ValueChanged<double> onZoomSelected;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -377,9 +499,12 @@ class _RecordingHudLayer extends ConsumerWidget {
|
|||||||
m.session.notificationsGranted,
|
m.session.notificationsGranted,
|
||||||
m.session.isRecording,
|
m.session.isRecording,
|
||||||
m.session.isStartingRecording,
|
m.session.isStartingRecording,
|
||||||
|
m.session.isSwitchingLens,
|
||||||
m.session.isTouchLocked,
|
m.session.isTouchLocked,
|
||||||
m.hasValidClipboardInfo,
|
m.session.zoomRatio,
|
||||||
m.clipboardRecordingModel.address.trim(),
|
m.session.minZoomRatio,
|
||||||
|
m.session.maxZoomRatio,
|
||||||
|
m.recordingContext.address.trim(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -391,9 +516,12 @@ class _RecordingHudLayer extends ConsumerWidget {
|
|||||||
notificationsGranted,
|
notificationsGranted,
|
||||||
isRecording,
|
isRecording,
|
||||||
isStartingRecording,
|
isStartingRecording,
|
||||||
|
isSwitchingLens,
|
||||||
isTouchLocked,
|
isTouchLocked,
|
||||||
showClipboardHint,
|
zoomRatio,
|
||||||
clipboardAddress,
|
minZoomRatio,
|
||||||
|
maxZoomRatio,
|
||||||
|
venue,
|
||||||
) = hudState;
|
) = hudState;
|
||||||
final viewModel = ref.read(recordingViewModelProvider.notifier);
|
final viewModel = ref.read(recordingViewModelProvider.notifier);
|
||||||
|
|
||||||
@@ -405,9 +533,13 @@ class _RecordingHudLayer extends ConsumerWidget {
|
|||||||
notificationsGranted: notificationsGranted,
|
notificationsGranted: notificationsGranted,
|
||||||
isRecording: isRecording,
|
isRecording: isRecording,
|
||||||
isStartingRecording: isStartingRecording,
|
isStartingRecording: isStartingRecording,
|
||||||
|
isSwitchingLens: isSwitchingLens,
|
||||||
isTouchLocked: isTouchLocked,
|
isTouchLocked: isTouchLocked,
|
||||||
showClipboardHint: showClipboardHint,
|
zoomRatio: zoomRatio,
|
||||||
clipboardAddress: clipboardAddress,
|
minZoomRatio: minZoomRatio,
|
||||||
|
maxZoomRatio: maxZoomRatio,
|
||||||
|
showContextHint: venue.isNotEmpty,
|
||||||
|
contextAddress: venue,
|
||||||
onStart: onStart,
|
onStart: onStart,
|
||||||
onStop: onStop,
|
onStop: onStop,
|
||||||
onOpenDnd: () async {
|
onOpenDnd: () async {
|
||||||
@@ -419,9 +551,13 @@ class _RecordingHudLayer extends ConsumerWidget {
|
|||||||
await viewModel.refreshBatteryOptimization();
|
await viewModel.refreshBatteryOptimization();
|
||||||
},
|
},
|
||||||
onToggleTouchLock: () {
|
onToggleTouchLock: () {
|
||||||
final locked = ref.read(recordingViewModelProvider).session.isTouchLocked;
|
final locked = ref
|
||||||
|
.read(recordingViewModelProvider)
|
||||||
|
.session
|
||||||
|
.isTouchLocked;
|
||||||
viewModel.setTouchLocked(!locked);
|
viewModel.setTouchLocked(!locked);
|
||||||
},
|
},
|
||||||
|
onZoomSelected: onZoomSelected,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -472,7 +608,7 @@ class _StartingRecordingOverlay extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return RecordingLoadingOverlayWidget(
|
return RecordingLoadingOverlayWidget(
|
||||||
message: '正在开始录制…',
|
message: '正在连接推流…',
|
||||||
backgroundColor: Colors.black.withValues(alpha: 0.24),
|
backgroundColor: Colors.black.withValues(alpha: 0.24),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
abstract final class RecordingChannelNames {
|
abstract final class RecordingChannelNames {
|
||||||
static const packageName = 'com.qxy.dronex';
|
static const namespace = 'app.record_tool';
|
||||||
static const method = '$packageName/recording';
|
static const method = '$namespace/recording';
|
||||||
static const events = '$packageName/recording_events';
|
static const events = '$namespace/recording_events';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -22,20 +21,20 @@ enum RecordingState {
|
|||||||
class RecordingStatus {
|
class RecordingStatus {
|
||||||
const RecordingStatus({
|
const RecordingStatus({
|
||||||
required this.state,
|
required this.state,
|
||||||
this.outputPath,
|
this.streamUrl,
|
||||||
this.elapsedMillis = 0,
|
this.elapsedMillis = 0,
|
||||||
this.message,
|
this.message,
|
||||||
});
|
});
|
||||||
|
|
||||||
final RecordingState state;
|
final RecordingState state;
|
||||||
final String? outputPath;
|
final String? streamUrl;
|
||||||
final int elapsedMillis;
|
final int elapsedMillis;
|
||||||
final String? message;
|
final String? message;
|
||||||
|
|
||||||
factory RecordingStatus.fromMap(Map<dynamic, dynamic> map) {
|
factory RecordingStatus.fromMap(Map<dynamic, dynamic> map) {
|
||||||
return RecordingStatus(
|
return RecordingStatus(
|
||||||
state: RecordingState.fromRaw(map['state'] as String?),
|
state: RecordingState.fromRaw(map['state'] as String?),
|
||||||
outputPath: map['outputPath'] as String?,
|
streamUrl: map['streamUrl'] as String?,
|
||||||
elapsedMillis: (map['elapsedMillis'] as num?)?.toInt() ?? 0,
|
elapsedMillis: (map['elapsedMillis'] as num?)?.toInt() ?? 0,
|
||||||
message: map['message'] as String?,
|
message: map['message'] as String?,
|
||||||
);
|
);
|
||||||
@@ -50,9 +49,6 @@ class RecordingPlatform {
|
|||||||
static const MethodChannel _channel = MethodChannel(
|
static const MethodChannel _channel = MethodChannel(
|
||||||
RecordingChannelNames.method,
|
RecordingChannelNames.method,
|
||||||
);
|
);
|
||||||
static const EventChannel _events = EventChannel(
|
|
||||||
RecordingChannelNames.events,
|
|
||||||
);
|
|
||||||
|
|
||||||
static bool get isSupported =>
|
static bool get isSupported =>
|
||||||
supportsHost(isAndroid: Platform.isAndroid, isIOS: Platform.isIOS);
|
supportsHost(isAndroid: Platform.isAndroid, isIOS: Platform.isIOS);
|
||||||
@@ -61,61 +57,6 @@ class RecordingPlatform {
|
|||||||
return isAndroid || isIOS;
|
return isAndroid || isIOS;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Stream<RecordingStatus>? _statusStream;
|
|
||||||
|
|
||||||
static Stream<RecordingStatus> statusStream() {
|
|
||||||
if (!isSupported) {
|
|
||||||
return const Stream.empty();
|
|
||||||
}
|
|
||||||
_statusStream ??= _events.receiveBroadcastStream().map(
|
|
||||||
(event) =>
|
|
||||||
RecordingStatus.fromMap(Map<dynamic, dynamic>.from(event as Map)),
|
|
||||||
);
|
|
||||||
return _statusStream!;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<RecordingStatus> initializePreview() async {
|
|
||||||
final result = await _channel.invokeMapMethod<String, dynamic>(
|
|
||||||
'initializePreview',
|
|
||||||
);
|
|
||||||
return RecordingStatus.fromMap(result ?? const {});
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<RecordingStartResult> startRecording({
|
|
||||||
bool withAudio = true,
|
|
||||||
bool enableDoNotDisturb = true,
|
|
||||||
String? displayName,
|
|
||||||
}) async {
|
|
||||||
final args = <String, dynamic>{
|
|
||||||
'withAudio': withAudio,
|
|
||||||
'enableDoNotDisturb': enableDoNotDisturb,
|
|
||||||
};
|
|
||||||
if (displayName != null) {
|
|
||||||
args['displayName'] = displayName;
|
|
||||||
}
|
|
||||||
|
|
||||||
final result = await _channel.invokeMapMethod<String, dynamic>(
|
|
||||||
'startRecording',
|
|
||||||
args,
|
|
||||||
);
|
|
||||||
return RecordingStartResult(
|
|
||||||
outputPath: result?['outputPath'] as String?,
|
|
||||||
status: RecordingStatus.fromMap(
|
|
||||||
Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<RecordingStopResult> stopRecording() async {
|
|
||||||
final result = await _channel.invokeMapMethod<String, dynamic>(
|
|
||||||
'stopRecording',
|
|
||||||
);
|
|
||||||
return RecordingStopResult.fromMap(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<void> disposePreview() =>
|
|
||||||
_channel.invokeMethod('disposePreview');
|
|
||||||
|
|
||||||
static Future<bool> hasNotificationPolicyAccess() async {
|
static Future<bool> hasNotificationPolicyAccess() async {
|
||||||
return await _channel.invokeMethod<bool>('hasNotificationPolicyAccess') ??
|
return await _channel.invokeMethod<bool>('hasNotificationPolicyAccess') ??
|
||||||
false;
|
false;
|
||||||
@@ -149,41 +90,4 @@ class RecordingPlatform {
|
|||||||
'enabled': enabled,
|
'enabled': enabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<RecordingStatus> getStatus() async {
|
|
||||||
final result = await _channel.invokeMapMethod<String, dynamic>('getStatus');
|
|
||||||
return RecordingStatus.fromMap(result ?? const {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RecordingStartResult {
|
|
||||||
const RecordingStartResult({this.outputPath, required this.status});
|
|
||||||
|
|
||||||
final String? outputPath;
|
|
||||||
final RecordingStatus status;
|
|
||||||
}
|
|
||||||
|
|
||||||
class RecordingStopResult {
|
|
||||||
const RecordingStopResult({
|
|
||||||
this.outputPath,
|
|
||||||
required this.status,
|
|
||||||
this.gallerySaved = true,
|
|
||||||
this.galleryErrorMessage,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String? outputPath;
|
|
||||||
final RecordingStatus status;
|
|
||||||
final bool gallerySaved;
|
|
||||||
final String? galleryErrorMessage;
|
|
||||||
|
|
||||||
factory RecordingStopResult.fromMap(Map<String, dynamic>? result) {
|
|
||||||
return RecordingStopResult(
|
|
||||||
outputPath: result?['outputPath'] as String?,
|
|
||||||
status: RecordingStatus.fromMap(
|
|
||||||
Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}),
|
|
||||||
),
|
|
||||||
gallerySaved: result?['gallerySaved'] as bool? ?? true,
|
|
||||||
galleryErrorMessage: result?['galleryErrorMessage'] as String?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
/// 非法文件名字符(路径分隔符等)。
|
|
||||||
final _invalidNameChars = RegExp(r'[/\\:*?"<>|]');
|
|
||||||
|
|
||||||
const _maxBaseNameLength = 120;
|
|
||||||
|
|
||||||
/// 清洗小程序复制的文件名基底(不含扩展名)。
|
|
||||||
String? sanitizeRecordingBaseName(String raw) {
|
|
||||||
var name = raw.replaceAll(_invalidNameChars, '_').trim();
|
|
||||||
if (name.isEmpty) return null;
|
|
||||||
if (name.length > _maxBaseNameLength) {
|
|
||||||
name = name.substring(0, _maxBaseNameLength);
|
|
||||||
}
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 解析录制展示名:优先剪切板 filename,否则 REC_时间戳。
|
|
||||||
String resolveRecordingDisplayName(String? clipboardFilename) {
|
|
||||||
final sanitized = clipboardFilename == null
|
|
||||||
? null
|
|
||||||
: sanitizeRecordingBaseName(clipboardFilename);
|
|
||||||
if (sanitized != null) return sanitized;
|
|
||||||
final now = DateTime.now();
|
|
||||||
final stamp =
|
|
||||||
'${now.year}'
|
|
||||||
'${now.month.toString().padLeft(2, '0')}'
|
|
||||||
'${now.day.toString().padLeft(2, '0')}_'
|
|
||||||
'${now.hour.toString().padLeft(2, '0')}'
|
|
||||||
'${now.minute.toString().padLeft(2, '0')}'
|
|
||||||
'${now.second.toString().padLeft(2, '0')}';
|
|
||||||
return 'REC_$stamp';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 为展示名补全视频扩展名(已有 .mp4/.mov 则保留)。
|
|
||||||
String withVideoExtension(String baseName, {bool? isIOS}) {
|
|
||||||
final ios = isIOS ?? Platform.isIOS;
|
|
||||||
final ext = ios ? '.mov' : '.mp4';
|
|
||||||
final lower = baseName.toLowerCase();
|
|
||||||
if (lower.endsWith('.mp4') || lower.endsWith('.mov')) {
|
|
||||||
return baseName;
|
|
||||||
}
|
|
||||||
return '$baseName$ext';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 传给原生的完整文件名(含扩展名)。
|
|
||||||
String recordingFileNameForPlatform(
|
|
||||||
String? clipboardFilename, {
|
|
||||||
bool? isIOS,
|
|
||||||
}) {
|
|
||||||
final base = resolveRecordingDisplayName(clipboardFilename);
|
|
||||||
return withVideoExtension(base, isIOS: isIOS);
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,13 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
import 'package:recording_tool/core/logging/app_logger.dart';
|
|
||||||
import 'package:recording_tool/core/permission/permission_service.dart';
|
import 'package:recording_tool/core/permission/permission_service.dart';
|
||||||
import 'package:recording_tool/features/recording/model/model_clipboard.dart';
|
|
||||||
import 'package:recording_tool/features/recording/model/model_recording.dart';
|
import 'package:recording_tool/features/recording/model/model_recording.dart';
|
||||||
|
import 'package:recording_tool/features/recording/model/model_recording_context.dart';
|
||||||
import 'package:recording_tool/features/recording/model/model_recording_session.dart';
|
import 'package:recording_tool/features/recording/model/model_recording_session.dart';
|
||||||
import 'package:recording_tool/features/recording/platform/recording_platform.dart';
|
import 'package:recording_tool/features/recording/platform/recording_platform.dart';
|
||||||
import 'package:recording_tool/features/recording/utils/recording_display_name.dart';
|
|
||||||
|
|
||||||
/// 录制页状态 Provider。
|
/// 录制页状态 Provider。
|
||||||
final recordingViewModelProvider =
|
final recordingViewModelProvider =
|
||||||
@@ -19,31 +15,6 @@ final recordingViewModelProvider =
|
|||||||
RecordingViewModel.new,
|
RecordingViewModel.new,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// 剪切板读取结果,供 UI 决定是否提示用户。
|
|
||||||
enum ClipboardReadResult {
|
|
||||||
/// 剪切板为空,不提示
|
|
||||||
empty,
|
|
||||||
|
|
||||||
/// 解析成功
|
|
||||||
success,
|
|
||||||
|
|
||||||
/// 有内容但格式不符合小程序录制信息
|
|
||||||
invalid,
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Permission> recordingGalleryPermissionsForHost({
|
|
||||||
required bool isIOS,
|
|
||||||
required bool isAndroid,
|
|
||||||
}) {
|
|
||||||
if (isIOS) {
|
|
||||||
return [Permission.photosAddOnly];
|
|
||||||
}
|
|
||||||
if (isAndroid) {
|
|
||||||
return [Permission.videos, Permission.storage];
|
|
||||||
}
|
|
||||||
return const [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 开始录制所需的相机/麦克风权限检测结果。
|
/// 开始录制所需的相机/麦克风权限检测结果。
|
||||||
class RecordingRequiredPermissions {
|
class RecordingRequiredPermissions {
|
||||||
const RecordingRequiredPermissions({
|
const RecordingRequiredPermissions({
|
||||||
@@ -57,20 +28,16 @@ class RecordingRequiredPermissions {
|
|||||||
bool get allGranted => cameraGranted && microphoneGranted;
|
bool get allGranted => cameraGranted && microphoneGranted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 录制页 ViewModel:剪贴板、权限、相机预览与录制流程。
|
/// 录制页 ViewModel:赛事上下文、权限、推流与页面状态。
|
||||||
class RecordingViewModel extends Notifier<RecordingModel> {
|
class RecordingViewModel extends Notifier<RecordingModel> {
|
||||||
static final _defaultClipboard = ClipboardRecordingModel(
|
Timer? _elapsedTimer;
|
||||||
title: '',
|
DateTime? _recordingStartedAt;
|
||||||
address: '',
|
|
||||||
);
|
|
||||||
|
|
||||||
StreamSubscription<RecordingStatus>? _statusSubscription;
|
|
||||||
|
|
||||||
/// 初始化状态并注册销毁回调。
|
/// 初始化状态并注册销毁回调。
|
||||||
@override
|
@override
|
||||||
RecordingModel build() {
|
RecordingModel build() {
|
||||||
ref.onDispose(_dispose);
|
ref.onDispose(_dispose);
|
||||||
return RecordingModel(clipboardRecordingModel: _defaultClipboard);
|
return RecordingModel(recordingContext: const RecordingContext.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 局部更新 session 子状态。
|
/// 局部更新 session 子状态。
|
||||||
@@ -80,64 +47,12 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
|||||||
state = state.copyWith(session: update(state.session));
|
state = state.copyWith(session: update(state.session));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 读取并解析剪贴板中的小程序录制信息。
|
/// 设置当前录制页上下文。
|
||||||
Future<ClipboardReadResult> getClipboardContent() async {
|
void setRecordingContext(RecordingContext recordingContext) {
|
||||||
try {
|
state = state.copyWith(recordingContext: recordingContext);
|
||||||
final clipboardData = await Clipboard.getData(Clipboard.kTextPlain);
|
|
||||||
final text = clipboardData?.text;
|
|
||||||
AppLogger.debug('读取剪切板内容:$text');
|
|
||||||
|
|
||||||
if (text == null || text.trim().isEmpty) {
|
|
||||||
AppLogger.info('剪切板内容为空,跳过录制信息解析');
|
|
||||||
_resetClipboardInfo();
|
|
||||||
return ClipboardReadResult.empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
final decoded = jsonDecode(text.trim());
|
|
||||||
if (decoded is! Map<String, dynamic>) {
|
|
||||||
AppLogger.warning('剪切板内容不是 JSON 对象,跳过录制信息解析');
|
|
||||||
_resetClipboardInfo();
|
|
||||||
return ClipboardReadResult.invalid;
|
|
||||||
}
|
|
||||||
|
|
||||||
final clipboardRecordingModel = ClipboardRecordingModel.fromJson(decoded);
|
|
||||||
if (clipboardRecordingModel.title.trim().isEmpty) {
|
|
||||||
AppLogger.warning('剪切板录制信息缺少有效 title');
|
|
||||||
_resetClipboardInfo();
|
|
||||||
return ClipboardReadResult.invalid;
|
|
||||||
}
|
|
||||||
|
|
||||||
state = state.copyWith(
|
|
||||||
clipboardRecordingModel: clipboardRecordingModel,
|
|
||||||
hasValidClipboardInfo: true,
|
|
||||||
);
|
|
||||||
AppLogger.info('剪切板录制信息解析成功:${clipboardRecordingModel.toJson()}');
|
|
||||||
return ClipboardReadResult.success;
|
|
||||||
} on FormatException catch (error) {
|
|
||||||
AppLogger.warning('剪切板录制信息格式错误:$error');
|
|
||||||
_resetClipboardInfo();
|
|
||||||
return ClipboardReadResult.invalid;
|
|
||||||
} catch (error, stackTrace) {
|
|
||||||
AppLogger.debug('读取剪切板录制信息失败', error: error, stackTrace: stackTrace);
|
|
||||||
_resetClipboardInfo();
|
|
||||||
return ClipboardReadResult.invalid;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 清空剪贴板赛事信息(供 UI 调用)。
|
/// 申请权限并检查系统设置。
|
||||||
void resetClipboardInfo() {
|
|
||||||
_resetClipboardInfo();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 重置剪贴板赛事信息为默认空值。
|
|
||||||
void _resetClipboardInfo() {
|
|
||||||
state = state.copyWith(
|
|
||||||
clipboardRecordingModel: _defaultClipboard,
|
|
||||||
hasValidClipboardInfo: false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 申请权限、检查系统设置并初始化相机预览。
|
|
||||||
Future<void> prepareSession() async {
|
Future<void> prepareSession() async {
|
||||||
if (!RecordingPlatform.isSupported) {
|
if (!RecordingPlatform.isSupported) {
|
||||||
_updateSession((s) => s.copyWith(errorMessage: '当前设备不支持录制'));
|
_updateSession((s) => s.copyWith(errorMessage: '当前设备不支持录制'));
|
||||||
@@ -148,7 +63,6 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
|||||||
Permission.camera,
|
Permission.camera,
|
||||||
Permission.microphone,
|
Permission.microphone,
|
||||||
if (Platform.isAndroid) Permission.notification,
|
if (Platform.isAndroid) Permission.notification,
|
||||||
..._galleryPermissions(),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
|
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
|
||||||
@@ -170,10 +84,6 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
|||||||
if (!microphoneGranted) {
|
if (!microphoneGranted) {
|
||||||
warnings.add('未授予麦克风权限,当前将以静音模式录制');
|
warnings.add('未授予麦克风权限,当前将以静音模式录制');
|
||||||
}
|
}
|
||||||
if (!_isGalleryPermissionGranted(permissions)) {
|
|
||||||
warnings.add('未授予相册权限,录制结束后可能无法保存到相册');
|
|
||||||
}
|
|
||||||
|
|
||||||
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
|
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
|
||||||
final batteryIgnored =
|
final batteryIgnored =
|
||||||
await RecordingPlatform.isIgnoringBatteryOptimizations();
|
await RecordingPlatform.isIgnoringBatteryOptimizations();
|
||||||
@@ -190,92 +100,13 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
await _listenStatus();
|
_updateSession((s) => s.copyWith(errorMessage: null));
|
||||||
try {
|
|
||||||
final status = await _initializePreviewWithRetry();
|
|
||||||
_updateSession(
|
|
||||||
(s) => s.copyWith(
|
|
||||||
status: status,
|
|
||||||
isPreviewReady: status.state == RecordingState.previewing,
|
|
||||||
errorMessage: status.state == RecordingState.previewing
|
|
||||||
? null
|
|
||||||
: (status.message ?? '相机预览初始化失败'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} on PlatformException catch (error) {
|
|
||||||
_updateSession(
|
|
||||||
(s) => s.copyWith(
|
|
||||||
isPreviewReady: false,
|
|
||||||
errorMessage: error.message ?? '相机预览初始化失败',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 初始化相机预览,PlatformView 未就绪时自动重试。
|
void setPreviewReady({required bool ready, String? errorMessage}) {
|
||||||
Future<RecordingStatus> _initializePreviewWithRetry() async {
|
|
||||||
const maxAttempts = 8;
|
|
||||||
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
|
||||||
try {
|
|
||||||
return await RecordingPlatform.initializePreview();
|
|
||||||
} on PlatformException catch (error) {
|
|
||||||
final shouldRetry =
|
|
||||||
error.code == 'NO_PREVIEW' && attempt < maxAttempts - 1;
|
|
||||||
if (!shouldRetry) {
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
await Future<void>.delayed(Duration(milliseconds: 150 * (attempt + 1)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw StateError('initializePreview retry exhausted');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 停止录制后重新绑定相机预览,并显示加载遮罩。
|
|
||||||
Future<void> restorePreview() async {
|
|
||||||
if (!RecordingPlatform.isSupported) return;
|
|
||||||
|
|
||||||
_updateSession(
|
_updateSession(
|
||||||
(s) => s.copyWith(isPreviewReady: false, errorMessage: null),
|
(s) => s.copyWith(isPreviewReady: ready, errorMessage: errorMessage),
|
||||||
);
|
);
|
||||||
try {
|
|
||||||
final status = await _initializePreviewWithRetry();
|
|
||||||
_updateSession(
|
|
||||||
(s) => s.copyWith(
|
|
||||||
status: status,
|
|
||||||
isPreviewReady: status.state == RecordingState.previewing,
|
|
||||||
errorMessage: status.state == RecordingState.previewing
|
|
||||||
? null
|
|
||||||
: (status.message ?? '相机预览初始化失败'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} on PlatformException catch (error) {
|
|
||||||
_updateSession(
|
|
||||||
(s) => s.copyWith(
|
|
||||||
isPreviewReady: false,
|
|
||||||
errorMessage: error.message ?? '相机预览初始化失败',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 当前平台所需的相册/视频保存权限列表。
|
|
||||||
List<Permission> _galleryPermissions() {
|
|
||||||
return recordingGalleryPermissionsForHost(
|
|
||||||
isIOS: Platform.isIOS,
|
|
||||||
isAndroid: Platform.isAndroid,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 判断相册相关权限是否至少有一项已授予。
|
|
||||||
bool _isGalleryPermissionGranted(
|
|
||||||
Map<Permission, PermissionStatus> permissions,
|
|
||||||
) {
|
|
||||||
for (final permission in _galleryPermissions()) {
|
|
||||||
if (permissions[permission]?.isGranted ?? false) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return _galleryPermissions().isEmpty;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 检测并尝试申请相机、麦克风权限,同步更新 session 中的 isMicrophoneGranted。
|
/// 检测并尝试申请相机、麦克风权限,同步更新 session 中的 isMicrophoneGranted。
|
||||||
@@ -295,8 +126,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
|||||||
|
|
||||||
if (cameraGranted && !state.session.isPreviewReady) {
|
if (cameraGranted && !state.session.isPreviewReady) {
|
||||||
_updateSession((s) => s.copyWith(errorMessage: null));
|
_updateSession((s) => s.copyWith(errorMessage: null));
|
||||||
await _listenStatus();
|
_updateSession((s) => s.copyWith(isPreviewReady: true));
|
||||||
await restorePreview();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return RecordingRequiredPermissions(
|
return RecordingRequiredPermissions(
|
||||||
@@ -309,76 +139,115 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
|||||||
return status?.isGranted == true || status?.isLimited == true;
|
return status?.isGranted == true || status?.isLimited == true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 开始录制,可选开启勿扰模式。
|
void updateZoomCapabilities({
|
||||||
Future<void> startRecording({bool enableDoNotDisturb = true}) async {
|
required double zoomRatio,
|
||||||
final session = state.session;
|
required double minZoomRatio,
|
||||||
if (session.isRecording || session.isStartingRecording) {
|
required double maxZoomRatio,
|
||||||
return;
|
}) {
|
||||||
}
|
|
||||||
if (!session.isPreviewReady) {
|
|
||||||
_updateSession((s) => s.copyWith(errorMessage: '相机预览未就绪,请稍后重试'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final displayName = recordingFileNameForPlatform(
|
|
||||||
state.clipboardRecordingModel.filename,
|
|
||||||
);
|
|
||||||
|
|
||||||
_updateSession(
|
_updateSession(
|
||||||
(s) => s.copyWith(isStartingRecording: true, errorMessage: null),
|
(s) => s.copyWith(
|
||||||
|
zoomRatio: zoomRatio,
|
||||||
|
minZoomRatio: minZoomRatio,
|
||||||
|
maxZoomRatio: maxZoomRatio,
|
||||||
|
errorMessage: null,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
try {
|
|
||||||
final result = await RecordingPlatform.startRecording(
|
|
||||||
enableDoNotDisturb: enableDoNotDisturb && state.session.hasDndAccess,
|
|
||||||
displayName: displayName,
|
|
||||||
);
|
|
||||||
_updateSession(
|
|
||||||
(s) => s.copyWith(
|
|
||||||
status: result.status,
|
|
||||||
lastOutputPath: result.outputPath,
|
|
||||||
isTouchLocked: true,
|
|
||||||
errorMessage: null,
|
|
||||||
gallerySaveFailed: false,
|
|
||||||
clearLastSaved: true,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} on PlatformException catch (error) {
|
|
||||||
_updateSession(
|
|
||||||
(s) => s.copyWith(errorMessage: error.message ?? '开始录制失败'),
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
_updateSession((s) => s.copyWith(isStartingRecording: false));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 停止录制、保存到相册,并恢复相机预览。
|
void markLensSwitching(bool switching) {
|
||||||
Future<void> stopRecording() async {
|
_updateSession((s) => s.copyWith(isSwitchingLens: switching));
|
||||||
if (!state.session.isRecording) return;
|
}
|
||||||
|
|
||||||
try {
|
void setZoomRatioValue(double ratio) {
|
||||||
final result = await RecordingPlatform.stopRecording();
|
_updateSession((s) => s.copyWith(zoomRatio: ratio, errorMessage: null));
|
||||||
final galleryFailed = !result.gallerySaved;
|
}
|
||||||
final savedName = recordingFileNameForPlatform(
|
|
||||||
state.clipboardRecordingModel.filename,
|
void setError(String message) {
|
||||||
);
|
_updateSession((s) => s.copyWith(errorMessage: message));
|
||||||
|
}
|
||||||
|
|
||||||
|
void markStartingRecording() {
|
||||||
|
_updateSession(
|
||||||
|
(s) => s.copyWith(
|
||||||
|
isStartingRecording: true,
|
||||||
|
errorMessage: null,
|
||||||
|
clearStreamResult: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markRecordingStarted(String streamUrl) async {
|
||||||
|
if (state.session.hasDndAccess) {
|
||||||
|
await RecordingPlatform.enableDoNotDisturb();
|
||||||
|
}
|
||||||
|
_recordingStartedAt = DateTime.now();
|
||||||
|
_elapsedTimer?.cancel();
|
||||||
|
_elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||||
|
final startedAt = _recordingStartedAt;
|
||||||
|
if (startedAt == null) return;
|
||||||
|
final elapsed = DateTime.now().difference(startedAt).inMilliseconds;
|
||||||
_updateSession(
|
_updateSession(
|
||||||
(s) => s.copyWith(
|
(s) => s.copyWith(
|
||||||
status: result.status,
|
status: RecordingStatus(
|
||||||
lastOutputPath: result.outputPath ?? s.lastOutputPath,
|
state: RecordingState.recording,
|
||||||
lastSavedDisplayName: galleryFailed ? null : savedName,
|
streamUrl: streamUrl,
|
||||||
errorMessage: galleryFailed
|
elapsedMillis: elapsed,
|
||||||
? (result.galleryErrorMessage ?? '保存到相册失败,请开启相册权限')
|
),
|
||||||
: null,
|
|
||||||
gallerySaveFailed: galleryFailed,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} on PlatformException catch (error) {
|
});
|
||||||
_updateSession(
|
_updateSession(
|
||||||
(s) => s.copyWith(errorMessage: error.message ?? '停止录制失败'),
|
(s) => s.copyWith(
|
||||||
);
|
status: RecordingStatus(
|
||||||
} finally {
|
state: RecordingState.recording,
|
||||||
await restorePreview();
|
streamUrl: streamUrl,
|
||||||
}
|
),
|
||||||
|
lastStreamUrl: streamUrl,
|
||||||
|
isStartingRecording: false,
|
||||||
|
isTouchLocked: true,
|
||||||
|
errorMessage: null,
|
||||||
|
streamFailed: false,
|
||||||
|
clearStreamResult: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void markRecordingStartFailed(String message) {
|
||||||
|
_recordingStartedAt = null;
|
||||||
|
_elapsedTimer?.cancel();
|
||||||
|
_elapsedTimer = null;
|
||||||
|
_updateSession(
|
||||||
|
(s) => s.copyWith(
|
||||||
|
status: const RecordingStatus(state: RecordingState.previewing),
|
||||||
|
isStartingRecording: false,
|
||||||
|
errorMessage: message,
|
||||||
|
streamFailed: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markRecordingStopped({String? errorMessage}) async {
|
||||||
|
final lastStreamUrl = state.session.lastStreamUrl;
|
||||||
|
final elapsed = _recordingStartedAt == null
|
||||||
|
? state.session.status.elapsedMillis
|
||||||
|
: DateTime.now().difference(_recordingStartedAt!).inMilliseconds;
|
||||||
|
_recordingStartedAt = null;
|
||||||
|
_elapsedTimer?.cancel();
|
||||||
|
_elapsedTimer = null;
|
||||||
|
await RecordingPlatform.disableDoNotDisturb();
|
||||||
|
_updateSession(
|
||||||
|
(s) => s.copyWith(
|
||||||
|
status: RecordingStatus(
|
||||||
|
state: RecordingState.previewing,
|
||||||
|
streamUrl: lastStreamUrl,
|
||||||
|
elapsedMillis: elapsed,
|
||||||
|
),
|
||||||
|
isStartingRecording: false,
|
||||||
|
errorMessage: errorMessage,
|
||||||
|
streamFinished: errorMessage == null,
|
||||||
|
streamFailed: errorMessage != null,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 切换录制中触屏锁定状态。
|
/// 切换录制中触屏锁定状态。
|
||||||
@@ -386,9 +255,9 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
|||||||
_updateSession((s) => s.copyWith(isTouchLocked: locked));
|
_updateSession((s) => s.copyWith(isTouchLocked: locked));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 清除上次保存成功的录制结果标记。
|
/// 清除上次推流完成结果标记。
|
||||||
void clearSavedRecordingResult() {
|
void clearSavedRecordingResult() {
|
||||||
_updateSession((s) => s.copyWith(clearLastSaved: true));
|
_updateSession((s) => s.copyWith(clearStreamResult: true));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 跳转系统勿扰/通知策略设置页。
|
/// 跳转系统勿扰/通知策略设置页。
|
||||||
@@ -415,22 +284,14 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
|||||||
Future<void> teardown() async {
|
Future<void> teardown() async {
|
||||||
await RecordingPlatform.setImmersiveMode(enabled: false);
|
await RecordingPlatform.setImmersiveMode(enabled: false);
|
||||||
await RecordingPlatform.disableDoNotDisturb();
|
await RecordingPlatform.disableDoNotDisturb();
|
||||||
await RecordingPlatform.disposePreview();
|
_recordingStartedAt = null;
|
||||||
await _statusSubscription?.cancel();
|
_elapsedTimer?.cancel();
|
||||||
_statusSubscription = null;
|
_elapsedTimer = null;
|
||||||
state = state.copyWith(session: const RecordingSessionState());
|
state = state.copyWith(session: const RecordingSessionState());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 订阅原生层录制状态流并同步到 session。
|
|
||||||
Future<void> _listenStatus() async {
|
|
||||||
await _statusSubscription?.cancel();
|
|
||||||
_statusSubscription = RecordingPlatform.statusStream().listen((status) {
|
|
||||||
_updateSession((s) => s.copyWith(status: status));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Provider 销毁时取消状态流订阅。
|
/// Provider 销毁时取消状态流订阅。
|
||||||
Future<void> _dispose() async {
|
Future<void> _dispose() async {
|
||||||
await _statusSubscription?.cancel();
|
_elapsedTimer?.cancel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,22 @@
|
|||||||
import 'dart:io';
|
import 'package:apivideo_live_stream/apivideo_live_stream.dart';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
|
|
||||||
class CameraPreviewWidget extends StatelessWidget {
|
class CameraPreviewWidget extends StatelessWidget {
|
||||||
const CameraPreviewWidget({super.key});
|
const CameraPreviewWidget({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
required this.isReady,
|
||||||
|
});
|
||||||
|
|
||||||
|
final ApiVideoLiveStreamController controller;
|
||||||
|
final bool isReady;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (Platform.isAndroid) {
|
if (!isReady) {
|
||||||
return AndroidView(
|
return const ColoredBox(color: Colors.black);
|
||||||
viewType: 'recording-camera-preview',
|
|
||||||
layoutDirection: TextDirection.ltr,
|
|
||||||
creationParams: const <String, dynamic>{},
|
|
||||||
creationParamsCodec: const StandardMessageCodec(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Platform.isIOS) {
|
return ApiVideoCameraPreview(controller: controller, fit: BoxFit.cover);
|
||||||
return UiKitView(
|
|
||||||
viewType: 'recording-camera-preview',
|
|
||||||
layoutDirection: TextDirection.ltr,
|
|
||||||
creationParams: const <String, dynamic>{},
|
|
||||||
creationParamsCodec: const StandardMessageCodec(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return const ColoredBox(
|
|
||||||
color: Colors.black,
|
|
||||||
child: Center(child: Text('当前平台不支持相机预览')),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||