Compare commits
55
Commits
main
...
d6e80df5bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
.idea/
|
||||
.cursor
|
||||
Podfile.lock
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
@@ -47,3 +48,6 @@ app.*.map.json
|
||||
/android/app/release
|
||||
/android/.kotlin
|
||||
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
test/
|
||||
@@ -4,7 +4,7 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
val appPackageName = "com.qxy.dronex"
|
||||
val appPackageName = "com.dronex.rec"
|
||||
|
||||
android {
|
||||
namespace = appPackageName
|
||||
@@ -42,12 +42,6 @@ kotlin {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val cameraxVersion = "1.4.1"
|
||||
implementation("androidx.camera:camera-core:$cameraxVersion")
|
||||
implementation("androidx.camera:camera-camera2:$cameraxVersion")
|
||||
implementation("androidx.camera:camera-lifecycle:$cameraxVersion")
|
||||
implementation("androidx.camera:camera-video:$cameraxVersion")
|
||||
implementation("androidx.camera:camera-view:$cameraxVersion")
|
||||
implementation("androidx.lifecycle:lifecycle-service:2.8.7")
|
||||
implementation("androidx.core:core-ktx:1.15.0")
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.qxy.dronex">
|
||||
package="com.dronex.rec">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
@@ -10,7 +10,6 @@
|
||||
<uses-permission android:name="android.permission.ACCESS_NOTIFICATION_POLICY" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
@@ -22,7 +21,8 @@
|
||||
<application
|
||||
android:label="飞行极控录像工作台"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.dronex.rec
|
||||
|
||||
object AppConstants {
|
||||
private const val CHANNEL_NAMESPACE = "app.record_tool"
|
||||
const val PLATFORM_INFO_CHANNEL = "$CHANNEL_NAMESPACE/platform_info"
|
||||
const val RECORDING_METHOD_CHANNEL = "$CHANNEL_NAMESPACE/recording"
|
||||
const val RECORDING_EVENT_CHANNEL = "$CHANNEL_NAMESPACE/recording_events"
|
||||
const val RECORDING_ACTION_START = "$CHANNEL_NAMESPACE.recording.START"
|
||||
const val RECORDING_ACTION_STOP = "$CHANNEL_NAMESPACE.recording.STOP"
|
||||
}
|
||||
+5
-21
@@ -1,4 +1,4 @@
|
||||
package com.qxy.dronex
|
||||
package com.dronex.rec
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ApplicationInfo
|
||||
@@ -6,9 +6,8 @@ import android.os.BatteryManager
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.StatFs
|
||||
import androidx.camera.view.PreviewView
|
||||
import com.qxy.dronex.recording.RecordingPlatformHandler
|
||||
import com.qxy.dronex.recording.RecordingPreviewFactory
|
||||
import android.provider.Settings
|
||||
import com.dronex.rec.recording.RecordingPlatformHandler
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
@@ -17,15 +16,8 @@ class MainActivity : FlutterActivity() {
|
||||
private var platformHandler: RecordingPlatformHandler? = null
|
||||
private var platformInfoChannel: MethodChannel? = null
|
||||
|
||||
var recordingPreviewView: PreviewView? = null
|
||||
private set
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
flutterEngine.platformViewsController.registry.registerViewFactory(
|
||||
"recording-camera-preview",
|
||||
RecordingPreviewFactory(this),
|
||||
)
|
||||
|
||||
platformInfoChannel =
|
||||
MethodChannel(
|
||||
@@ -50,16 +42,6 @@ class MainActivity : FlutterActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
fun attachRecordingPreview(previewView: PreviewView) {
|
||||
recordingPreviewView = previewView
|
||||
}
|
||||
|
||||
fun detachRecordingPreview(previewView: PreviewView? = null) {
|
||||
if (previewView == null || recordingPreviewView === previewView) {
|
||||
recordingPreviewView = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
platformInfoChannel?.setMethodCallHandler(null)
|
||||
platformInfoChannel = null
|
||||
@@ -111,9 +93,11 @@ class MainActivity : FlutterActivity() {
|
||||
|
||||
return mapOf(
|
||||
"platform" to "android",
|
||||
"deviceCode" to Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID).orEmpty(),
|
||||
"brand" to Build.BRAND,
|
||||
"model" to Build.MODEL,
|
||||
"systemVersion" to Build.VERSION.RELEASE,
|
||||
"sdkInt" to Build.VERSION.SDK_INT,
|
||||
"isPhysicalDevice" to !isEmulator,
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.qxy.dronex.recording
|
||||
package com.dronex.rec.recording
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package com.qxy.dronex.recording
|
||||
package com.dronex.rec.recording
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
package com.qxy.dronex.recording
|
||||
package com.dronex.rec.recording
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
@@ -14,8 +14,8 @@ import android.os.PowerManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.LifecycleService
|
||||
import com.qxy.dronex.AppConstants
|
||||
import com.qxy.dronex.MainActivity
|
||||
import com.dronex.rec.AppConstants
|
||||
import com.dronex.rec.MainActivity
|
||||
|
||||
class RecordingForegroundService : LifecycleService() {
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.dronex.rec.recording
|
||||
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
import com.dronex.rec.AppConstants
|
||||
import com.dronex.rec.MainActivity
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class RecordingPlatformHandler(
|
||||
private val activity: MainActivity,
|
||||
messenger: BinaryMessenger,
|
||||
) : MethodChannel.MethodCallHandler {
|
||||
private val methodChannel = MethodChannel(messenger, AppConstants.RECORDING_METHOD_CHANNEL)
|
||||
|
||||
init {
|
||||
methodChannel.setMethodCallHandler(this)
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
methodChannel.setMethodCallHandler(null)
|
||||
}
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"hasNotificationPolicyAccess" -> result.success(DoNotDisturbHelper.hasAccess(activity))
|
||||
"openNotificationPolicySettings" -> {
|
||||
DoNotDisturbHelper.openAccessSettings(activity)
|
||||
result.success(null)
|
||||
}
|
||||
"enableDoNotDisturb" -> result.success(DoNotDisturbHelper.enable(activity))
|
||||
"disableDoNotDisturb" -> {
|
||||
DoNotDisturbHelper.disable(activity)
|
||||
result.success(null)
|
||||
}
|
||||
"isIgnoringBatteryOptimizations" ->
|
||||
result.success(BatteryOptimizationHelper.isIgnoringOptimizations(activity))
|
||||
"openBatteryOptimizationSettings" -> {
|
||||
BatteryOptimizationHelper.openSettings(activity)
|
||||
result.success(null)
|
||||
}
|
||||
"setImmersiveMode" -> {
|
||||
val enabled = call.argument<Boolean>("enabled") ?: false
|
||||
setImmersiveMode(enabled)
|
||||
result.success(null)
|
||||
}
|
||||
"isForegroundServiceRunning" -> result.success(RecordingForegroundService.isRunning)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun setImmersiveMode(enabled: Boolean) {
|
||||
val window = activity.window
|
||||
WindowCompat.setDecorFitsSystemWindows(window, !enabled)
|
||||
val insetsController = WindowInsetsControllerCompat(window, window.decorView)
|
||||
if (enabled) {
|
||||
insetsController.hide(WindowInsetsCompat.Type.systemBars())
|
||||
insetsController.systemBarsBehavior =
|
||||
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||
} else {
|
||||
insetsController.show(WindowInsetsCompat.Type.systemBars())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package com.qxy.dronex
|
||||
|
||||
object AppConstants {
|
||||
const val PACKAGE_NAME = "com.qxy.dronex"
|
||||
const val PLATFORM_INFO_CHANNEL = "$PACKAGE_NAME/platform_info"
|
||||
const val RECORDING_METHOD_CHANNEL = "$PACKAGE_NAME/recording"
|
||||
const val RECORDING_EVENT_CHANNEL = "$PACKAGE_NAME/recording_events"
|
||||
const val RECORDING_ACTION_START = "$PACKAGE_NAME.recording.START"
|
||||
const val RECORDING_ACTION_STOP = "$PACKAGE_NAME.recording.STOP"
|
||||
}
|
||||
@@ -1,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,
|
||||
)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
@@ -1 +1,6 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
flutter build apk --release --split-per-abi
|
||||
|
||||
echo "构建完成时间: $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
|
||||
@@ -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.
|
||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||
extensions:
|
||||
- riverpod: true
|
||||
- shared_preferences: true
|
||||
@@ -1,2 +1,3 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}"
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}"
|
||||
|
||||
+27
-2
@@ -45,9 +45,34 @@ post_install do |installer|
|
||||
'$(inherited)',
|
||||
'PERMISSION_CAMERA=1',
|
||||
'PERMISSION_MICROPHONE=1',
|
||||
'PERMISSION_PHOTOS=1',
|
||||
'PERMISSION_PHOTOS_ADD_ONLY=1',
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
pods_runner_dir = File.join(
|
||||
installer.sandbox.root,
|
||||
'Target Support Files',
|
||||
'Pods-Runner'
|
||||
)
|
||||
Dir.glob(File.join(pods_runner_dir, 'Pods-Runner.*.xcconfig')).each do |config_path|
|
||||
config = File.read(config_path)
|
||||
config.gsub!(
|
||||
'FRAMEWORK_SEARCH_PATHS = $(inherited)',
|
||||
'FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}"'
|
||||
)
|
||||
File.write(config_path, config)
|
||||
end
|
||||
|
||||
Dir.glob(File.join(pods_runner_dir, 'Pods-Runner-frameworks-*input-files.xcfilelist')).each do |file_list_path|
|
||||
file_list = File.read(file_list_path)
|
||||
file_list.gsub!('${BUILT_PRODUCTS_DIR}/', '${PODS_CONFIGURATION_BUILD_DIR}/')
|
||||
File.write(file_list_path, file_list)
|
||||
end
|
||||
|
||||
frameworks_script = File.join(pods_runner_dir, 'Pods-Runner-frameworks.sh')
|
||||
if File.exist?(frameworks_script)
|
||||
script = File.read(frameworks_script)
|
||||
script.gsub!('${BUILT_PRODUCTS_DIR}/', '${PODS_CONFIGURATION_BUILD_DIR}/')
|
||||
File.write(frameworks_script, script)
|
||||
end
|
||||
end
|
||||
|
||||
+1
-8
@@ -2,9 +2,6 @@ PODS:
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- permission_handler_apple (9.4.8):
|
||||
- Flutter
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
@@ -19,7 +16,6 @@ PODS:
|
||||
DEPENDENCIES:
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||
@@ -30,8 +26,6 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||
permission_handler_apple:
|
||||
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||
shared_preferences_foundation:
|
||||
@@ -44,12 +38,11 @@ EXTERNAL SOURCES:
|
||||
SPEC CHECKSUMS:
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
|
||||
permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
|
||||
PODFILE CHECKSUM: 5a82b772179df87e6518bddc6f9bb8b4053ce48b
|
||||
PODFILE CHECKSUM: 858401fbd980bedce6ecd2f9b429bf271f11f74b
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -495,16 +495,22 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 35634V629S;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MT26BPCKF6;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.qxy.dronex;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.dronex.rec;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "dev-profile-dronex";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
@@ -678,16 +684,22 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 35634V629S;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MT26BPCKF6;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.qxy.dronex;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.dronex.rec;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "dev-profile-dronex";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@@ -701,16 +713,22 @@
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = 35634V629S;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MT26BPCKF6;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.qxy.dronex;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.dronex.rec;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "dev-profile-dronex";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
VERSIONING_SYSTEM = "apple-generic";
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "startup_background.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 262 KiB |
@@ -16,13 +16,15 @@
|
||||
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<subviews>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" image="StartupBackground" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="4X2-HB-R7a"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="E8f-e3-JEx"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="trailing" secondItem="Ze5-6b-2t3" secondAttribute="trailing" id="rwG-Bh-0uU"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
@@ -32,6 +34,6 @@
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="LaunchImage" width="168" height="185"/>
|
||||
<image name="StartupBackground" width="750" height="1624"/>
|
||||
</resources>
|
||||
</document>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="24412" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
|
||||
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24405"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Flutter View Controller-->
|
||||
@@ -14,13 +16,28 @@
|
||||
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||
<subviews>
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" image="StartupBackground" translatesAutoresizingMaskIntoConstraints="NO" id="YQm-Ov-qw7">
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YQm-Ov-qw7" firstAttribute="top" secondItem="8bC-Xf-vdC" secondAttribute="top" id="7pp-OK-Dgk"/>
|
||||
<constraint firstItem="YQm-Ov-qw7" firstAttribute="bottom" secondItem="8bC-Xf-vdC" secondAttribute="bottom" id="Hbf-gY-rbf"/>
|
||||
<constraint firstItem="YQm-Ov-qw7" firstAttribute="leading" secondItem="8bC-Xf-vdC" secondAttribute="leading" id="WPc-7k-20p"/>
|
||||
<constraint firstItem="YQm-Ov-qw7" firstAttribute="trailing" secondItem="8bC-Xf-vdC" secondAttribute="trailing" id="w1G-WA-3QR"/>
|
||||
</constraints>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="139" y="122"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
<resources>
|
||||
<image name="StartupBackground" width="750" height="1624"/>
|
||||
</resources>
|
||||
</document>
|
||||
|
||||
@@ -30,8 +30,10 @@
|
||||
<string>需要访问相机以显示预览并录制视频。</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string>
|
||||
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||
<string>需要将录制的视频保存到相册。</string>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
<dict>
|
||||
<key>UIApplicationSupportsMultipleScenes</key>
|
||||
|
||||
@@ -4,7 +4,7 @@ import UIKit
|
||||
final class PlatformInfoPlugin: NSObject, FlutterPlugin {
|
||||
static func register(with registrar: FlutterPluginRegistrar) {
|
||||
let channel = FlutterMethodChannel(
|
||||
name: "com.qxy.dronex/platform_info",
|
||||
name: "app.record_tool/platform_info",
|
||||
binaryMessenger: registrar.messenger()
|
||||
)
|
||||
let plugin = PlatformInfoPlugin()
|
||||
@@ -62,6 +62,7 @@ final class PlatformInfoPlugin: NSObject, FlutterPlugin {
|
||||
let device = UIDevice.current
|
||||
return [
|
||||
"platform": "ios",
|
||||
"deviceCode": "",
|
||||
"brand": device.systemName,
|
||||
"model": machineIdentifier(),
|
||||
"systemVersion": device.systemVersion,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import AVFoundation
|
||||
import Flutter
|
||||
import Photos
|
||||
import UIKit
|
||||
|
||||
private enum RecordingState: String {
|
||||
@@ -110,12 +109,13 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
private var audioInput: AVCaptureDeviceInput?
|
||||
private var configured = false
|
||||
private var latestOutputPath: String?
|
||||
private var latestGallerySaved = true
|
||||
private var latestGalleryErrorMessage: String?
|
||||
private var latestFileSaved = true
|
||||
private var latestFileErrorMessage: String?
|
||||
private var pendingDisplayName: String?
|
||||
private var recordingStartedAt: Date?
|
||||
private var elapsedTimer: Timer?
|
||||
private var pendingStopResult: FlutterResult?
|
||||
private var currentZoomRatio: CGFloat = 1.0
|
||||
|
||||
private(set) var status = RecordingStatus(state: .idle) {
|
||||
didSet {
|
||||
@@ -215,10 +215,10 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
}
|
||||
|
||||
self.pendingDisplayName = displayName
|
||||
self.latestGallerySaved = true
|
||||
self.latestGalleryErrorMessage = nil
|
||||
self.latestFileSaved = true
|
||||
self.latestFileErrorMessage = nil
|
||||
let outputURL = try self.createOutputURL(displayName: displayName)
|
||||
self.latestOutputPath = outputURL.lastPathComponent
|
||||
self.latestOutputPath = outputURL.path
|
||||
self.recordingStartedAt = Date()
|
||||
self.updateStatus(RecordingStatus(state: .recording, outputPath: outputURL.path))
|
||||
self.movieOutput.startRecording(to: outputURL, recordingDelegate: self)
|
||||
@@ -254,11 +254,11 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
var payload: [String: Any] = [
|
||||
"outputPath": self.latestOutputPath as Any,
|
||||
"status": self.currentStatusMap(),
|
||||
"gallerySaved": self.latestGallerySaved,
|
||||
"fileSaved": self.latestFileSaved,
|
||||
]
|
||||
if !self.latestGallerySaved {
|
||||
payload["galleryErrorMessage"] =
|
||||
self.latestGalleryErrorMessage ?? "保存到相册失败"
|
||||
if !self.latestFileSaved {
|
||||
payload["fileErrorMessage"] =
|
||||
self.latestFileErrorMessage ?? "保存到文件夹失败"
|
||||
}
|
||||
result(payload)
|
||||
}
|
||||
@@ -291,6 +291,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
self.session.commitConfiguration()
|
||||
self.videoInput = nil
|
||||
self.audioInput = nil
|
||||
self.currentZoomRatio = 1.0
|
||||
self.configured = false
|
||||
self.updateStatus(RecordingStatus(state: .idle))
|
||||
|
||||
@@ -312,6 +313,54 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
return status.toMap()
|
||||
}
|
||||
|
||||
func zoomCapabilities(result: @escaping FlutterResult) {
|
||||
sessionQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
let capabilities = self.currentZoomCapabilitiesMap()
|
||||
DispatchQueue.main.async {
|
||||
result(capabilities)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setZoomRatio(_ ratio: CGFloat, result: @escaping FlutterResult) {
|
||||
sessionQueue.async { [weak self] in
|
||||
guard let self else { return }
|
||||
guard let device = self.videoInput?.device else {
|
||||
self.currentZoomRatio = max(1.0, ratio)
|
||||
let capabilities = self.currentZoomCapabilitiesMap()
|
||||
DispatchQueue.main.async {
|
||||
result(capabilities)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
// 入参是显示倍率(1.0x = 主摄),按 S 基准换算回设备 zoomFactor。
|
||||
let baseline = self.mainBaselineFactor(for: device)
|
||||
let nextZoom = self.clampedZoomRatio(ratio * baseline, for: device)
|
||||
try device.lockForConfiguration()
|
||||
device.videoZoomFactor = nextZoom
|
||||
device.unlockForConfiguration()
|
||||
self.currentZoomRatio = nextZoom
|
||||
let capabilities = self.currentZoomCapabilitiesMap()
|
||||
DispatchQueue.main.async {
|
||||
result(capabilities)
|
||||
}
|
||||
} catch {
|
||||
DispatchQueue.main.async {
|
||||
result(
|
||||
FlutterError(
|
||||
code: "ZOOM_FAILED",
|
||||
message: error.localizedDescription,
|
||||
details: nil
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fileOutput(
|
||||
_ output: AVCaptureFileOutput,
|
||||
didFinishRecordingTo outputFileURL: URL,
|
||||
@@ -322,8 +371,8 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
pendingStopResult = nil
|
||||
|
||||
if let error {
|
||||
latestGallerySaved = false
|
||||
latestGalleryErrorMessage = error.localizedDescription
|
||||
latestFileSaved = false
|
||||
latestFileErrorMessage = error.localizedDescription
|
||||
updateStatus(
|
||||
RecordingStatus(
|
||||
state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
|
||||
@@ -331,29 +380,30 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
return
|
||||
}
|
||||
|
||||
saveVideoToPhotoLibrary(fileURL: outputFileURL) { [weak self] success, message in
|
||||
guard let self else { return }
|
||||
self.latestGallerySaved = success
|
||||
self.latestGalleryErrorMessage = message
|
||||
if success {
|
||||
self.updateStatus(
|
||||
RecordingStatus(
|
||||
state: .previewing,
|
||||
outputPath: self.latestOutputPath,
|
||||
elapsedMillis: self.elapsedMillis()
|
||||
)
|
||||
)
|
||||
} else {
|
||||
self.updateStatus(
|
||||
latestFileSaved = true
|
||||
latestFileErrorMessage = nil
|
||||
latestOutputPath = outputFileURL.path
|
||||
guard FileManager.default.fileExists(atPath: outputFileURL.path) else {
|
||||
latestFileSaved = false
|
||||
latestFileErrorMessage = "录制文件未生成"
|
||||
updateStatus(
|
||||
RecordingStatus(
|
||||
state: .error,
|
||||
outputPath: self.latestOutputPath,
|
||||
message: message ?? "保存到相册失败"
|
||||
outputPath: latestOutputPath,
|
||||
message: latestFileErrorMessage
|
||||
)
|
||||
)
|
||||
finishStopRecording(stopResult: stopResult)
|
||||
return
|
||||
}
|
||||
self.finishStopRecording(stopResult: stopResult)
|
||||
}
|
||||
updateStatus(
|
||||
RecordingStatus(
|
||||
state: .previewing,
|
||||
outputPath: latestOutputPath,
|
||||
elapsedMillis: elapsedMillis()
|
||||
)
|
||||
)
|
||||
finishStopRecording(stopResult: stopResult)
|
||||
}
|
||||
|
||||
private func finishStopRecording(stopResult: FlutterResult?) {
|
||||
@@ -363,79 +413,23 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
var payload: [String: Any] = [
|
||||
"outputPath": self.latestOutputPath as Any,
|
||||
"status": self.currentStatusMap(),
|
||||
"gallerySaved": self.latestGallerySaved,
|
||||
"fileSaved": self.latestFileSaved,
|
||||
]
|
||||
if !self.latestGallerySaved {
|
||||
payload["galleryErrorMessage"] =
|
||||
self.latestGalleryErrorMessage ?? "保存到相册失败,请开启相册权限"
|
||||
if !self.latestFileSaved {
|
||||
payload["fileErrorMessage"] =
|
||||
self.latestFileErrorMessage ?? "保存到文件夹失败,请检查文件保存权限"
|
||||
}
|
||||
stopResult?(payload)
|
||||
}
|
||||
}
|
||||
|
||||
private func saveVideoToPhotoLibrary(
|
||||
fileURL: URL,
|
||||
completion: @escaping (Bool, String?) -> Void
|
||||
) {
|
||||
let performSave = {
|
||||
PHPhotoLibrary.shared().performChanges({
|
||||
PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: fileURL, options: nil)
|
||||
}) { success, error in
|
||||
if success {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
completion(true, nil)
|
||||
} else {
|
||||
completion(false, error?.localizedDescription ?? "保存到相册失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if #available(iOS 14, *) {
|
||||
let status = PHPhotoLibrary.authorizationStatus(for: .addOnly)
|
||||
switch status {
|
||||
case .authorized, .limited:
|
||||
performSave()
|
||||
case .notDetermined:
|
||||
PHPhotoLibrary.requestAuthorization(for: .addOnly) { newStatus in
|
||||
if newStatus == .authorized || newStatus == .limited {
|
||||
performSave()
|
||||
} else {
|
||||
completion(false, "未授予相册权限")
|
||||
}
|
||||
}
|
||||
default:
|
||||
completion(false, "未授予相册权限")
|
||||
}
|
||||
} else {
|
||||
let status = PHPhotoLibrary.authorizationStatus()
|
||||
switch status {
|
||||
case .authorized:
|
||||
performSave()
|
||||
case .notDetermined:
|
||||
PHPhotoLibrary.requestAuthorization { newStatus in
|
||||
if newStatus == .authorized {
|
||||
performSave()
|
||||
} else {
|
||||
completion(false, "未授予相册权限")
|
||||
}
|
||||
}
|
||||
default:
|
||||
completion(false, "未授予相册权限")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configureSession(withAudio: Bool) throws {
|
||||
if configured {
|
||||
try configureAudioInput(enabled: withAudio)
|
||||
return
|
||||
}
|
||||
|
||||
guard
|
||||
let videoDevice = AVCaptureDevice.default(
|
||||
.builtInWideAngleCamera, for: .video, position: .back)
|
||||
?? AVCaptureDevice.default(for: .video)
|
||||
else {
|
||||
guard let videoDevice = Self.preferredVideoDevice() else {
|
||||
throw NSError(
|
||||
domain: "RecordingCamera", code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "No camera device available"])
|
||||
@@ -465,9 +459,74 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
session.commitConfiguration()
|
||||
|
||||
configured = true
|
||||
// 默认以主摄(显示 1.0x)开场:虚拟多摄设备里主摄对应的 zoomFactor 是 S。
|
||||
currentZoomRatio = mainBaselineFactor(for: videoDevice)
|
||||
try applyCurrentZoom()
|
||||
try configureAudioInput(enabled: withAudio)
|
||||
}
|
||||
|
||||
/// 优先选用包含超广角的虚拟多摄设备,使 minAvailableVideoZoomFactor 能低于主摄(从而支持 0.6x)。
|
||||
private static func preferredVideoDevice() -> AVCaptureDevice? {
|
||||
let preferredTypes: [AVCaptureDevice.DeviceType] = [
|
||||
.builtInTripleCamera,
|
||||
.builtInDualWideCamera,
|
||||
.builtInWideAngleCamera,
|
||||
]
|
||||
for type in preferredTypes {
|
||||
if let device = AVCaptureDevice.default(type, for: .video, position: .back) {
|
||||
return device
|
||||
}
|
||||
}
|
||||
return AVCaptureDevice.default(for: .video)
|
||||
}
|
||||
|
||||
/// 虚拟多摄设备中「主摄(显示 1.0x)」对应的设备 zoomFactor 基准 S。
|
||||
/// 取 ultra-wide → wide 的切换点;非虚拟设备无切换点时返回 1.0(向后兼容)。
|
||||
private func mainBaselineFactor(for device: AVCaptureDevice) -> CGFloat {
|
||||
if let first = device.virtualDeviceSwitchOverVideoZoomFactors.first {
|
||||
let value = CGFloat(truncating: first)
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 1.0
|
||||
}
|
||||
|
||||
private func currentZoomCapabilitiesMap() -> [String: Any] {
|
||||
guard let device = videoInput?.device else {
|
||||
return [
|
||||
"zoomRatio": Double(currentZoomRatio),
|
||||
"minZoomRatio": 1.0,
|
||||
"maxZoomRatio": 3.0,
|
||||
]
|
||||
}
|
||||
|
||||
// 设备 zoomFactor 以 S 为基准换算成 App 使用的「显示倍率」(1.0x = 主摄)。
|
||||
let baseline = mainBaselineFactor(for: device)
|
||||
let minZoom = device.minAvailableVideoZoomFactor
|
||||
let maxZoom = device.maxAvailableVideoZoomFactor
|
||||
let zoom = clampedZoomRatio(device.videoZoomFactor, for: device)
|
||||
currentZoomRatio = zoom
|
||||
return [
|
||||
"zoomRatio": Double(zoom / baseline),
|
||||
"minZoomRatio": Double(minZoom / baseline),
|
||||
"maxZoomRatio": Double(maxZoom / baseline),
|
||||
]
|
||||
}
|
||||
|
||||
private func applyCurrentZoom() throws {
|
||||
guard let device = videoInput?.device else { return }
|
||||
let nextZoom = clampedZoomRatio(currentZoomRatio, for: device)
|
||||
try device.lockForConfiguration()
|
||||
device.videoZoomFactor = nextZoom
|
||||
device.unlockForConfiguration()
|
||||
currentZoomRatio = nextZoom
|
||||
}
|
||||
|
||||
private func clampedZoomRatio(_ ratio: CGFloat, for device: AVCaptureDevice) -> CGFloat {
|
||||
min(max(ratio, device.minAvailableVideoZoomFactor), device.maxAvailableVideoZoomFactor)
|
||||
}
|
||||
|
||||
private func configureAudioInput(enabled: Bool) throws {
|
||||
session.beginConfiguration()
|
||||
defer { session.commitConfiguration() }
|
||||
@@ -502,7 +561,30 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
try FileManager.default.createDirectory(at: recordingsURL, withIntermediateDirectories: true)
|
||||
|
||||
let fileName = Self.resolveFileName(displayName: displayName)
|
||||
return recordingsURL.appendingPathComponent(fileName)
|
||||
return uniqueOutputURL(in: recordingsURL, preferredFileName: fileName)
|
||||
}
|
||||
|
||||
private func uniqueOutputURL(in directoryURL: URL, preferredFileName: String) -> URL {
|
||||
let preferredURL = directoryURL.appendingPathComponent(preferredFileName)
|
||||
guard !FileManager.default.fileExists(atPath: preferredURL.path) else {
|
||||
let fileExtension = preferredURL.pathExtension
|
||||
let baseName = preferredURL.deletingPathExtension().lastPathComponent
|
||||
let timestamp = Self.fileNameDateFormatter.string(from: Date())
|
||||
|
||||
var index = 0
|
||||
while true {
|
||||
let suffix = index == 0 ? timestamp : "\(timestamp)_\(index)"
|
||||
let nextName = fileExtension.isEmpty
|
||||
? "\(baseName)_\(suffix)"
|
||||
: "\(baseName)_\(suffix).\(fileExtension)"
|
||||
let nextURL = directoryURL.appendingPathComponent(nextName)
|
||||
if !FileManager.default.fileExists(atPath: nextURL.path) {
|
||||
return nextURL
|
||||
}
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
return preferredURL
|
||||
}
|
||||
|
||||
private static func resolveFileName(displayName: String?) -> String {
|
||||
@@ -520,6 +602,13 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
return "REC_\(formatter.string(from: Date())).mov"
|
||||
}
|
||||
|
||||
private static let fileNameDateFormatter: DateFormatter = {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||
return formatter
|
||||
}()
|
||||
|
||||
private func updateStatus(_ next: RecordingStatus) {
|
||||
status = next
|
||||
}
|
||||
@@ -544,9 +633,9 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
|
||||
}
|
||||
|
||||
private enum RecordingChannelNames {
|
||||
static let packageName = "com.qxy.dronex"
|
||||
static let method = "\(packageName)/recording"
|
||||
static let events = "\(packageName)/recording_events"
|
||||
static let namespace = "app.record_tool"
|
||||
static let method = "\(namespace)/recording"
|
||||
static let events = "\(namespace)/recording_events"
|
||||
}
|
||||
|
||||
final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
|
||||
@@ -587,6 +676,12 @@ final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
|
||||
controller.startRecording(withAudio: withAudio, displayName: displayName, result: result)
|
||||
case "stopRecording":
|
||||
controller.stopRecording(result: result)
|
||||
case "getZoomCapabilities":
|
||||
controller.zoomCapabilities(result: result)
|
||||
case "setZoomRatio":
|
||||
let args = call.arguments as? [String: Any]
|
||||
let ratio = args?["zoomRatio"] as? Double ?? 1.0
|
||||
controller.setZoomRatio(CGFloat(ratio), result: result)
|
||||
case "disposePreview":
|
||||
controller.disposePreview(result: result)
|
||||
case "getStatus":
|
||||
|
||||
+3
-33
@@ -1,46 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_easyloading/flutter_easyloading.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
import 'package:recording_tool/app/config/app_config.dart';
|
||||
import 'package:recording_tool/app/router/app_navigator.dart';
|
||||
import 'package:recording_tool/app/theme/app_theme.dart';
|
||||
import 'package:recording_tool/features/recording/pages/page_record.dart';
|
||||
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
|
||||
import 'package:recording_tool/features/auth/pages/page_auth.dart';
|
||||
|
||||
class FlutterTemplateApp extends ConsumerStatefulWidget {
|
||||
class FlutterTemplateApp extends StatelessWidget {
|
||||
const FlutterTemplateApp({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<FlutterTemplateApp> createState() => _FlutterTemplateAppState();
|
||||
}
|
||||
|
||||
class _FlutterTemplateAppState extends ConsumerState<FlutterTemplateApp>
|
||||
with WidgetsBindingObserver {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(recordingViewModelProvider.notifier).getClipboardContent();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
ref.read(recordingViewModelProvider.notifier).getClipboardContent();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ScreenUtilInit(
|
||||
@@ -74,7 +44,7 @@ class _FlutterTemplateAppState extends ConsumerState<FlutterTemplateApp>
|
||||
home: RefreshConfiguration(
|
||||
enableLoadingWhenNoData: false,
|
||||
headerTriggerDistance: 80.h,
|
||||
child: const RecordingPage(),
|
||||
child: const AuthPageWidget(),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
+19
-1
@@ -3,10 +3,14 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_ume/flutter_ume.dart';
|
||||
import 'package:flutter_ume_kit_device/components/device_info/device_info_panel.dart';
|
||||
import 'package:flutter_ume_kit_dio/flutter_ume_kit_dio.dart';
|
||||
import 'package:recording_tool/app/app.dart';
|
||||
import 'package:recording_tool/app/config/app_config.dart';
|
||||
import 'package:recording_tool/core/cache/app_storage.dart';
|
||||
import 'package:recording_tool/core/logging/app_logger.dart';
|
||||
import 'package:recording_tool/core/network/app_dio.dart';
|
||||
import 'package:recording_tool/core/platform/app_platform_info.dart';
|
||||
|
||||
class AppBootstrapper {
|
||||
@@ -25,7 +29,21 @@ class AppBootstrapper {
|
||||
|
||||
AppLogger.debug('App started in ${AppConfig.current.environment.name}');
|
||||
|
||||
runApp(const ProviderScope(child: FlutterTemplateApp()));
|
||||
// 注册 UME 调试插件(DioInspector 需与业务共用同一 Dio 实例)
|
||||
PluginManager.instance
|
||||
..register(DioInspector(dio: AppDio.instance))
|
||||
..register(DeviceInfoPanel());
|
||||
|
||||
runApp(
|
||||
UMEWidget(
|
||||
enable: true,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [const ProviderScope(child: FlutterTemplateApp())],
|
||||
),
|
||||
),
|
||||
);
|
||||
// runApp(const ProviderScope(child: FlutterTemplateApp()));
|
||||
|
||||
// Load native package metadata after the first frame can render.
|
||||
// Awaiting MethodChannel calls before runApp() can stall the Android
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
enum AuthApi {
|
||||
/// 获取 token
|
||||
getToken('/api/events/device/token'),
|
||||
|
||||
/// 获取推流地址
|
||||
getStreamKey('/api/events/device/stream/key'),
|
||||
|
||||
/// 根据赛事目录获取视频列表
|
||||
getRecordList('/api/files'),
|
||||
|
||||
/// 获取参赛队伍列表
|
||||
getTeamList('/api/events/device/item/group'),
|
||||
|
||||
/// 获取选手的赛事信息
|
||||
playerRegistrationList('/api/events/device/player/registration/list');
|
||||
|
||||
final String path;
|
||||
const AuthApi(this.path);
|
||||
}
|
||||
@@ -8,11 +8,13 @@ class EnvironmentValues {
|
||||
required this.environment,
|
||||
required this.baseUrl,
|
||||
required this.enableNetworkLog,
|
||||
this.mainRefereeScoreH5Url = '',
|
||||
});
|
||||
|
||||
final AppEnvironment environment;
|
||||
final String baseUrl;
|
||||
final bool enableNetworkLog;
|
||||
final String mainRefereeScoreH5Url;
|
||||
}
|
||||
|
||||
class AppConfig {
|
||||
@@ -24,6 +26,8 @@ class AppConfig {
|
||||
static const appName = '飞行极控录像工作台';
|
||||
static const designSize = Size(375, 812);
|
||||
|
||||
/// 主裁判计分 H5 链接
|
||||
|
||||
static void configure({
|
||||
required AppEnvironment environment,
|
||||
AppPackageInfo? packageInfo,
|
||||
@@ -32,17 +36,24 @@ class AppConfig {
|
||||
current = switch (environment) {
|
||||
AppEnvironment.dev => const EnvironmentValues(
|
||||
environment: AppEnvironment.dev,
|
||||
baseUrl: 'https://example.com/api',
|
||||
// baseUrl: 'http://192.168.1.104:8000',
|
||||
baseUrl: 'https://apitest.dronex.cc',
|
||||
mainRefereeScoreH5Url:
|
||||
'https://drone.apptest.sportsx.cc/#/pages/h5/score-entry',
|
||||
enableNetworkLog: true,
|
||||
),
|
||||
AppEnvironment.staging => const EnvironmentValues(
|
||||
environment: AppEnvironment.staging,
|
||||
baseUrl: 'https://staging.example.com/api',
|
||||
baseUrl: 'https://apitest.dronex.cc',
|
||||
mainRefereeScoreH5Url:
|
||||
'https://drone.apptest.sportsx.cc/#/pages/h5/score-entry',
|
||||
enableNetworkLog: true,
|
||||
),
|
||||
AppEnvironment.prod => const EnvironmentValues(
|
||||
environment: AppEnvironment.prod,
|
||||
baseUrl: 'https://api.example.com',
|
||||
baseUrl: 'https://api.dronex.cc',
|
||||
mainRefereeScoreH5Url:
|
||||
'https://drone.sportsx.cc/#/pages/h5/score-entry',
|
||||
enableNetworkLog: false,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -103,10 +103,7 @@ class AppNavigator {
|
||||
}
|
||||
|
||||
static void pop<T extends Object?>({BuildContext? context, T? result}) {
|
||||
Navigator.of(
|
||||
context ?? AppNavigator.context!,
|
||||
rootNavigator: true,
|
||||
).pop<T>(result);
|
||||
Navigator.maybePop(context ?? AppNavigator.context!);
|
||||
}
|
||||
|
||||
static void popTimes({BuildContext? context, int count = 1}) {
|
||||
|
||||
@@ -108,6 +108,7 @@ class ApiClient {
|
||||
final statusCode = error.response?.statusCode;
|
||||
final message = switch (error.type) {
|
||||
DioExceptionType.connectionTimeout => '网络连接超时',
|
||||
DioExceptionType.transformTimeout => '网络请求处理超时',
|
||||
DioExceptionType.sendTimeout => '请求发送超时',
|
||||
DioExceptionType.receiveTimeout => '响应接收超时',
|
||||
DioExceptionType.badCertificate => '证书校验失败',
|
||||
|
||||
@@ -5,7 +5,7 @@ class ApiResponse<T> {
|
||||
final String message;
|
||||
final T? data;
|
||||
|
||||
bool get isSuccess => code >= 200 && code < 300;
|
||||
bool get isSuccess => code == 0 || code >= 200 && code < 300;
|
||||
|
||||
factory ApiResponse.fromJson(
|
||||
Map<String, dynamic> json, {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:recording_tool/app/config/app_config.dart';
|
||||
|
||||
/// 应用全局 Dio 单例,供业务请求与调试插件共用。
|
||||
class AppDio {
|
||||
AppDio._();
|
||||
|
||||
static Dio? _instance;
|
||||
|
||||
static Dio get instance {
|
||||
return _instance ??= Dio(
|
||||
BaseOptions(
|
||||
baseUrl: AppConfig.current.baseUrl,
|
||||
connectTimeout: const Duration(seconds: 30),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
sendTimeout: const Duration(seconds: 30),
|
||||
responseType: ResponseType.json,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,12 @@ import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:recording_tool/app/config/app_config.dart';
|
||||
import 'package:recording_tool/app/router/app_navigator.dart';
|
||||
import 'package:recording_tool/core/cache/app_storage.dart';
|
||||
import 'package:recording_tool/core/cache/storage_keys.dart';
|
||||
import 'package:recording_tool/core/utils/device_utils.dart';
|
||||
import 'package:recording_tool/features/auth/pages/page_auth.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_toast.dart';
|
||||
|
||||
class HeaderInterceptor extends Interceptor {
|
||||
@override
|
||||
@@ -28,4 +31,17 @@ class HeaderInterceptor extends Interceptor {
|
||||
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
if (err.response?.statusCode == 401) {
|
||||
await AppStorage.remove(StorageKeys.authToken);
|
||||
AppToast.show('口令已过期,请重新输入执裁口令');
|
||||
AppNavigator.pushAndRemoveUntil(AuthPageWidget());
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,26 +2,23 @@ import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:recording_tool/app/config/app_config.dart';
|
||||
import 'package:recording_tool/core/network/api_client.dart';
|
||||
import 'package:recording_tool/core/network/app_dio.dart';
|
||||
import 'package:recording_tool/core/network/header_interceptor.dart';
|
||||
import 'package:recording_tool/core/network/offline_queue/offline_queue_interceptor.dart';
|
||||
import 'package:recording_tool/core/network/providers/network_providers.dart';
|
||||
import 'package:recording_tool/core/network/providers/offline_queue_providers.dart';
|
||||
|
||||
final dioProvider = Provider<Dio>((ref) {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: AppConfig.current.baseUrl,
|
||||
connectTimeout: const Duration(seconds: 30),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
sendTimeout: const Duration(seconds: 30),
|
||||
responseType: ResponseType.json,
|
||||
),
|
||||
);
|
||||
bool _dioConfigured = false;
|
||||
|
||||
final dioProvider = Provider<Dio>((ref) {
|
||||
final dio = AppDio.instance;
|
||||
|
||||
if (!_dioConfigured) {
|
||||
_dioConfigured = true;
|
||||
dio.interceptors.add(HeaderInterceptor());
|
||||
|
||||
final monitor = ref.watch(networkMonitorProvider);
|
||||
final queueManager = ref.watch(offlineQueueManagerProvider);
|
||||
final monitor = ref.read(networkMonitorProvider);
|
||||
final queueManager = ref.read(offlineQueueManagerProvider);
|
||||
dio.interceptors.add(
|
||||
OfflineQueueInterceptor(
|
||||
monitor: monitor,
|
||||
@@ -31,7 +28,10 @@ final dioProvider = Provider<Dio>((ref) {
|
||||
);
|
||||
|
||||
if (AppConfig.current.enableNetworkLog) {
|
||||
dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true));
|
||||
dio.interceptors.add(
|
||||
LogInterceptor(requestBody: true, responseBody: true),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return dio;
|
||||
|
||||
@@ -31,6 +31,7 @@ class AppDeviceInfo {
|
||||
required this.platform,
|
||||
required this.isPhysicalDevice,
|
||||
required this.values,
|
||||
required this.deviceCode,
|
||||
});
|
||||
|
||||
factory AppDeviceInfo.fromMap(Map<Object?, Object?> map) {
|
||||
@@ -48,19 +49,21 @@ class AppDeviceInfo {
|
||||
platform: map['platform'] as String? ?? Platform.operatingSystem,
|
||||
isPhysicalDevice: isPhysicalDevice is bool ? isPhysicalDevice : true,
|
||||
values: values,
|
||||
deviceCode: values['deviceCode'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
final String platform;
|
||||
final bool isPhysicalDevice;
|
||||
final Map<String, String> values;
|
||||
final String deviceCode;
|
||||
}
|
||||
|
||||
class AppPlatformInfo {
|
||||
AppPlatformInfo._();
|
||||
|
||||
static const MethodChannel _channel = MethodChannel(
|
||||
'com.qxy.dronex/platform_info',
|
||||
'app.record_tool/platform_info',
|
||||
);
|
||||
|
||||
static Future<AppPackageInfo> packageInfo() async {
|
||||
|
||||
@@ -28,4 +28,12 @@ class DeviceUtils {
|
||||
}
|
||||
return (await AppPlatformInfo.deviceInfo()).values;
|
||||
}
|
||||
|
||||
static Future<String> deviceCode() async {
|
||||
try {
|
||||
return (await AppPlatformInfo.deviceInfo()).deviceCode.trim();
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/// 获取 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) =>
|
||||
GetRecordListResModel(
|
||||
path: json['path'],
|
||||
items: json['items'] == null
|
||||
? []
|
||||
: List<RecordListItem>.from(
|
||||
json['items']!.map((x) => RecordListItem.fromJson(x)),
|
||||
),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'path': path,
|
||||
'items': items == null
|
||||
? []
|
||||
: List<dynamic>.from(items!.map((x) => x.toJson())),
|
||||
};
|
||||
}
|
||||
|
||||
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,56 @@
|
||||
// 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;
|
||||
double? oId;
|
||||
List<double>? oIds;
|
||||
double? 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"],
|
||||
deviceCode: json["deviceCode"],
|
||||
deviceId: json["deviceId"],
|
||||
deviceRole: json["deviceRole"],
|
||||
eventName: json["eventName"],
|
||||
oId: json["oId"]?.toDouble(),
|
||||
oIds: json["oIds"] == null
|
||||
? []
|
||||
: List<double>.from(json["oIds"]!.map((x) => x?.toDouble())),
|
||||
organizerId: json["organizerId"]?.toDouble(),
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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/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/shared/widgets/widgets.dart';
|
||||
|
||||
class AuthPageWidget extends ConsumerStatefulWidget {
|
||||
const AuthPageWidget({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<AuthPageWidget> createState() => _AuthPageWidgetState();
|
||||
}
|
||||
|
||||
class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
|
||||
late TextEditingController? _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController();
|
||||
_controller?.text = '555';
|
||||
|
||||
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 Center(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(height: 180.h),
|
||||
AppText('裁判工作台', fontSize: 30.sp),
|
||||
SizedBox(height: 20.h),
|
||||
Text(
|
||||
'输入执裁口令',
|
||||
style: TextStyle(fontSize: 18.sp, color: Colors.black),
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: Column(
|
||||
children: [
|
||||
AppTextField(controller: _controller),
|
||||
SizedBox(height: 20.h),
|
||||
SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: AppButton(
|
||||
label: '确定',
|
||||
onPressed: () async {
|
||||
final code = _controller?.text;
|
||||
final success = await ref
|
||||
.read(authProvider.notifier)
|
||||
.auth(code ?? '');
|
||||
if (!mounted) return;
|
||||
if (success) {
|
||||
AppNavigator.push(const ScanQrCodePage());
|
||||
return;
|
||||
}
|
||||
final message = ref.read(authProvider).errorMessage;
|
||||
if (message != null && message.isNotEmpty) {
|
||||
AppToast.show(message);
|
||||
}
|
||||
},
|
||||
variant: AppButtonVariant.secondary,
|
||||
isLoading: authState.isLoading,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
..._buildTestArea(authState.isLoading),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 测试区域
|
||||
List<Widget> _buildTestArea(bool isLoading) {
|
||||
return [
|
||||
SizedBox(height: 20.h),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 20.w),
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: AppButton(
|
||||
label: '获取设备码',
|
||||
onPressed: () async {
|
||||
DeviceUtils.deviceCode().then((code) {
|
||||
AppDialog.confirm(context, title: '设备码', message: code);
|
||||
});
|
||||
},
|
||||
variant: AppButtonVariant.secondary,
|
||||
isLoading: isLoading,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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 {
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
final data = await apiClient.get<GetRecordListResModel>(
|
||||
'http://sheling.local:9001/${AuthApi.getRecordList.path}',
|
||||
queryParameters: {'path': path},
|
||||
parser: (json) =>
|
||||
GetRecordListResModel.fromJson(json as Map<String, dynamic>),
|
||||
);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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_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);
|
||||
if (decoded['data'] != null && decoded['data'] is Map<String, dynamic>) {
|
||||
final data = JwtDecodedData.fromJson(decoded['data']);
|
||||
state = state.copyWith(jwtDecodedData: data);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 获取赛事列表
|
||||
Future<bool> getRecordList(String eventName) async {
|
||||
if (eventName.isEmpty) return false;
|
||||
final data = await AuthServer.getRecordList(_ref, eventName);
|
||||
if (data.items == null || data.items!.isEmpty) return false;
|
||||
state = state.copyWith(recordList: data.items);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 清空授权信息(本地 token + 内存状态)
|
||||
Future<void> clearAuth() async {
|
||||
await AppStorage.remove(StorageKeys.authToken);
|
||||
state = const AuthState();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CompetitionTeamListItem {
|
||||
const CompetitionTeamListItem({
|
||||
required this.id,
|
||||
required this.eventName,
|
||||
required this.itemName,
|
||||
required this.groupName,
|
||||
required this.matchPlace,
|
||||
required this.matchStartTime,
|
||||
required this.matchEndTime,
|
||||
required this.matchups,
|
||||
this.completed = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String eventName;
|
||||
final String itemName;
|
||||
final String groupName;
|
||||
final String matchPlace;
|
||||
final String matchStartTime;
|
||||
final String matchEndTime;
|
||||
final List<CompetitionMatchup> matchups;
|
||||
final bool completed;
|
||||
|
||||
String get title => groupName.isEmpty ? itemName : '$itemName ($groupName)';
|
||||
|
||||
String get scheduleTime => '$matchStartTime-$matchEndTime';
|
||||
|
||||
CompetitionTeamListItem copyWith({List<CompetitionMatchup>? matchups}) {
|
||||
return CompetitionTeamListItem(
|
||||
id: id,
|
||||
eventName: eventName,
|
||||
itemName: itemName,
|
||||
groupName: groupName,
|
||||
matchPlace: matchPlace,
|
||||
matchStartTime: matchStartTime,
|
||||
matchEndTime: matchEndTime,
|
||||
matchups: matchups ?? this.matchups,
|
||||
completed: completed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart';
|
||||
import 'package:recording_tool/features/competition_teams/view_model/view_model_competition_teams.dart';
|
||||
import 'package:recording_tool/features/competition_teams/widgets/widget_manual_winner_dialog.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_empty_view.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_toast.dart';
|
||||
|
||||
class CompetitionTeamDetailPage extends ConsumerWidget {
|
||||
const CompetitionTeamDetailPage({super.key, required this.itemId});
|
||||
|
||||
final String itemId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final items = ref.watch(
|
||||
competitionTeamsProvider.select((state) => state.items),
|
||||
);
|
||||
CompetitionTeamListItem? item;
|
||||
for (final candidate in items) {
|
||||
if (candidate.id == itemId) {
|
||||
item = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(title: Text(item?.title ?? '参赛队伍')),
|
||||
body: item == null
|
||||
? const AppEmptyView(message: '未找到对阵信息')
|
||||
: ListView.separated(
|
||||
padding: EdgeInsets.fromLTRB(20.w, 24.h, 20.w, 36.h),
|
||||
itemCount: item.matchups.length,
|
||||
separatorBuilder: (_, _) => SizedBox(height: 22.h),
|
||||
itemBuilder: (context, index) {
|
||||
final matchup = item!.matchups[index];
|
||||
return _MatchupCard(
|
||||
matchup: matchup,
|
||||
index: index,
|
||||
onManualProcess: () => _handleManualProcess(
|
||||
context,
|
||||
ref,
|
||||
itemId: item!.id,
|
||||
matchup: matchup,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleManualProcess(
|
||||
BuildContext context,
|
||||
WidgetRef ref, {
|
||||
required String itemId,
|
||||
required CompetitionMatchup matchup,
|
||||
}) async {
|
||||
final winnerTeamId = await ManualWinnerDialog.show(
|
||||
context,
|
||||
matchup: matchup,
|
||||
);
|
||||
if (winnerTeamId == null || !context.mounted) return;
|
||||
|
||||
ref
|
||||
.read(competitionTeamsProvider.notifier)
|
||||
.selectWinner(
|
||||
itemId: itemId,
|
||||
matchupId: matchup.id,
|
||||
winnerTeamId: winnerTeamId,
|
||||
);
|
||||
final winnerName = winnerTeamId == matchup.teamA.id
|
||||
? matchup.teamA.name
|
||||
: matchup.teamB.name;
|
||||
AppToast.show('已设置$winnerName直接获胜');
|
||||
}
|
||||
}
|
||||
|
||||
class _MatchupCard extends StatelessWidget {
|
||||
const _MatchupCard({
|
||||
required this.matchup,
|
||||
required this.index,
|
||||
required this.onManualProcess,
|
||||
});
|
||||
|
||||
final CompetitionMatchup matchup;
|
||||
final int index;
|
||||
final VoidCallback onManualProcess;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
key: ValueKey('competition-matchup-${matchup.id}'),
|
||||
padding: EdgeInsets.fromLTRB(16.w, 10.h, 16.w, 18.h),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border.all(color: const Color(0xFFC7CCD4)),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'第 ${index + 1} 场',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF7A828E),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
key: ValueKey('manual-process-${matchup.id}'),
|
||||
onPressed: onManualProcess,
|
||||
child: Text(
|
||||
'人工处理',
|
||||
style: TextStyle(
|
||||
fontSize: 17.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF078AF2),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TeamPanel(
|
||||
team: matchup.teamA,
|
||||
alignment: CrossAxisAlignment.start,
|
||||
winner: matchup.winnerTeamId == matchup.teamA.id,
|
||||
accentColor: const Color(0xFFFF6B75),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12.w),
|
||||
child: Text(
|
||||
'VS',
|
||||
style: TextStyle(
|
||||
fontSize: 21.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF303640),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _TeamPanel(
|
||||
team: matchup.teamB,
|
||||
alignment: CrossAxisAlignment.end,
|
||||
textAlign: TextAlign.end,
|
||||
winner: matchup.winnerTeamId == matchup.teamB.id,
|
||||
accentColor: const Color(0xFF20BFA9),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TeamPanel extends StatelessWidget {
|
||||
const _TeamPanel({
|
||||
required this.team,
|
||||
required this.alignment,
|
||||
required this.winner,
|
||||
required this.accentColor,
|
||||
this.textAlign = TextAlign.start,
|
||||
});
|
||||
|
||||
final CompetitionTeam team;
|
||||
final CrossAxisAlignment alignment;
|
||||
final TextAlign textAlign;
|
||||
final bool winner;
|
||||
final Color accentColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: alignment,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
|
||||
decoration: BoxDecoration(
|
||||
color: winner
|
||||
? accentColor.withValues(alpha: 0.14)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8.r),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
team.name,
|
||||
textAlign: textAlign,
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF282E37),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (winner) ...[
|
||||
SizedBox(width: 5.w),
|
||||
Icon(Icons.emoji_events, size: 17.r, color: accentColor),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
Text(
|
||||
team.playerNames,
|
||||
textAlign: textAlign,
|
||||
style: TextStyle(
|
||||
fontSize: 15.sp,
|
||||
height: 1.45,
|
||||
color: const Color(0xFF4E5662),
|
||||
),
|
||||
),
|
||||
if (winner) ...[
|
||||
SizedBox(height: 8.h),
|
||||
Container(
|
||||
key: ValueKey('winner-${team.id}'),
|
||||
padding: EdgeInsets.symmetric(horizontal: 9.w, vertical: 3.h),
|
||||
decoration: BoxDecoration(
|
||||
color: accentColor,
|
||||
borderRadius: BorderRadius.circular(10.r),
|
||||
),
|
||||
child: Text(
|
||||
'胜方',
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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/view_model/view_model_competition_teams.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';
|
||||
|
||||
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: Colors.white,
|
||||
appBar: AppBar(title: const Text('参赛队伍')),
|
||||
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(20.w, 18.h, 20.w, 28.h),
|
||||
separator: SizedBox(height: 14.h),
|
||||
empty: const AppEmptyView(message: '暂无参赛队伍'),
|
||||
itemBuilder: (context, item, index) {
|
||||
return _CompetitionScheduleCard(
|
||||
item: item,
|
||||
onTap: () => AppNavigator.push(
|
||||
CompetitionTeamDetailPage(itemId: item.id),
|
||||
context: context,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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.id}'),
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14.r),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14.r),
|
||||
child: Container(
|
||||
constraints: BoxConstraints(minHeight: 142.h),
|
||||
padding: EdgeInsets.all(16.r),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14.r),
|
||||
border: Border.all(color: const Color(0xFFD7DBE2)),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x0F1A2230),
|
||||
blurRadius: 16,
|
||||
offset: Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 19.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF20242B),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 18.h),
|
||||
_InfoLine(
|
||||
icon: Icons.location_on_outlined,
|
||||
text: item.matchPlace,
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
_InfoLine(
|
||||
icon: Icons.schedule_outlined,
|
||||
text: item.scheduleTime,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 14.w),
|
||||
Container(
|
||||
width: 72.w,
|
||||
height: 72.h,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: item.completed
|
||||
? const Color(0xFFF1F3F6)
|
||||
: const Color(0xFFEAF3FF),
|
||||
borderRadius: BorderRadius.circular(16.r),
|
||||
),
|
||||
child: Text(
|
||||
item.completed ? '已完成' : item.matchPlace,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: item.completed ? 14.sp : 20.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: item.completed
|
||||
? const Color(0xFF69717D)
|
||||
: const Color(0xFF147FEA),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoLine extends StatelessWidget {
|
||||
const _InfoLine({required this.icon, required this.text});
|
||||
|
||||
final IconData icon;
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 18.r, color: const Color(0xFF7B8491)),
|
||||
SizedBox(width: 8.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 15.sp, color: const Color(0xFF525B68)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import 'package:flutter/material.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.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/shared/widgets/app_button.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 StatefulWidget {
|
||||
const EventTeamMatchPage({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.playerId,
|
||||
this.qrScanner,
|
||||
});
|
||||
|
||||
final EventRegistrationItem item;
|
||||
final String playerId;
|
||||
final TeamQrScanner? qrScanner;
|
||||
|
||||
@override
|
||||
State<EventTeamMatchPage> createState() => _EventTeamMatchPageState();
|
||||
}
|
||||
|
||||
class _EventTeamMatchPageState extends State<EventTeamMatchPage> {
|
||||
final Set<String> _verifiedUserIds = <String>{};
|
||||
String? _winnerTeamId;
|
||||
|
||||
CompetitionTeam get _homeTeam {
|
||||
final leader = widget.item.teamLeader;
|
||||
final teamId = leader?.userId.isNotEmpty == true
|
||||
? leader!.userId
|
||||
: widget.item.userId.isNotEmpty
|
||||
? widget.item.userId
|
||||
: 'home-team';
|
||||
final teamName = leader?.name.isNotEmpty == true
|
||||
? leader!.name
|
||||
: widget.item.playerName.isNotEmpty
|
||||
? widget.item.playerName
|
||||
: '本方队伍';
|
||||
return CompetitionTeam(
|
||||
id: teamId,
|
||||
name: teamName,
|
||||
players: widget.item.teamMembers
|
||||
.map(
|
||||
(member) => CompetitionPlayer(id: member.userId, name: member.name),
|
||||
)
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
CompetitionTeam get _opponentTeam {
|
||||
final opponentId = widget.item.opponentId.isNotEmpty
|
||||
? widget.item.opponentId
|
||||
: 'opponent-team';
|
||||
final opponentName = widget.item.opponentName.isNotEmpty
|
||||
? widget.item.opponentName
|
||||
: '对方队伍';
|
||||
return CompetitionTeam(
|
||||
id: opponentId,
|
||||
name: opponentName,
|
||||
players:
|
||||
widget.item.opponentId.isEmpty && widget.item.opponentName.isEmpty
|
||||
? const []
|
||||
: [CompetitionPlayer(id: opponentId, name: opponentName)],
|
||||
);
|
||||
}
|
||||
|
||||
CompetitionMatchup get _matchup => CompetitionMatchup(
|
||||
id: widget.item.scheduleId.isNotEmpty
|
||||
? widget.item.scheduleId
|
||||
: '${widget.item.itemId}-team-match',
|
||||
teamA: _homeTeam,
|
||||
teamB: _opponentTeam,
|
||||
winnerTeamId: _winnerTeamId,
|
||||
);
|
||||
|
||||
@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;
|
||||
if (userId == widget.item.opponentId) return true;
|
||||
return widget.item.teamMembers.any((member) => member.userId == userId);
|
||||
}
|
||||
|
||||
String _memberNameOf(String userId) {
|
||||
if (userId == widget.item.opponentId) {
|
||||
return widget.item.opponentName.isEmpty
|
||||
? '对方队长'
|
||||
: widget.item.opponentName;
|
||||
}
|
||||
for (final member in widget.item.teamMembers) {
|
||||
if (member.userId == userId) return 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 winnerTeamId = await ManualWinnerDialog.show(
|
||||
context,
|
||||
matchup: _matchup,
|
||||
);
|
||||
if (!mounted || winnerTeamId == null) return;
|
||||
setState(() => _winnerTeamId = winnerTeamId);
|
||||
final winnerName = winnerTeamId == _homeTeam.id
|
||||
? _homeTeam.name
|
||||
: _opponentTeam.name;
|
||||
AppToast.show('已设置$winnerName直接获胜');
|
||||
}
|
||||
|
||||
void _startDirectly() {
|
||||
AppNavigator.push(
|
||||
buildTeamScorePage(item: widget.item, playerId: widget.playerId),
|
||||
context: context,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final homeTeam = _homeTeam;
|
||||
final opponentTeam = _opponentTeam;
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 20.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_MatchMetadata(item: widget.item),
|
||||
SizedBox(height: 16.h),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
key: const ValueKey('event-team-manual-process'),
|
||||
onPressed: _manualProcess,
|
||||
child: Text(
|
||||
'人工处理',
|
||||
style: TextStyle(
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF078AF2),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_TeamCard(
|
||||
team: homeTeam,
|
||||
members: widget.item.teamMembers,
|
||||
backgroundColor: const Color(0xFFFFEEF0),
|
||||
accentColor: const Color(0xFFE84B5B),
|
||||
verifiedUserIds: _verifiedUserIds,
|
||||
winner: _winnerTeamId == homeTeam.id,
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
_TeamCard(
|
||||
team: opponentTeam,
|
||||
members: [
|
||||
EventTeamMember(
|
||||
userId: widget.item.opponentId,
|
||||
name: widget.item.opponentName,
|
||||
isLeader: true,
|
||||
),
|
||||
],
|
||||
backgroundColor: const Color(0xFFEDF5FF),
|
||||
accentColor: const Color(0xFF287FDD),
|
||||
verifiedUserIds: _verifiedUserIds,
|
||||
winner: _winnerTeamId == opponentTeam.id,
|
||||
emptyMessage: '暂无对方成员数据',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 20.h),
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: AppButton(
|
||||
label: '继续扫码',
|
||||
variant: AppButtonVariant.outline,
|
||||
onPressed: _continueScan,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: AppButton(label: '直接开赛', 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.item});
|
||||
|
||||
final EventRegistrationItem item;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.all(18.r),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF7F9FC),
|
||||
borderRadius: BorderRadius.circular(14.r),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MetadataText(label: '比赛项目', value: item.itemName),
|
||||
),
|
||||
SizedBox(width: 16.w),
|
||||
_MetadataText(label: '场地', value: item.matchPlace),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 14.h),
|
||||
_MetadataText(label: '组别', value: item.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: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 17.sp,
|
||||
height: 1.35,
|
||||
color: const Color(0xFF303640),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TeamCard extends StatelessWidget {
|
||||
const _TeamCard({
|
||||
required this.team,
|
||||
required this.members,
|
||||
required this.backgroundColor,
|
||||
required this.accentColor,
|
||||
required this.verifiedUserIds,
|
||||
required this.winner,
|
||||
this.emptyMessage = '暂无成员数据',
|
||||
});
|
||||
|
||||
final CompetitionTeam team;
|
||||
final List<EventTeamMember> members;
|
||||
final Color backgroundColor;
|
||||
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);
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.all(18.r),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(14.r),
|
||||
border: Border.all(
|
||||
color: winner ? accentColor : accentColor.withValues(alpha: 0.24),
|
||||
width: winner ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'队长:${team.name}',
|
||||
style: TextStyle(
|
||||
fontSize: 19.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF252B34),
|
||||
),
|
||||
),
|
||||
),
|
||||
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: 12.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 14.h),
|
||||
if (visibleMembers.isEmpty)
|
||||
Text(
|
||||
emptyMessage,
|
||||
style: TextStyle(fontSize: 15.sp, color: const Color(0xFF747D89)),
|
||||
)
|
||||
else
|
||||
...visibleMembers.map(
|
||||
(member) => _MemberRow(
|
||||
member: member,
|
||||
verified: verifiedUserIds.contains(member.userId),
|
||||
accentColor: accentColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MemberRow extends StatelessWidget {
|
||||
const _MemberRow({
|
||||
required this.member,
|
||||
required this.verified,
|
||||
required this.accentColor,
|
||||
});
|
||||
|
||||
final EventTeamMember member;
|
||||
final bool verified;
|
||||
final Color accentColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 5.h),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 6.r,
|
||||
height: 6.r,
|
||||
decoration: BoxDecoration(
|
||||
color: accentColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
SizedBox(width: 10.w),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${member.isLeader ? '队长' : '选手'}:${member.name.isEmpty ? '暂无' : member.name}',
|
||||
style: TextStyle(fontSize: 16.sp, color: const Color(0xFF303640)),
|
||||
),
|
||||
),
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
child: verified
|
||||
? Icon(
|
||||
Icons.check_circle,
|
||||
key: ValueKey('verified-member-${member.userId}'),
|
||||
color: const Color(0xFF18A957),
|
||||
size: 24.r,
|
||||
)
|
||||
: SizedBox(width: 24.r, height: 24.r),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart';
|
||||
|
||||
final competitionTeamsServerProvider = Provider<CompetitionTeamsServer>((ref) {
|
||||
return const CompetitionTeamsServer();
|
||||
});
|
||||
|
||||
class CompetitionTeamsServer {
|
||||
const CompetitionTeamsServer();
|
||||
|
||||
static const totalCount = 10;
|
||||
|
||||
Future<CompetitionTeamPageResult> fetchPage({
|
||||
required int page,
|
||||
required int pageSize,
|
||||
}) async {
|
||||
final start = (page - 1) * pageSize;
|
||||
if (start >= totalCount) {
|
||||
return CompetitionTeamPageResult(
|
||||
items: const [],
|
||||
total: totalCount,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
);
|
||||
}
|
||||
|
||||
final end = (start + pageSize).clamp(0, totalCount);
|
||||
final items = List.generate(
|
||||
end - start,
|
||||
(index) => _buildItem(start + index),
|
||||
);
|
||||
return CompetitionTeamPageResult(
|
||||
items: items,
|
||||
total: totalCount,
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
);
|
||||
}
|
||||
|
||||
CompetitionTeamListItem _buildItem(int index) {
|
||||
final number = index + 1;
|
||||
final group = index.isEven ? '小学组' : '初中组';
|
||||
final hour = 9 + index ~/ 2;
|
||||
return CompetitionTeamListItem(
|
||||
id: 'competition-$number',
|
||||
eventName: '全国青少年无人机大赛',
|
||||
itemName: index % 3 == 0 ? '空中足球赛' : '空中格斗赛',
|
||||
groupName: group,
|
||||
matchPlace: '场地 ${index % 4 + 1}',
|
||||
matchStartTime: '${hour.toString().padLeft(2, '0')}:00',
|
||||
matchEndTime: '${hour.toString().padLeft(2, '0')}:30',
|
||||
completed: index == totalCount - 1,
|
||||
matchups: [
|
||||
_buildMatchup(itemNumber: number, matchNumber: 1),
|
||||
_buildMatchup(itemNumber: number, matchNumber: 2),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
CompetitionMatchup _buildMatchup({
|
||||
required int itemNumber,
|
||||
required int matchNumber,
|
||||
}) {
|
||||
final matchupId = 'match-$itemNumber-$matchNumber';
|
||||
final teamAIndex = (itemNumber + matchNumber - 2) % _teamNames.length;
|
||||
final teamBIndex = (teamAIndex + 1) % _teamNames.length;
|
||||
return CompetitionMatchup(
|
||||
id: matchupId,
|
||||
teamA: _buildTeam(
|
||||
id: '$matchupId-team-a',
|
||||
name: _teamNames[teamAIndex],
|
||||
playerOffset: teamAIndex * 3,
|
||||
),
|
||||
teamB: _buildTeam(
|
||||
id: '$matchupId-team-b',
|
||||
name: _teamNames[teamBIndex],
|
||||
playerOffset: teamBIndex * 3,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
CompetitionTeam _buildTeam({
|
||||
required String id,
|
||||
required String name,
|
||||
required int playerOffset,
|
||||
}) {
|
||||
return CompetitionTeam(
|
||||
id: id,
|
||||
name: name,
|
||||
players: List.generate(3, (index) {
|
||||
final playerIndex = (playerOffset + index) % _playerNames.length;
|
||||
return CompetitionPlayer(
|
||||
id: '$id-player-$index',
|
||||
name: _playerNames[playerIndex],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
static const _teamNames = ['王多鱼队', '钱多多队', '逐风少年队', '蓝翼飞行队', '星火队'];
|
||||
static const _playerNames = [
|
||||
'王伟',
|
||||
'李庆超',
|
||||
'王大亮',
|
||||
'韩宁政',
|
||||
'阮晴桦',
|
||||
'刘美玲',
|
||||
'陈子航',
|
||||
'周白芷',
|
||||
'林培伦',
|
||||
'蔡依婷',
|
||||
'夏志豪',
|
||||
'赵云飞',
|
||||
'孙雨泽',
|
||||
'吴佳琪',
|
||||
'郑凯文',
|
||||
];
|
||||
}
|
||||
@@ -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.id != 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.id == 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,210 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:recording_tool/features/competition_teams/model/model_competition_team.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_button.dart';
|
||||
|
||||
class ManualWinnerDialog extends StatefulWidget {
|
||||
const ManualWinnerDialog({super.key, required this.matchup});
|
||||
|
||||
final CompetitionMatchup matchup;
|
||||
|
||||
static Future<String?> show(
|
||||
BuildContext context, {
|
||||
required CompetitionMatchup matchup,
|
||||
}) {
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => ManualWinnerDialog(matchup: matchup),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<ManualWinnerDialog> createState() => _ManualWinnerDialogState();
|
||||
}
|
||||
|
||||
class _ManualWinnerDialogState extends State<ManualWinnerDialog> {
|
||||
String? _selectedTeamId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedTeamId = widget.matchup.winnerTeamId;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
insetPadding: EdgeInsets.symmetric(horizontal: 22.w),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18.r)),
|
||||
content: SizedBox(
|
||||
width: 320.w,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/images/image_dialog_bg.png',
|
||||
width: 320.w,
|
||||
height: 112.h,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(20.w, 8.h, 20.w, 20.h),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'请在比赛结束前处理',
|
||||
key: const ValueKey('manual-winner-dialog-title'),
|
||||
style: TextStyle(
|
||||
fontSize: 20.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF20242B),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
_TeamChoice(
|
||||
team: widget.matchup.teamA,
|
||||
color: const Color(0xFFFF6B75),
|
||||
selected: _selectedTeamId == widget.matchup.teamA.id,
|
||||
onTap: () => setState(
|
||||
() => _selectedTeamId = widget.matchup.teamA.id,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10.h),
|
||||
_TeamChoice(
|
||||
team: widget.matchup.teamB,
|
||||
color: const Color(0xFF20BFA9),
|
||||
selected: _selectedTeamId == widget.matchup.teamB.id,
|
||||
onTap: () => setState(
|
||||
() => _selectedTeamId = widget.matchup.teamB.id,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: AppButton(
|
||||
label: '取消',
|
||||
variant: AppButtonVariant.secondary,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 14.w),
|
||||
Expanded(
|
||||
child: AppButton(
|
||||
label: '确定',
|
||||
onPressed: _selectedTeamId == null
|
||||
? null
|
||||
: () => Navigator.of(
|
||||
context,
|
||||
).pop(_selectedTeamId),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TeamChoice extends StatelessWidget {
|
||||
const _TeamChoice({
|
||||
required this.team,
|
||||
required this.color,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final CompetitionTeam team;
|
||||
final Color color;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Semantics(
|
||||
selected: selected,
|
||||
button: true,
|
||||
label: '选择${team.name}直接获胜',
|
||||
child: Material(
|
||||
color: color.withValues(alpha: selected ? 0.16 : 0.08),
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
child: InkWell(
|
||||
key: ValueKey('winner-choice-${team.id}'),
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.symmetric(horizontal: 14.w, vertical: 12.h),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12.r),
|
||||
border: Border.all(
|
||||
color: selected ? color : color.withValues(alpha: 0.35),
|
||||
width: selected ? 2 : 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 : Colors.grey),
|
||||
color: selected ? color : Colors.transparent,
|
||||
),
|
||||
child: selected
|
||||
? Icon(Icons.check, size: 15.r, color: Colors.white)
|
||||
: null,
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
team.name,
|
||||
style: TextStyle(
|
||||
fontSize: 17.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF20242B),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 4.h),
|
||||
Text(
|
||||
team.playerNames,
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF606874),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Text(
|
||||
'直接获胜',
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,366 @@
|
||||
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/features/competition_teams/pages/page_event_team_match.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/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 {
|
||||
// debugPrint('item tapped: ${item.itemName}');
|
||||
// await ref.read(eventInfoProvider.notifier).requestStreamKey(item);
|
||||
if (!mounted) return;
|
||||
// final profile = ref.read(eventInfoProvider).profile;
|
||||
|
||||
AppNavigator.push(
|
||||
buildEventRegistrationDestination(item: item, playerId: widget.playerId),
|
||||
context: context,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(eventInfoProvider);
|
||||
final profile = state.profile;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_Header(onBack: () => AppNavigator.pop(context: context)),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: EdgeInsets.fromLTRB(62.w, 8.h, 62.w, 40.h),
|
||||
child: Column(
|
||||
children: [
|
||||
_ProfileSection(profile: profile),
|
||||
SizedBox(height: 20.h),
|
||||
Text(
|
||||
state.eventTitle,
|
||||
style: TextStyle(
|
||||
fontSize: 20.sp,
|
||||
height: 1.2,
|
||||
color: const Color(0xFF2F2F2F),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 24.h),
|
||||
SizedBox(
|
||||
height: 400.h,
|
||||
child: _ScheduleList(
|
||||
state: state,
|
||||
onRetry: () => ref
|
||||
.read(eventInfoProvider.notifier)
|
||||
.loadRegistrationList(),
|
||||
onItemTap: onItemTap,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildEventRegistrationDestination({
|
||||
required EventRegistrationItem item,
|
||||
required String playerId,
|
||||
}) {
|
||||
if (item.opponentId.isNotEmpty && item.opponentId != '0') {
|
||||
return EventTeamMatchPage(item: item, playerId: playerId);
|
||||
}
|
||||
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: 52.h,
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: IconButton(
|
||||
onPressed: onBack,
|
||||
icon: Icon(Icons.arrow_back_ios_new, size: 32.r),
|
||||
color: Colors.black,
|
||||
tooltip: '返回',
|
||||
padding: EdgeInsets.only(left: 16.w),
|
||||
constraints: BoxConstraints(minWidth: 56.w, minHeight: 52.h),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileSection extends StatelessWidget {
|
||||
const _ProfileSection({required this.profile});
|
||||
|
||||
final EventProfile profile;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
profile.avatarUrl.isEmpty
|
||||
? AppAvatar(size: 50.r)
|
||||
: AppAvatar(size: 50.r, imageUrl: profile.avatarUrl),
|
||||
|
||||
SizedBox(width: 28.w),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: 30.h),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
profile.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 22.sp,
|
||||
height: 1.2,
|
||||
color: const Color(0xFF2F2F2F),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 36.h),
|
||||
Text(
|
||||
profile.phone,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 22.sp,
|
||||
height: 1.2,
|
||||
color: const Color(0xFF2F2F2F),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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: 18.sp, color: const Color(0xFF2F2F2F)),
|
||||
),
|
||||
SizedBox(height: 18.h),
|
||||
TextButton(onPressed: onRetry, child: const Text('重新加载')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: state.items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = state.items[index];
|
||||
return _ScheduleCard(item: item, onTap: () => onItemTap(item));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScheduleCard extends StatelessWidget {
|
||||
const _ScheduleCard({required this.item, required this.onTap});
|
||||
|
||||
final EventRegistrationItem item;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(minHeight: 138.h),
|
||||
padding: EdgeInsets.fromLTRB(10.w, 14.h, 14.w, 0),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: const Color(0xFF7A7A7A)),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.groupName.isEmpty
|
||||
? item.itemName
|
||||
: '${item.itemName} (${item.groupName})',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 20.sp,
|
||||
height: 1.2,
|
||||
color: const Color(0xFF2F2F2F),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 26.h),
|
||||
Text(
|
||||
item.matchPlace,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 20.sp,
|
||||
height: 1.2,
|
||||
color: const Color(0xFF2F2F2F),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 26.h),
|
||||
Text(
|
||||
item.scheduleTime,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 20.sp,
|
||||
height: 1.2,
|
||||
color: const Color(0xFF2F2F2F),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: 12.w),
|
||||
SizedBox(
|
||||
width: 148.w,
|
||||
height: 120.h,
|
||||
child: Align(
|
||||
alignment: Alignment.center,
|
||||
child: _ScheduleBadge(item: item),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScheduleBadge extends StatelessWidget {
|
||||
const _ScheduleBadge({required this.item});
|
||||
|
||||
final EventRegistrationItem item;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (item.statusLabel != null) {
|
||||
return CustomPaint(
|
||||
painter: _CutCornerBorderPainter(color: const Color(0xFF7A7A7A)),
|
||||
child: SizedBox(
|
||||
width: 148.w,
|
||||
height: 90.h,
|
||||
child: Center(
|
||||
child: Text(
|
||||
item.statusLabel!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontSize: 20.sp, color: const Color(0xFF2F2F2F)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Text(
|
||||
item.matchPlace,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 34.sp,
|
||||
height: 1,
|
||||
color: const Color(0xFF2F2F2F),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CutCornerBorderPainter extends CustomPainter {
|
||||
const _CutCornerBorderPainter({required this.color});
|
||||
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final side = 12.r;
|
||||
final path = Path()
|
||||
..moveTo(side, 0)
|
||||
..lineTo(size.width - side, 0)
|
||||
..lineTo(size.width, side)
|
||||
..lineTo(size.width, size.height - side)
|
||||
..lineTo(size.width - side, size.height)
|
||||
..lineTo(side, size.height)
|
||||
..lineTo(0, size.height - side)
|
||||
..lineTo(0, side)
|
||||
..close();
|
||||
final paint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1
|
||||
..color = color;
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _CutCornerBorderPainter oldDelegate) {
|
||||
return oldDelegate.color != color;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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';
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
class RecordingModel {
|
||||
/// 剪切板内容
|
||||
final ClipboardRecordingModel clipboardRecordingModel;
|
||||
|
||||
/// 剪切板是否包含有效的小程序录制信息
|
||||
final bool hasValidClipboardInfo;
|
||||
/// 从赛事项进入录制页时传入的录制上下文。
|
||||
final RecordingContext recordingContext;
|
||||
|
||||
/// 录制会话状态
|
||||
final RecordingSessionState session;
|
||||
|
||||
RecordingModel({
|
||||
required this.clipboardRecordingModel,
|
||||
this.hasValidClipboardInfo = false,
|
||||
required this.recordingContext,
|
||||
this.session = const RecordingSessionState(),
|
||||
});
|
||||
|
||||
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({
|
||||
ClipboardRecordingModel? clipboardRecordingModel,
|
||||
bool? hasValidClipboardInfo,
|
||||
RecordingContext? recordingContext,
|
||||
RecordingSessionState? session,
|
||||
}) {
|
||||
return RecordingModel(
|
||||
clipboardRecordingModel:
|
||||
clipboardRecordingModel ?? this.clipboardRecordingModel,
|
||||
hasValidClipboardInfo:
|
||||
hasValidClipboardInfo ?? this.hasValidClipboardInfo,
|
||||
recordingContext: recordingContext ?? this.recordingContext,
|
||||
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.isPreviewReady = false,
|
||||
this.isStartingRecording = false,
|
||||
this.isSwitchingLens = false,
|
||||
this.hasDndAccess = false,
|
||||
this.isBatteryOptimizedIgnored = true,
|
||||
this.notificationsGranted = true,
|
||||
this.isMicrophoneGranted = false,
|
||||
this.lastOutputPath,
|
||||
this.lastSavedDisplayName,
|
||||
this.zoomRatio = 1.0,
|
||||
this.minZoomRatio = 1.0,
|
||||
this.maxZoomRatio = 3.0,
|
||||
this.lastStreamUrl,
|
||||
this.errorMessage,
|
||||
this.permissionWarning,
|
||||
this.gallerySaveFailed = false,
|
||||
this.streamFinished = false,
|
||||
this.streamFailed = false,
|
||||
});
|
||||
|
||||
final RecordingStatus status;
|
||||
final bool isTouchLocked;
|
||||
final bool isPreviewReady;
|
||||
final bool isStartingRecording;
|
||||
final bool isSwitchingLens;
|
||||
final bool hasDndAccess;
|
||||
final bool isBatteryOptimizedIgnored;
|
||||
final bool notificationsGranted;
|
||||
final bool isMicrophoneGranted;
|
||||
final String? lastOutputPath;
|
||||
final String? lastSavedDisplayName;
|
||||
final double zoomRatio;
|
||||
final double minZoomRatio;
|
||||
final double maxZoomRatio;
|
||||
final String? lastStreamUrl;
|
||||
final String? errorMessage;
|
||||
final String? permissionWarning;
|
||||
final bool gallerySaveFailed;
|
||||
final bool streamFinished;
|
||||
final bool streamFailed;
|
||||
|
||||
bool get isRecording => status.isRecording;
|
||||
|
||||
@@ -47,37 +55,47 @@ class RecordingSessionState {
|
||||
bool? isTouchLocked,
|
||||
bool? isPreviewReady,
|
||||
bool? isStartingRecording,
|
||||
bool? isSwitchingLens,
|
||||
bool? hasDndAccess,
|
||||
bool? isBatteryOptimizedIgnored,
|
||||
bool? notificationsGranted,
|
||||
bool? isMicrophoneGranted,
|
||||
String? lastOutputPath,
|
||||
String? lastSavedDisplayName,
|
||||
double? zoomRatio,
|
||||
double? minZoomRatio,
|
||||
double? maxZoomRatio,
|
||||
String? lastStreamUrl,
|
||||
String? errorMessage,
|
||||
String? permissionWarning,
|
||||
bool? gallerySaveFailed,
|
||||
bool? streamFinished,
|
||||
bool? streamFailed,
|
||||
bool clearPermissionWarning = false,
|
||||
bool clearLastSaved = false,
|
||||
bool clearStreamResult = false,
|
||||
}) {
|
||||
return RecordingSessionState(
|
||||
status: status ?? this.status,
|
||||
isTouchLocked: isTouchLocked ?? this.isTouchLocked,
|
||||
isPreviewReady: isPreviewReady ?? this.isPreviewReady,
|
||||
isStartingRecording: isStartingRecording ?? this.isStartingRecording,
|
||||
isSwitchingLens: isSwitchingLens ?? this.isSwitchingLens,
|
||||
hasDndAccess: hasDndAccess ?? this.hasDndAccess,
|
||||
isBatteryOptimizedIgnored:
|
||||
isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored,
|
||||
notificationsGranted: notificationsGranted ?? this.notificationsGranted,
|
||||
isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted,
|
||||
lastOutputPath: lastOutputPath ?? this.lastOutputPath,
|
||||
lastSavedDisplayName: clearLastSaved
|
||||
? null
|
||||
: (lastSavedDisplayName ?? this.lastSavedDisplayName),
|
||||
zoomRatio: zoomRatio ?? this.zoomRatio,
|
||||
minZoomRatio: minZoomRatio ?? this.minZoomRatio,
|
||||
maxZoomRatio: maxZoomRatio ?? this.maxZoomRatio,
|
||||
lastStreamUrl: lastStreamUrl ?? this.lastStreamUrl,
|
||||
errorMessage: errorMessage,
|
||||
permissionWarning: clearPermissionWarning
|
||||
? null
|
||||
: (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 'package:apivideo_live_stream/apivideo_live_stream.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:recording_tool/app/router/app_navigator.dart';
|
||||
import 'package:recording_tool/core/platform/app_platform_info.dart';
|
||||
import 'package:recording_tool/core/platform/device_health_checker.dart';
|
||||
import 'package:recording_tool/features/dialog/dialog-record.dart';
|
||||
import 'package:recording_tool/features/recording/model/model_recording.dart';
|
||||
import 'package:recording_tool/features/recording/dialog/dialog-record.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/utils/recording_display_name.dart';
|
||||
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/widget_camera_preview.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/widget_record_footer.dart';
|
||||
@@ -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_saved_dialog.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';
|
||||
|
||||
/// 录制页入口
|
||||
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
|
||||
/// 创建页面状态
|
||||
@@ -31,13 +41,60 @@ class RecordingPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
late final ApiVideoLiveStreamController _streamController;
|
||||
var _immersiveApplied = false;
|
||||
var _previewReady = false;
|
||||
var _stoppingByUser = false;
|
||||
var _controllerDisposed = false;
|
||||
String? _mainCameraId;
|
||||
String? _ultraWideCameraId;
|
||||
double _ultraWideZoomRatio = 1.0;
|
||||
|
||||
@override
|
||||
/// 首帧后初始化录制流程
|
||||
void 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 {
|
||||
await _checkAndShowDeviceHealthAlerts();
|
||||
if (!mounted) return;
|
||||
|
||||
final clipboardResult = await ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
if (!mounted) return;
|
||||
if (clipboardResult == ClipboardReadResult.invalid) {
|
||||
AppToast.show('无选手信息');
|
||||
}
|
||||
await _enterRecordingMode();
|
||||
// Allow PlatformView to attach before binding CameraX preview.
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
if (!mounted) return;
|
||||
await ref.read(recordingViewModelProvider.notifier).prepareSession();
|
||||
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 进入沉浸式全屏
|
||||
@@ -82,43 +197,6 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
_immersiveApplied = true;
|
||||
}
|
||||
|
||||
/// 解析保存成功弹窗的标题文案
|
||||
String _savedDialogSessionTitle(
|
||||
RecordingModel recordingInfo,
|
||||
String? savedName,
|
||||
) {
|
||||
final clipboard = recordingInfo.clipboardRecordingModel;
|
||||
if (recordingInfo.hasValidClipboardInfo &&
|
||||
clipboard.title.trim().isNotEmpty) {
|
||||
return clipboard.title.trim();
|
||||
}
|
||||
if (savedName != null && savedName.isNotEmpty) {
|
||||
return resolveRecordingDisplayName(savedName);
|
||||
}
|
||||
return '录制完成';
|
||||
}
|
||||
|
||||
/// 从剪贴板粘贴赛事信息(与 header「粘贴选手信息」一致)。
|
||||
Future<void> _pasteEventInfo() async {
|
||||
final result = await ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
if (!mounted) return;
|
||||
if (result != ClipboardReadResult.success) {
|
||||
AppToast.show('无选手信息');
|
||||
}
|
||||
}
|
||||
|
||||
/// 无选手信息时弹窗提示
|
||||
Future<void> _showNoPlayerInfoDialog() {
|
||||
return RecordDialog.showSingle(
|
||||
context,
|
||||
title: '无选手信息!',
|
||||
buttonText: '粘贴',
|
||||
onPressed: _pasteEventInfo,
|
||||
);
|
||||
}
|
||||
|
||||
/// 根据缺失权限生成弹窗文案。
|
||||
String _recordingPermissionDialogTitle(RecordingRequiredPermissions result) {
|
||||
if (!result.cameraGranted && !result.microphoneGranted) {
|
||||
@@ -153,61 +231,109 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 点击开始录制:校验剪贴板、权限与健康状态
|
||||
/// 点击开始录制:校验推流地址、权限与健康状态
|
||||
Future<void> _onStartRecording() async {
|
||||
final recordingInfo = ref.read(recordingViewModelProvider);
|
||||
if (!recordingInfo.hasClipboardFilename) {
|
||||
await _showNoPlayerInfoDialog();
|
||||
if (widget.streamUrl.trim().isEmpty) {
|
||||
AppToast.show('推流地址为空,请重新进入录制页');
|
||||
return;
|
||||
}
|
||||
if (!await _ensureRecordingPermissions()) return;
|
||||
if (!mounted) return;
|
||||
await _checkAndShowDeviceHealthAlerts();
|
||||
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 {
|
||||
await ref.read(recordingViewModelProvider.notifier).stopRecording();
|
||||
final viewModel = ref.read(recordingViewModelProvider.notifier);
|
||||
_stoppingByUser = true;
|
||||
try {
|
||||
await _streamController.stopStreaming();
|
||||
if (!mounted) return;
|
||||
final latest = ref.read(recordingViewModelProvider).session;
|
||||
if (latest.gallerySaveFailed) {
|
||||
AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限');
|
||||
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;
|
||||
}
|
||||
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() {
|
||||
final notifier = ref.read(recordingViewModelProvider.notifier);
|
||||
notifier.resetClipboardInfo();
|
||||
notifier.clearSavedRecordingResult();
|
||||
/// 返回扫码页,准备新一轮录制。
|
||||
void _recordNewRound() {
|
||||
AppNavigator.pushAndRemoveUntil(const ScanQrCodePage());
|
||||
}
|
||||
|
||||
/// 保存成功后按需弹出完成对话框
|
||||
Future<void> _showRecordingSavedDialogIfNeeded() async {
|
||||
/// 推流结束后按需弹出完成对话框
|
||||
Future<void> _showRecordingFinishedDialogIfNeeded() async {
|
||||
final recordingInfo = ref.read(recordingViewModelProvider);
|
||||
final session = recordingInfo.session;
|
||||
if (session.lastSavedDisplayName == null || session.gallerySaveFailed) {
|
||||
if (!session.streamFinished || session.streamFailed) {
|
||||
return;
|
||||
}
|
||||
|
||||
final sessionTitle = _savedDialogSessionTitle(
|
||||
recordingInfo,
|
||||
session.lastSavedDisplayName,
|
||||
);
|
||||
|
||||
await showRecordingSavedDialog(
|
||||
context,
|
||||
sessionTitle: sessionTitle,
|
||||
sessionTitle: recordingInfo.recordingContext.title,
|
||||
onContinueRound: () {
|
||||
ref
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.clearSavedRecordingResult();
|
||||
},
|
||||
onRecordNewRound: _clearClipboardForNewRound,
|
||||
onRecordNewRound: _recordNewRound,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -215,6 +341,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
Future<void> _exitRecordingMode() async {
|
||||
if (!_immersiveApplied) return;
|
||||
await ref.read(recordingViewModelProvider.notifier).teardown();
|
||||
await _disposeStreamController();
|
||||
await SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values,
|
||||
@@ -233,9 +360,17 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
);
|
||||
RecordingPlatform.setImmersiveMode(enabled: false);
|
||||
}
|
||||
_disposeStreamController();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _disposeStreamController() async {
|
||||
if (_controllerDisposed) return;
|
||||
_controllerDisposed = true;
|
||||
await _streamController.stop();
|
||||
await _streamController.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
/// 构建录制页 UI
|
||||
Widget build(BuildContext context) {
|
||||
@@ -245,19 +380,20 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
|
||||
backgroundColor: Colors.black,
|
||||
body: Column(
|
||||
children: [
|
||||
_RecordHeaderSection(
|
||||
onPasteEventInfo: _pasteEventInfo,
|
||||
onClearEventInfo: _clearClipboardForNewRound,
|
||||
),
|
||||
const _RecordHeaderSection(),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
const CameraPreviewWidget(),
|
||||
CameraPreviewWidget(
|
||||
controller: _streamController,
|
||||
isReady: _previewReady,
|
||||
),
|
||||
const _PreviewLoadingLayer(),
|
||||
const RecordTimerWidget(),
|
||||
_RecordingHudLayer(
|
||||
onStart: _onStartRecording,
|
||||
onStop: _stopRecordingAndShowResult,
|
||||
onZoomSelected: _setCameraZoomRatio,
|
||||
),
|
||||
_TouchLockOverlayLayer(
|
||||
onStopRecording: _stopRecordingAndShowResult,
|
||||
@@ -306,34 +442,18 @@ class _RecordingPopScope extends ConsumerWidget {
|
||||
}
|
||||
|
||||
class _RecordHeaderSection extends ConsumerWidget {
|
||||
const _RecordHeaderSection({
|
||||
required this.onPasteEventInfo,
|
||||
required this.onClearEventInfo,
|
||||
});
|
||||
|
||||
final Future<void> Function() onPasteEventInfo;
|
||||
final VoidCallback onClearEventInfo;
|
||||
const _RecordHeaderSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final headerState = ref.watch(
|
||||
recordingViewModelProvider.select(
|
||||
(m) => (
|
||||
m.hasValidClipboardInfo,
|
||||
m.hasValidClipboardInfo ? m.clipboardRecordingModel.title : null,
|
||||
m.session.isRecording,
|
||||
),
|
||||
(m) => (m.recordingContext.title, m.session.isRecording),
|
||||
),
|
||||
);
|
||||
final (hasValidClipboardInfo, eventTitle, isRecording) = headerState;
|
||||
final (eventTitle, isRecording) = headerState;
|
||||
|
||||
return RecordHeaderWidget(
|
||||
hasValidClipboardInfo: hasValidClipboardInfo,
|
||||
eventTitle: eventTitle,
|
||||
isRecording: isRecording,
|
||||
onPasteEventInfo: onPasteEventInfo,
|
||||
onClearEventInfo: onClearEventInfo,
|
||||
);
|
||||
return RecordHeaderWidget(eventTitle: eventTitle, isRecording: isRecording);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,10 +480,12 @@ class _RecordingHudLayer extends ConsumerWidget {
|
||||
const _RecordingHudLayer({
|
||||
required this.onStart,
|
||||
required this.onStop,
|
||||
required this.onZoomSelected,
|
||||
});
|
||||
|
||||
final Future<void> Function() onStart;
|
||||
final Future<void> Function() onStop;
|
||||
final ValueChanged<double> onZoomSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -377,9 +499,12 @@ class _RecordingHudLayer extends ConsumerWidget {
|
||||
m.session.notificationsGranted,
|
||||
m.session.isRecording,
|
||||
m.session.isStartingRecording,
|
||||
m.session.isSwitchingLens,
|
||||
m.session.isTouchLocked,
|
||||
m.hasValidClipboardInfo,
|
||||
m.clipboardRecordingModel.address.trim(),
|
||||
m.session.zoomRatio,
|
||||
m.session.minZoomRatio,
|
||||
m.session.maxZoomRatio,
|
||||
m.recordingContext.address.trim(),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -391,9 +516,12 @@ class _RecordingHudLayer extends ConsumerWidget {
|
||||
notificationsGranted,
|
||||
isRecording,
|
||||
isStartingRecording,
|
||||
isSwitchingLens,
|
||||
isTouchLocked,
|
||||
showClipboardHint,
|
||||
clipboardAddress,
|
||||
zoomRatio,
|
||||
minZoomRatio,
|
||||
maxZoomRatio,
|
||||
venue,
|
||||
) = hudState;
|
||||
final viewModel = ref.read(recordingViewModelProvider.notifier);
|
||||
|
||||
@@ -405,9 +533,13 @@ class _RecordingHudLayer extends ConsumerWidget {
|
||||
notificationsGranted: notificationsGranted,
|
||||
isRecording: isRecording,
|
||||
isStartingRecording: isStartingRecording,
|
||||
isSwitchingLens: isSwitchingLens,
|
||||
isTouchLocked: isTouchLocked,
|
||||
showClipboardHint: showClipboardHint,
|
||||
clipboardAddress: clipboardAddress,
|
||||
zoomRatio: zoomRatio,
|
||||
minZoomRatio: minZoomRatio,
|
||||
maxZoomRatio: maxZoomRatio,
|
||||
showContextHint: venue.isNotEmpty,
|
||||
contextAddress: venue,
|
||||
onStart: onStart,
|
||||
onStop: onStop,
|
||||
onOpenDnd: () async {
|
||||
@@ -419,9 +551,13 @@ class _RecordingHudLayer extends ConsumerWidget {
|
||||
await viewModel.refreshBatteryOptimization();
|
||||
},
|
||||
onToggleTouchLock: () {
|
||||
final locked = ref.read(recordingViewModelProvider).session.isTouchLocked;
|
||||
final locked = ref
|
||||
.read(recordingViewModelProvider)
|
||||
.session
|
||||
.isTouchLocked;
|
||||
viewModel.setTouchLocked(!locked);
|
||||
},
|
||||
onZoomSelected: onZoomSelected,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -472,7 +608,7 @@ class _StartingRecordingOverlay extends ConsumerWidget {
|
||||
}
|
||||
|
||||
return RecordingLoadingOverlayWidget(
|
||||
message: '正在开始录制…',
|
||||
message: '正在连接推流…',
|
||||
backgroundColor: Colors.black.withValues(alpha: 0.24),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
abstract final class RecordingChannelNames {
|
||||
static const packageName = 'com.qxy.dronex';
|
||||
static const method = '$packageName/recording';
|
||||
static const events = '$packageName/recording_events';
|
||||
static const namespace = 'app.record_tool';
|
||||
static const method = '$namespace/recording';
|
||||
static const events = '$namespace/recording_events';
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -22,20 +21,20 @@ enum RecordingState {
|
||||
class RecordingStatus {
|
||||
const RecordingStatus({
|
||||
required this.state,
|
||||
this.outputPath,
|
||||
this.streamUrl,
|
||||
this.elapsedMillis = 0,
|
||||
this.message,
|
||||
});
|
||||
|
||||
final RecordingState state;
|
||||
final String? outputPath;
|
||||
final String? streamUrl;
|
||||
final int elapsedMillis;
|
||||
final String? message;
|
||||
|
||||
factory RecordingStatus.fromMap(Map<dynamic, dynamic> map) {
|
||||
return RecordingStatus(
|
||||
state: RecordingState.fromRaw(map['state'] as String?),
|
||||
outputPath: map['outputPath'] as String?,
|
||||
streamUrl: map['streamUrl'] as String?,
|
||||
elapsedMillis: (map['elapsedMillis'] as num?)?.toInt() ?? 0,
|
||||
message: map['message'] as String?,
|
||||
);
|
||||
@@ -50,9 +49,6 @@ class RecordingPlatform {
|
||||
static const MethodChannel _channel = MethodChannel(
|
||||
RecordingChannelNames.method,
|
||||
);
|
||||
static const EventChannel _events = EventChannel(
|
||||
RecordingChannelNames.events,
|
||||
);
|
||||
|
||||
static bool get isSupported =>
|
||||
supportsHost(isAndroid: Platform.isAndroid, isIOS: Platform.isIOS);
|
||||
@@ -61,61 +57,6 @@ class RecordingPlatform {
|
||||
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 {
|
||||
return await _channel.invokeMethod<bool>('hasNotificationPolicyAccess') ??
|
||||
false;
|
||||
@@ -149,41 +90,4 @@ class RecordingPlatform {
|
||||
'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:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:recording_tool/core/logging/app_logger.dart';
|
||||
import 'package:recording_tool/core/permission/permission_service.dart';
|
||||
import 'package:recording_tool/features/recording/model/model_clipboard.dart';
|
||||
import 'package:recording_tool/features/recording/model/model_recording.dart';
|
||||
import 'package:recording_tool/features/recording/model/model_recording_context.dart';
|
||||
import 'package:recording_tool/features/recording/model/model_recording_session.dart';
|
||||
import 'package:recording_tool/features/recording/platform/recording_platform.dart';
|
||||
import 'package:recording_tool/features/recording/utils/recording_display_name.dart';
|
||||
|
||||
/// 录制页状态 Provider。
|
||||
final recordingViewModelProvider =
|
||||
@@ -19,31 +15,6 @@ final recordingViewModelProvider =
|
||||
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 {
|
||||
const RecordingRequiredPermissions({
|
||||
@@ -57,20 +28,16 @@ class RecordingRequiredPermissions {
|
||||
bool get allGranted => cameraGranted && microphoneGranted;
|
||||
}
|
||||
|
||||
/// 录制页 ViewModel:剪贴板、权限、相机预览与录制流程。
|
||||
/// 录制页 ViewModel:赛事上下文、权限、推流与页面状态。
|
||||
class RecordingViewModel extends Notifier<RecordingModel> {
|
||||
static final _defaultClipboard = ClipboardRecordingModel(
|
||||
title: '',
|
||||
address: '',
|
||||
);
|
||||
|
||||
StreamSubscription<RecordingStatus>? _statusSubscription;
|
||||
Timer? _elapsedTimer;
|
||||
DateTime? _recordingStartedAt;
|
||||
|
||||
/// 初始化状态并注册销毁回调。
|
||||
@override
|
||||
RecordingModel build() {
|
||||
ref.onDispose(_dispose);
|
||||
return RecordingModel(clipboardRecordingModel: _defaultClipboard);
|
||||
return RecordingModel(recordingContext: const RecordingContext.empty());
|
||||
}
|
||||
|
||||
/// 局部更新 session 子状态。
|
||||
@@ -80,64 +47,12 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
||||
state = state.copyWith(session: update(state.session));
|
||||
}
|
||||
|
||||
/// 读取并解析剪贴板中的小程序录制信息。
|
||||
Future<ClipboardReadResult> getClipboardContent() async {
|
||||
try {
|
||||
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;
|
||||
/// 设置当前录制页上下文。
|
||||
void setRecordingContext(RecordingContext recordingContext) {
|
||||
state = state.copyWith(recordingContext: recordingContext);
|
||||
}
|
||||
|
||||
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 {
|
||||
if (!RecordingPlatform.isSupported) {
|
||||
_updateSession((s) => s.copyWith(errorMessage: '当前设备不支持录制'));
|
||||
@@ -148,7 +63,6 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
||||
Permission.camera,
|
||||
Permission.microphone,
|
||||
if (Platform.isAndroid) Permission.notification,
|
||||
..._galleryPermissions(),
|
||||
]);
|
||||
|
||||
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
|
||||
@@ -170,10 +84,6 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
||||
if (!microphoneGranted) {
|
||||
warnings.add('未授予麦克风权限,当前将以静音模式录制');
|
||||
}
|
||||
if (!_isGalleryPermissionGranted(permissions)) {
|
||||
warnings.add('未授予相册权限,录制结束后可能无法保存到相册');
|
||||
}
|
||||
|
||||
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
|
||||
final batteryIgnored =
|
||||
await RecordingPlatform.isIgnoringBatteryOptimizations();
|
||||
@@ -190,92 +100,13 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
||||
),
|
||||
);
|
||||
|
||||
await _listenStatus();
|
||||
try {
|
||||
final status = await _initializePreviewWithRetry();
|
||||
_updateSession(
|
||||
(s) => s.copyWith(
|
||||
status: status,
|
||||
isPreviewReady: status.state == RecordingState.previewing,
|
||||
errorMessage: status.state == RecordingState.previewing
|
||||
? null
|
||||
: (status.message ?? '相机预览初始化失败'),
|
||||
),
|
||||
);
|
||||
} on PlatformException catch (error) {
|
||||
_updateSession(
|
||||
(s) => s.copyWith(
|
||||
isPreviewReady: false,
|
||||
errorMessage: error.message ?? '相机预览初始化失败',
|
||||
),
|
||||
);
|
||||
}
|
||||
_updateSession((s) => s.copyWith(errorMessage: null));
|
||||
}
|
||||
|
||||
/// 初始化相机预览,PlatformView 未就绪时自动重试。
|
||||
Future<RecordingStatus> _initializePreviewWithRetry() async {
|
||||
const maxAttempts = 8;
|
||||
for (var attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
return await RecordingPlatform.initializePreview();
|
||||
} on PlatformException catch (error) {
|
||||
final shouldRetry =
|
||||
error.code == 'NO_PREVIEW' && attempt < maxAttempts - 1;
|
||||
if (!shouldRetry) {
|
||||
rethrow;
|
||||
}
|
||||
await Future<void>.delayed(Duration(milliseconds: 150 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
throw StateError('initializePreview retry exhausted');
|
||||
}
|
||||
|
||||
/// 停止录制后重新绑定相机预览,并显示加载遮罩。
|
||||
Future<void> restorePreview() async {
|
||||
if (!RecordingPlatform.isSupported) return;
|
||||
|
||||
void setPreviewReady({required bool ready, String? errorMessage}) {
|
||||
_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。
|
||||
@@ -295,8 +126,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
||||
|
||||
if (cameraGranted && !state.session.isPreviewReady) {
|
||||
_updateSession((s) => s.copyWith(errorMessage: null));
|
||||
await _listenStatus();
|
||||
await restorePreview();
|
||||
_updateSession((s) => s.copyWith(isPreviewReady: true));
|
||||
}
|
||||
|
||||
return RecordingRequiredPermissions(
|
||||
@@ -309,76 +139,115 @@ class RecordingViewModel extends Notifier<RecordingModel> {
|
||||
return status?.isGranted == true || status?.isLimited == true;
|
||||
}
|
||||
|
||||
/// 开始录制,可选开启勿扰模式。
|
||||
Future<void> startRecording({bool enableDoNotDisturb = true}) async {
|
||||
final session = state.session;
|
||||
if (session.isRecording || session.isStartingRecording) {
|
||||
return;
|
||||
}
|
||||
if (!session.isPreviewReady) {
|
||||
_updateSession((s) => s.copyWith(errorMessage: '相机预览未就绪,请稍后重试'));
|
||||
return;
|
||||
}
|
||||
|
||||
final displayName = recordingFileNameForPlatform(
|
||||
state.clipboardRecordingModel.filename,
|
||||
);
|
||||
|
||||
_updateSession(
|
||||
(s) => s.copyWith(isStartingRecording: true, errorMessage: null),
|
||||
);
|
||||
try {
|
||||
final result = await RecordingPlatform.startRecording(
|
||||
enableDoNotDisturb: enableDoNotDisturb && state.session.hasDndAccess,
|
||||
displayName: displayName,
|
||||
);
|
||||
void updateZoomCapabilities({
|
||||
required double zoomRatio,
|
||||
required double minZoomRatio,
|
||||
required double maxZoomRatio,
|
||||
}) {
|
||||
_updateSession(
|
||||
(s) => s.copyWith(
|
||||
status: result.status,
|
||||
lastOutputPath: result.outputPath,
|
||||
zoomRatio: zoomRatio,
|
||||
minZoomRatio: minZoomRatio,
|
||||
maxZoomRatio: maxZoomRatio,
|
||||
errorMessage: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void markLensSwitching(bool switching) {
|
||||
_updateSession((s) => s.copyWith(isSwitchingLens: switching));
|
||||
}
|
||||
|
||||
void setZoomRatioValue(double ratio) {
|
||||
_updateSession((s) => s.copyWith(zoomRatio: ratio, errorMessage: null));
|
||||
}
|
||||
|
||||
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(
|
||||
(s) => s.copyWith(
|
||||
status: RecordingStatus(
|
||||
state: RecordingState.recording,
|
||||
streamUrl: streamUrl,
|
||||
elapsedMillis: elapsed,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
_updateSession(
|
||||
(s) => s.copyWith(
|
||||
status: RecordingStatus(
|
||||
state: RecordingState.recording,
|
||||
streamUrl: streamUrl,
|
||||
),
|
||||
lastStreamUrl: streamUrl,
|
||||
isStartingRecording: false,
|
||||
isTouchLocked: true,
|
||||
errorMessage: null,
|
||||
gallerySaveFailed: false,
|
||||
clearLastSaved: true,
|
||||
streamFailed: false,
|
||||
clearStreamResult: true,
|
||||
),
|
||||
);
|
||||
} on PlatformException catch (error) {
|
||||
_updateSession(
|
||||
(s) => s.copyWith(errorMessage: error.message ?? '开始录制失败'),
|
||||
);
|
||||
} finally {
|
||||
_updateSession((s) => s.copyWith(isStartingRecording: false));
|
||||
}
|
||||
}
|
||||
|
||||
/// 停止录制、保存到相册,并恢复相机预览。
|
||||
Future<void> stopRecording() async {
|
||||
if (!state.session.isRecording) return;
|
||||
|
||||
try {
|
||||
final result = await RecordingPlatform.stopRecording();
|
||||
final galleryFailed = !result.gallerySaved;
|
||||
final savedName = recordingFileNameForPlatform(
|
||||
state.clipboardRecordingModel.filename,
|
||||
);
|
||||
void markRecordingStartFailed(String message) {
|
||||
_recordingStartedAt = null;
|
||||
_elapsedTimer?.cancel();
|
||||
_elapsedTimer = null;
|
||||
_updateSession(
|
||||
(s) => s.copyWith(
|
||||
status: result.status,
|
||||
lastOutputPath: result.outputPath ?? s.lastOutputPath,
|
||||
lastSavedDisplayName: galleryFailed ? null : savedName,
|
||||
errorMessage: galleryFailed
|
||||
? (result.galleryErrorMessage ?? '保存到相册失败,请开启相册权限')
|
||||
: null,
|
||||
gallerySaveFailed: galleryFailed,
|
||||
status: const RecordingStatus(state: RecordingState.previewing),
|
||||
isStartingRecording: false,
|
||||
errorMessage: message,
|
||||
streamFailed: true,
|
||||
),
|
||||
);
|
||||
} on PlatformException catch (error) {
|
||||
_updateSession(
|
||||
(s) => s.copyWith(errorMessage: error.message ?? '停止录制失败'),
|
||||
);
|
||||
} finally {
|
||||
await restorePreview();
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
/// 清除上次保存成功的录制结果标记。
|
||||
/// 清除上次推流完成结果标记。
|
||||
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 {
|
||||
await RecordingPlatform.setImmersiveMode(enabled: false);
|
||||
await RecordingPlatform.disableDoNotDisturb();
|
||||
await RecordingPlatform.disposePreview();
|
||||
await _statusSubscription?.cancel();
|
||||
_statusSubscription = null;
|
||||
_recordingStartedAt = null;
|
||||
_elapsedTimer?.cancel();
|
||||
_elapsedTimer = null;
|
||||
state = state.copyWith(session: const RecordingSessionState());
|
||||
}
|
||||
|
||||
/// 订阅原生层录制状态流并同步到 session。
|
||||
Future<void> _listenStatus() async {
|
||||
await _statusSubscription?.cancel();
|
||||
_statusSubscription = RecordingPlatform.statusStream().listen((status) {
|
||||
_updateSession((s) => s.copyWith(status: status));
|
||||
});
|
||||
}
|
||||
|
||||
/// Provider 销毁时取消状态流订阅。
|
||||
Future<void> _dispose() async {
|
||||
await _statusSubscription?.cancel();
|
||||
_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/services.dart';
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
if (Platform.isAndroid) {
|
||||
return AndroidView(
|
||||
viewType: 'recording-camera-preview',
|
||||
layoutDirection: TextDirection.ltr,
|
||||
creationParams: const <String, dynamic>{},
|
||||
creationParamsCodec: const StandardMessageCodec(),
|
||||
);
|
||||
if (!isReady) {
|
||||
return const ColoredBox(color: Colors.black);
|
||||
}
|
||||
|
||||
if (Platform.isIOS) {
|
||||
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('当前平台不支持相机预览')),
|
||||
);
|
||||
return ApiVideoCameraPreview(controller: controller, fit: BoxFit.cover);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/record_content_transition.dart';
|
||||
import 'package:recording_tool/gen/assets.gen.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_toast.dart';
|
||||
|
||||
/// 录制页顶部:Logo、粘贴赛事、赛事标题
|
||||
/// 录制页顶部:Logo、赛事标题
|
||||
class RecordHeaderWidget extends StatelessWidget {
|
||||
const RecordHeaderWidget({
|
||||
super.key,
|
||||
required this.hasValidClipboardInfo,
|
||||
this.eventTitle,
|
||||
required this.isRecording,
|
||||
required this.onPasteEventInfo,
|
||||
required this.onClearEventInfo,
|
||||
});
|
||||
|
||||
final bool hasValidClipboardInfo;
|
||||
final String? eventTitle;
|
||||
final bool isRecording;
|
||||
final Future<void> Function() onPasteEventInfo;
|
||||
final VoidCallback onClearEventInfo;
|
||||
|
||||
bool get _showPasteButtons => !hasValidClipboardInfo && !isRecording;
|
||||
|
||||
bool get _showEventTitle => hasValidClipboardInfo;
|
||||
bool get _showEventTitle => eventTitle?.trim().isNotEmpty == true;
|
||||
|
||||
Widget _buildAnimatedHeaderContent() {
|
||||
if (_showEventTitle) {
|
||||
return _HeaderEventTitleRow(
|
||||
key: ValueKey('title-${eventTitle ?? ''}'),
|
||||
title: eventTitle ?? '',
|
||||
isRecording: isRecording,
|
||||
onClearEventInfo: onClearEventInfo,
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink(key: ValueKey('header-empty'));
|
||||
}
|
||||
|
||||
void _mockCopyEventInfo() {
|
||||
const strTemp =
|
||||
'{"title":"蔡依婷vs夏志豪 空中格斗赛 初中组","address":"黑龙江省鹤岗市11111","filename":"蔡依婷_夏志豪_测试循环赛-7_空中格斗赛"}';
|
||||
Clipboard.setData(const ClipboardData(text: strTemp));
|
||||
AppToast.show('模拟复制赛事信息成功');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
@@ -75,14 +56,6 @@ class RecordHeaderWidget extends StatelessWidget {
|
||||
transitionBuilder: RecordContentTransition.builder,
|
||||
child: _buildAnimatedHeaderContent(),
|
||||
),
|
||||
if (_showPasteButtons)
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: _HeaderPasteActions(
|
||||
onMockCopy: _mockCopyEventInfo,
|
||||
onPasteEventInfo: onPasteEventInfo,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -95,16 +68,9 @@ class RecordHeaderWidget extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _HeaderEventTitleRow extends StatelessWidget {
|
||||
const _HeaderEventTitleRow({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.isRecording,
|
||||
required this.onClearEventInfo,
|
||||
});
|
||||
const _HeaderEventTitleRow({super.key, required this.title});
|
||||
|
||||
final String title;
|
||||
final bool isRecording;
|
||||
final VoidCallback onClearEventInfo;
|
||||
|
||||
static TextStyle get _overlayTextStyle => TextStyle(
|
||||
color: Colors.white,
|
||||
@@ -135,87 +101,7 @@ class _HeaderEventTitleRow extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
!isRecording
|
||||
? IconButton(
|
||||
key: const ValueKey('clear-event-info'),
|
||||
onPressed: onClearEventInfo,
|
||||
icon: Assets.images.imageDelete.image(
|
||||
width: 15.r,
|
||||
height: 15.r,
|
||||
fit: BoxFit.contain,
|
||||
excludeFromSemantics: true,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: BoxConstraints(minWidth: 40.r, minHeight: 40.r),
|
||||
alignment: Alignment.centerRight,
|
||||
tooltip: '删除',
|
||||
)
|
||||
: const SizedBox.shrink(key: ValueKey('clear-event-info-hidden')),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeaderPasteActions extends StatelessWidget {
|
||||
const _HeaderPasteActions({
|
||||
required this.onMockCopy,
|
||||
required this.onPasteEventInfo,
|
||||
});
|
||||
|
||||
final VoidCallback onMockCopy;
|
||||
final Future<void> Function() onPasteEventInfo;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
// _HeaderActionButton(label: 'mock', onPressed: onMockCopy),
|
||||
_HeaderActionButton(
|
||||
label: '粘贴选手信息',
|
||||
onPressed: () => onPasteEventInfo(),
|
||||
icon: Assets.images.imageCopy.image(
|
||||
width: 10.r,
|
||||
height: 10.r,
|
||||
fit: BoxFit.contain,
|
||||
excludeFromSemantics: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HeaderActionButton extends StatelessWidget {
|
||||
const _HeaderActionButton({
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
final Widget? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextButton.icon(
|
||||
onPressed: onPressed,
|
||||
icon: icon ?? Icon(Icons.content_paste, size: 10.r),
|
||||
label: Text(label),
|
||||
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: Size.zero, // 取消 40dp 最小高度
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap, // 取消额外点击热区
|
||||
foregroundColor: Colors.white,
|
||||
backgroundColor: Colors.black.withValues(alpha: 0.5),
|
||||
textStyle: TextStyle(fontSize: 10.sp),
|
||||
padding: EdgeInsets.symmetric(horizontal: 7.r, vertical: 4.r),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(25.r),
|
||||
side: const BorderSide(color: Colors.white30),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-12
@@ -5,19 +5,19 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:recording_tool/core/utils/date_time_formatter.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/record_content_transition.dart';
|
||||
|
||||
/// 左下角实时时钟与剪贴板地址
|
||||
class ClipboardAddressClockChipWidget extends StatefulWidget {
|
||||
const ClipboardAddressClockChipWidget({super.key, required this.address});
|
||||
/// 左下角实时时钟与赛事场地信息。
|
||||
class RecordingContextClockChipWidget extends StatefulWidget {
|
||||
const RecordingContextClockChipWidget({super.key, required this.address});
|
||||
|
||||
final String address;
|
||||
|
||||
@override
|
||||
State<ClipboardAddressClockChipWidget> createState() =>
|
||||
_ClipboardAddressClockChipWidgetState();
|
||||
State<RecordingContextClockChipWidget> createState() =>
|
||||
_RecordingContextClockChipWidgetState();
|
||||
}
|
||||
|
||||
class _ClipboardAddressClockChipWidgetState
|
||||
extends State<ClipboardAddressClockChipWidget> {
|
||||
class _RecordingContextClockChipWidgetState
|
||||
extends State<RecordingContextClockChipWidget> {
|
||||
Timer? _clockTimer;
|
||||
|
||||
static TextStyle get _textStyle => TextStyle(
|
||||
@@ -42,10 +42,8 @@ class _ClipboardAddressClockChipWidgetState
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String get _nowText => DateTimeFormatter.format(
|
||||
DateTime.now(),
|
||||
pattern: 'yyyy-M-d-H:mm:ss',
|
||||
);
|
||||
String get _nowText =>
|
||||
DateTimeFormatter.format(DateTime.now(), pattern: 'yyyy-M-d-H:mm:ss');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -71,7 +69,9 @@ class _ClipboardAddressClockChipWidgetState
|
||||
key: ValueKey(widget.address),
|
||||
style: _textStyle,
|
||||
)
|
||||
: const SizedBox.shrink(key: ValueKey('clipboard-address-empty')),
|
||||
: const SizedBox.shrink(
|
||||
key: ValueKey('recording-address-empty'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -3,8 +3,8 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:recording_tool/core/utils/rate_limiter.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/record_content_transition.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/widget_clipboard_address_clock_chip.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/widget_recording_button.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/widget_recording_context_clock_chip.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/widget_recording_setup_hints.dart';
|
||||
|
||||
/// 录制页 HUD 层(状态提示、录制控制)
|
||||
@@ -18,14 +18,19 @@ class RecordingHudWidget extends StatelessWidget {
|
||||
required this.notificationsGranted,
|
||||
required this.isRecording,
|
||||
required this.isStartingRecording,
|
||||
required this.isSwitchingLens,
|
||||
required this.isTouchLocked,
|
||||
this.showClipboardHint = false,
|
||||
this.clipboardAddress = '',
|
||||
this.showContextHint = false,
|
||||
this.contextAddress = '',
|
||||
required this.zoomRatio,
|
||||
required this.minZoomRatio,
|
||||
required this.maxZoomRatio,
|
||||
required this.onStart,
|
||||
required this.onStop,
|
||||
required this.onOpenDnd,
|
||||
required this.onOpenBattery,
|
||||
required this.onToggleTouchLock,
|
||||
required this.onZoomSelected,
|
||||
});
|
||||
|
||||
final String? errorMessage;
|
||||
@@ -35,14 +40,19 @@ class RecordingHudWidget extends StatelessWidget {
|
||||
final bool notificationsGranted;
|
||||
final bool isRecording;
|
||||
final bool isStartingRecording;
|
||||
final bool isSwitchingLens;
|
||||
final bool isTouchLocked;
|
||||
final bool showClipboardHint;
|
||||
final String clipboardAddress;
|
||||
final bool showContextHint;
|
||||
final String contextAddress;
|
||||
final double zoomRatio;
|
||||
final double minZoomRatio;
|
||||
final double maxZoomRatio;
|
||||
final Future<void> Function() onStart;
|
||||
final Future<void> Function() onStop;
|
||||
final VoidCallback onOpenDnd;
|
||||
final VoidCallback onOpenBattery;
|
||||
final VoidCallback onToggleTouchLock;
|
||||
final ValueChanged<double> onZoomSelected;
|
||||
|
||||
static double get _recordButtonSize => 70.r;
|
||||
static double get _recordButtonBottom => 63.r;
|
||||
@@ -107,12 +117,14 @@ class RecordingHudWidget extends StatelessWidget {
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
layoutBuilder: RecordContentTransition.bottomStackLayoutBuilder,
|
||||
transitionBuilder: RecordContentTransition.builder,
|
||||
child: showClipboardHint
|
||||
? ClipboardAddressClockChipWidget(
|
||||
key: const ValueKey('clipboard-info'),
|
||||
address: clipboardAddress,
|
||||
child: showContextHint
|
||||
? RecordingContextClockChipWidget(
|
||||
key: const ValueKey('recording-context-info'),
|
||||
address: contextAddress,
|
||||
)
|
||||
: const SizedBox.shrink(key: ValueKey('clipboard-info-hidden')),
|
||||
: const SizedBox.shrink(
|
||||
key: ValueKey('recording-context-info-hidden'),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isRecording)
|
||||
@@ -133,6 +145,18 @@ class RecordingHudWidget extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 16.r,
|
||||
bottom: 260.r,
|
||||
child: _ZoomPresetControl(
|
||||
enabled: !isSwitchingLens,
|
||||
zoomRatio: zoomRatio,
|
||||
minZoomRatio: minZoomRatio,
|
||||
maxZoomRatio: maxZoomRatio,
|
||||
presets: _zoomPresetsForRange(minZoomRatio),
|
||||
onSelected: onZoomSelected,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
@@ -141,7 +165,7 @@ class RecordingHudWidget extends StatelessWidget {
|
||||
child: RecordingControlButton(
|
||||
isRecording: isRecording,
|
||||
isStartingRecording: isStartingRecording,
|
||||
enabled: !isStartingRecording,
|
||||
enabled: !isStartingRecording && !isSwitchingLens,
|
||||
size: _recordButtonSize,
|
||||
onTap: () {
|
||||
if (isRecording) {
|
||||
@@ -170,4 +194,140 @@ class RecordingHudWidget extends StatelessWidget {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<double> _zoomPresetsForRange(double minZoomRatio) {
|
||||
return [if (minZoomRatio < 1.0) minZoomRatio, 1.0];
|
||||
}
|
||||
}
|
||||
|
||||
class _ZoomPresetControl extends StatelessWidget {
|
||||
const _ZoomPresetControl({
|
||||
required this.enabled,
|
||||
required this.zoomRatio,
|
||||
required this.minZoomRatio,
|
||||
required this.maxZoomRatio,
|
||||
required this.presets,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final bool enabled;
|
||||
final double zoomRatio;
|
||||
final double minZoomRatio;
|
||||
final double maxZoomRatio;
|
||||
final List<double> presets;
|
||||
final ValueChanged<double> onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final availablePresets = presets
|
||||
.where(_isPresetAvailable)
|
||||
.toList(growable: false);
|
||||
if (availablePresets.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.46),
|
||||
borderRadius: BorderRadius.circular(18.r),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.18)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(3.r),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final preset in availablePresets)
|
||||
_ZoomPresetButton(
|
||||
displayRatio: preset,
|
||||
requestRatio: preset,
|
||||
selected: _isPresetSelected(preset),
|
||||
enabled: enabled,
|
||||
onSelected: onSelected,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isPresetAvailable(double preset) {
|
||||
if (preset < 1.0) {
|
||||
return minZoomRatio <= preset && maxZoomRatio >= preset;
|
||||
}
|
||||
return preset >= minZoomRatio && preset <= maxZoomRatio;
|
||||
}
|
||||
|
||||
bool _isPresetSelected(double preset) {
|
||||
if (preset < 1.0) {
|
||||
return zoomRatio < 1.0;
|
||||
}
|
||||
return (zoomRatio - preset).abs() < 0.05;
|
||||
}
|
||||
}
|
||||
|
||||
class _ZoomPresetButton extends StatelessWidget {
|
||||
const _ZoomPresetButton({
|
||||
required this.displayRatio,
|
||||
required this.requestRatio,
|
||||
required this.selected,
|
||||
required this.enabled,
|
||||
required this.onSelected,
|
||||
});
|
||||
|
||||
final double displayRatio;
|
||||
final double requestRatio;
|
||||
final bool selected;
|
||||
final bool enabled;
|
||||
final ValueChanged<double> onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 1.r),
|
||||
child: TextButton(
|
||||
onPressed: selected || !enabled
|
||||
? null
|
||||
: () => RateLimit.instance.debounce<void>(
|
||||
key:
|
||||
'recording.session.zoom.${requestRatio.toStringAsFixed(1)}',
|
||||
value: null,
|
||||
duration: Duration(milliseconds: 300),
|
||||
onCallback: (_) async {
|
||||
onSelected(requestRatio);
|
||||
},
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
minimumSize: Size(38.r, 32.r),
|
||||
padding: EdgeInsets.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
foregroundColor: selected ? Colors.black : Colors.white,
|
||||
disabledForegroundColor: Colors.black,
|
||||
backgroundColor: selected ? Colors.white : Colors.transparent,
|
||||
disabledBackgroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15.r),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_formatZoomRatio(displayRatio),
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatZoomRatio(double ratio) {
|
||||
if (ratio < 1.0) {
|
||||
return '广角';
|
||||
}
|
||||
if (ratio == ratio.roundToDouble()) {
|
||||
return '${ratio.toStringAsFixed(0)}x';
|
||||
}
|
||||
return '${ratio.toStringAsFixed(1)}x';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:recording_tool/features/dialog/dialog-record.dart';
|
||||
import 'package:recording_tool/features/recording/dialog/dialog-record.dart';
|
||||
|
||||
/// 录制结束并保存到相册后的后续操作弹窗。
|
||||
/// 推流录制结束后的后续操作弹窗。
|
||||
Future<void> showRecordingSavedDialog(
|
||||
BuildContext context, {
|
||||
required String sessionTitle,
|
||||
@@ -10,7 +10,7 @@ Future<void> showRecordingSavedDialog(
|
||||
}) {
|
||||
return RecordDialog.showDouble(
|
||||
context,
|
||||
title: '本轮比赛视频已保存到相册\n请选择后续录制信息',
|
||||
title: '本轮比赛视频已提交 NAS 录制\n请选择后续录制信息',
|
||||
leftText: '继续本轮',
|
||||
rightText: '录制新轮',
|
||||
onLeftPressed: onContinueRound,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'package:apivideo_live_stream/apivideo_live_stream.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:recording_tool/features/scan_qrcode/utils/rtmp_stream_target.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_bar.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_toast.dart';
|
||||
|
||||
class PushSteamTestWidget extends StatefulWidget {
|
||||
const PushSteamTestWidget({
|
||||
super.key,
|
||||
this.rtmpUrl =
|
||||
'rtmp://192.168.1.245:19090/蔡依婷vs夏志豪_空中格斗赛_高中组/蔡依婷vs夏志豪_空中格斗赛_高中组',
|
||||
});
|
||||
|
||||
final String rtmpUrl;
|
||||
|
||||
@override
|
||||
State<PushSteamTestWidget> createState() => _PushSteamTestWidgetState();
|
||||
}
|
||||
|
||||
class _PushSteamTestWidgetState extends State<PushSteamTestWidget>
|
||||
with WidgetsBindingObserver {
|
||||
late final ApiVideoLiveStreamController _controller;
|
||||
bool _ready = false;
|
||||
bool _isStreaming = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
_controller = ApiVideoLiveStreamController(
|
||||
initialAudioConfig: AudioConfig(bitrate: 128000),
|
||||
initialVideoConfig: VideoConfig.withDefaultBitrate(
|
||||
resolution: Resolution.RESOLUTION_1080,
|
||||
fps: 30,
|
||||
),
|
||||
onConnectionSuccess: () => {
|
||||
debugPrint('推流成功'),
|
||||
|
||||
setState(() => _isStreaming = true),
|
||||
},
|
||||
onConnectionFailed: (reason) {
|
||||
setState(() => _isStreaming = false);
|
||||
debugPrint('推流失败: $reason');
|
||||
},
|
||||
onDisconnection: () => {
|
||||
debugPrint('推流断开'),
|
||||
|
||||
setState(() => _isStreaming = false),
|
||||
},
|
||||
);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _initialize());
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
try {
|
||||
await _controller.initialize();
|
||||
await _controller.startPreview();
|
||||
|
||||
setState(() => _ready = true);
|
||||
} catch (e) {
|
||||
debugPrint('初始化失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startPush() async {
|
||||
try {
|
||||
final target = RtmpStreamTarget.parse(widget.rtmpUrl);
|
||||
await _controller.startStreaming(
|
||||
streamKey: target.streamKey,
|
||||
url: target.url,
|
||||
);
|
||||
} on FormatException catch (e) {
|
||||
debugPrint('推流地址错误: ${e.message}');
|
||||
AppToast.show(e.message);
|
||||
} on PlatformException catch (e) {
|
||||
final message = e.message ?? e.code;
|
||||
debugPrint('推流失败: ${e.code} $message');
|
||||
AppToast.show('推流失败: $message');
|
||||
} catch (e) {
|
||||
debugPrint('推流失败: $e');
|
||||
AppToast.showError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopPush() => _controller.stopStreaming();
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.inactive) {
|
||||
_controller.stop();
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
_controller.startPreview();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: myAppBar(context: context),
|
||||
body: Stack(
|
||||
children: [
|
||||
if (_ready)
|
||||
ApiVideoCameraPreview(controller: _controller, fit: BoxFit.cover),
|
||||
Positioned(
|
||||
bottom: 32,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: FloatingActionButton(
|
||||
backgroundColor: _isStreaming ? Colors.red : Colors.green,
|
||||
onPressed: _ready
|
||||
? (_isStreaming ? _stopPush : _startPush)
|
||||
: null,
|
||||
child: Icon(_isStreaming ? Icons.stop : Icons.circle),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_easyloading/flutter_easyloading.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/features/auth/pages/page_auth.dart';
|
||||
import 'package:recording_tool/features/auth/view_model_auth/view_model_auth.dart';
|
||||
import 'package:recording_tool/features/competition_teams/pages/page_competition_team_list.dart';
|
||||
import 'package:recording_tool/features/events/model/model_event_info.dart';
|
||||
import 'package:recording_tool/features/events/pages/page_event_info.dart';
|
||||
import 'package:recording_tool/features/events/view_model/view_model_event_info.dart';
|
||||
import 'package:recording_tool/features/recording/model/model_recording_context.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_bar.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_button.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_qr_scanner_dialog.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_toast.dart';
|
||||
|
||||
class ScanQrCodePage extends ConsumerStatefulWidget {
|
||||
const ScanQrCodePage({super.key});
|
||||
|
||||
static const mockRtmpUrl =
|
||||
'rtmp://192.168.1.245:19090/蔡依婷vs夏志豪_空中格斗赛_高中组/蔡依婷vs夏志豪_空中格斗赛_高中组';
|
||||
|
||||
static const mockRecordingContext = RecordingContext(
|
||||
eventTitle: '全国青少年无人机大赛',
|
||||
matchName: '空中格斗赛',
|
||||
group: '高中组',
|
||||
venue: '场地 1',
|
||||
time: '7月1日 12:00-15:00',
|
||||
playerName: '蔡依婷vs夏志豪',
|
||||
playerPhone: '',
|
||||
);
|
||||
|
||||
@override
|
||||
ConsumerState<ScanQrCodePage> createState() => _AuthPageWidgetState();
|
||||
}
|
||||
|
||||
class _AuthPageWidgetState extends ConsumerState<ScanQrCodePage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
final token = AppStorage.getString(StorageKeys.authToken);
|
||||
if (token?.isNotEmpty ?? false) {
|
||||
final success = await ref
|
||||
.read(authProvider.notifier)
|
||||
.parseTokenSetState(token!);
|
||||
if (!success) {
|
||||
AppToast.show('请重新鉴权');
|
||||
AppNavigator.pushAndRemoveUntil(const AuthPageWidget());
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (!didPop) return;
|
||||
await ref.read(authProvider.notifier).clearAuth();
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: myAppBar(context: context, title: '扫码'),
|
||||
body: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(height: 100.h),
|
||||
Text(
|
||||
'裁判工作台',
|
||||
style: TextStyle(fontSize: 30, color: Colors.black),
|
||||
),
|
||||
SizedBox(height: 100.h),
|
||||
Text(
|
||||
'扫描选手参赛凭证进行执裁',
|
||||
style: TextStyle(fontSize: 30, color: Colors.black),
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
|
||||
// Consumer(
|
||||
// builder: (context, ref, child) {
|
||||
// return SizedBox(
|
||||
// width: 280.w,
|
||||
// height: 80.h,
|
||||
// child: AppButton(
|
||||
// label: '查看录像',
|
||||
// onPressed: () async {
|
||||
// final data = ref.watch(
|
||||
// authProvider.select((state) => state.jwtDecodedData),
|
||||
// );
|
||||
// if (data == null) return;
|
||||
// debugPrint('赛事名字: ${data.eventName}');
|
||||
// final eventName = data.eventName ?? '';
|
||||
// await ref
|
||||
// .read(authProvider.notifier)
|
||||
// .getRecordList(eventName);
|
||||
// },
|
||||
// variant: AppButtonVariant.secondary,
|
||||
// ),
|
||||
// );
|
||||
// },
|
||||
// ),
|
||||
SizedBox(height: 16.h),
|
||||
|
||||
Consumer(
|
||||
builder: (context, ref, child) {
|
||||
return SizedBox(
|
||||
width: 280.w,
|
||||
height: 80.h,
|
||||
child: AppButton(
|
||||
label: '参赛队伍',
|
||||
onPressed: () async {
|
||||
await AppNavigator.push(
|
||||
const CompetitionTeamListPage(),
|
||||
context: context,
|
||||
);
|
||||
},
|
||||
variant: AppButtonVariant.secondary,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
SizedBox(
|
||||
width: 280.w,
|
||||
height: 80.h,
|
||||
child: AppButton(
|
||||
label: '扫码',
|
||||
onPressed: () async {
|
||||
final String? playerId = await AppQrScannerDialog.show(
|
||||
context,
|
||||
);
|
||||
if (playerId == null || playerId.isEmpty) {
|
||||
AppToast.show('查询选手信息失败');
|
||||
return;
|
||||
}
|
||||
EasyLoading.show(status: '查询选手信息...');
|
||||
try {
|
||||
final success = await ref
|
||||
.read(eventInfoProvider.notifier)
|
||||
.loadRegistrationList(
|
||||
request: PlayerRegistrationListReq(
|
||||
userId: playerId,
|
||||
),
|
||||
);
|
||||
EasyLoading.dismiss();
|
||||
if (!success) {
|
||||
AppToast.show('查询选手信息失败');
|
||||
return;
|
||||
}
|
||||
if (!mounted) return;
|
||||
|
||||
AppNavigator.push(EventInfoPage(playerId: playerId));
|
||||
} catch (error) {
|
||||
AppToast.show('查询选手信息失败');
|
||||
EasyLoading.dismiss();
|
||||
}
|
||||
},
|
||||
variant: AppButtonVariant.secondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
class RtmpStreamTarget {
|
||||
const RtmpStreamTarget({required this.url, required this.streamKey});
|
||||
|
||||
final String url;
|
||||
final String streamKey;
|
||||
|
||||
factory RtmpStreamTarget.parse(String value) {
|
||||
final source = value.trim();
|
||||
final uri = Uri.tryParse(source);
|
||||
if (uri == null ||
|
||||
(uri.scheme != 'rtmp' && uri.scheme != 'rtmps') ||
|
||||
uri.host.isEmpty) {
|
||||
throw const FormatException('推流地址必须是 rtmp:// 或 rtmps:// 开头的完整地址');
|
||||
}
|
||||
|
||||
final pathSegments = uri.pathSegments
|
||||
.where((segment) => segment.trim().isNotEmpty)
|
||||
.toList(growable: false);
|
||||
if (pathSegments.length < 2) {
|
||||
throw const FormatException('推流地址路径必须包含 app 和 streamKey,例如 /app/xxxx');
|
||||
}
|
||||
|
||||
final appPath = pathSegments.take(pathSegments.length - 1).join('/');
|
||||
final streamKey = pathSegments.last;
|
||||
final baseUrl = uri
|
||||
.replace(path: '/$appPath', query: null, fragment: null)
|
||||
.toString();
|
||||
|
||||
return RtmpStreamTarget(url: baseUrl, streamKey: streamKey);
|
||||
}
|
||||
|
||||
String get fullUrl => '$url/$streamKey';
|
||||
}
|
||||
@@ -11,6 +11,16 @@
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class $AssetsHtmlGen {
|
||||
const $AssetsHtmlGen();
|
||||
|
||||
/// File path: assets/html/index.html
|
||||
String get index => 'assets/html/index.html';
|
||||
|
||||
/// List of all assets
|
||||
List<String> get values => [index];
|
||||
}
|
||||
|
||||
class $AssetsImagesGen {
|
||||
const $AssetsImagesGen();
|
||||
|
||||
@@ -30,18 +40,24 @@ class $AssetsImagesGen {
|
||||
AssetGenImage get imageLogo =>
|
||||
const AssetGenImage('assets/images/image_logo.png');
|
||||
|
||||
/// File path: assets/images/image_vs.png
|
||||
AssetGenImage get imageVs =>
|
||||
const AssetGenImage('assets/images/image_vs.png');
|
||||
|
||||
/// List of all assets
|
||||
List<AssetGenImage> get values => [
|
||||
imageCopy,
|
||||
imageDelete,
|
||||
imageDialogBg,
|
||||
imageLogo,
|
||||
imageVs,
|
||||
];
|
||||
}
|
||||
|
||||
class Assets {
|
||||
const Assets._();
|
||||
|
||||
static const $AssetsHtmlGen html = $AssetsHtmlGen();
|
||||
static const $AssetsImagesGen images = $AssetsImagesGen();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
|
||||
AppBar myAppBar({
|
||||
required BuildContext context,
|
||||
String title = '',
|
||||
Widget? titleWidget,
|
||||
}) => AppBar(
|
||||
leadingWidth: 56.w,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
Navigator.of(context).maybePop();
|
||||
},
|
||||
),
|
||||
title:
|
||||
titleWidget ??
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20.sp,
|
||||
fontFamily: 'PingFang SC',
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
);
|
||||
@@ -0,0 +1,346 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||
|
||||
class AppQrScannerDialog extends StatefulWidget {
|
||||
const AppQrScannerDialog({super.key});
|
||||
|
||||
static Future<String?> show(BuildContext context) {
|
||||
return showGeneralDialog<String>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
|
||||
barrierColor: Colors.black,
|
||||
transitionDuration: const Duration(milliseconds: 180),
|
||||
pageBuilder: (dialogContext, animation, secondaryAnimation) {
|
||||
return const AppQrScannerDialog();
|
||||
},
|
||||
transitionBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<AppQrScannerDialog> createState() => _AppQrScannerDialogState();
|
||||
}
|
||||
|
||||
class _AppQrScannerDialogState extends State<AppQrScannerDialog>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final MobileScannerController _controller;
|
||||
late final AnimationController _scanLineController;
|
||||
bool _hasScanned = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = MobileScannerController(
|
||||
detectionSpeed: DetectionSpeed.noDuplicates,
|
||||
facing: CameraFacing.back,
|
||||
formats: const [BarcodeFormat.qrCode],
|
||||
autoZoom: true,
|
||||
);
|
||||
_scanLineController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1800),
|
||||
)..repeat(reverse: true);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scanLineController.dispose();
|
||||
unawaited(_controller.dispose());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleDetect(BarcodeCapture capture) async {
|
||||
if (_hasScanned || capture.barcodes.isEmpty) return;
|
||||
|
||||
final value = capture.barcodes.first.rawValue;
|
||||
if (value == null || value.isEmpty) return;
|
||||
|
||||
_hasScanned = true;
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
debugPrint('扫码结果: $value');
|
||||
if (!mounted) return;
|
||||
if (navigator.canPop()) {
|
||||
navigator.pop(value);
|
||||
}
|
||||
unawaited(_stopScanner());
|
||||
}
|
||||
|
||||
void _handleDetectError(Object error, StackTrace stackTrace) {
|
||||
debugPrint('扫码识别失败: $error');
|
||||
}
|
||||
|
||||
Future<void> _stopScanner() async {
|
||||
try {
|
||||
await _controller.stop();
|
||||
} catch (error) {
|
||||
debugPrint('停止扫码相机失败: $error');
|
||||
}
|
||||
}
|
||||
|
||||
void _close() {
|
||||
if (!mounted) return;
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
if (navigator.canPop()) {
|
||||
navigator.pop();
|
||||
}
|
||||
}
|
||||
|
||||
Rect _scanWindowFor(Size size) {
|
||||
final scanSize = math.min(size.width * 0.72, 300.w);
|
||||
final center = Offset(size.width / 2, size.height * 0.46);
|
||||
return Rect.fromCenter(center: center, width: scanSize, height: scanSize);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.black,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final size = constraints.biggest;
|
||||
final scanWindow = _scanWindowFor(size);
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: MobileScanner(
|
||||
controller: _controller,
|
||||
fit: BoxFit.cover,
|
||||
onDetect: _handleDetect,
|
||||
onDetectError: _handleDetectError,
|
||||
errorBuilder: _buildCameraError,
|
||||
placeholderBuilder: (_) => const ColoredBox(
|
||||
color: Colors.black,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: CustomPaint(
|
||||
painter: _ScannerOverlayPainter(scanWindow: scanWindow),
|
||||
),
|
||||
),
|
||||
_ScanLine(animation: _scanLineController, scanWindow: scanWindow),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: SizedBox(
|
||||
height: 56.h,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: IconButton(
|
||||
onPressed: _close,
|
||||
icon: const Icon(Icons.close),
|
||||
color: Colors.white,
|
||||
tooltip: '关闭',
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'扫一扫',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: scanWindow.bottom + 28.h,
|
||||
left: 24.w,
|
||||
right: 24.w,
|
||||
child: Text(
|
||||
'将二维码放入框内,即可自动扫描',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.86),
|
||||
fontSize: 15.sp,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCameraError(BuildContext context, MobileScannerException error) {
|
||||
return ColoredBox(
|
||||
color: Colors.black,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 32.w),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.camera_alt_outlined,
|
||||
color: Colors.white.withValues(alpha: 0.82),
|
||||
size: 44.r,
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
Text(
|
||||
'无法启动相机,请检查相机权限后重试',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16.sp,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8.h),
|
||||
Text(
|
||||
error.errorDetails?.message ?? error.errorCode.message,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.58),
|
||||
fontSize: 12.sp,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 24.h),
|
||||
TextButton(
|
||||
onPressed: _close,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
side: BorderSide(color: Colors.white.withValues(alpha: 0.44)),
|
||||
),
|
||||
child: const Text('关闭'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScanLine extends StatelessWidget {
|
||||
const _ScanLine({required this.animation, required this.scanWindow});
|
||||
|
||||
final Animation<double> animation;
|
||||
final Rect scanWindow;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) {
|
||||
final inset = 14.r;
|
||||
final top =
|
||||
scanWindow.top +
|
||||
inset +
|
||||
(scanWindow.height - inset * 2) * animation.value;
|
||||
|
||||
return Positioned(
|
||||
left: scanWindow.left + inset,
|
||||
top: top,
|
||||
width: scanWindow.width - inset * 2,
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
height: 2.h,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1BD760),
|
||||
borderRadius: BorderRadius.circular(2.r),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF1BD760).withValues(alpha: 0.65),
|
||||
blurRadius: 12.r,
|
||||
spreadRadius: 2.r,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ScannerOverlayPainter extends CustomPainter {
|
||||
const _ScannerOverlayPainter({required this.scanWindow});
|
||||
|
||||
final Rect scanWindow;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final overlayPaint = Paint()..color = Colors.black.withValues(alpha: 0.58);
|
||||
final outerPath = Path()..addRect(Offset.zero & size);
|
||||
final innerPath = Path()
|
||||
..addRRect(RRect.fromRectAndRadius(scanWindow, Radius.circular(8.r)));
|
||||
final overlayPath = Path.combine(
|
||||
PathOperation.difference,
|
||||
outerPath,
|
||||
innerPath,
|
||||
);
|
||||
canvas.drawPath(overlayPath, overlayPaint);
|
||||
|
||||
final borderPaint = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.38)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.r;
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(scanWindow, Radius.circular(8.r)),
|
||||
borderPaint,
|
||||
);
|
||||
|
||||
final cornerPaint = Paint()
|
||||
..color = const Color(0xFF1BD760)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeWidth = 4.r;
|
||||
final cornerLength = 28.r;
|
||||
|
||||
void drawCorner(Offset start, Offset horizontalEnd, Offset verticalEnd) {
|
||||
canvas
|
||||
..drawLine(start, horizontalEnd, cornerPaint)
|
||||
..drawLine(start, verticalEnd, cornerPaint);
|
||||
}
|
||||
|
||||
drawCorner(
|
||||
scanWindow.topLeft,
|
||||
scanWindow.topLeft.translate(cornerLength, 0),
|
||||
scanWindow.topLeft.translate(0, cornerLength),
|
||||
);
|
||||
drawCorner(
|
||||
scanWindow.topRight,
|
||||
scanWindow.topRight.translate(-cornerLength, 0),
|
||||
scanWindow.topRight.translate(0, cornerLength),
|
||||
);
|
||||
drawCorner(
|
||||
scanWindow.bottomLeft,
|
||||
scanWindow.bottomLeft.translate(cornerLength, 0),
|
||||
scanWindow.bottomLeft.translate(0, -cornerLength),
|
||||
);
|
||||
drawCorner(
|
||||
scanWindow.bottomRight,
|
||||
scanWindow.bottomRight.translate(-cornerLength, 0),
|
||||
scanWindow.bottomRight.translate(0, -cornerLength),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _ScannerOverlayPainter oldDelegate) {
|
||||
return oldDelegate.scanWindow != scanWindow;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 全局文本尺寸语义。
|
||||
///
|
||||
/// 业务代码应优先选择语义化的 variant,而不是直接散落 `fontSize`。
|
||||
/// 这样后续调整整套字号体系时,只需要维护这一处映射。
|
||||
enum AppTextVariant {
|
||||
display,
|
||||
headline,
|
||||
title,
|
||||
subtitle,
|
||||
body,
|
||||
bodySmall,
|
||||
label,
|
||||
caption,
|
||||
}
|
||||
|
||||
/// 全局文本颜色语义。
|
||||
///
|
||||
/// tone 只表达“文本在界面中的语义角色”,具体颜色从当前 Theme 解析,
|
||||
/// 避免页面直接依赖硬编码色值。
|
||||
enum AppTextTone {
|
||||
primary,
|
||||
secondary,
|
||||
tertiary,
|
||||
inverse,
|
||||
brand,
|
||||
success,
|
||||
warning,
|
||||
danger,
|
||||
disabled,
|
||||
}
|
||||
|
||||
/// 应用级文本组件。
|
||||
///
|
||||
/// `AppText` 是对 Flutter `Text` 的轻量封装,目标是统一页面里的字号、
|
||||
/// 字重、颜色和溢出策略,同时保留 `Text` 的常用能力。
|
||||
///
|
||||
/// 使用建议:
|
||||
/// - 普通文案使用默认 `AppText('内容')`。
|
||||
/// - 标题使用 `variant: AppTextVariant.title`。
|
||||
/// - 错误、警告、成功等状态文案使用 `tone`,不要在业务里直接写颜色。
|
||||
/// - 只有遇到一次性视觉细节时才传入 `style`、`fontSize` 或 `fontWeight`。
|
||||
class AppText extends StatelessWidget {
|
||||
const AppText(
|
||||
this.data, {
|
||||
super.key,
|
||||
this.variant = AppTextVariant.body,
|
||||
this.tone = AppTextTone.primary,
|
||||
this.style,
|
||||
this.color,
|
||||
this.fontSize,
|
||||
this.fontWeight,
|
||||
this.height,
|
||||
this.letterSpacing,
|
||||
this.textAlign,
|
||||
this.textDirection,
|
||||
this.locale,
|
||||
this.softWrap,
|
||||
this.overflow,
|
||||
this.maxLines,
|
||||
this.semanticsLabel,
|
||||
this.textWidthBasis,
|
||||
this.textHeightBehavior,
|
||||
this.textScaler,
|
||||
this.selectionColor,
|
||||
}) : textSpan = null;
|
||||
|
||||
/// 富文本构造器。
|
||||
///
|
||||
/// 用于同一段文案中存在局部强调、不同颜色或不同字重的场景。
|
||||
/// 外层的 `variant`、`tone` 和通用排版参数仍会作为默认样式作用到 span。
|
||||
const AppText.rich(
|
||||
this.textSpan, {
|
||||
super.key,
|
||||
this.variant = AppTextVariant.body,
|
||||
this.tone = AppTextTone.primary,
|
||||
this.style,
|
||||
this.color,
|
||||
this.fontSize,
|
||||
this.fontWeight,
|
||||
this.height,
|
||||
this.letterSpacing,
|
||||
this.textAlign,
|
||||
this.textDirection,
|
||||
this.locale,
|
||||
this.softWrap,
|
||||
this.overflow,
|
||||
this.maxLines,
|
||||
this.semanticsLabel,
|
||||
this.textWidthBasis,
|
||||
this.textHeightBehavior,
|
||||
this.textScaler,
|
||||
this.selectionColor,
|
||||
}) : data = null;
|
||||
|
||||
/// 普通文本内容。与 [textSpan] 二选一。
|
||||
final String? data;
|
||||
|
||||
/// 富文本内容。与 [data] 二选一。
|
||||
final InlineSpan? textSpan;
|
||||
|
||||
/// 文本尺寸和基础字重语义。
|
||||
final AppTextVariant variant;
|
||||
|
||||
/// 文本颜色语义。
|
||||
final AppTextTone tone;
|
||||
|
||||
/// 额外样式覆盖。优先级高于 variant 和 tone。
|
||||
final TextStyle? style;
|
||||
|
||||
/// 显式颜色覆盖。优先级高于 tone 和 `style.color`。
|
||||
final Color? color;
|
||||
|
||||
/// 一次性字号覆盖。常规场景优先使用 [variant]。
|
||||
final double? fontSize;
|
||||
|
||||
/// 一次性字重覆盖。常规场景优先使用 [variant]。
|
||||
final FontWeight? fontWeight;
|
||||
|
||||
/// 行高覆盖。
|
||||
final double? height;
|
||||
|
||||
/// 字间距覆盖。
|
||||
final double? letterSpacing;
|
||||
|
||||
final TextAlign? textAlign;
|
||||
final TextDirection? textDirection;
|
||||
final Locale? locale;
|
||||
final bool? softWrap;
|
||||
final TextOverflow? overflow;
|
||||
final int? maxLines;
|
||||
final String? semanticsLabel;
|
||||
final TextWidthBasis? textWidthBasis;
|
||||
final TextHeightBehavior? textHeightBehavior;
|
||||
final TextScaler? textScaler;
|
||||
final Color? selectionColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final effectiveStyle = _resolveStyle(context);
|
||||
|
||||
if (textSpan != null) {
|
||||
return Text.rich(
|
||||
textSpan!,
|
||||
style: effectiveStyle,
|
||||
textAlign: textAlign,
|
||||
textDirection: textDirection,
|
||||
locale: locale,
|
||||
softWrap: softWrap,
|
||||
overflow: overflow,
|
||||
maxLines: maxLines,
|
||||
semanticsLabel: semanticsLabel,
|
||||
textWidthBasis: textWidthBasis,
|
||||
textHeightBehavior: textHeightBehavior,
|
||||
textScaler: textScaler,
|
||||
selectionColor: selectionColor,
|
||||
);
|
||||
}
|
||||
|
||||
return Text(
|
||||
data ?? '',
|
||||
style: effectiveStyle,
|
||||
textAlign: textAlign,
|
||||
textDirection: textDirection,
|
||||
locale: locale,
|
||||
softWrap: softWrap,
|
||||
overflow: overflow,
|
||||
maxLines: maxLines,
|
||||
semanticsLabel: semanticsLabel,
|
||||
textWidthBasis: textWidthBasis,
|
||||
textHeightBehavior: textHeightBehavior,
|
||||
textScaler: textScaler,
|
||||
selectionColor: selectionColor,
|
||||
);
|
||||
}
|
||||
|
||||
/// 合成最终样式。
|
||||
///
|
||||
/// 优先级从低到高:
|
||||
/// 1. Theme 中的 TextTheme。
|
||||
/// 2. `variant` 与 `tone` 对应的默认样式。
|
||||
/// 3. 外部传入的 `style`。
|
||||
/// 4. `color`、`fontSize`、`fontWeight` 等显式字段。
|
||||
TextStyle _resolveStyle(BuildContext context) {
|
||||
final baseStyle = _variantStyle(Theme.of(context).textTheme);
|
||||
final toneStyle = baseStyle.copyWith(color: _toneColor(context));
|
||||
final mergedStyle = style == null ? toneStyle : toneStyle.merge(style);
|
||||
|
||||
return mergedStyle.copyWith(
|
||||
color: color ?? mergedStyle.color,
|
||||
fontSize: fontSize ?? mergedStyle.fontSize,
|
||||
fontWeight: fontWeight ?? mergedStyle.fontWeight,
|
||||
height: height ?? mergedStyle.height,
|
||||
letterSpacing: letterSpacing ?? mergedStyle.letterSpacing,
|
||||
);
|
||||
}
|
||||
|
||||
TextStyle _variantStyle(TextTheme textTheme) {
|
||||
return switch (variant) {
|
||||
AppTextVariant.display =>
|
||||
textTheme.displaySmall ?? const TextStyle(fontSize: 36),
|
||||
AppTextVariant.headline =>
|
||||
textTheme.headlineSmall ?? const TextStyle(fontSize: 24),
|
||||
AppTextVariant.title =>
|
||||
textTheme.titleMedium ?? const TextStyle(fontSize: 16),
|
||||
AppTextVariant.subtitle =>
|
||||
textTheme.titleSmall ?? const TextStyle(fontSize: 14),
|
||||
AppTextVariant.body =>
|
||||
textTheme.bodyMedium ?? const TextStyle(fontSize: 14),
|
||||
AppTextVariant.bodySmall =>
|
||||
textTheme.bodySmall ?? const TextStyle(fontSize: 12),
|
||||
AppTextVariant.label =>
|
||||
textTheme.labelLarge ?? const TextStyle(fontSize: 14),
|
||||
AppTextVariant.caption =>
|
||||
textTheme.labelSmall ?? const TextStyle(fontSize: 11),
|
||||
};
|
||||
}
|
||||
|
||||
Color _toneColor(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
return switch (tone) {
|
||||
AppTextTone.primary => colors.onSurface,
|
||||
AppTextTone.secondary => colors.onSurfaceVariant,
|
||||
AppTextTone.tertiary => colors.outline,
|
||||
AppTextTone.inverse => colors.onInverseSurface,
|
||||
AppTextTone.brand => colors.primary,
|
||||
AppTextTone.success => colors.tertiary,
|
||||
AppTextTone.warning => colors.secondary,
|
||||
AppTextTone.danger => colors.error,
|
||||
AppTextTone.disabled => colors.onSurface.withValues(alpha: 0.38),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.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/features/events/model/model_event_info.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:webview_flutter_android/webview_flutter_android.dart';
|
||||
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
|
||||
|
||||
class WebviewPage extends StatefulWidget {
|
||||
final String? url;
|
||||
final String? title;
|
||||
final String? assetPath;
|
||||
final bool? canPop;
|
||||
final EventRegistrationItem? eventRegistrationItem;
|
||||
final String? playerId;
|
||||
|
||||
const WebviewPage({
|
||||
super.key,
|
||||
this.url,
|
||||
this.title,
|
||||
this.assetPath,
|
||||
this.canPop,
|
||||
this.eventRegistrationItem,
|
||||
this.playerId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<WebviewPage> createState() => WebviewPageState();
|
||||
}
|
||||
|
||||
class WebviewPageState extends State<WebviewPage> {
|
||||
static const String _javaScriptChannelName = 'AppBridge';
|
||||
|
||||
WebViewController? _controller;
|
||||
bool _isLoading = true;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final controller = _controller;
|
||||
_controller = null;
|
||||
if (controller != null) {
|
||||
unawaited(_disposeWebViewController(controller));
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _initController() async {
|
||||
if ((widget.url?.isEmpty ?? true) && widget.assetPath == null) {
|
||||
return;
|
||||
}
|
||||
final params = _createPlatformParams();
|
||||
late final WebViewController controller;
|
||||
controller = WebViewController.fromPlatformCreationParams(params)
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setBackgroundColor(Colors.white)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onProgress: (int progress) {
|
||||
debugPrint('webview - progress: $progress');
|
||||
},
|
||||
onPageStarted: (String url) {
|
||||
debugPrint('webview - page started: $url');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
},
|
||||
onPageFinished: (String url) async {
|
||||
debugPrint('webview - page finished: $url');
|
||||
// await _injectPostMessageBridge(controller);
|
||||
await _injectH5NeedData(controller);
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = false);
|
||||
},
|
||||
onHttpError: (HttpResponseError error) {
|
||||
debugPrint(
|
||||
'webview - http error: ${error.response?.uri}, code: ${error.response?.statusCode}',
|
||||
);
|
||||
},
|
||||
onWebResourceError: (WebResourceError error) {
|
||||
debugPrint(
|
||||
'webview error: ${error.description}, code: ${error.errorCode}, url: ${error.url}',
|
||||
);
|
||||
if (!mounted || error.isForMainFrame != true) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = error.description;
|
||||
});
|
||||
},
|
||||
onNavigationRequest: (NavigationRequest request) {
|
||||
return NavigationDecision.navigate;
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
controller.addJavaScriptChannel(
|
||||
_javaScriptChannelName,
|
||||
onMessageReceived: (JavaScriptMessage message) {
|
||||
_onMessageReceived(message);
|
||||
},
|
||||
);
|
||||
|
||||
if (controller.platform is AndroidWebViewController) {
|
||||
AndroidWebViewController.enableDebugging(kDebugMode);
|
||||
await (controller.platform as AndroidWebViewController)
|
||||
.setMediaPlaybackRequiresUserGesture(false);
|
||||
}
|
||||
|
||||
try {
|
||||
if (widget.assetPath != null) {
|
||||
await controller.loadFlutterAsset(widget.assetPath!);
|
||||
} else {
|
||||
final targetUrl = widget.url ?? '';
|
||||
await controller.loadRequest(Uri.parse(targetUrl));
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('webview - load failed: $e');
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = e.toString();
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
unawaited(_disposeWebViewController(controller));
|
||||
return;
|
||||
}
|
||||
setState(() => _controller = controller);
|
||||
}
|
||||
|
||||
Future<void> _disposeWebViewController(WebViewController controller) async {
|
||||
await _runWebViewCleanupStep(
|
||||
'reset navigation delegate',
|
||||
() => controller.setNavigationDelegate(NavigationDelegate()),
|
||||
);
|
||||
await _runWebViewCleanupStep(
|
||||
'remove javascript channel',
|
||||
() => controller.removeJavaScriptChannel(_javaScriptChannelName),
|
||||
);
|
||||
await _runWebViewCleanupStep(
|
||||
'load blank page',
|
||||
() => controller.loadRequest(Uri.parse('about:blank')),
|
||||
);
|
||||
await _runWebViewCleanupStep('clear cache', controller.clearCache);
|
||||
await _runWebViewCleanupStep(
|
||||
'clear local storage',
|
||||
controller.clearLocalStorage,
|
||||
);
|
||||
await _runWebViewCleanupStep(
|
||||
'clear cookies',
|
||||
() => WebViewCookieManager().clearCookies(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _runWebViewCleanupStep(
|
||||
String step,
|
||||
Future<void> Function() cleanup,
|
||||
) async {
|
||||
try {
|
||||
debugPrint('webview - dispose cleanup at $step ');
|
||||
|
||||
await cleanup();
|
||||
} catch (e) {
|
||||
debugPrint('webview - dispose cleanup failed at $step: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _onMessageReceived(JavaScriptMessage message) {
|
||||
debugPrint('收到来自 WebView 的消息: ${message.message}');
|
||||
final jsonMap = json.decode(message.message);
|
||||
if (jsonMap['type'] != null && jsonMap['type'] == 'navigator_pop') {
|
||||
Navigator.of(context).maybePop();
|
||||
}
|
||||
}
|
||||
|
||||
/// 脚本注入传输给h5 数据
|
||||
Future<void> _injectH5NeedData(WebViewController controller) async {
|
||||
final token = AppStorage.getString(StorageKeys.authToken);
|
||||
try {
|
||||
await controller.runJavaScript('''
|
||||
(function () {
|
||||
if (window.__appInstallData) {
|
||||
return;
|
||||
}
|
||||
window.__appInstallData = ${true};
|
||||
|
||||
window.AppBridge.token = '$token';
|
||||
window.AppBridge.scoreEntryContext = {
|
||||
eventId: '${widget.eventRegistrationItem?.eventId}',
|
||||
itemId: '${widget.eventRegistrationItem?.itemId}',
|
||||
scheduleId: '${widget.eventRegistrationItem?.scheduleId}',
|
||||
userId: '${widget.playerId}',
|
||||
opponentId: '${widget.eventRegistrationItem?.opponentId}',
|
||||
};
|
||||
// console.log('window.AppBridge?.scoreEntryContext.eventId', window.AppBridge?.scoreEntryContext.eventId);
|
||||
// console.log('window.AppBridge?.scoreEntryContext.itemId', window.AppBridge?.scoreEntryContext.itemId);
|
||||
// console.log('window.AppBridge?.scoreEntryContext.scheduleId', window.AppBridge?.scoreEntryContext.scheduleId);
|
||||
// console.log('window.AppBridge?.scoreEntryContext.userId', window.AppBridge?.scoreEntryContext.userId);
|
||||
// console.log('window.AppBridge?.scoreEntryContext.opponentId', window.AppBridge?.scoreEntryContext.opponentId);
|
||||
// confirm('已经注入完成');
|
||||
|
||||
})();
|
||||
''');
|
||||
} catch (e) {
|
||||
debugPrint('webview - inject postMessage bridge failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
PlatformWebViewControllerCreationParams _createPlatformParams() {
|
||||
if (WebViewPlatform.instance is WebKitWebViewPlatform) {
|
||||
return WebKitWebViewControllerCreationParams(
|
||||
allowsInlineMediaPlayback: true,
|
||||
mediaTypesRequiringUserAction: const <PlaybackMediaTypes>{},
|
||||
);
|
||||
}
|
||||
return const PlatformWebViewControllerCreationParams();
|
||||
}
|
||||
|
||||
Widget _buildWebView(WebViewController controller) {
|
||||
PlatformWebViewWidgetCreationParams params =
|
||||
PlatformWebViewWidgetCreationParams(controller: controller.platform);
|
||||
|
||||
if (WebViewPlatform.instance is AndroidWebViewPlatform) {
|
||||
params =
|
||||
AndroidWebViewWidgetCreationParams.fromPlatformWebViewWidgetCreationParams(
|
||||
params,
|
||||
displayWithHybridComposition: true,
|
||||
);
|
||||
}
|
||||
|
||||
return WebViewWidget.fromPlatformCreationParams(params: params);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
extendBodyBehindAppBar: true,
|
||||
|
||||
// appBar: myAppBar(context: context),
|
||||
body: _buildBody(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_errorMessage != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'页面加载失败\n$_errorMessage',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final controller = _controller;
|
||||
if (controller == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
canPop: widget.canPop ?? true,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (didPop) return;
|
||||
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('提示'),
|
||||
content: const Text('是否返回上一页?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) {
|
||||
AppNavigator.pop();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
SizedBox(height: 48),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(child: _buildWebView(controller)),
|
||||
if (_isLoading)
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,12 @@ export 'app_empty_view.dart';
|
||||
export 'app_error_view.dart';
|
||||
export 'app_loading_view.dart';
|
||||
export 'app_network_image.dart';
|
||||
export 'app_qr_scanner_dialog.dart';
|
||||
export 'app_refresh_list.dart';
|
||||
export 'app_search_bar.dart';
|
||||
export 'app_status_view.dart';
|
||||
export 'app_tag.dart';
|
||||
export 'app_text.dart';
|
||||
export 'app_text_field.dart';
|
||||
export 'app_toast.dart';
|
||||
export 'safe_area_wrapper.dart';
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# 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
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
build/
|
||||
*pubspec.lock
|
||||
|
||||
# Android related
|
||||
**/android/**/gradle-wrapper.jar
|
||||
**/android/.gradle
|
||||
**/android/captures/
|
||||
**/android/gradlew
|
||||
**/android/gradlew.bat
|
||||
**/android/local.properties
|
||||
**/android/**/GeneratedPluginRegistrant.java
|
||||
|
||||
# iOS/XCode related
|
||||
**/ios/**/*.mode1v3
|
||||
**/ios/**/*.mode2v3
|
||||
**/ios/**/*.moved-aside
|
||||
**/ios/**/*.pbxuser
|
||||
**/ios/**/*.perspectivev3
|
||||
**/ios/**/*sync/
|
||||
**/ios/**/.sconsign.dblite
|
||||
**/ios/**/.tags*
|
||||
**/ios/**/.vagrant/
|
||||
**/ios/**/DerivedData/
|
||||
**/ios/**/Icon?
|
||||
**/ios/**/Pods/
|
||||
**/ios/**/.symlinks/
|
||||
**/ios/**/profile
|
||||
**/ios/**/xcuserdata
|
||||
**/ios/.generated/
|
||||
**/ios/Flutter/App.framework
|
||||
**/ios/Flutter/Flutter.framework
|
||||
**/ios/Flutter/Flutter.podspec
|
||||
**/ios/Flutter/Generated.xcconfig
|
||||
**/ios/Flutter/app.flx
|
||||
**/ios/Flutter/app.zip
|
||||
**/ios/Flutter/flutter_assets/
|
||||
**/ios/Flutter/flutter_export_environment.sh
|
||||
**/ios/ServiceDefinitions.json
|
||||
**/ios/Runner/GeneratedPluginRegistrant.*
|
||||
|
||||
# Exceptions to above rules.
|
||||
!**/ios/**/default.mode1v3
|
||||
!**/ios/**/default.mode2v3
|
||||
!**/ios/**/default.pbxuser
|
||||
!**/ios/**/default.perspectivev3
|
||||
!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages
|
||||
|
||||
# Coverage
|
||||
**/coverage/output/
|
||||
**/coverage/new_lcov.info
|
||||
@@ -0,0 +1,10 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: 02c026b03cd31dd3f867e5faeb7e104cce174c5f
|
||||
channel: unknown
|
||||
|
||||
project_type: package
|
||||
@@ -0,0 +1,84 @@
|
||||
# Changelog
|
||||
|
||||
[简体中文](./CHANGELOG_cn.md)
|
||||
|
||||
## [1.1.2]
|
||||
|
||||
* #82 Fix flutter_logo.dart error in Flutter 3.0.5
|
||||
|
||||
## [1.1.1+1]
|
||||
|
||||
* Update latest dependencies.
|
||||
|
||||
## [1.1.1]
|
||||
|
||||
* #66 [fix] toolbar initial position is incorrect
|
||||
|
||||
## [1.1.0+3]
|
||||
|
||||
* Fix static analyze issues.
|
||||
|
||||
## [1.1.0+2]
|
||||
|
||||
* Fix static analyze issues.
|
||||
|
||||
## [1.1.0]
|
||||
|
||||
* #76 Introduce `UMEWidget.closeActivatedPlugin()`. Issue #35
|
||||
* #75 Remove overlay entry only when it's been inserted. Issue #65
|
||||
* #72 [Android] Migrate the example to the v2 embedding
|
||||
|
||||
## [1.0.2+1]
|
||||
|
||||
* Dart format.
|
||||
|
||||
## [1.0.2]
|
||||
|
||||
* Fix error in code static analysis.
|
||||
|
||||
## [1.0.1]
|
||||
|
||||
* Fix error in pubspec.yaml in example
|
||||
|
||||
## [1.0.0]
|
||||
|
||||
* Normal version with adaption of Flutter 3.
|
||||
* Feature: Anywhere door (Route)
|
||||
|
||||
## [1.0.0-dev.0]
|
||||
|
||||
* Adapt Flutter 3.
|
||||
|
||||
## [0.3.0+1]
|
||||
|
||||
* Fix the version error
|
||||
|
||||
## [0.3.0]
|
||||
|
||||
* Remove static function. Use the `UMEWidget`.
|
||||
* Allow insert `Widget` into Widget tree, in order to access new plugin easily.
|
||||
* Fix the issue of multiple instances of FloatingWidget caused by the refresh state.
|
||||
* Fix the isseue that the plugin is not displayed due to the first layout exception in AOT mode
|
||||
|
||||
## [0.3.0]
|
||||
|
||||
* 移除静态方法,更换为壳 Widget
|
||||
* 允许在 Widget tree 增加自定义嵌套结构组件,从而快速接入新插件
|
||||
* 修复刷新状态引发的浮窗组件出现多实例的问题
|
||||
* 修复在 AOT 模式下首次布局异常导致插件不展示的问题
|
||||
|
||||
## [0.2.1]
|
||||
|
||||
* Remove the extra MaterialApp Widget
|
||||
|
||||
## [0.2.0-dev.0]
|
||||
|
||||
* Adapted Null-Safety.
|
||||
|
||||
## [0.1.0+1]
|
||||
|
||||
* Add some docs comments, modify description in pubspec.yaml.
|
||||
|
||||
## [0.1.0]
|
||||
|
||||
* Open source.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Changelog
|
||||
|
||||
[English](./CHANGELOG.md)
|
||||
|
||||
## [1.1.2]
|
||||
|
||||
* #82 修复 flutter_logo.dart 在 Flutter 3.0.5 上的错误
|
||||
|
||||
## [1.1.1+1]
|
||||
|
||||
* 更新依赖版本
|
||||
|
||||
## [1.1.1]
|
||||
|
||||
* #66 [fix] toolbar initial position is incorrect
|
||||
|
||||
## [1.1.0+3]
|
||||
|
||||
* 修复静态分析问题
|
||||
|
||||
## [1.1.0+2]
|
||||
|
||||
* 修复静态分析问题
|
||||
|
||||
## [1.1.0]
|
||||
|
||||
* #76 新增 `UMEWidget.closeActivatedPlugin()`。 Issue #35
|
||||
* #75 修复重复插入 Overlay 的问题。 Issue #65
|
||||
* #72 [Android] 迁移 example 到 v2 embedding。
|
||||
|
||||
## [1.0.2+1]
|
||||
|
||||
* Dart format
|
||||
|
||||
## [1.0.2]
|
||||
|
||||
* 修复静态分析错误
|
||||
|
||||
## [1.0.1]
|
||||
|
||||
* 修复 example 工程的 pubspec.yaml 错误
|
||||
|
||||
## [1.0.0]
|
||||
|
||||
* 适配 Flutter 3 正式版
|
||||
* 新功能:任意门(Route)
|
||||
|
||||
## [1.0.0-dev.0]
|
||||
|
||||
* 适配 Flutter 3
|
||||
|
||||
## [0.3.0+1]
|
||||
|
||||
* 修复版本号错误
|
||||
|
||||
## [0.3.0]
|
||||
|
||||
* 移除静态方法,更换为壳 Widget
|
||||
* 允许在 Widget tree 增加自定义嵌套结构组件,从而快速接入新插件
|
||||
* 修复刷新状态引发的浮窗组件出现多实例的问题
|
||||
* 修复在 AOT 模式下首次布局异常导致插件不展示的问题
|
||||
|
||||
## [0.2.1]
|
||||
|
||||
* 移除独立的 MaterialApp Widget
|
||||
|
||||
## [0.2.0-dev.0]
|
||||
|
||||
* 适配 null-safety
|
||||
|
||||
## [0.1.0+1]
|
||||
|
||||
* 增加一些 docs comment,修改 pubspec.yaml 的描述信息
|
||||
|
||||
## [0.1.0]
|
||||
|
||||
* 开源
|
||||
@@ -0,0 +1,26 @@
|
||||
# Changelog
|
||||
|
||||
[简体中文](./CHANGELOG.md)
|
||||
|
||||
## [0.3.0]
|
||||
|
||||
* Remove static function. Use the `UMEWidget`.
|
||||
* Allow insert `Widget` into Widget tree, in order to access new plugin easily.
|
||||
* Fix the issue of multiple instances of FloatingWidget caused by the refresh state.
|
||||
* Fix the isseue that the plugin is not displayed due to the first layout exception in AOT mode
|
||||
|
||||
## [0.2.1]
|
||||
|
||||
* Remove the extra MaterialApp Widget
|
||||
|
||||
## [0.2.0-dev.0]
|
||||
|
||||
* Adapted Null-Safety.
|
||||
|
||||
## [0.1.0+1]
|
||||
|
||||
* Add some docs comments, modify description in pubspec.yaml.
|
||||
|
||||
## [0.1.0]
|
||||
|
||||
* Open source.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Contributing
|
||||
|
||||
[简体中文](./CONTRIBUTING_cn.md)
|
||||
|
||||
Thank you for your interest in open source contributions.
|
||||
Not only the code, but also contributions such as issues and rich documents are also welcome.
|
||||
|
||||
Please follow the guidelines in this article to make open source contributions to the UME project.
|
||||
|
||||
- [Contributing](#contributing)
|
||||
- [How to contact author](#how-to-contact-author)
|
||||
- [How to raise an Issue](#how-to-raise-an-issue)
|
||||
- [How to raise a Pull Request](#how-to-raise-a-pull-request)
|
||||
- [Commit Message specification](#commit-message-specification)
|
||||
|
||||
## How to contact author
|
||||
|
||||
**Maybe...**
|
||||
|
||||
- Found a bug in the code, or an error in the documentation
|
||||
- Produces an exception when you use the UME
|
||||
- UME is not compatible with the new version Flutter
|
||||
- Have a good idea or suggestion
|
||||
|
||||
You can [submit an issue](#how-to-raise-an-issue) in any of the above situations。
|
||||
|
||||
**Maybe...**
|
||||
|
||||
- Communicate with the author
|
||||
- Communicate with more community developers
|
||||
- Cooperate with UME
|
||||
|
||||
Welcome to [Join the ByteDance Flutter Exchange Group](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=b07u55bb-68f0-4a4b-871d-687637766a68).
|
||||
|
||||
Or contact [author](mailto:sunkai.dev@bytedance.com).
|
||||
|
||||
## How to raise an Issue
|
||||
|
||||
1. Go to [Issues](https://github.com/bytedance/flutter_ume/issues).
|
||||
2. Search for similar situations, if there is a match, directly feedback in it.
|
||||
3. If there is not, press [New issue](https://github.com/bytedance/flutter_ume/issues/new/choose) to raise a new one.
|
||||
4. Select a template.
|
||||
5. Describe your situation, and fill in the template.
|
||||
6. It is better to attach a demo that can reproduce the problem.
|
||||
|
||||
## How to raise a Pull Request
|
||||
|
||||
1. Fork the repository.
|
||||
2. Clone your fork repository.
|
||||
3. Checkout to the correct develop branch, and then create a new brnach based on the develop branch.
|
||||
4. Edit code.
|
||||
5. Edit test code in example project, and test it manually.
|
||||
6. Edit unit test in test directory.
|
||||
7. Commit your changes and push it. Please follow the [Commit Message specification](#commit-message-specification) to write the commit message.
|
||||
8. Create Pull Request in GitHub, and fill in the template.
|
||||
|
||||
> Now, UME support null-safety and non-null-safety.
|
||||
> Null-safety version corresponds to `develop_nullsafety` branch, non-null-safety version corresponds to `develop` branch.
|
||||
> PR should be merged into the corresponding branch.
|
||||
|
||||
## Commit Message specification
|
||||
|
||||
1. Please use english.
|
||||
2. If you have references, please attach a link.
|
||||
3. Format: `[tags] description`
|
||||
1. `tags` is the type of PR, such as `fix`, `feat`, `improve`.
|
||||
2. `description` is used to describe changes.
|
||||
|
||||
The following is a standard Commit message example:
|
||||
|
||||
``` plaintext
|
||||
[fix] README.md document syntax error
|
||||
```
|
||||
|
||||
``` plaintext
|
||||
[feat] New feature description
|
||||
|
||||
[https://flutter.dev/dash](https://flutter.dev/dash)
|
||||
```
|
||||
@@ -0,0 +1,79 @@
|
||||
# Contributing
|
||||
|
||||
[English](./CONTRIBUTING.md)
|
||||
|
||||
感谢你对开源贡献感兴趣。
|
||||
不止是代码,提 issue、补充和扩展文档等贡献也都欢迎。
|
||||
|
||||
请根据本文的指引,对 UME 项目进行开源贡献。
|
||||
|
||||
- [Contributing](#contributing)
|
||||
- [如何联系开发者](#如何联系开发者)
|
||||
- [如何提 Issue](#如何提-issue)
|
||||
- [如何提 Pull Request](#如何提-pull-request)
|
||||
- [Commit Message 规范](#commit-message-规范)
|
||||
|
||||
## 如何联系开发者
|
||||
|
||||
**可能你:**
|
||||
|
||||
- 发现文档错误、代码有 bug
|
||||
- 使用 UME 后应用运行产生异常
|
||||
- 发现新版本 Flutter 无法兼容
|
||||
- 有好的点子或产品建议
|
||||
|
||||
上述情况均可以[提一个 issue](#how-to-issue)。
|
||||
|
||||
**可能你:**
|
||||
|
||||
- 想与开发者交流
|
||||
- 想与更多 Flutter 开发者交流
|
||||
- 想与 UME 开展交流或合作
|
||||
|
||||
欢迎[加入字节跳动 Flutter 交流群](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=b07u55bb-68f0-4a4b-871d-687637766a68)
|
||||
|
||||
或随时[联系开发者](mailto:sunkai.dev@bytedance.com)
|
||||
|
||||
## 如何提 Issue
|
||||
|
||||
1. 点击本仓库的 [Issue 页面](https://github.com/bytedance/flutter_ume/issues)
|
||||
2. 先搜索是否有和你类似情况的 issue,若有请直接在该 issue 中反馈问题
|
||||
3. 若没有类似情况 issue,点击 [New issue 按钮](https://github.com/bytedance/flutter_ume/issues/new/choose)
|
||||
4. 选择一个适合你的 issue 模板
|
||||
5. 在模板中填写对应信息
|
||||
6. 如果有能复现问题的最简 Demo 就再好不过了
|
||||
|
||||
## 如何提 Pull Request
|
||||
|
||||
1. Fork 本仓库
|
||||
2. 将你 fork 的仓库 clone 到本地
|
||||
3. 切换到对应开发分支,并 checkout 出新分支
|
||||
4. 在本地修改代码
|
||||
5. 修改 example 工程的测试代码,并进行手工测试
|
||||
6. 在 test 目录下,修改单元测试
|
||||
7. 在本地提交改动并推送到你 fork 的仓库,commit message 格式请遵循本文 [Commit Message 规范](#commit-message) 部分
|
||||
8. 在 GitHub 上创建 Pull Request,在模板中填写对应信息
|
||||
|
||||
> 目前,UME 同时支持 null-safety 版本与非 null-safety 版本。
|
||||
> null-safety 版本开发分支为 `develop_nullsafety`,非 null-safety 版本开发分支为 `develop`。
|
||||
> PR 需要合入对应的开发分支中。
|
||||
|
||||
## Commit Message 规范
|
||||
|
||||
1. 原则上请尽量使用英文
|
||||
2. 涉及到参考资料的,请附链接
|
||||
3. 格式:`[tags] description`
|
||||
1. `tags` 为 PR 的类型,如 `fix` 修复错误、`feat` 新增功能、`improve` 改进代码或文档
|
||||
2. `description` 为具体的改动描述
|
||||
|
||||
以下为标准的 Commit message 示例:
|
||||
|
||||
``` plaintext
|
||||
[fix] README.md document syntax error
|
||||
```
|
||||
|
||||
``` plaintext
|
||||
[feat] New feature description
|
||||
|
||||
[https://flutter.dev/dash](https://flutter.dev/dash)
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 ByteDance Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,471 @@
|
||||
# flutter_ume
|
||||
|
||||
[简体中文](./README_cn.md)
|
||||
|
||||
UME is an in-app debug kits platform for Flutter apps.
|
||||
|
||||
[](https://pub.dev/packages/flutter_ume) [](https://github.com/bytedance/flutter_ume/blob/master/LICENSE)
|
||||
|
||||
[](https://pub.dev/packages/flutter_ume)
|
||||
[](https://pub.dev/packages/flutter_ume)
|
||||
[](https://pub.dev/packages/flutter_ume)
|
||||
[](https://pub.dev/packages/flutter_ume)
|
||||
[](https://pub.dev/packages/flutter_ume)
|
||||
|
||||
**Since `^1.0.0`, flutter_ume starts adapting to the Flutter 3. See [Quick Start] to learn more.**
|
||||
|
||||
<img src="https://github.com/bytedance/flutter_ume/raw/master/apk_qrcode.png" width = "128" height = "128" alt="banner" />
|
||||
|
||||
Scan QR code or click link to download apk. Try it now!
|
||||
https://github.com/bytedance/flutter_ume/releases/download/v0.2.1.0/app-debug.apk
|
||||
|
||||
There are 13 plugin kits built in the latest open source version of UME.
|
||||
Developer could create custom plugin kits, and integrate them into UME.
|
||||
Visit [Develop plugin kits for UME](#develop-plugin-kits-for-ume) for more details.
|
||||
|
||||
**Please see [Plugins from community](#plugins-from-community) to make your flutter_ume stronger.**
|
||||
|
||||
- [flutter_ume](#flutter_ume)
|
||||
- [Quick Start](#quick-start)
|
||||
- [IMPORTANT](#important)
|
||||
- [Features](#features)
|
||||
- [Develop plugin kits for UME](#develop-plugin-kits-for-ume)
|
||||
- [Access the nested widget debug kits quickly](#access-the-nested-widget-debug-kits-quickly)
|
||||
- [How to use UME in Release/Profile mode](#how-to-use-ume-in-releaseprofile-mode)
|
||||
- [About version](#about-version)
|
||||
- [Compatibility](#compatibility)
|
||||
- [Coverage](#coverage)
|
||||
- [Version upgrade rules](#version-upgrade-rules)
|
||||
- [Null-safety](#null-safety)
|
||||
- [Change log](#change-log)
|
||||
- [Contributing](#contributing)
|
||||
- [Contributors](#contributors)
|
||||
- [Plugins from community](#plugins-from-community)
|
||||
- [About the third-party open-source project dependencies](#about-the-third-party-open-source-project-dependencies)
|
||||
- [LICENSE](#license)
|
||||
- [Contact the author](#contact-the-author)
|
||||
|
||||
## Quick Start
|
||||
|
||||
**All packages whose names are prefixed with `flutter_ume_kit_` are function**
|
||||
**plug-ins of UME, and users can access them according to demand**
|
||||
|
||||
1. Edit `pubspec.yaml`, and add dependencies.
|
||||
|
||||
**Compatible with Flutter 3 since version `1.0.0`.**
|
||||
|
||||
``` yaml
|
||||
dev_dependencies:
|
||||
flutter_ume: ^1.0.1
|
||||
flutter_ume_kit_ui: ^1.0.0
|
||||
flutter_ume_kit_device: ^1.0.0
|
||||
flutter_ume_kit_perf: ^1.0.0
|
||||
flutter_ume_kit_show_code: ^1.0.0
|
||||
flutter_ume_kit_console: ^1.0.0
|
||||
flutter_ume_kit_dio: ^1.0.0
|
||||
```
|
||||
|
||||
|
||||
**↓ Null-safety version, compatible with Flutter 2.x**
|
||||
|
||||
``` yaml
|
||||
dev_dependencies: # Don't use UME in release mode
|
||||
flutter_ume: ^0.3.0+1
|
||||
flutter_ume_kit_ui: ^0.3.0+1
|
||||
flutter_ume_kit_device: ^0.3.0
|
||||
flutter_ume_kit_perf: ^0.3.0
|
||||
flutter_ume_kit_show_code: ^0.3.0
|
||||
flutter_ume_kit_console: ^0.3.0
|
||||
flutter_ume_kit_dio: ^0.3.0
|
||||
```
|
||||
|
||||
**↓ Non-null-safety version, compatible with Flutter 1.x**
|
||||
|
||||
``` yaml
|
||||
dev_dependencies: # Don't use UME in release mode
|
||||
flutter_ume: ^0.1.1
|
||||
flutter_ume_kit_ui: ^0.1.1
|
||||
flutter_ume_kit_device: ^0.1.1
|
||||
flutter_ume_kit_perf: ^0.1.1
|
||||
flutter_ume_kit_show_code: ^0.1.1
|
||||
flutter_ume_kit_console: ^0.1.1
|
||||
```
|
||||
|
||||
2. Run `flutter pub get`
|
||||
3. Import packages
|
||||
|
||||
``` dart
|
||||
import 'package:flutter_ume/flutter_ume.dart'; // UME framework
|
||||
import 'package:flutter_ume_kit_ui/flutter_ume_kit_ui.dart'; // UI kits
|
||||
import 'package:flutter_ume_kit_perf/flutter_ume_kit_perf.dart'; // Performance kits
|
||||
import 'package:flutter_ume_kit_show_code/flutter_ume_kit_show_code.dart'; // Show Code
|
||||
import 'package:flutter_ume_kit_device/flutter_ume_kit_device.dart'; // Device info
|
||||
import 'package:flutter_ume_kit_console/flutter_ume_kit_console.dart'; // Show debugPrint
|
||||
import 'package:flutter_ume_kit_dio/flutter_ume_kit_dio.dart'; // Dio Inspector
|
||||
```
|
||||
|
||||
4. Edit main method of your app, register plugin kits and initial UME
|
||||
|
||||
``` dart
|
||||
void main() {
|
||||
if (kDebugMode) {
|
||||
PluginManager.instance // Register plugin kits
|
||||
..register(WidgetInfoInspector())
|
||||
..register(WidgetDetailInspector())
|
||||
..register(ColorSucker())
|
||||
..register(AlignRuler())
|
||||
..register(ColorPicker()) // New feature
|
||||
..register(TouchIndicator()) // New feature
|
||||
..register(Performance())
|
||||
..register(ShowCode())
|
||||
..register(MemoryInfoPage())
|
||||
..register(CpuInfoPage())
|
||||
..register(DeviceInfoPanel())
|
||||
..register(Console())
|
||||
..register(DioInspector(dio: dio)); // Pass in your Dio instance
|
||||
// After flutter_ume 0.3.0
|
||||
runApp(UMEWidget(child: MyApp(), enable: true));
|
||||
// Before flutter_ume 0.3.0
|
||||
runApp(injectUMEWidget(child: MyApp(), enable: true));
|
||||
} else {
|
||||
runApp(MyApp());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
5. `flutter run` for running
|
||||
or `flutter build apk --debug`、`flutter build ios --debug` for building productions.
|
||||
|
||||
> Some functions rely on VM Service, and additional parameters need to be added for local operation to ensure that it can connect to the VM Service.
|
||||
>
|
||||
> Flutter 2.0.x, 2.2.x and other versions run on real devices, `flutter run` needs to add the `--disable-dds` parameter.
|
||||
> After [Pull Request #80900](https://github.com/flutter/flutter/pull/80900) merging, `--disable-dds` was renamed to `--no-dds`.
|
||||
|
||||
## IMPORTANT
|
||||
|
||||
**From `0.1.1`/`0.2.1` version,we don't need set `useRootNavigator: false`.**
|
||||
The following section only applies to versions before version `0.1.1`/`0.2.1` .
|
||||
|
||||
<s>
|
||||
|
||||
Since UME manages the routing stack at the top level, methods such as `showDialog` use `rootNavigator` to pop up by default,
|
||||
therefore **must** pass in the parameter `useRootNavigator: false` in `showDialog`, `showGeneralDialog` and other 'show dialog' methods to avoid navigator errors.
|
||||
|
||||
``` dart
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Dialog'),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('OK'))
|
||||
],
|
||||
),
|
||||
useRootNavigator: false); // <===== It's very IMPORTANT!
|
||||
```
|
||||
|
||||
</s>
|
||||
|
||||
## Features
|
||||
|
||||
There are 13 plugin kits built in the current open source version of UME.
|
||||
|
||||
<table border="1" width="100%">
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><p>UI kits</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/widget_info.png" width="100%" alt="Widget Info" /></br>Widget Info</td>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/widget_detail.png" width="100%" alt="Widget Detail" /></br>Widget Detail</td>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/align_ruler.png" width="100%" alt="Align Ruler" /></br>Align Ruler</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/color_picker.png" width="100%" alt="Color Picker" /></br>Color Picker</td>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/color_sucker.png" width="100%" alt="Color Sucker" /></br>Color Sucker</td>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/touch_indicator.png" width="100%" alt="Touch Indicator" /></br>Touch Indicator</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><p>Performance Kits</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/memory_info.png" width="100%" alt="Memory Info" /></br>Memory Info</td>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/perf_overlay.png" width="100%" alt="Perf Overlay" /></br>Perf Overlay</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><p>Device Info Kits</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/cpu_info.png" width="100%" alt="CPU Info" /></br>CPU Info</td>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/device_info.png" width="100%" alt="Device Info" /></br>Device Info</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><p>Show Code</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/show_code.png" width="100%" alt="Show Code" /></br>Show Code</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><p>Console</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/console.png" width="100%" alt="Console" /></br>Console</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><p>Dio Inspector</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/dio_inspector.png" width="100%" alt="Dio Inspector" /></br>Dio Inspector</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Develop plugin kits for UME
|
||||
|
||||
> UME plugins are located in the `./kits` directory, and each one is a `package`.
|
||||
> You can refer to the example in [`./custom_plugin_example`](./custom_plugin_example/) about this chapter.
|
||||
|
||||
1. Run `flutter create -t package custom_plugin` to create your custom plugin kit, it could be `package` or `plugin`.
|
||||
2. Edit `pubspec.yaml` of the custom plugin kit to add UME framework dependency.
|
||||
|
||||
``` yaml
|
||||
dependencies:
|
||||
flutter_ume: '>=0.3.0 <0.4.0'
|
||||
```
|
||||
|
||||
3. Create the class of the plugin kit which should implement `Pluggable`.
|
||||
|
||||
``` dart
|
||||
import 'package:flutter_ume/flutter_ume.dart';
|
||||
|
||||
class CustomPlugin implements Pluggable {
|
||||
CustomPlugin({Key key});
|
||||
|
||||
@override
|
||||
Widget buildWidget(BuildContext context) => Container(
|
||||
color: Colors.white
|
||||
width: 100,
|
||||
height: 100,
|
||||
child: Center(
|
||||
child: Text('Custom Plugin')
|
||||
),
|
||||
); // The panel of the plugin kit
|
||||
|
||||
@override
|
||||
String get name => 'CustomPlugin'; // The name of the plugin kit
|
||||
|
||||
@override
|
||||
String get displayName => 'CustomPlugin';
|
||||
|
||||
@override
|
||||
void onTrigger() {} // Call when tap the icon of plugin kit
|
||||
|
||||
@override
|
||||
ImageProvider<Object> get iconImageProvider => NetworkImage('url'); // The icon image of the plugin kit
|
||||
}
|
||||
```
|
||||
|
||||
4. Use your custom plugin kit in project
|
||||
|
||||
1. Edit `pubspec.yaml` of host app project to add `custom_plugin` dependency.
|
||||
|
||||
``` yaml
|
||||
dev_dependencies:
|
||||
custom_plugin:
|
||||
path: path/to/custom_plugin
|
||||
```
|
||||
|
||||
2. Run `flutter pub get`
|
||||
|
||||
3. Import package
|
||||
|
||||
``` dart
|
||||
import 'package:custom_plugin/custom_plugin.dart';
|
||||
```
|
||||
|
||||
5. Edit main method of your app, register your custom_plugin plugin kit
|
||||
|
||||
``` dart
|
||||
if (kDebugMode) {
|
||||
PluginManager.instance
|
||||
..register(CustomPlugin());
|
||||
runApp(
|
||||
UMEWidget(
|
||||
child: MyApp(),
|
||||
enable: true
|
||||
)
|
||||
);
|
||||
} else {
|
||||
runApp(MyApp());
|
||||
}
|
||||
```
|
||||
|
||||
6. Run your app
|
||||
|
||||
### Access the nested widget debug kits quickly
|
||||
|
||||
We introduce the `PluggableWithNestedWidget` from `0.3.0`. It is used to insert nested Widgets in the Widget tree and quickly access embedded kits with nested widget.
|
||||
|
||||
For more details, see [./kits/flutter_ume_kit_ui/lib/components/color_picker/color_picker.dart](https://github.com/bytedance/flutter_ume/blob/master/kits/flutter_ume_kit_ui/lib/components/color_picker/color_picker.dart) and [./kits/flutter_ume_kit_ui/lib/components/touch_indicator/touch_indicator.dart](https://github.com/bytedance/flutter_ume/blob/master/kits/flutter_ume_kit_ui/lib/components/touch_indicator/touch_indicator.dart).
|
||||
|
||||
The key steps are as follows:
|
||||
|
||||
1. The class of your plugin should implement `PluggableWithNestedWidget`.
|
||||
2. Implements `Widget buildNestedWidget(Widget child)`. Handling the nested widgets and returning the new Widget.
|
||||
|
||||
## How to use UME in Release/Profile mode
|
||||
|
||||
**Once you use flutter_ume in Release/Profile mode, you agree that you will**
|
||||
**bear the relevant risks by yourself.**
|
||||
|
||||
**The maintainer of flutter_ume does not assume any responsibility for the accident**
|
||||
**caused by this.**
|
||||
|
||||
**We recommend not to use it in Release/Profile mode for the following reasons:**
|
||||
|
||||
1. VM Service is not available in these environments, so some functions are not available
|
||||
2. In this environment, developers need to isolate the app distribution channels by themselves to avoid submitting relevant debugging code to the production environment
|
||||
|
||||
In order to use in Release/Profile mode, the details that need to be adjusted in the normal access process:
|
||||
|
||||
1. In `pubspec.yaml`, `flutter_ume` and plugins should be write below `dependencies` rather than `dev_dependencies`.
|
||||
2. Don't put the code which call `PluginManager.instance.register()` and `UMEWidget(child: App())` into conditionals which represent debug mode. (Such as `kDebugMode`)
|
||||
3. Ensure the above details, run `flutter clean` and `flutter pub get`, then build your app.
|
||||
|
||||
## About version
|
||||
|
||||
### Compatibility
|
||||
|
||||
| UME version | 1.12.13 | 1.22.3 | 2.0.1 | 2.2.3 | 2.5.3 | 2.8.0 | 3.0.5 | 3.3.1
|
||||
| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
|
||||
| 0.1.x | ✅ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ❌ | ❌ |
|
||||
| 0.2.x | ❌ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ❌ | ❌ |
|
||||
| 0.3.x | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
| 1.0.x | ❌ | ❌ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ | ✅ |
|
||||
| 1.1.x | ❌ | ❌ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ | ✅ |
|
||||
|
||||
⚠️ means the version has not been fully tested for compatibility.
|
||||
|
||||
### Special case
|
||||
|
||||
- Please use `flutter_ume_kit_ui: ^1.1.0` and above version when you are using Flutter 3.7 and above.
|
||||
|
||||
### Coverage
|
||||
|
||||
| Package | master | develop | develop_nullsafety |
|
||||
| ---- | ---- | ---- | ---- |
|
||||
| flutter_ume |  |  |  |
|
||||
| flutter_ume_kit_device |  |  |  |
|
||||
| flutter_ume_kit_perf |  |  |  |
|
||||
| flutter_ume_kit_show_code |  |  |  |
|
||||
| flutter_ume_kit_ui |  |  |  |
|
||||
| flutter_ume_kit_console |  |  |  |
|
||||
| flutter_ume_kit_dio |  | N/A |  |
|
||||
|
||||
### Version upgrade rules
|
||||
|
||||
Please refer to [Semantic versions](https://dart.dev/tools/pub/versioning#semantic-versions) for details.
|
||||
|
||||
### Change log
|
||||
|
||||
[Changelog](./CHANGELOG.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributing rules: [Contributing](./CONTRIBUTING_en.md)
|
||||
|
||||
### Contributors
|
||||
|
||||
Thanks to the following contributors (names not listed in order):
|
||||
|
||||
| | |
|
||||
| ---- | ---- |
|
||||
|  | [ShirelyC](https://github.com/smileShirely) |
|
||||
|  | [lpylpyleo](https://github.com/lpylpyleo) |
|
||||
|  | [Alex Li](https://github.com/AlexV525) |
|
||||
|  | [Swain](https://github.com/talisk) |
|
||||
|  | [mengdouer](https://github.com/mengdouer) |
|
||||
|  | [LAIIIHZ](https://github.com/laiiihz) |
|
||||
|  | [XinLei](https://github.com/Vadaski) |
|
||||
|  | [suli](https://github.com/suli1) |
|
||||
|  | [wei-spring](https://github.com/wei-spring) |
|
||||
|
||||
### Plugins from community
|
||||
|
||||
- [flutter_ume_kit_channel_monitor](https://pub.dev/packages/flutter_ume_kit_channel_monitor)
|
||||
- Channel communication monitor
|
||||
- Cource code: https://github.com/bytedance/flutter_ume/tree/master/kits/flutter_ume_kit_channel_monitor
|
||||
- [flutter_ume_kit_slow_animation](https://pub.dev/packages/flutter_ume_kit_slow_animation)
|
||||
- Animation speed control
|
||||
- Cource code: https://github.com/cfug/flutter_ume_kits
|
||||
- [flutter_ume_kit_shared_preferences](https://pub.dev/packages/flutter_ume_kit_shared_preferences)
|
||||
- shared_preferences tool
|
||||
- Cource code: https://github.com/cfug/flutter_ume_kits
|
||||
- [flutter_ume_kit_designer_check](https://pub.dev/packages/)
|
||||
- Comparing tool for Design UI and real UI
|
||||
- Cource code: https://github.com/cfug/flutter_ume_kits
|
||||
- [flutter_ume_kit_clean_local_data](https://pub.dev/packages/flutter_ume_kit_clean_local_data)
|
||||
- Clean local data
|
||||
- Cource code: https://github.com/cfug/flutter_ume_kits 。
|
||||
- [flutter_ume_kit_database_kit](https://pub.dev/packages/flutter_ume_kit_database_kit)
|
||||
- DB tool
|
||||
- Cource code: https://github.com/cfug/flutter_ume_kits 。
|
||||
- [ume_kit_monitor](https://pub.dev/packages/ume_kit_monitor)
|
||||
- Parameters monitor tools
|
||||
- Cource code: https://github.com/fastcode555/ume_kit_monitor 。
|
||||
- [json2dart_viewerffi](https://pub.dev/packages/json2dart_viewerffi)
|
||||
- DB tool
|
||||
- Cource code: https://github.com/fastcode555/Json2Dart_Null_Safety 。
|
||||
- [json2dart_viewer](https://pub.dev/packages/json2dart_viewer)
|
||||
- DB tool
|
||||
- Cource code: https://github.com/fastcode555/Json2Dart_Null_Safety 。
|
||||
- [memory_detector_of_kit](https://github.com/bladeofgod/memory_detector_of_kit)
|
||||
- Leaks tool
|
||||
- [channel_observer_of_kit](https://github.com/bladeofgod/channel_observer_of_kit)
|
||||
- Channel communication monitor
|
||||
- [flutter-ume-kit-dio-enhance](https://github.com/linversion/flutter-ume-kit-dio-enhance)
|
||||
- Plugin base on flutter_ume_kit_dio
|
||||
|
||||
### About the third-party open-source project dependencies
|
||||
|
||||
- The TouchIndicator use the pub [touch_indicator](https://pub.dev/packages/touch_indicator), the ColorPicker use the pub [cyclop](https://pub.dev/packages/cyclop).
|
||||
- We [fork](https://github.com/talisk/cyclop) the package [cyclop](https://pub.dev/packages/cyclop) and modify some code meet our functional needs. We should depend cyclop by pub version after the [PR](https://github.com/rxlabz/cyclop/pull/11) being merged.
|
||||
|
||||
## LICENSE
|
||||
|
||||
This project is licensed under the MIT License - visit the [LICENSE](./LICENSE) for details.
|
||||
|
||||
## Contact the author
|
||||
|
||||
**Maybe...**
|
||||
|
||||
- Found a bug in the code, or an error in the documentation
|
||||
- Produces an exception when you use the UME
|
||||
- UME is not compatible with the new version Flutter
|
||||
- Have a good idea or suggestion
|
||||
|
||||
You can [submit an issue](./CONTRIBUTING_en.md#how-to-raise-an-issue) in any of the above situations.
|
||||
|
||||
**Maybe...**
|
||||
|
||||
- Communicate with the author
|
||||
- Communicate with more community developers
|
||||
- Cooperate with UME
|
||||
|
||||
Welcome to [Join the ByteDance Flutter Exchange Group](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=67au2f75-3783-41b0-8868-0fc0178f1fd8).
|
||||
|
||||
Or contact [author](mailto:sunkai.dev@bytedance.com).
|
||||
@@ -0,0 +1,470 @@
|
||||
# flutter_ume
|
||||
|
||||
[English](./README.md)
|
||||
|
||||
Flutter 应用内调试工具平台
|
||||
|
||||
[](https://pub.dev/packages/flutter_ume) [](https://github.com/bytedance/flutter_ume/blob/master/LICENSE)
|
||||
|
||||
[](https://pub.dev/packages/flutter_ume)
|
||||
[](https://pub.dev/packages/flutter_ume)
|
||||
[](https://pub.dev/packages/flutter_ume)
|
||||
[](https://pub.dev/packages/flutter_ume)
|
||||
[](https://pub.dev/packages/flutter_ume)
|
||||
|
||||
**Since `^1.0.0`, flutter_ume starts adapting to the Flutter 3. See [Quick Start] to learn more.**
|
||||
|
||||
<img src="https://github.com/bytedance/flutter_ume/raw/master/apk_qrcode.png" width = "128" height = "128" alt="banner" />
|
||||
|
||||
扫码或点击链接下载 apk,快速体验 UME。
|
||||
https://github.com/bytedance/flutter_ume/releases/download/v0.2.1.0/app-debug.apk
|
||||
|
||||
最新版本(1.0.1)内置 13 个插件,
|
||||
开发者可以创建自己的插件,并集成进 UME 平台。
|
||||
详见本文[为 UME 开发插件](#为-ume-开发插件)部分。
|
||||
|
||||
**更多开源社区贡献的调试插件,请见[社区插件](#社区插件)部分。**
|
||||
|
||||
- [flutter_ume](#flutter_ume)
|
||||
- [快速接入](#快速接入)
|
||||
- [特别说明](#特别说明)
|
||||
- [功能介绍](#功能介绍)
|
||||
- [为 UME 开发插件](#为-ume-开发插件)
|
||||
- [快速集成嵌入式插件](#快速集成嵌入式插件)
|
||||
- [如何在 Release/Profile mode 下使用 UME](#如何在-releaseprofile-mode-下使用-ume)
|
||||
- [版本说明](#版本说明)
|
||||
- [兼容性](#兼容性)
|
||||
- [单测覆盖率](#单测覆盖率)
|
||||
- [版本号规则](#版本号规则)
|
||||
- [Null-safety 版本](#null-safety-版本)
|
||||
- [更新日志](#更新日志)
|
||||
- [开源贡献](#开源贡献)
|
||||
- [贡献者](#贡献者)
|
||||
- [社区插件](#社区插件)
|
||||
- [第三方开源项目说明](#第三方开源项目说明)
|
||||
- [开源协议](#开源协议)
|
||||
- [联系开发者](#联系开发者)
|
||||
|
||||
## 快速接入
|
||||
|
||||
**所有名称前缀为 `flutter_ume_kit_` 的 package 都是 UME 的功能插件,**
|
||||
**用户可按需接入。**
|
||||
|
||||
1. 修改 `pubspec.yaml`,添加依赖
|
||||
|
||||
**自 `1.0.0` 版本开始适配 Flutter 3。**
|
||||
|
||||
``` yaml
|
||||
dev_dependencies:
|
||||
flutter_ume: ^1.0.1
|
||||
flutter_ume_kit_ui: ^1.0.0
|
||||
flutter_ume_kit_device: ^1.0.0
|
||||
flutter_ume_kit_perf: ^1.0.0
|
||||
flutter_ume_kit_show_code: ^1.0.0
|
||||
flutter_ume_kit_console: ^1.0.0
|
||||
flutter_ume_kit_dio: ^1.0.0
|
||||
```
|
||||
|
||||
**↓ Null-safety 版本,适用于 Flutter 2.x**
|
||||
|
||||
``` yaml
|
||||
dev_dependencies:
|
||||
flutter_ume: ^0.3.0+1
|
||||
flutter_ume_kit_ui: ^0.3.0+1
|
||||
flutter_ume_kit_device: ^0.3.0
|
||||
flutter_ume_kit_perf: ^0.3.0
|
||||
flutter_ume_kit_show_code: ^0.3.0
|
||||
flutter_ume_kit_console: ^0.3.0
|
||||
flutter_ume_kit_dio: ^0.3.0
|
||||
```
|
||||
|
||||
**↓ 非 Null-safety 版本,适用于 Flutter 1.x**
|
||||
|
||||
``` yaml
|
||||
dev_dependencies:
|
||||
flutter_ume: ^0.1.1
|
||||
flutter_ume_kit_ui: ^0.1.1
|
||||
flutter_ume_kit_device: ^0.1.1
|
||||
flutter_ume_kit_perf: ^0.1.1
|
||||
flutter_ume_kit_show_code: ^0.1.1
|
||||
flutter_ume_kit_console: ^0.1.1
|
||||
```
|
||||
|
||||
2. 执行 `flutter pub get`
|
||||
3. 引入包
|
||||
|
||||
``` dart
|
||||
import 'package:flutter_ume/flutter_ume.dart'; // UME 框架
|
||||
import 'package:flutter_ume_kit_ui/flutter_ume_kit_ui.dart'; // UI 插件包
|
||||
import 'package:flutter_ume_kit_perf/flutter_ume_kit_perf.dart'; // 性能插件包
|
||||
import 'package:flutter_ume_kit_show_code/flutter_ume_kit_show_code.dart'; // 代码查看插件包
|
||||
import 'package:flutter_ume_kit_device/flutter_ume_kit_device.dart'; // 设备信息插件包
|
||||
import 'package:flutter_ume_kit_console/flutter_ume_kit_console.dart'; // debugPrint 插件包
|
||||
import 'package:flutter_ume_kit_dio/flutter_ume_kit_dio.dart'; // Dio 网络请求调试工具
|
||||
```
|
||||
|
||||
4. 修改程序入口,增加初始化方法及注册插件代码
|
||||
|
||||
``` dart
|
||||
void main() {
|
||||
if (kDebugMode) {
|
||||
PluginManager.instance // 注册插件
|
||||
..register(WidgetInfoInspector())
|
||||
..register(WidgetDetailInspector())
|
||||
..register(ColorSucker())
|
||||
..register(AlignRuler())
|
||||
..register(ColorPicker()) // 新插件
|
||||
..register(TouchIndicator()) // 新插件
|
||||
..register(Performance())
|
||||
..register(ShowCode())
|
||||
..register(MemoryInfoPage())
|
||||
..register(CpuInfoPage())
|
||||
..register(DeviceInfoPanel())
|
||||
..register(Console())
|
||||
..register(DioInspector(dio: dio)); // 传入你的 Dio 实例
|
||||
// flutter_ume 0.3.0 版本之后
|
||||
runApp(UMEWidget(child: MyApp(), enable: true)); // 初始化
|
||||
// flutter_ume 0.3.0 版本之前
|
||||
runApp(injectUMEWidget(child: MyApp(), enable: true)); // 初始化
|
||||
} else {
|
||||
runApp(MyApp());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
5. `flutter run` 运行代码
|
||||
或 `flutter build apk --debug`、`flutter build ios --debug` 构建产物
|
||||
|
||||
> 部分功能依赖 VM Service,本地运行需要添加额外参数,以确保能够连接到 VM Service。
|
||||
>
|
||||
> Flutter 2.0.x、2.2.x 等版本在真机上运行,`flutter run` 需要添加 `--disable-dds` 参数。
|
||||
> 在 [Pull Request #80900](https://github.com/flutter/flutter/pull/80900) 合入之后,`--disable-dds` 参数被更名为 `--no-dds`。
|
||||
|
||||
## 特别说明
|
||||
|
||||
**自 `0.1.1`/`0.2.1` 版本起,已经不需要设置 `useRootNavigator: false`。**
|
||||
以下部分仅适用于 `0.1.1`/`0.2.1` 之前的版本。
|
||||
|
||||
<s>
|
||||
|
||||
由于 UME 在顶层管理了路由栈,`showDialog` 等方法默认使用 `rootNavigator` 弹出,
|
||||
所以**必须**在 `showDialog`、`showGeneralDialog` 等弹窗方法,传入参数 `useRootNavigator: false` 避免路由栈错误。
|
||||
|
||||
``` dart
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Dialog'),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('OK'))
|
||||
],
|
||||
),
|
||||
useRootNavigator: false); // <===== 非常重要
|
||||
```
|
||||
|
||||
</s>
|
||||
|
||||
## 功能介绍
|
||||
|
||||
当前开源版 UME 内置了 13 个插件
|
||||
|
||||
<table border="1" width="100%">
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><p>UI 工具包</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/widget_info.png" width="100%" alt="Widget 信息" /></br>Widget 信息</td>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/widget_detail.png" width="100%" alt="Widget 详情" /></br>Widget 详情</td>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/align_ruler.png" width="100%" alt="对齐标尺" /></br>对齐标尺</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/color_picker.png" width="100%" alt="颜色吸管(新)" /></br>颜色吸管(新)</td>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/color_sucker.png" width="100%" alt="颜色吸管" /></br>颜色吸管</td>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/touch_indicator.png" width="100%" alt="触控标记" /></br>触控标记</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><p>性能工具包</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/memory_info.png" width="100%" alt="内存信息" /></br>内存信息</td>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/perf_overlay.png" width="100%" alt="性能浮层" /></br>性能浮层</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><p>设备信息工具包</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/cpu_info.png" width="100%" alt="CPU 信息" /></br>CPU 信息</td>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/device_info.png" width="100%" alt="设备信息" /></br>设备信息</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><p>代码查看</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/show_code.png" width="100%" alt="代码查看" /></br>代码查看</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><p>日志展示</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/console.png" width="100%" alt="日志展示" /></br>日志展示</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><p>Dio 网络请求调试工具</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/dio_inspector.png" width="100%" alt="Dio 网络请求调试工具" /></br>Dio 网络请求调试工具</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 为 UME 开发插件
|
||||
|
||||
> UME 插件位于 `./kits` 目录下,每个插件包都是一个 `package`
|
||||
> 本小节示例可参考 [`./custom_plugin_example`](./custom_plugin_example/)
|
||||
|
||||
1. `flutter create -t package custom_plugin` 创建一个插件包,可以是 `package`,也可以是 `plugin`
|
||||
2. 修改插件包的 `pubspec.yaml`,添加依赖
|
||||
|
||||
``` yaml
|
||||
dependencies:
|
||||
flutter_ume: '>=0.3.0 <0.4.0'
|
||||
```
|
||||
|
||||
3. 创建插件配置,实现 `Pluggable` 虚类
|
||||
|
||||
``` dart
|
||||
import 'package:flutter_ume/flutter_ume.dart';
|
||||
|
||||
class CustomPlugin implements Pluggable {
|
||||
CustomPlugin({Key key});
|
||||
|
||||
@override
|
||||
Widget buildWidget(BuildContext context) => Container(
|
||||
color: Colors.white
|
||||
width: 100,
|
||||
height: 100,
|
||||
child: Center(
|
||||
child: Text('Custom Plugin')
|
||||
),
|
||||
); // 返回插件面板
|
||||
|
||||
@override
|
||||
String get name => 'CustomPlugin'; // 插件名称
|
||||
|
||||
@override
|
||||
String get displayName => 'CustomPlugin';
|
||||
|
||||
@override
|
||||
void onTrigger() {} // 点击插件面板图标时调用
|
||||
|
||||
@override
|
||||
ImageProvider<Object> get iconImageProvider => NetworkImage('url'); // 插件图标
|
||||
}
|
||||
```
|
||||
|
||||
4. 在工程中引入自定义插件
|
||||
|
||||
1. 修改 `pubspec.yaml`,添加依赖
|
||||
|
||||
``` yaml
|
||||
dev_dependencies:
|
||||
custom_plugin:
|
||||
path: path/to/custom_plugin
|
||||
```
|
||||
|
||||
2. 执行 `flutter pub get`
|
||||
|
||||
3. 引入包
|
||||
|
||||
``` dart
|
||||
import 'package:custom_plugin/custom_plugin.dart';
|
||||
```
|
||||
|
||||
5. 在工程中注册插件
|
||||
|
||||
``` dart
|
||||
if (kDebugMode) {
|
||||
PluginManager.instance
|
||||
..register(CustomPlugin());
|
||||
runApp(
|
||||
UMEWidget(
|
||||
child: MyApp(),
|
||||
enable: true
|
||||
)
|
||||
);
|
||||
} else {
|
||||
runApp(MyApp());
|
||||
}
|
||||
```
|
||||
|
||||
6. 运行代码
|
||||
|
||||
### 快速集成嵌入式插件
|
||||
|
||||
自 `0.3.0` 版本起引入了 `PluggableWithNestedWidget`,用以实现在 Widget tree 中插入嵌套 Widget,快速接入嵌入式插件。
|
||||
|
||||
可参考 [./kits/flutter_ume_kit_ui/lib/components/color_picker/color_picker.dart](https://github.com/bytedance/flutter_ume/blob/master/kits/flutter_ume_kit_ui/lib/components/color_picker/color_picker.dart) 与 [./kits/flutter_ume_kit_ui/lib/components/touch_indicator/touch_indicator.dart](https://github.com/bytedance/flutter_ume/blob/master/kits/flutter_ume_kit_ui/lib/components/touch_indicator/touch_indicator.dart)。
|
||||
|
||||
集成重点如下:
|
||||
|
||||
1. 插件主体类实现 `PluggableWithNestedWidget`
|
||||
2. 实现 `Widget buildNestedWidget(Widget child)`,在该方法中处理嵌套结构并返回 Widget
|
||||
|
||||
## 如何在 Release/Profile mode 下使用 UME
|
||||
|
||||
**开发者一旦在 Release/Profile mode 下使用 flutter_ume,**
|
||||
**即认同将自行承担相关风险,**
|
||||
|
||||
**对于由此引发的事故,flutter_ume 维护方不承担**
|
||||
**任何责任。**
|
||||
|
||||
**不建议在 Release/Profile mode 下使用,原因如下:**
|
||||
|
||||
1. 在该环境下 VM Service 不可用,因此部分插件功能不可用
|
||||
2. 在该环境下开发者需要自行隔离分发渠道,避免将相关调试代码提交到生产环境
|
||||
|
||||
为在 Release/Profile mode 下使用,正常接入流程中需要调整的细节:
|
||||
|
||||
1. `pubspec.yaml` 中,`flutter_ume` 及相关插件包需要在 `dependencies` 中引入,而不是 `dev_dependencies`
|
||||
2. 调用 `PluginManager.instance.register()` 及 `UMEWidget(child: App())` 初始化方法的代码,不得由于 debug 标记剪枝(如 `kDebugMode`)
|
||||
3. 确保以上细节后,依次执行 `flutter clean`、`flutter pub get` 后再进行构建
|
||||
|
||||
## 版本说明
|
||||
|
||||
### 兼容性
|
||||
|
||||
| UME 版本 | 1.12.13 | 1.22.3 | 2.0.1 | 2.2.3 | 2.5.3 | 2.8.0 | 3.0.5 | 3.3.1
|
||||
| ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
|
||||
| 0.1.x | ✅ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ❌ | ❌ |
|
||||
| 0.2.x | ❌ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ❌ | ❌ |
|
||||
| 0.3.x | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ |
|
||||
| 1.0.x | ❌ | ❌ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ | ✅ |
|
||||
| 1.1.x | ❌ | ❌ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ | ✅ |
|
||||
|
||||
⚠️ 意为未经过完整的兼容性测试,不建议使用。
|
||||
|
||||
### 特例
|
||||
|
||||
- Flutter 3.7 及以上版本请使用 `flutter_ume_kit_ui: ^1.1.0` 及以上版本
|
||||
|
||||
### 单测覆盖率
|
||||
|
||||
| 包 | master | develop | develop_nullsafety |
|
||||
| ---- | ---- | ---- | ---- |
|
||||
| flutter_ume |  |  |  |
|
||||
| flutter_ume_kit_device |  |  |  |
|
||||
| flutter_ume_kit_perf |  |  |  |
|
||||
| flutter_ume_kit_show_code |  |  |  |
|
||||
| flutter_ume_kit_ui |  |  |  |
|
||||
| flutter_ume_kit_console |  |  |  |
|
||||
| flutter_ume_kit_dio |  | N/A |  |
|
||||
|
||||
### 版本号规则
|
||||
|
||||
请参考 [Semantic versions](https://dart.dev/tools/pub/versioning#semantic-versions)
|
||||
|
||||
### 更新日志
|
||||
|
||||
[Changelog](./CHANGELOG_cn.md)
|
||||
|
||||
## 开源贡献
|
||||
|
||||
贡献文档:[Contributing](./CONTRIBUTING.md)
|
||||
|
||||
### 贡献者
|
||||
|
||||
感谢以下贡献者(排名不分先后):
|
||||
|
||||
| | |
|
||||
| ---- | ---- |
|
||||
|  | [ShirelyC](https://github.com/smileShirely) |
|
||||
|  | [lpylpyleo](https://github.com/lpylpyleo) |
|
||||
|  | [Alex Li](https://github.com/AlexV525) |
|
||||
|  | [Swain](https://github.com/talisk) |
|
||||
|  | [mengdouer](https://github.com/mengdouer) |
|
||||
|  | [LAIIIHZ](https://github.com/laiiihz) |
|
||||
|  | [XinLei](https://github.com/Vadaski) |
|
||||
|  | [suli](https://github.com/suli1) |
|
||||
|  | [wei-spring](https://github.com/wei-spring) |
|
||||
|
||||
### 社区插件
|
||||
|
||||
- [flutter_ume_kit_channel_monitor](https://pub.dev/packages/flutter_ume_kit_channel_monitor)
|
||||
- channel 通信监控工具
|
||||
- 源代码托管于 https://github.com/bytedance/flutter_ume/tree/master/kits/flutter_ume_kit_channel_monitor
|
||||
- [flutter_ume_kit_slow_animation](https://pub.dev/packages/flutter_ume_kit_slow_animation)
|
||||
- 动画速度调节插件
|
||||
- 源代码托管于 https://github.com/cfug/flutter_ume_kits
|
||||
- [flutter_ume_kit_shared_preferences](https://pub.dev/packages/flutter_ume_kit_shared_preferences)
|
||||
- shared_preferences 调试工具
|
||||
- 源代码托管于 https://github.com/cfug/flutter_ume_kits
|
||||
- [flutter_ume_kit_designer_check](https://pub.dev/packages/)
|
||||
- 设计稿比对工具
|
||||
- 源代码托管于 https://github.com/cfug/flutter_ume_kits
|
||||
- [flutter_ume_kit_clean_local_data](https://pub.dev/packages/flutter_ume_kit_clean_local_data)
|
||||
- 清理本地数据插件
|
||||
- 源代码托管于 https://github.com/cfug/flutter_ume_kits 。
|
||||
- [flutter_ume_kit_database_kit](https://pub.dev/packages/flutter_ume_kit_database_kit)
|
||||
- 数据库调试插件
|
||||
- 源代码托管于 https://github.com/cfug/flutter_ume_kits 。
|
||||
- [ume_kit_monitor](https://pub.dev/packages/ume_kit_monitor)
|
||||
- 参数监控插件
|
||||
- 源代码托管于 https://github.com/fastcode555/ume_kit_monitor 。
|
||||
- [json2dart_viewerffi](https://pub.dev/packages/json2dart_viewerffi)
|
||||
- 数据库调试插件
|
||||
- 源代码托管于 https://github.com/fastcode555/Json2Dart_Null_Safety 。
|
||||
- [json2dart_viewer](https://pub.dev/packages/json2dart_viewer)
|
||||
- 数据库调试插件
|
||||
- 源代码托管于 https://github.com/fastcode555/Json2Dart_Null_Safety 。
|
||||
- [memory_detector_of_kit](https://github.com/bladeofgod/memory_detector_of_kit)
|
||||
- 内存泄漏检测插件
|
||||
- [channel_observer_of_kit](https://github.com/bladeofgod/channel_observer_of_kit)
|
||||
- channel 调用记录监控插件
|
||||
- [flutter-ume-kit-dio-enhance](https://github.com/linversion/flutter-ume-kit-dio-enhance)
|
||||
- 基于 flutter_ume_kit_dio 扩展了一些功能的插件
|
||||
|
||||
### 第三方开源项目说明
|
||||
|
||||
- 触控标记使用了 [touch_indicator](https://pub.dev/packages/touch_indicator),颜色吸管插件使用了 [cyclop](https://pub.dev/packages/cyclop)。
|
||||
- 对 [cyclop](https://pub.dev/packages/cyclop) 进行了 [fork](https://github.com/talisk/cyclop) 并修改代码以满足需要。当 [PR](https://github.com/rxlabz/cyclop/pull/11) 合入后,我们将通过 pub 的形式依赖。
|
||||
|
||||
## 开源协议
|
||||
|
||||
该项目遵循 MIT 协议,详情请见 [LICENSE](./LICENSE)。
|
||||
|
||||
## 联系开发者
|
||||
|
||||
**可能你:**
|
||||
|
||||
- 发现文档错误、代码有 bug
|
||||
- 使用 UME 后应用运行产生异常
|
||||
- 发现新版本 Flutter 无法兼容
|
||||
- 有好的点子或产品建议
|
||||
|
||||
上述情况均可以[提一个 issue](./CONTRIBUTING.md#如何提-issue)。
|
||||
|
||||
**可能你:**
|
||||
|
||||
- 想与开发者交流
|
||||
- 想与更多 Flutter 开发者交流
|
||||
- 想与 UME 开展交流或合作
|
||||
|
||||
欢迎[加入字节跳动 Flutter 交流群](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=67au2f75-3783-41b0-8868-0fc0178f1fd8)
|
||||
|
||||
或随时[联系开发者](mailto:sunkai.dev@bytedance.com)
|
||||
@@ -0,0 +1,419 @@
|
||||
# flutter_ume
|
||||
|
||||
[简体中文](./README.md)
|
||||
|
||||
UME is an in-app debug kits platform for Flutter apps.
|
||||
|
||||
[](https://pub.dev/packages/flutter_ume) [](https://github.com/bytedance/flutter_ume/blob/master/LICENSE) [](https://pub.dev/packages/flutter_ume) ](https://pub.dev/packages/flutter_ume/score) ](https://pub.dev/packages/flutter_ume/score) ](https://pub.dev/packages/flutter_ume/score)
|
||||
|
||||
<img src="https://github.com/bytedance/flutter_ume/raw/master/ume_logo_256.png" width = "128" height = "128" alt="banner" />
|
||||
|
||||
**UME Kits competition is in full swing!** Rich prizes are waiting for you.
|
||||
|
||||
See https://mp.weixin.qq.com/s/RuwiiQAdrGqI00fDhUO77g for more details.
|
||||
|
||||
<img src="https://github.com/bytedance/flutter_ume/raw/master/apk_qrcode.png" width = "256" height = "256" alt="banner" />
|
||||
|
||||
Scan QR code or click link to download apk. Try it now!
|
||||
https://github.com/bytedance/flutter_ume/releases/download/v0.2.1.0/app-debug.apk
|
||||
|
||||
There are 13 plugin kits built in the latest open source version of UME.
|
||||
Developer could create custom plugin kits, and integrate them into UME.
|
||||
Visit [Develop plugin kits for UME](#develop-plugin-kits-for-ume) for more details.
|
||||
|
||||
- [flutter_ume](#flutter_ume)
|
||||
- [Quick Start](#quick-start)
|
||||
- [IMPORTANT](#important)
|
||||
- [Features](#features)
|
||||
- [Develop plugin kits for UME](#develop-plugin-kits-for-ume)
|
||||
- [Access the nested widget debug kits quickly](#access-the-nested-widget-debug-kits-quickly)
|
||||
- [How to use UME in Release/Profile mode](#how-to-use-ume-in-releaseprofile-mode)
|
||||
- [About version](#about-version)
|
||||
- [Compatibility](#compatibility)
|
||||
- [Coverage](#coverage)
|
||||
- [Version upgrade rules](#version-upgrade-rules)
|
||||
- [Null-safety](#null-safety)
|
||||
- [Change log](#change-log)
|
||||
- [Contributing](#contributing)
|
||||
- [Contributors](#contributors)
|
||||
- [About the third-party open-source project dependencies](#about-the-third-party-open-source-project-dependencies)
|
||||
- [LICENSE](#license)
|
||||
- [Contact the author](#contact-the-author)
|
||||
|
||||
## Quick Start
|
||||
|
||||
**All packages whose names are prefixed with `flutter_ume_kit_` are function**
|
||||
**plug-ins of UME, and users can access them according to demand**
|
||||
|
||||
1. Edit `pubspec.yaml`, and add dependencies.
|
||||
|
||||
**↓ Null-safety version, compatible with Flutter 2.x**
|
||||
|
||||
``` yaml
|
||||
dev_dependencies: # Don't use UME in release mode
|
||||
flutter_ume: ^0.3.0+1
|
||||
flutter_ume_kit_ui: ^0.3.0+1
|
||||
flutter_ume_kit_device: ^0.3.0
|
||||
flutter_ume_kit_perf: ^0.3.0
|
||||
flutter_ume_kit_show_code: ^0.3.0
|
||||
flutter_ume_kit_console: ^0.3.0
|
||||
flutter_ume_kit_dio: ^0.3.0
|
||||
```
|
||||
|
||||
**↓ Non-null-safety version, compatible with Flutter 1.x**
|
||||
|
||||
``` yaml
|
||||
dev_dependencies: # Don't use UME in release mode
|
||||
flutter_ume: ^0.1.1
|
||||
flutter_ume_kit_ui: ^0.1.1.1
|
||||
flutter_ume_kit_device: ^0.1.1
|
||||
flutter_ume_kit_perf: ^0.1.1
|
||||
flutter_ume_kit_show_code: ^0.1.1
|
||||
flutter_ume_kit_console: ^0.1.1
|
||||
```
|
||||
|
||||
2. Run `flutter pub get`
|
||||
3. Import packages
|
||||
|
||||
``` dart
|
||||
import 'package:flutter_ume/flutter_ume.dart'; // UME framework
|
||||
import 'package:flutter_ume_kit_ui/flutter_ume_kit_ui.dart'; // UI kits
|
||||
import 'package:flutter_ume_kit_perf/flutter_ume_kit_perf.dart'; // Performance kits
|
||||
import 'package:flutter_ume_kit_show_code/flutter_ume_kit_show_code.dart'; // Show Code
|
||||
import 'package:flutter_ume_kit_device/flutter_ume_kit_device.dart'; // Device info
|
||||
import 'package:flutter_ume_kit_console/flutter_ume_kit_console.dart'; // Show debugPrint
|
||||
import 'package:flutter_ume_kit_dio/flutter_ume_kit_dio.dart'; // Dio Inspector
|
||||
```
|
||||
|
||||
4. Edit main method of your app, register plugin kits and initial UME
|
||||
|
||||
``` dart
|
||||
void main() {
|
||||
if (kDebugMode) {
|
||||
PluginManager.instance // Register plugin kits
|
||||
..register(WidgetInfoInspector())
|
||||
..register(WidgetDetailInspector())
|
||||
..register(ColorSucker())
|
||||
..register(AlignRuler())
|
||||
..register(ColorPicker()) // New feature
|
||||
..register(TouchIndicator()) // New feature
|
||||
..register(Performance())
|
||||
..register(ShowCode())
|
||||
..register(MemoryInfoPage())
|
||||
..register(CpuInfoPage())
|
||||
..register(DeviceInfoPanel())
|
||||
..register(Console())
|
||||
..register(DioInspector(dio: dio)); // Pass in your Dio instance
|
||||
// After flutter_ume 0.3.0
|
||||
runApp(UMEWidget(child: MyApp(), enable: true));
|
||||
// Before flutter_ume 0.3.0
|
||||
runApp(injectUMEWidget(child: MyApp(), enable: true));
|
||||
} else {
|
||||
runApp(MyApp());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
5. `flutter run` for running
|
||||
or `flutter build apk --debug`、`flutter build ios --debug` for building productions.
|
||||
|
||||
> Some functions rely on VM Service, and additional parameters need to be added for local operation to ensure that it can connect to the VM Service.
|
||||
>
|
||||
> Flutter 2.0.x, 2.2.x and other versions run on real devices, `flutter run` needs to add the `--disable-dds` parameter.
|
||||
> After [Pull Request #80900](https://github.com/flutter/flutter/pull/80900) merging, `--disable-dds` was renamed to `--no-dds`.
|
||||
|
||||
## IMPORTANT
|
||||
|
||||
**From `0.1.1`/`0.2.1` version,we don't need set `useRootNavigator: false`.**
|
||||
The following section only applies to versions before version `0.1.1`/`0.2.1` .
|
||||
|
||||
<s>
|
||||
|
||||
Since UME manages the routing stack at the top level, methods such as `showDialog` use `rootNavigator` to pop up by default,
|
||||
therefore **must** pass in the parameter `useRootNavigator: false` in `showDialog`, `showGeneralDialog` and other 'show dialog' methods to avoid navigator errors.
|
||||
|
||||
``` dart
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Dialog'),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('OK'))
|
||||
],
|
||||
),
|
||||
useRootNavigator: false); // <===== It's very IMPORTANT!
|
||||
```
|
||||
|
||||
</s>
|
||||
|
||||
## Features
|
||||
|
||||
There are 13 plugin kits built in the current open source version of UME.
|
||||
|
||||
<table border="1" width="100%">
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><p>UI kits</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/widget_info.png" width="100%" alt="Widget Info" /></br>Widget Info</td>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/widget_detail.png" width="100%" alt="Widget Detail" /></br>Widget Detail</td>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/align_ruler.png" width="100%" alt="Align Ruler" /></br>Align Ruler</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/color_picker.png" width="100%" alt="Color Picker" /></br>Color Picker</td>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/color_sucker.png" width="100%" alt="Color Sucker" /></br>Color Sucker</td>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/touch_indicator.png" width="100%" alt="Touch Indicator" /></br>Touch Indicator</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><p>Performance Kits</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/memory_info.png" width="100%" alt="Memory Info" /></br>Memory Info</td>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/perf_overlay.png" width="100%" alt="Perf Overlay" /></br>Perf Overlay</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><p>Device Info Kits</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/cpu_info.png" width="100%" alt="CPU Info" /></br>CPU Info</td>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/device_info.png" width="100%" alt="Device Info" /></br>Device Info</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><p>Show Code</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/show_code.png" width="100%" alt="Show Code" /></br>Show Code</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><p>Console</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/console.png" width="100%" alt="Console" /></br>Console</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><p>Dio Inspector</p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="33.33%" align="center"><img src="https://github.com/bytedance/flutter_ume/raw/master/screenshots/dio_inspector.png" width="100%" alt="Dio Inspector" /></br>Dio Inspector</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Develop plugin kits for UME
|
||||
|
||||
> UME plugins are located in the `./kits` directory, and each one is a `package`.
|
||||
> You can refer to the example in [`./custom_plugin_example`](./custom_plugin_example/) about this chapter.
|
||||
|
||||
1. Run `flutter create -t package custom_plugin` to create your custom plugin kit, it could be `package` or `plugin`.
|
||||
2. Edit `pubspec.yaml` of the custom plugin kit to add UME framework dependency.
|
||||
|
||||
``` yaml
|
||||
dependencies:
|
||||
flutter_ume: '>=0.3.0 <0.4.0'
|
||||
```
|
||||
|
||||
3. Create the class of the plugin kit which should implement `Pluggable`.
|
||||
|
||||
``` dart
|
||||
import 'package:flutter_ume/flutter_ume.dart';
|
||||
|
||||
class CustomPlugin implements Pluggable {
|
||||
CustomPlugin({Key key});
|
||||
|
||||
@override
|
||||
Widget buildWidget(BuildContext context) => Container(
|
||||
color: Colors.white
|
||||
width: 100,
|
||||
height: 100,
|
||||
child: Center(
|
||||
child: Text('Custom Plugin')
|
||||
),
|
||||
); // The panel of the plugin kit
|
||||
|
||||
@override
|
||||
String get name => 'CustomPlugin'; // The name of the plugin kit
|
||||
|
||||
@override
|
||||
String get displayName => 'CustomPlugin';
|
||||
|
||||
@override
|
||||
void onTrigger() {} // Call when tap the icon of plugin kit
|
||||
|
||||
@override
|
||||
ImageProvider<Object> get iconImageProvider => NetworkImage('url'); // The icon image of the plugin kit
|
||||
}
|
||||
```
|
||||
|
||||
4. Use your custom plugin kit in project
|
||||
|
||||
1. Edit `pubspec.yaml` of host app project to add `custom_plugin` dependency.
|
||||
|
||||
``` yaml
|
||||
dev_dependencies:
|
||||
custom_plugin:
|
||||
path: path/to/custom_plugin
|
||||
```
|
||||
|
||||
2. Run `flutter pub get`
|
||||
|
||||
3. Import package
|
||||
|
||||
``` dart
|
||||
import 'package:custom_plugin/custom_plugin.dart';
|
||||
```
|
||||
|
||||
5. Edit main method of your app, register your custom_plugin plugin kit
|
||||
|
||||
``` dart
|
||||
if (kDebugMode) {
|
||||
PluginManager.instance
|
||||
..register(CustomPlugin());
|
||||
runApp(
|
||||
UMEWidget(
|
||||
child: MyApp(),
|
||||
enable: true
|
||||
)
|
||||
);
|
||||
} else {
|
||||
runApp(MyApp());
|
||||
}
|
||||
```
|
||||
|
||||
6. Run your app
|
||||
|
||||
### Access the nested widget debug kits quickly
|
||||
|
||||
We introduce the `PluggableWithNestedWidget` from `0.3.0`. It is used to insert nested Widgets in the Widget tree and quickly access embedded kits with nested widget.
|
||||
|
||||
For more details, see [./kits/flutter_ume_kit_ui/lib/components/color_picker/color_picker.dart](https://github.com/bytedance/flutter_ume/blob/master/kits/flutter_ume_kit_ui/lib/components/color_picker/color_picker.dart) and [./kits/flutter_ume_kit_ui/lib/components/touch_indicator/touch_indicator.dart](https://github.com/bytedance/flutter_ume/blob/master/kits/flutter_ume_kit_ui/lib/components/touch_indicator/touch_indicator.dart).
|
||||
|
||||
The key steps are as follows:
|
||||
|
||||
1. The class of your plugin should implement `PluggableWithNestedWidget`.
|
||||
2. Implements `Widget buildNestedWidget(Widget child)`. Handling the nested widgets and returning the new Widget.
|
||||
|
||||
## How to use UME in Release/Profile mode
|
||||
|
||||
**Once you use flutter_ume in Release/Profile mode, you agree that you will**
|
||||
**bear the relevant risks by yourself.**
|
||||
|
||||
**The maintainer of flutter_ume does not assume any responsibility for the accident**
|
||||
**caused by this.**
|
||||
|
||||
**We recommend not to use it in Release/Profile mode for the following reasons:**
|
||||
|
||||
1. VM Service is not available in these environments, so some functions are not available
|
||||
2. In this environment, developers need to isolate the app distribution channels by themselves to avoid submitting relevant debugging code to the production environment
|
||||
|
||||
In order to use in Release/Profile mode, the details that need to be adjusted in the normal access process:
|
||||
|
||||
1. In `pubspec.yaml`, `flutter_ume` and plugins should be write below `dependencies` rather than `dev_dependencies`.
|
||||
2. Don't put the code which call `PluginManager.instance.register()` and `UMEWidget(child: App())` into conditionals which represent debug mode. (Such as `kDebugMode`)
|
||||
3. Ensure the above details, run `flutter clean` and `flutter pub get`, then build your app.
|
||||
|
||||
## About version
|
||||
|
||||
### Compatibility
|
||||
|
||||
| UME version | Flutter 1.12.13 | Flutter 1.22.3 | Flutter 2.0.1 | Flutter 2.2.3 | Flutter 2.5.3 |
|
||||
| ---- | ---- | ---- | ---- | ---- | ---- |
|
||||
| 0.1.x | ✅ | ✅ | ✅ | ✅ | ⚠️ |
|
||||
| 0.2.x | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
| 0.3.x | ❌ | ❌ | ✅ | ✅ | ✅ |
|
||||
|
||||
⚠️ means the version has not been fully tested for compatibility.
|
||||
|
||||
⚠️ means the version has not been fully tested for compatibility.
|
||||
### Coverage
|
||||
|
||||
| Package | master | develop | develop_nullsafety |
|
||||
| ---- | ---- | ---- | ---- |
|
||||
| flutter_ume |  |  |  |
|
||||
| flutter_ume_kit_device |  |  |  |
|
||||
| flutter_ume_kit_perf |  |  |  |
|
||||
| flutter_ume_kit_show_code |  |  |  |
|
||||
| flutter_ume_kit_ui |  |  |  |
|
||||
| flutter_ume_kit_console |  |  |  |
|
||||
| flutter_ume_kit_dio |  | N/A |  |
|
||||
|
||||
### Version upgrade rules
|
||||
|
||||
Please refer to [Semantic versions](https://dart.dev/tools/pub/versioning#semantic-versions) for details.
|
||||
|
||||
### Null-safety
|
||||
|
||||
| Package | Suggest version |
|
||||
| ---- | ---- |
|
||||
| flutter_ume | 0.3.0+1 |
|
||||
| flutter_ume_kit_ui | 0.3.0+1 |
|
||||
| flutter_ume_kit_device | 0.3.0 |
|
||||
| flutter_ume_kit_perf | 0.3.0 |
|
||||
| flutter_ume_kit_show_code | 0.3.0 |
|
||||
| flutter_ume_kit_console | 0.3.0 |
|
||||
| flutter_ume_kit_dio | 0.3.0 |
|
||||
|
||||
### Change log
|
||||
|
||||
[Changelog](./CHANGELOG.md)
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributing rules: [Contributing](./CONTRIBUTING_en.md)
|
||||
|
||||
### Contributors
|
||||
|
||||
Thanks to the following contributors (names not listed in order):
|
||||
|
||||
| | |
|
||||
| ---- | ---- |
|
||||
|  | [ShirelyC](https://github.com/smileShirely) |
|
||||
|  | [lpylpyleo](https://github.com/lpylpyleo) |
|
||||
|  | [Alex Li](https://github.com/AlexV525) |
|
||||
|  | [Swain](https://github.com/talisk) |
|
||||
|  | [harbor](https://github.com/zzm990321) |
|
||||
|
||||
### About the third-party open-source project dependencies
|
||||
|
||||
- The TouchIndicator use the pub [touch_indicator](https://pub.dev/packages/touch_indicator), the ColorPicker use the pub [cyclop](https://pub.dev/packages/cyclop).
|
||||
- We [fork](https://github.com/talisk/cyclop) the package [cyclop](https://pub.dev/packages/cyclop) and modify some code meet our functional needs. We should depend cyclop by pub version after the [PR](https://github.com/rxlabz/cyclop/pull/11) being merged.
|
||||
|
||||
## LICENSE
|
||||
|
||||
This project is licensed under the MIT License - visit the [LICENSE](./LICENSE) for details.
|
||||
|
||||
## Contact the author
|
||||
|
||||
**Maybe...**
|
||||
|
||||
- Found a bug in the code, or an error in the documentation
|
||||
- Produces an exception when you use the UME
|
||||
- UME is not compatible with the new version Flutter
|
||||
- Have a good idea or suggestion
|
||||
|
||||
You can [submit an issue](./CONTRIBUTING_en.md#how-to-raise-an-issue) in any of the above situations.
|
||||
|
||||
**Maybe...**
|
||||
|
||||
- Communicate with the author
|
||||
- Communicate with more community developers
|
||||
- Cooperate with UME
|
||||
|
||||
Welcome to [Join the ByteDance Flutter Exchange Group](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=67au2f75-3783-41b0-8868-0fc0178f1fd8).
|
||||
|
||||
Or contact [author](mailto:sunkai.dev@bytedance.com).
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user