8 Commits

43 changed files with 875 additions and 214 deletions

1
.gitignore vendored
View File

@@ -19,6 +19,7 @@ pubspec.lock
*.iws *.iws
.idea/ .idea/
.cursor .cursor
Podfile.lock
# The .vscode folder contains launch configuration and tasks you configure in # The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line # VS Code which you may wish to be included in version control, so this line

View File

@@ -4,7 +4,7 @@ plugins {
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
} }
val appPackageName = "com.qxy.dronex" val appPackageName = "com.dronex.rec"
android { android {
namespace = appPackageName namespace = appPackageName

View File

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

View File

@@ -1,7 +1,7 @@
package com.qxy.dronex package com.dronex.rec
object AppConstants { object AppConstants {
const val PACKAGE_NAME = "com.qxy.dronex" const val PACKAGE_NAME = "com.dronex.rec"
const val PLATFORM_INFO_CHANNEL = "$PACKAGE_NAME/platform_info" const val PLATFORM_INFO_CHANNEL = "$PACKAGE_NAME/platform_info"
const val RECORDING_METHOD_CHANNEL = "$PACKAGE_NAME/recording" const val RECORDING_METHOD_CHANNEL = "$PACKAGE_NAME/recording"
const val RECORDING_EVENT_CHANNEL = "$PACKAGE_NAME/recording_events" const val RECORDING_EVENT_CHANNEL = "$PACKAGE_NAME/recording_events"

View File

@@ -1,4 +1,4 @@
package com.qxy.dronex package com.dronex.rec
import android.content.Context import android.content.Context
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
@@ -7,8 +7,8 @@ import android.os.Build
import android.os.Environment import android.os.Environment
import android.os.StatFs import android.os.StatFs
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import com.qxy.dronex.recording.RecordingPlatformHandler import com.dronex.rec.recording.RecordingPlatformHandler
import com.qxy.dronex.recording.RecordingPreviewFactory import com.dronex.rec.recording.RecordingPreviewFactory
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
@@ -114,6 +114,7 @@ class MainActivity : FlutterActivity() {
"brand" to Build.BRAND, "brand" to Build.BRAND,
"model" to Build.MODEL, "model" to Build.MODEL,
"systemVersion" to Build.VERSION.RELEASE, "systemVersion" to Build.VERSION.RELEASE,
"sdkInt" to Build.VERSION.SDK_INT,
"isPhysicalDevice" to !isEmulator, "isPhysicalDevice" to !isEmulator,
) )
} }

View File

@@ -1,4 +1,4 @@
package com.qxy.dronex.recording package com.dronex.rec.recording
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent

View File

@@ -1,4 +1,4 @@
package com.qxy.dronex.recording package com.dronex.rec.recording
import android.app.NotificationManager import android.app.NotificationManager
import android.content.Context import android.content.Context

View File

@@ -1,7 +1,8 @@
package com.qxy.dronex.recording package com.dronex.rec.recording
import android.content.Context import android.content.Context
import android.util.Log import android.util.Log
import androidx.camera.core.Camera
import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector
import androidx.camera.core.Preview import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.ProcessCameraProvider
@@ -24,8 +25,10 @@ class RecordingCameraController(
private var cameraProvider: ProcessCameraProvider? = null private var cameraProvider: ProcessCameraProvider? = null
private var preview: Preview? = null private var preview: Preview? = null
private var videoCapture: VideoCapture<Recorder>? = null private var videoCapture: VideoCapture<Recorder>? = null
private var camera: Camera? = null
private var activeRecording: Recording? = null private var activeRecording: Recording? = null
private var boundLifecycleOwner: LifecycleOwner? = null private var boundLifecycleOwner: LifecycleOwner? = null
private var currentZoomRatio: Float = 1f
var status: RecordingStatus = RecordingStatus(RecordingState.IDLE) var status: RecordingStatus = RecordingStatus(RecordingState.IDLE)
private set private set
@@ -61,12 +64,14 @@ class RecordingCameraController(
videoCapture = VideoCapture.withOutput(recorder) videoCapture = VideoCapture.withOutput(recorder)
provider.unbindAll() provider.unbindAll()
camera =
provider.bindToLifecycle( provider.bindToLifecycle(
lifecycleOwner, lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA, CameraSelector.DEFAULT_BACK_CAMERA,
preview, preview,
videoCapture, videoCapture,
) )
applyCurrentZoom()
updateStatus(RecordingStatus(RecordingState.PREVIEWING)) updateStatus(RecordingStatus(RecordingState.PREVIEWING))
onReady(true) onReady(true)
@@ -108,12 +113,14 @@ class RecordingCameraController(
try { try {
boundLifecycleOwner = lifecycleOwner boundLifecycleOwner = lifecycleOwner
provider.unbindAll() provider.unbindAll()
camera =
provider.bindToLifecycle( provider.bindToLifecycle(
lifecycleOwner, lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA, CameraSelector.DEFAULT_BACK_CAMERA,
preview, preview,
videoCapture, videoCapture,
) )
applyCurrentZoom()
onReady(true) onReady(true)
} catch (error: Exception) { } catch (error: Exception) {
Log.e(TAG, "rebindForRecording failed", error) Log.e(TAG, "rebindForRecording failed", error)
@@ -221,6 +228,52 @@ class RecordingCameraController(
activeRecording = null activeRecording = null
} }
fun zoomCapabilitiesMap(): Map<String, Any> {
val zoomState = camera?.cameraInfo?.zoomState?.value
val minZoom = zoomState?.minZoomRatio ?: 1f
val maxZoom = zoomState?.maxZoomRatio ?: 3f
val zoom = (zoomState?.zoomRatio ?: currentZoomRatio).coerceIn(minZoom, maxZoom)
currentZoomRatio = zoom
return mapOf(
"zoomRatio" to zoom.toDouble(),
"minZoomRatio" to minZoom.toDouble(),
"maxZoomRatio" to maxZoom.toDouble(),
)
}
fun setZoomRatio(
ratio: Double,
onComplete: (Boolean, Map<String, Any>, String?) -> Unit,
) {
val boundCamera = camera
if (boundCamera == null) {
val clamped = ratio.toFloat().coerceAtLeast(1f)
currentZoomRatio = clamped
onComplete(true, zoomCapabilitiesMap(), null)
return
}
val zoomState = boundCamera.cameraInfo.zoomState.value
val minZoom = zoomState?.minZoomRatio ?: 1f
val maxZoom = zoomState?.maxZoomRatio ?: clampedMaxZoom()
val nextZoom = ratio.toFloat().coerceIn(minZoom, maxZoom)
currentZoomRatio = nextZoom
val future = boundCamera.cameraControl.setZoomRatio(nextZoom)
future.addListener(
{
try {
future.get()
onComplete(true, zoomCapabilitiesMap(), null)
} catch (error: Exception) {
Log.e(TAG, "setZoomRatio failed", error)
onComplete(false, zoomCapabilitiesMap(), error.message)
}
},
mainExecutor,
)
}
fun unbind() { fun unbind() {
activeRecording?.stop() activeRecording?.stop()
activeRecording = null activeRecording = null
@@ -228,7 +281,9 @@ class RecordingCameraController(
cameraProvider = null cameraProvider = null
preview = null preview = null
videoCapture = null videoCapture = null
camera = null
boundLifecycleOwner = null boundLifecycleOwner = null
currentZoomRatio = 1f
updateStatus(RecordingStatus(RecordingState.IDLE)) updateStatus(RecordingStatus(RecordingState.IDLE))
} }
@@ -242,6 +297,19 @@ class RecordingCameraController(
statusListener?.invoke(next) statusListener?.invoke(next)
} }
private fun applyCurrentZoom() {
val boundCamera = camera ?: return
val zoomState = boundCamera.cameraInfo.zoomState.value
val minZoom = zoomState?.minZoomRatio ?: 1f
val maxZoom = zoomState?.maxZoomRatio ?: clampedMaxZoom()
currentZoomRatio = currentZoomRatio.coerceIn(minZoom, maxZoom)
boundCamera.cameraControl.setZoomRatio(currentZoomRatio)
}
private fun clampedMaxZoom(): Float {
return camera?.cameraInfo?.zoomState?.value?.maxZoomRatio ?: 3f
}
companion object { companion object {
private const val TAG = "RecordingCamera" private const val TAG = "RecordingCamera"
} }

View File

@@ -1,4 +1,4 @@
package com.qxy.dronex.recording package com.dronex.rec.recording
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
@@ -14,8 +14,8 @@ import android.os.PowerManager
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleService import androidx.lifecycle.LifecycleService
import com.qxy.dronex.AppConstants import com.dronex.rec.AppConstants
import com.qxy.dronex.MainActivity import com.dronex.rec.MainActivity
class RecordingForegroundService : LifecycleService() { class RecordingForegroundService : LifecycleService() {
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null

View File

@@ -1,4 +1,4 @@
package com.qxy.dronex.recording package com.dronex.rec.recording
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context

View File

@@ -1,12 +1,12 @@
package com.qxy.dronex.recording package com.dronex.rec.recording
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat import androidx.core.view.WindowInsetsControllerCompat
import com.qxy.dronex.AppConstants import com.dronex.rec.AppConstants
import com.qxy.dronex.MainActivity import com.dronex.rec.MainActivity
import io.flutter.plugin.common.BinaryMessenger import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
@@ -50,6 +50,11 @@ class RecordingPlatformHandler(
startRecording(withAudio, enableDnd, displayName, result) startRecording(withAudio, enableDnd, displayName, result)
} }
"stopRecording" -> stopRecording(result) "stopRecording" -> stopRecording(result)
"getZoomCapabilities" -> result.success(controller.zoomCapabilitiesMap())
"setZoomRatio" -> {
val ratio = call.argument<Double>("zoomRatio") ?: 1.0
setZoomRatio(ratio, result)
}
"disposePreview" -> { "disposePreview" -> {
controller.unbind() controller.unbind()
result.success(null) result.success(null)
@@ -172,16 +177,28 @@ class RecordingPlatformHandler(
} }
} }
private fun setZoomRatio(ratio: Double, result: MethodChannel.Result) {
controller.setZoomRatio(ratio) { success, capabilities, message ->
mainHandler.post {
if (success) {
result.success(capabilities)
} else {
result.error("ZOOM_FAILED", message ?: "Failed to set camera zoom", null)
}
}
}
}
private fun deliverStopResult(result: MethodChannel.Result, path: String?) { private fun deliverStopResult(result: MethodChannel.Result, path: String?) {
val gallerySaved = path != null && controller.status.state != RecordingState.ERROR val fileSaved = path != null && controller.status.state != RecordingState.ERROR
val payload = val payload =
mutableMapOf<String, Any?>( mutableMapOf<String, Any?>(
"outputPath" to path, "outputPath" to path,
"status" to controller.status.toMap(), "status" to controller.status.toMap(),
"gallerySaved" to gallerySaved, "fileSaved" to fileSaved,
) )
if (!gallerySaved) { if (!fileSaved) {
payload["galleryErrorMessage"] = controller.status.message ?: "保存到相册失败" payload["fileErrorMessage"] = controller.status.message ?: "保存到文件夹失败"
} }
result.success(payload) result.success(payload)
} }

View File

@@ -1,9 +1,9 @@
package com.qxy.dronex.recording package com.dronex.rec.recording
import android.content.Context import android.content.Context
import android.view.View import android.view.View
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import com.qxy.dronex.MainActivity import com.dronex.rec.MainActivity
import io.flutter.plugin.common.StandardMessageCodec import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory import io.flutter.plugin.platform.PlatformViewFactory

View File

@@ -1,4 +1,4 @@
package com.qxy.dronex.recording package com.dronex.rec.recording
import android.content.Context import android.content.Context
import androidx.lifecycle.LifecycleService import androidx.lifecycle.LifecycleService

View File

@@ -1,4 +1,4 @@
package com.qxy.dronex.recording package com.dronex.rec.recording
enum class RecordingState { enum class RecordingState {
IDLE, IDLE,

File diff suppressed because one or more lines are too long

7
clean.sh Normal file
View File

@@ -0,0 +1,7 @@
flutter clean
flutter pub get
rm -rf ios/Pods
rm -rf ios/Podfile.lock
cd ios
pod install
cd ..

View File

@@ -1,2 +1,3 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}"

View File

@@ -1,2 +1,3 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"
FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}"

View File

@@ -45,9 +45,34 @@ post_install do |installer|
'$(inherited)', '$(inherited)',
'PERMISSION_CAMERA=1', 'PERMISSION_CAMERA=1',
'PERMISSION_MICROPHONE=1', 'PERMISSION_MICROPHONE=1',
'PERMISSION_PHOTOS=1',
'PERMISSION_PHOTOS_ADD_ONLY=1',
] ]
end end
end end
pods_runner_dir = File.join(
installer.sandbox.root,
'Target Support Files',
'Pods-Runner'
)
Dir.glob(File.join(pods_runner_dir, 'Pods-Runner.*.xcconfig')).each do |config_path|
config = File.read(config_path)
config.gsub!(
'FRAMEWORK_SEARCH_PATHS = $(inherited)',
'FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}"'
)
File.write(config_path, config)
end
Dir.glob(File.join(pods_runner_dir, 'Pods-Runner-frameworks-*input-files.xcfilelist')).each do |file_list_path|
file_list = File.read(file_list_path)
file_list.gsub!('${BUILT_PRODUCTS_DIR}/', '${PODS_CONFIGURATION_BUILD_DIR}/')
File.write(file_list_path, file_list)
end
frameworks_script = File.join(pods_runner_dir, 'Pods-Runner-frameworks.sh')
if File.exist?(frameworks_script)
script = File.read(frameworks_script)
script.gsub!('${BUILT_PRODUCTS_DIR}/', '${PODS_CONFIGURATION_BUILD_DIR}/')
File.write(frameworks_script, script)
end
end end

View File

@@ -2,9 +2,6 @@ PODS:
- connectivity_plus (0.0.1): - connectivity_plus (0.0.1):
- Flutter - Flutter
- Flutter (1.0.0) - Flutter (1.0.0)
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- permission_handler_apple (9.4.8): - permission_handler_apple (9.4.8):
- Flutter - Flutter
- shared_preferences_foundation (0.0.1): - shared_preferences_foundation (0.0.1):
@@ -19,7 +16,6 @@ PODS:
DEPENDENCIES: DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- Flutter (from `Flutter`) - Flutter (from `Flutter`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
@@ -30,8 +26,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/connectivity_plus/ios" :path: ".symlinks/plugins/connectivity_plus/ios"
Flutter: Flutter:
:path: Flutter :path: Flutter
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple: permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios" :path: ".symlinks/plugins/permission_handler_apple/ios"
shared_preferences_foundation: shared_preferences_foundation:
@@ -44,12 +38,11 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS: SPEC CHECKSUMS:
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e permission_handler_apple: 92d754bbaa7361d436db2d6c3c1c2a0fdcec462e
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
PODFILE CHECKSUM: 5a82b772179df87e6518bddc6f9bb8b4053ce48b PODFILE CHECKSUM: 858401fbd980bedce6ecd2f9b429bf271f11f74b
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2

View File

@@ -495,16 +495,22 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 35634V629S; DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MT26BPCKF6;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.qxy.dronex; PRODUCT_BUNDLE_IDENTIFIER = com.dronex.rec;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "dev-profile-dronex";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";
@@ -678,16 +684,22 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 35634V629S; DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MT26BPCKF6;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.qxy.dronex; PRODUCT_BUNDLE_IDENTIFIER = com.dronex.rec;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "dev-profile-dronex";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@@ -701,16 +713,22 @@
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = 35634V629S; DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = MT26BPCKF6;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
PRODUCT_BUNDLE_IDENTIFIER = com.qxy.dronex; PRODUCT_BUNDLE_IDENTIFIER = com.dronex.rec;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "dev-profile-dronex";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";

View File

@@ -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

View File

@@ -16,13 +16,15 @@
<view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3"> <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews> <subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4"> <imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" image="StartupBackground" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView> </imageView>
</subviews> </subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints> <constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/> <constraint firstItem="YRO-k0-Ey4" firstAttribute="top" secondItem="Ze5-6b-2t3" secondAttribute="top" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/> <constraint firstItem="YRO-k0-Ey4" firstAttribute="bottom" secondItem="Ze5-6b-2t3" secondAttribute="bottom" id="4X2-HB-R7a"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="leading" secondItem="Ze5-6b-2t3" secondAttribute="leading" id="E8f-e3-JEx"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="trailing" secondItem="Ze5-6b-2t3" secondAttribute="trailing" id="rwG-Bh-0uU"/>
</constraints> </constraints>
</view> </view>
</viewController> </viewController>
@@ -32,6 +34,6 @@
</scene> </scene>
</scenes> </scenes>
<resources> <resources>
<image name="LaunchImage" width="168" height="185"/> <image name="StartupBackground" width="750" height="1624"/>
</resources> </resources>
</document> </document>

View File

@@ -1,8 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r"> <document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="24412" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
<device id="retina6_12" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24405"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies> </dependencies>
<scenes> <scenes>
<!--Flutter View Controller--> <!--Flutter View Controller-->
@@ -14,13 +16,28 @@
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/> <viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
</layoutGuides> </layoutGuides>
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC"> <view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/> <rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/> <subviews>
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" image="StartupBackground" translatesAutoresizingMaskIntoConstraints="NO" id="YQm-Ov-qw7">
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YQm-Ov-qw7" firstAttribute="top" secondItem="8bC-Xf-vdC" secondAttribute="top" id="7pp-OK-Dgk"/>
<constraint firstItem="YQm-Ov-qw7" firstAttribute="bottom" secondItem="8bC-Xf-vdC" secondAttribute="bottom" id="Hbf-gY-rbf"/>
<constraint firstItem="YQm-Ov-qw7" firstAttribute="leading" secondItem="8bC-Xf-vdC" secondAttribute="leading" id="WPc-7k-20p"/>
<constraint firstItem="YQm-Ov-qw7" firstAttribute="trailing" secondItem="8bC-Xf-vdC" secondAttribute="trailing" id="w1G-WA-3QR"/>
</constraints>
</view> </view>
</viewController> </viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/> <placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
</objects> </objects>
<point key="canvasLocation" x="139" y="122"/>
</scene> </scene>
</scenes> </scenes>
<resources>
<image name="StartupBackground" width="750" height="1624"/>
</resources>
</document> </document>

View File

@@ -30,8 +30,10 @@
<string>需要访问相机以显示预览并录制视频。</string> <string>需要访问相机以显示预览并录制视频。</string>
<key>NSMicrophoneUsageDescription</key> <key>NSMicrophoneUsageDescription</key>
<string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string> <string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string>
<key>NSPhotoLibraryAddUsageDescription</key> <key>UIFileSharingEnabled</key>
<string>需要将录制的视频保存到相册。</string> <true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>
<dict> <dict>
<key>UIApplicationSupportsMultipleScenes</key> <key>UIApplicationSupportsMultipleScenes</key>

View File

@@ -4,7 +4,7 @@ import UIKit
final class PlatformInfoPlugin: NSObject, FlutterPlugin { final class PlatformInfoPlugin: NSObject, FlutterPlugin {
static func register(with registrar: FlutterPluginRegistrar) { static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel( let channel = FlutterMethodChannel(
name: "com.qxy.dronex/platform_info", name: "com.dronex.rec/platform_info",
binaryMessenger: registrar.messenger() binaryMessenger: registrar.messenger()
) )
let plugin = PlatformInfoPlugin() let plugin = PlatformInfoPlugin()

View File

@@ -1,6 +1,5 @@
import AVFoundation import AVFoundation
import Flutter import Flutter
import Photos
import UIKit import UIKit
private enum RecordingState: String { private enum RecordingState: String {
@@ -110,12 +109,13 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
private var audioInput: AVCaptureDeviceInput? private var audioInput: AVCaptureDeviceInput?
private var configured = false private var configured = false
private var latestOutputPath: String? private var latestOutputPath: String?
private var latestGallerySaved = true private var latestFileSaved = true
private var latestGalleryErrorMessage: String? private var latestFileErrorMessage: String?
private var pendingDisplayName: String? private var pendingDisplayName: String?
private var recordingStartedAt: Date? private var recordingStartedAt: Date?
private var elapsedTimer: Timer? private var elapsedTimer: Timer?
private var pendingStopResult: FlutterResult? private var pendingStopResult: FlutterResult?
private var currentZoomRatio: CGFloat = 1.0
private(set) var status = RecordingStatus(state: .idle) { private(set) var status = RecordingStatus(state: .idle) {
didSet { didSet {
@@ -215,10 +215,10 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
} }
self.pendingDisplayName = displayName self.pendingDisplayName = displayName
self.latestGallerySaved = true self.latestFileSaved = true
self.latestGalleryErrorMessage = nil self.latestFileErrorMessage = nil
let outputURL = try self.createOutputURL(displayName: displayName) let outputURL = try self.createOutputURL(displayName: displayName)
self.latestOutputPath = outputURL.lastPathComponent self.latestOutputPath = outputURL.path
self.recordingStartedAt = Date() self.recordingStartedAt = Date()
self.updateStatus(RecordingStatus(state: .recording, outputPath: outputURL.path)) self.updateStatus(RecordingStatus(state: .recording, outputPath: outputURL.path))
self.movieOutput.startRecording(to: outputURL, recordingDelegate: self) self.movieOutput.startRecording(to: outputURL, recordingDelegate: self)
@@ -254,11 +254,11 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
var payload: [String: Any] = [ var payload: [String: Any] = [
"outputPath": self.latestOutputPath as Any, "outputPath": self.latestOutputPath as Any,
"status": self.currentStatusMap(), "status": self.currentStatusMap(),
"gallerySaved": self.latestGallerySaved, "fileSaved": self.latestFileSaved,
] ]
if !self.latestGallerySaved { if !self.latestFileSaved {
payload["galleryErrorMessage"] = payload["fileErrorMessage"] =
self.latestGalleryErrorMessage ?? "保存到相册失败" self.latestFileErrorMessage ?? "保存到文件夹失败"
} }
result(payload) result(payload)
} }
@@ -291,6 +291,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
self.session.commitConfiguration() self.session.commitConfiguration()
self.videoInput = nil self.videoInput = nil
self.audioInput = nil self.audioInput = nil
self.currentZoomRatio = 1.0
self.configured = false self.configured = false
self.updateStatus(RecordingStatus(state: .idle)) self.updateStatus(RecordingStatus(state: .idle))
@@ -312,6 +313,52 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
return status.toMap() return status.toMap()
} }
func zoomCapabilities(result: @escaping FlutterResult) {
sessionQueue.async { [weak self] in
guard let self else { return }
let capabilities = self.currentZoomCapabilitiesMap()
DispatchQueue.main.async {
result(capabilities)
}
}
}
func setZoomRatio(_ ratio: CGFloat, result: @escaping FlutterResult) {
sessionQueue.async { [weak self] in
guard let self else { return }
guard let device = self.videoInput?.device else {
self.currentZoomRatio = max(1.0, ratio)
let capabilities = self.currentZoomCapabilitiesMap()
DispatchQueue.main.async {
result(capabilities)
}
return
}
do {
let nextZoom = self.clampedZoomRatio(ratio, for: device)
try device.lockForConfiguration()
device.videoZoomFactor = nextZoom
device.unlockForConfiguration()
self.currentZoomRatio = nextZoom
let capabilities = self.currentZoomCapabilitiesMap()
DispatchQueue.main.async {
result(capabilities)
}
} catch {
DispatchQueue.main.async {
result(
FlutterError(
code: "ZOOM_FAILED",
message: error.localizedDescription,
details: nil
)
)
}
}
}
}
func fileOutput( func fileOutput(
_ output: AVCaptureFileOutput, _ output: AVCaptureFileOutput,
didFinishRecordingTo outputFileURL: URL, didFinishRecordingTo outputFileURL: URL,
@@ -322,8 +369,8 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
pendingStopResult = nil pendingStopResult = nil
if let error { if let error {
latestGallerySaved = false latestFileSaved = false
latestGalleryErrorMessage = error.localizedDescription latestFileErrorMessage = error.localizedDescription
updateStatus( updateStatus(
RecordingStatus( RecordingStatus(
state: .error, outputPath: latestOutputPath, message: error.localizedDescription)) state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
@@ -331,29 +378,30 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
return return
} }
saveVideoToPhotoLibrary(fileURL: outputFileURL) { [weak self] success, message in latestFileSaved = true
guard let self else { return } latestFileErrorMessage = nil
self.latestGallerySaved = success latestOutputPath = outputFileURL.path
self.latestGalleryErrorMessage = message guard FileManager.default.fileExists(atPath: outputFileURL.path) else {
if success { latestFileSaved = false
self.updateStatus( latestFileErrorMessage = "录制文件未生成"
RecordingStatus( updateStatus(
state: .previewing,
outputPath: self.latestOutputPath,
elapsedMillis: self.elapsedMillis()
)
)
} else {
self.updateStatus(
RecordingStatus( RecordingStatus(
state: .error, state: .error,
outputPath: self.latestOutputPath, outputPath: latestOutputPath,
message: message ?? "保存到相册失败" 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?) { private func finishStopRecording(stopResult: FlutterResult?) {
@@ -363,68 +411,16 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
var payload: [String: Any] = [ var payload: [String: Any] = [
"outputPath": self.latestOutputPath as Any, "outputPath": self.latestOutputPath as Any,
"status": self.currentStatusMap(), "status": self.currentStatusMap(),
"gallerySaved": self.latestGallerySaved, "fileSaved": self.latestFileSaved,
] ]
if !self.latestGallerySaved { if !self.latestFileSaved {
payload["galleryErrorMessage"] = payload["fileErrorMessage"] =
self.latestGalleryErrorMessage ?? "保存到相册失败,请开启相册权限" self.latestFileErrorMessage ?? "保存到文件夹失败,请检查文件保存权限"
} }
stopResult?(payload) stopResult?(payload)
} }
} }
private func saveVideoToPhotoLibrary(
fileURL: URL,
completion: @escaping (Bool, String?) -> Void
) {
let performSave = {
PHPhotoLibrary.shared().performChanges({
PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: fileURL, options: nil)
}) { success, error in
if success {
try? FileManager.default.removeItem(at: fileURL)
completion(true, nil)
} else {
completion(false, error?.localizedDescription ?? "保存到相册失败")
}
}
}
if #available(iOS 14, *) {
let status = PHPhotoLibrary.authorizationStatus(for: .addOnly)
switch status {
case .authorized, .limited:
performSave()
case .notDetermined:
PHPhotoLibrary.requestAuthorization(for: .addOnly) { newStatus in
if newStatus == .authorized || newStatus == .limited {
performSave()
} else {
completion(false, "未授予相册权限")
}
}
default:
completion(false, "未授予相册权限")
}
} else {
let status = PHPhotoLibrary.authorizationStatus()
switch status {
case .authorized:
performSave()
case .notDetermined:
PHPhotoLibrary.requestAuthorization { newStatus in
if newStatus == .authorized {
performSave()
} else {
completion(false, "未授予相册权限")
}
}
default:
completion(false, "未授予相册权限")
}
}
}
private func configureSession(withAudio: Bool) throws { private func configureSession(withAudio: Bool) throws {
if configured { if configured {
try configureAudioInput(enabled: withAudio) try configureAudioInput(enabled: withAudio)
@@ -465,9 +461,43 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
session.commitConfiguration() session.commitConfiguration()
configured = true configured = true
try applyCurrentZoom()
try configureAudioInput(enabled: withAudio) try configureAudioInput(enabled: withAudio)
} }
private func currentZoomCapabilitiesMap() -> [String: Any] {
guard let device = videoInput?.device else {
return [
"zoomRatio": Double(currentZoomRatio),
"minZoomRatio": 1.0,
"maxZoomRatio": 3.0,
]
}
let minZoom = device.minAvailableVideoZoomFactor
let maxZoom = device.maxAvailableVideoZoomFactor
let zoom = clampedZoomRatio(device.videoZoomFactor, for: device)
currentZoomRatio = zoom
return [
"zoomRatio": Double(zoom),
"minZoomRatio": Double(minZoom),
"maxZoomRatio": Double(maxZoom),
]
}
private func applyCurrentZoom() throws {
guard let device = videoInput?.device else { return }
let nextZoom = clampedZoomRatio(currentZoomRatio, for: device)
try device.lockForConfiguration()
device.videoZoomFactor = nextZoom
device.unlockForConfiguration()
currentZoomRatio = nextZoom
}
private func clampedZoomRatio(_ ratio: CGFloat, for device: AVCaptureDevice) -> CGFloat {
min(max(ratio, device.minAvailableVideoZoomFactor), device.maxAvailableVideoZoomFactor)
}
private func configureAudioInput(enabled: Bool) throws { private func configureAudioInput(enabled: Bool) throws {
session.beginConfiguration() session.beginConfiguration()
defer { session.commitConfiguration() } defer { session.commitConfiguration() }
@@ -502,7 +532,30 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
try FileManager.default.createDirectory(at: recordingsURL, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: recordingsURL, withIntermediateDirectories: true)
let fileName = Self.resolveFileName(displayName: displayName) let fileName = Self.resolveFileName(displayName: displayName)
return recordingsURL.appendingPathComponent(fileName) return uniqueOutputURL(in: recordingsURL, preferredFileName: fileName)
}
private func uniqueOutputURL(in directoryURL: URL, preferredFileName: String) -> URL {
let preferredURL = directoryURL.appendingPathComponent(preferredFileName)
guard !FileManager.default.fileExists(atPath: preferredURL.path) else {
let fileExtension = preferredURL.pathExtension
let baseName = preferredURL.deletingPathExtension().lastPathComponent
let timestamp = Self.fileNameDateFormatter.string(from: Date())
var index = 0
while true {
let suffix = index == 0 ? timestamp : "\(timestamp)_\(index)"
let nextName = fileExtension.isEmpty
? "\(baseName)_\(suffix)"
: "\(baseName)_\(suffix).\(fileExtension)"
let nextURL = directoryURL.appendingPathComponent(nextName)
if !FileManager.default.fileExists(atPath: nextURL.path) {
return nextURL
}
index += 1
}
}
return preferredURL
} }
private static func resolveFileName(displayName: String?) -> String { private static func resolveFileName(displayName: String?) -> String {
@@ -520,6 +573,13 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
return "REC_\(formatter.string(from: Date())).mov" return "REC_\(formatter.string(from: Date())).mov"
} }
private static let fileNameDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyyMMdd_HHmmss"
return formatter
}()
private func updateStatus(_ next: RecordingStatus) { private func updateStatus(_ next: RecordingStatus) {
status = next status = next
} }
@@ -544,7 +604,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
} }
private enum RecordingChannelNames { private enum RecordingChannelNames {
static let packageName = "com.qxy.dronex" static let packageName = "com.dronex.rec"
static let method = "\(packageName)/recording" static let method = "\(packageName)/recording"
static let events = "\(packageName)/recording_events" static let events = "\(packageName)/recording_events"
} }
@@ -587,6 +647,12 @@ final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
controller.startRecording(withAudio: withAudio, displayName: displayName, result: result) controller.startRecording(withAudio: withAudio, displayName: displayName, result: result)
case "stopRecording": case "stopRecording":
controller.stopRecording(result: result) controller.stopRecording(result: result)
case "getZoomCapabilities":
controller.zoomCapabilities(result: result)
case "setZoomRatio":
let args = call.arguments as? [String: Any]
let ratio = args?["zoomRatio"] as? Double ?? 1.0
controller.setZoomRatio(CGFloat(ratio), result: result)
case "disposePreview": case "disposePreview":
controller.disposePreview(result: result) controller.disposePreview(result: result)
case "getStatus": case "getStatus":

View File

@@ -37,12 +37,12 @@ class AppConfig {
), ),
AppEnvironment.staging => const EnvironmentValues( AppEnvironment.staging => const EnvironmentValues(
environment: AppEnvironment.staging, environment: AppEnvironment.staging,
baseUrl: 'https://staging.example.com/api', baseUrl: 'https://example.com/api',
enableNetworkLog: true, enableNetworkLog: true,
), ),
AppEnvironment.prod => const EnvironmentValues( AppEnvironment.prod => const EnvironmentValues(
environment: AppEnvironment.prod, environment: AppEnvironment.prod,
baseUrl: 'https://api.example.com', baseUrl: 'https://example.com/api',
enableNetworkLog: false, enableNetworkLog: false,
), ),
}; };

View File

@@ -60,7 +60,7 @@ class AppPlatformInfo {
AppPlatformInfo._(); AppPlatformInfo._();
static const MethodChannel _channel = MethodChannel( static const MethodChannel _channel = MethodChannel(
'com.qxy.dronex/platform_info', 'com.dronex.rec/platform_info',
); );
static Future<AppPackageInfo> packageInfo() async { static Future<AppPackageInfo> packageInfo() async {

View File

@@ -11,11 +11,14 @@ class RecordingSessionState {
this.isBatteryOptimizedIgnored = true, this.isBatteryOptimizedIgnored = true,
this.notificationsGranted = true, this.notificationsGranted = true,
this.isMicrophoneGranted = false, this.isMicrophoneGranted = false,
this.zoomRatio = 1.0,
this.minZoomRatio = 1.0,
this.maxZoomRatio = 3.0,
this.lastOutputPath, this.lastOutputPath,
this.lastSavedDisplayName, this.lastSavedDisplayName,
this.errorMessage, this.errorMessage,
this.permissionWarning, this.permissionWarning,
this.gallerySaveFailed = false, this.fileSaveFailed = false,
}); });
final RecordingStatus status; final RecordingStatus status;
@@ -26,11 +29,14 @@ class RecordingSessionState {
final bool isBatteryOptimizedIgnored; final bool isBatteryOptimizedIgnored;
final bool notificationsGranted; final bool notificationsGranted;
final bool isMicrophoneGranted; final bool isMicrophoneGranted;
final double zoomRatio;
final double minZoomRatio;
final double maxZoomRatio;
final String? lastOutputPath; final String? lastOutputPath;
final String? lastSavedDisplayName; final String? lastSavedDisplayName;
final String? errorMessage; final String? errorMessage;
final String? permissionWarning; final String? permissionWarning;
final bool gallerySaveFailed; final bool fileSaveFailed;
bool get isRecording => status.isRecording; bool get isRecording => status.isRecording;
@@ -51,11 +57,14 @@ class RecordingSessionState {
bool? isBatteryOptimizedIgnored, bool? isBatteryOptimizedIgnored,
bool? notificationsGranted, bool? notificationsGranted,
bool? isMicrophoneGranted, bool? isMicrophoneGranted,
double? zoomRatio,
double? minZoomRatio,
double? maxZoomRatio,
String? lastOutputPath, String? lastOutputPath,
String? lastSavedDisplayName, String? lastSavedDisplayName,
String? errorMessage, String? errorMessage,
String? permissionWarning, String? permissionWarning,
bool? gallerySaveFailed, bool? fileSaveFailed,
bool clearPermissionWarning = false, bool clearPermissionWarning = false,
bool clearLastSaved = false, bool clearLastSaved = false,
}) { }) {
@@ -69,6 +78,9 @@ class RecordingSessionState {
isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored, isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored,
notificationsGranted: notificationsGranted ?? this.notificationsGranted, notificationsGranted: notificationsGranted ?? this.notificationsGranted,
isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted, isMicrophoneGranted: isMicrophoneGranted ?? this.isMicrophoneGranted,
zoomRatio: zoomRatio ?? this.zoomRatio,
minZoomRatio: minZoomRatio ?? this.minZoomRatio,
maxZoomRatio: maxZoomRatio ?? this.maxZoomRatio,
lastOutputPath: lastOutputPath ?? this.lastOutputPath, lastOutputPath: lastOutputPath ?? this.lastOutputPath,
lastSavedDisplayName: clearLastSaved lastSavedDisplayName: clearLastSaved
? null ? null
@@ -77,7 +89,7 @@ class RecordingSessionState {
permissionWarning: clearPermissionWarning permissionWarning: clearPermissionWarning
? null ? null
: (permissionWarning ?? this.permissionWarning), : (permissionWarning ?? this.permissionWarning),
gallerySaveFailed: gallerySaveFailed ?? this.gallerySaveFailed, fileSaveFailed: fileSaveFailed ?? this.fileSaveFailed,
); );
} }
} }

View File

@@ -172,8 +172,8 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
await ref.read(recordingViewModelProvider.notifier).stopRecording(); await ref.read(recordingViewModelProvider.notifier).stopRecording();
if (!mounted) return; if (!mounted) return;
final latest = ref.read(recordingViewModelProvider).session; final latest = ref.read(recordingViewModelProvider).session;
if (latest.gallerySaveFailed) { if (latest.fileSaveFailed) {
AppToast.show(latest.errorMessage ?? '保存到相册失败,请开启相册权限'); AppToast.show(latest.errorMessage ?? '保存到文件夹失败,请检查文件保存权限');
return; return;
} }
await _showRecordingSavedDialogIfNeeded(); await _showRecordingSavedDialogIfNeeded();
@@ -190,7 +190,7 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
Future<void> _showRecordingSavedDialogIfNeeded() async { Future<void> _showRecordingSavedDialogIfNeeded() async {
final recordingInfo = ref.read(recordingViewModelProvider); final recordingInfo = ref.read(recordingViewModelProvider);
final session = recordingInfo.session; final session = recordingInfo.session;
if (session.lastSavedDisplayName == null || session.gallerySaveFailed) { if (session.lastSavedDisplayName == null || session.fileSaveFailed) {
return; return;
} }
@@ -357,10 +357,7 @@ class _PreviewLoadingLayer extends ConsumerWidget {
} }
class _RecordingHudLayer extends ConsumerWidget { class _RecordingHudLayer extends ConsumerWidget {
const _RecordingHudLayer({ const _RecordingHudLayer({required this.onStart, required this.onStop});
required this.onStart,
required this.onStop,
});
final Future<void> Function() onStart; final Future<void> Function() onStart;
final Future<void> Function() onStop; final Future<void> Function() onStop;
@@ -378,6 +375,9 @@ class _RecordingHudLayer extends ConsumerWidget {
m.session.isRecording, m.session.isRecording,
m.session.isStartingRecording, m.session.isStartingRecording,
m.session.isTouchLocked, m.session.isTouchLocked,
m.session.zoomRatio,
m.session.minZoomRatio,
m.session.maxZoomRatio,
m.hasValidClipboardInfo, m.hasValidClipboardInfo,
m.clipboardRecordingModel.address.trim(), m.clipboardRecordingModel.address.trim(),
), ),
@@ -392,6 +392,9 @@ class _RecordingHudLayer extends ConsumerWidget {
isRecording, isRecording,
isStartingRecording, isStartingRecording,
isTouchLocked, isTouchLocked,
zoomRatio,
minZoomRatio,
maxZoomRatio,
showClipboardHint, showClipboardHint,
clipboardAddress, clipboardAddress,
) = hudState; ) = hudState;
@@ -406,6 +409,9 @@ class _RecordingHudLayer extends ConsumerWidget {
isRecording: isRecording, isRecording: isRecording,
isStartingRecording: isStartingRecording, isStartingRecording: isStartingRecording,
isTouchLocked: isTouchLocked, isTouchLocked: isTouchLocked,
zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio,
maxZoomRatio: maxZoomRatio,
showClipboardHint: showClipboardHint, showClipboardHint: showClipboardHint,
clipboardAddress: clipboardAddress, clipboardAddress: clipboardAddress,
onStart: onStart, onStart: onStart,
@@ -419,9 +425,15 @@ class _RecordingHudLayer extends ConsumerWidget {
await viewModel.refreshBatteryOptimization(); await viewModel.refreshBatteryOptimization();
}, },
onToggleTouchLock: () { onToggleTouchLock: () {
final locked = ref.read(recordingViewModelProvider).session.isTouchLocked; final locked = ref
.read(recordingViewModelProvider)
.session
.isTouchLocked;
viewModel.setTouchLocked(!locked); viewModel.setTouchLocked(!locked);
}, },
onZoomSelected: (ratio) async {
await viewModel.setZoomRatio(ratio);
},
); );
} }
} }

View File

@@ -1,5 +1,5 @@
abstract final class RecordingChannelNames { abstract final class RecordingChannelNames {
static const packageName = 'com.qxy.dronex'; static const packageName = 'com.dronex.rec';
static const method = '$packageName/recording'; static const method = '$packageName/recording';
static const events = '$packageName/recording_events'; static const events = '$packageName/recording_events';
} }

View File

@@ -81,6 +81,21 @@ class RecordingPlatform {
return RecordingStatus.fromMap(result ?? const {}); return RecordingStatus.fromMap(result ?? const {});
} }
static Future<RecordingZoomCapabilities> getZoomCapabilities() async {
final result = await _channel.invokeMapMethod<String, dynamic>(
'getZoomCapabilities',
);
return RecordingZoomCapabilities.fromMap(result);
}
static Future<RecordingZoomCapabilities> setZoomRatio(double ratio) async {
final result = await _channel.invokeMapMethod<String, dynamic>(
'setZoomRatio',
<String, dynamic>{'zoomRatio': ratio},
);
return RecordingZoomCapabilities.fromMap(result);
}
static Future<RecordingStartResult> startRecording({ static Future<RecordingStartResult> startRecording({
bool withAudio = true, bool withAudio = true,
bool enableDoNotDisturb = true, bool enableDoNotDisturb = true,
@@ -156,6 +171,29 @@ class RecordingPlatform {
} }
} }
class RecordingZoomCapabilities {
const RecordingZoomCapabilities({
required this.zoomRatio,
required this.minZoomRatio,
required this.maxZoomRatio,
});
final double zoomRatio;
final double minZoomRatio;
final double maxZoomRatio;
factory RecordingZoomCapabilities.fromMap(Map<String, dynamic>? map) {
final minZoomRatio = (map?['minZoomRatio'] as num?)?.toDouble() ?? 1.0;
final maxZoomRatio = (map?['maxZoomRatio'] as num?)?.toDouble() ?? 3.0;
final zoomRatio = (map?['zoomRatio'] as num?)?.toDouble() ?? minZoomRatio;
return RecordingZoomCapabilities(
zoomRatio: zoomRatio.clamp(minZoomRatio, maxZoomRatio).toDouble(),
minZoomRatio: minZoomRatio,
maxZoomRatio: maxZoomRatio,
);
}
}
class RecordingStartResult { class RecordingStartResult {
const RecordingStartResult({this.outputPath, required this.status}); const RecordingStartResult({this.outputPath, required this.status});
@@ -167,14 +205,14 @@ class RecordingStopResult {
const RecordingStopResult({ const RecordingStopResult({
this.outputPath, this.outputPath,
required this.status, required this.status,
this.gallerySaved = true, this.fileSaved = true,
this.galleryErrorMessage, this.fileErrorMessage,
}); });
final String? outputPath; final String? outputPath;
final RecordingStatus status; final RecordingStatus status;
final bool gallerySaved; final bool fileSaved;
final String? galleryErrorMessage; final String? fileErrorMessage;
factory RecordingStopResult.fromMap(Map<String, dynamic>? result) { factory RecordingStopResult.fromMap(Map<String, dynamic>? result) {
return RecordingStopResult( return RecordingStopResult(
@@ -182,8 +220,8 @@ class RecordingStopResult {
status: RecordingStatus.fromMap( status: RecordingStatus.fromMap(
Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}), Map<dynamic, dynamic>.from(result?['status'] as Map? ?? const {}),
), ),
gallerySaved: result?['gallerySaved'] as bool? ?? true, fileSaved: result?['fileSaved'] as bool? ?? true,
galleryErrorMessage: result?['galleryErrorMessage'] as String?, fileErrorMessage: result?['fileErrorMessage'] as String?,
); );
} }
} }

View File

@@ -7,6 +7,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:recording_tool/core/logging/app_logger.dart'; import 'package:recording_tool/core/logging/app_logger.dart';
import 'package:recording_tool/core/permission/permission_service.dart'; import 'package:recording_tool/core/permission/permission_service.dart';
import 'package:recording_tool/core/platform/app_platform_info.dart';
import 'package:recording_tool/features/recording/model/model_clipboard.dart'; import 'package:recording_tool/features/recording/model/model_clipboard.dart';
import 'package:recording_tool/features/recording/model/model_recording.dart'; import 'package:recording_tool/features/recording/model/model_recording.dart';
import 'package:recording_tool/features/recording/model/model_recording_session.dart'; import 'package:recording_tool/features/recording/model/model_recording_session.dart';
@@ -31,15 +32,19 @@ enum ClipboardReadResult {
invalid, invalid,
} }
List<Permission> recordingGalleryPermissionsForHost({ List<Permission> recordingFileSavePermissionsForHost({
required bool isIOS, required bool isIOS,
required bool isAndroid, required bool isAndroid,
int? androidSdkInt,
}) { }) {
if (isIOS) { if (isIOS) {
return [Permission.photosAddOnly]; return const [];
} }
if (isAndroid) { if (isAndroid) {
return [Permission.videos, Permission.storage]; if (androidSdkInt != null && androidSdkInt >= 29) {
return const [];
}
return [Permission.storage];
} }
return const []; return const [];
} }
@@ -144,11 +149,12 @@ class RecordingViewModel extends Notifier<RecordingModel> {
return; return;
} }
final fileSavePermissions = await _fileSavePermissions();
final permissions = await PermissionService.requestMissing([ final permissions = await PermissionService.requestMissing([
Permission.camera, Permission.camera,
Permission.microphone, Permission.microphone,
if (Platform.isAndroid) Permission.notification, if (Platform.isAndroid) Permission.notification,
..._galleryPermissions(), ...fileSavePermissions,
]); ]);
final cameraGranted = permissions[Permission.camera]?.isGranted ?? false; final cameraGranted = permissions[Permission.camera]?.isGranted ?? false;
@@ -170,8 +176,8 @@ class RecordingViewModel extends Notifier<RecordingModel> {
if (!microphoneGranted) { if (!microphoneGranted) {
warnings.add('未授予麦克风权限,当前将以静音模式录制'); warnings.add('未授予麦克风权限,当前将以静音模式录制');
} }
if (!_isGalleryPermissionGranted(permissions)) { if (!_isFileSavePermissionGranted(permissions, fileSavePermissions)) {
warnings.add('未授予相册权限,录制结束后可能无法保存到相册'); warnings.add('未授予文件保存权限,录制结束后可能无法保存到文件夹');
} }
final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess(); final hasDnd = await RecordingPlatform.hasNotificationPolicyAccess();
@@ -193,6 +199,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
await _listenStatus(); await _listenStatus();
try { try {
final status = await _initializePreviewWithRetry(); final status = await _initializePreviewWithRetry();
await _refreshZoomCapabilities();
_updateSession( _updateSession(
(s) => s.copyWith( (s) => s.copyWith(
status: status, status: status,
@@ -239,6 +246,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
); );
try { try {
final status = await _initializePreviewWithRetry(); final status = await _initializePreviewWithRetry();
await _refreshZoomCapabilities();
_updateSession( _updateSession(
(s) => s.copyWith( (s) => s.copyWith(
status: status, status: status,
@@ -258,24 +266,36 @@ class RecordingViewModel extends Notifier<RecordingModel> {
} }
} }
/// 当前平台所需的相册/视频保存权限列表。 /// 当前平台所需的视频文件保存权限列表。
List<Permission> _galleryPermissions() { Future<List<Permission>> _fileSavePermissions() async {
return recordingGalleryPermissionsForHost( int? androidSdkInt;
if (Platform.isAndroid) {
try {
androidSdkInt = int.tryParse(
(await AppPlatformInfo.deviceInfo()).values['sdkInt'] ?? '',
);
} on PlatformException {
androidSdkInt = null;
}
}
return recordingFileSavePermissionsForHost(
isIOS: Platform.isIOS, isIOS: Platform.isIOS,
isAndroid: Platform.isAndroid, isAndroid: Platform.isAndroid,
androidSdkInt: androidSdkInt,
); );
} }
/// 判断相册相关权限是否至少有一项已授予。 /// 判断文件保存相关权限是否至少有一项已授予。
bool _isGalleryPermissionGranted( bool _isFileSavePermissionGranted(
Map<Permission, PermissionStatus> permissions, Map<Permission, PermissionStatus> permissions,
List<Permission> fileSavePermissions,
) { ) {
for (final permission in _galleryPermissions()) { for (final permission in fileSavePermissions) {
if (permissions[permission]?.isGranted ?? false) { if (permissions[permission]?.isGranted ?? false) {
return true; return true;
} }
} }
return _galleryPermissions().isEmpty; return fileSavePermissions.isEmpty;
} }
/// 检测并尝试申请相机、麦克风权限,同步更新 session 中的 isMicrophoneGranted。 /// 检测并尝试申请相机、麦克风权限,同步更新 session 中的 isMicrophoneGranted。
@@ -309,6 +329,47 @@ class RecordingViewModel extends Notifier<RecordingModel> {
return status?.isGranted == true || status?.isLimited == true; return status?.isGranted == true || status?.isLimited == true;
} }
/// 读取相机支持的倍距范围并同步当前倍距。
Future<void> _refreshZoomCapabilities() async {
try {
final zoom = await RecordingPlatform.getZoomCapabilities();
_updateSession(
(s) => s.copyWith(
zoomRatio: zoom.zoomRatio,
minZoomRatio: zoom.minZoomRatio,
maxZoomRatio: zoom.maxZoomRatio,
errorMessage: null,
),
);
} on PlatformException catch (error) {
AppLogger.debug('读取相机倍距能力失败', error: error);
}
}
/// 设置相机倍距,原生层会返回设备实际应用后的倍距范围与当前值。
Future<void> setZoomRatio(double ratio) async {
final session = state.session;
final clamped = ratio
.clamp(session.minZoomRatio, session.maxZoomRatio)
.toDouble();
try {
final zoom = await RecordingPlatform.setZoomRatio(clamped);
_updateSession(
(s) => s.copyWith(
zoomRatio: zoom.zoomRatio,
minZoomRatio: zoom.minZoomRatio,
maxZoomRatio: zoom.maxZoomRatio,
errorMessage: null,
),
);
} on PlatformException catch (error) {
_updateSession(
(s) => s.copyWith(errorMessage: error.message ?? '相机倍距设置失败'),
);
}
}
/// 开始录制,可选开启勿扰模式。 /// 开始录制,可选开启勿扰模式。
Future<void> startRecording({bool enableDoNotDisturb = true}) async { Future<void> startRecording({bool enableDoNotDisturb = true}) async {
final session = state.session; final session = state.session;
@@ -338,7 +399,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
lastOutputPath: result.outputPath, lastOutputPath: result.outputPath,
isTouchLocked: true, isTouchLocked: true,
errorMessage: null, errorMessage: null,
gallerySaveFailed: false, fileSaveFailed: false,
clearLastSaved: true, clearLastSaved: true,
), ),
); );
@@ -351,13 +412,13 @@ class RecordingViewModel extends Notifier<RecordingModel> {
} }
} }
/// 停止录制、保存到相册,并恢复相机预览。 /// 停止录制、保存到文件夹,并恢复相机预览。
Future<void> stopRecording() async { Future<void> stopRecording() async {
if (!state.session.isRecording) return; if (!state.session.isRecording) return;
try { try {
final result = await RecordingPlatform.stopRecording(); final result = await RecordingPlatform.stopRecording();
final galleryFailed = !result.gallerySaved; final fileFailed = !result.fileSaved;
final savedName = recordingFileNameForPlatform( final savedName = recordingFileNameForPlatform(
state.clipboardRecordingModel.filename, state.clipboardRecordingModel.filename,
); );
@@ -365,11 +426,11 @@ class RecordingViewModel extends Notifier<RecordingModel> {
(s) => s.copyWith( (s) => s.copyWith(
status: result.status, status: result.status,
lastOutputPath: result.outputPath ?? s.lastOutputPath, lastOutputPath: result.outputPath ?? s.lastOutputPath,
lastSavedDisplayName: galleryFailed ? null : savedName, lastSavedDisplayName: fileFailed ? null : savedName,
errorMessage: galleryFailed errorMessage: fileFailed
? (result.galleryErrorMessage ?? '保存到相册失败,请开启相册权限') ? (result.fileErrorMessage ?? '保存到文件夹失败,请检查文件保存权限')
: null, : null,
gallerySaveFailed: galleryFailed, fileSaveFailed: fileFailed,
), ),
); );
} on PlatformException catch (error) { } on PlatformException catch (error) {

View File

@@ -21,11 +21,15 @@ class RecordingHudWidget extends StatelessWidget {
required this.isTouchLocked, required this.isTouchLocked,
this.showClipboardHint = false, this.showClipboardHint = false,
this.clipboardAddress = '', this.clipboardAddress = '',
required this.zoomRatio,
required this.minZoomRatio,
required this.maxZoomRatio,
required this.onStart, required this.onStart,
required this.onStop, required this.onStop,
required this.onOpenDnd, required this.onOpenDnd,
required this.onOpenBattery, required this.onOpenBattery,
required this.onToggleTouchLock, required this.onToggleTouchLock,
required this.onZoomSelected,
}); });
final String? errorMessage; final String? errorMessage;
@@ -38,16 +42,21 @@ class RecordingHudWidget extends StatelessWidget {
final bool isTouchLocked; final bool isTouchLocked;
final bool showClipboardHint; final bool showClipboardHint;
final String clipboardAddress; final String clipboardAddress;
final double zoomRatio;
final double minZoomRatio;
final double maxZoomRatio;
final Future<void> Function() onStart; final Future<void> Function() onStart;
final Future<void> Function() onStop; final Future<void> Function() onStop;
final VoidCallback onOpenDnd; final VoidCallback onOpenDnd;
final VoidCallback onOpenBattery; final VoidCallback onOpenBattery;
final VoidCallback onToggleTouchLock; final VoidCallback onToggleTouchLock;
final ValueChanged<double> onZoomSelected;
static double get _recordButtonSize => 70.r; static double get _recordButtonSize => 70.r;
static double get _recordButtonBottom => 63.r; static double get _recordButtonBottom => 63.r;
static double get _overlayInfoLeft => 13.r; static double get _overlayInfoLeft => 13.r;
static double get _overlayInfoBottom => 10.r; static double get _overlayInfoBottom => 10.r;
static const List<double> _zoomPresets = [1.0, 2.0, 3.0];
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -133,6 +142,17 @@ class RecordingHudWidget extends StatelessWidget {
), ),
), ),
), ),
Positioned(
right: 16.r,
bottom: _recordButtonBottom + _recordButtonSize + 14.h,
child: _ZoomPresetControl(
zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio,
maxZoomRatio: maxZoomRatio,
presets: _zoomPresets,
onSelected: onZoomSelected,
),
),
Positioned( Positioned(
left: 0, left: 0,
right: 0, right: 0,
@@ -171,3 +191,94 @@ class RecordingHudWidget extends StatelessWidget {
); );
} }
} }
class _ZoomPresetControl extends StatelessWidget {
const _ZoomPresetControl({
required this.zoomRatio,
required this.minZoomRatio,
required this.maxZoomRatio,
required this.presets,
required this.onSelected,
});
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((preset) => preset >= minZoomRatio && preset <= maxZoomRatio)
.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(
ratio: preset,
selected: (zoomRatio - preset).abs() < 0.05,
onSelected: onSelected,
),
],
),
),
);
}
}
class _ZoomPresetButton extends StatelessWidget {
const _ZoomPresetButton({
required this.ratio,
required this.selected,
required this.onSelected,
});
final double ratio;
final bool selected;
final ValueChanged<double> onSelected;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.symmetric(horizontal: 1.r),
child: TextButton(
onPressed: selected ? null : () => onSelected(ratio),
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(
'${ratio.toStringAsFixed(0)}x',
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w700,
letterSpacing: 0,
),
),
),
);
}
}

View File

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

View File

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+2002 version: 1.0.0
environment: environment:
sdk: ^3.9.0 sdk: ^3.9.0

View File

@@ -71,7 +71,7 @@ void main() {
}); });
group('iOS permission configuration', () { group('iOS permission configuration', () {
test('Podfile enables camera, microphone and photos permission macros', () { test('Podfile enables camera and microphone permission macros only', () {
final podfile = File('ios/Podfile').readAsStringSync(); final podfile = File('ios/Podfile').readAsStringSync();
expect( expect(
@@ -80,8 +80,8 @@ void main() {
); );
expect(podfile, contains("'PERMISSION_CAMERA=1'")); expect(podfile, contains("'PERMISSION_CAMERA=1'"));
expect(podfile, contains("'PERMISSION_MICROPHONE=1'")); expect(podfile, contains("'PERMISSION_MICROPHONE=1'"));
expect(podfile, contains("'PERMISSION_PHOTOS=1'")); expect(podfile, isNot(contains("'PERMISSION_PHOTOS=1'")));
expect(podfile, contains("'PERMISSION_PHOTOS_ADD_ONLY=1'")); expect(podfile, isNot(contains("'PERMISSION_PHOTOS_ADD_ONLY=1'")));
}); });
}); });
} }

View File

@@ -68,7 +68,7 @@ void main() {
onPressed: () { onPressed: () {
RecordDialog.showDouble( RecordDialog.showDouble(
context, context,
title: '本轮比赛视频已保存到相册\n请选择后续录制信息', title: '本轮比赛视频已保存到文件夹\n请选择后续录制信息',
leftText: '继续本轮', leftText: '继续本轮',
rightText: '录制新轮', rightText: '录制新轮',
onLeftPressed: () => leftTapped = true, onLeftPressed: () => leftTapped = true,
@@ -123,7 +123,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('王东方 丨李想 空中格斗赛'), findsNothing); expect(find.text('王东方 丨李想 空中格斗赛'), findsNothing);
expect(find.text('本轮比赛视频已保存到相册\n请选择后续录制信息'), findsOneWidget); expect(find.text('本轮比赛视频已保存到文件夹\n请选择后续录制信息'), findsOneWidget);
expect(find.text('继续本轮'), findsOneWidget); expect(find.text('继续本轮'), findsOneWidget);
expect(find.text('录制新轮'), findsOneWidget); expect(find.text('录制新轮'), findsOneWidget);
}); });

View File

@@ -18,4 +18,20 @@ void main() {
); );
}); });
}); });
group('RecordingStopResult', () {
test('parses file save result fields from platform payload', () {
final result = RecordingStopResult.fromMap(<String, dynamic>{
'outputPath': '/Documents/recordings/test.mov',
'status': <String, dynamic>{'state': 'previewing'},
'fileSaved': false,
'fileErrorMessage': '保存到文件夹失败',
});
expect(result.outputPath, '/Documents/recordings/test.mov');
expect(result.status.state, RecordingState.previewing);
expect(result.fileSaved, isFalse);
expect(result.fileErrorMessage, '保存到文件夹失败');
});
});
} }

View File

@@ -2,6 +2,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import 'package:recording_tool/features/recording/platform/recording_channel_names.dart';
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart'; import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
void main() { void main() {
@@ -24,6 +25,11 @@ void main() {
tearDown(() { tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null); .setMockMethodCallHandler(SystemChannels.platform, null);
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
null,
);
}); });
group('RecordingViewModel', () { group('RecordingViewModel', () {
@@ -36,27 +42,120 @@ void main() {
expect(model.clipboardRecordingModel.title, defaultClipboardTitle); expect(model.clipboardRecordingModel.title, defaultClipboardTitle);
expect(model.session.isPreviewReady, isFalse); expect(model.session.isPreviewReady, isFalse);
expect(model.session.isRecording, isFalse); expect(model.session.isRecording, isFalse);
expect(model.session.zoomRatio, 1.0);
expect(model.session.minZoomRatio, 1.0);
expect(model.session.maxZoomRatio, 3.0);
}); });
}); });
group('recordingGalleryPermissionsForHost', () { group('RecordingViewModel.setZoomRatio', () {
test('requests only add-only photo permission on iOS', () { test('updates zoom ratio from native response', () async {
final permissions = recordingGalleryPermissionsForHost( TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) async {
expect(call.method, 'setZoomRatio');
expect(call.arguments, <String, dynamic>{'zoomRatio': 2.0});
return <String, dynamic>{
'zoomRatio': 2.0,
'minZoomRatio': 1.0,
'maxZoomRatio': 3.0,
};
},
);
final container = ProviderContainer();
addTearDown(container.dispose);
await container.read(recordingViewModelProvider.notifier).setZoomRatio(2);
final session = container.read(recordingViewModelProvider).session;
expect(session.zoomRatio, 2.0);
expect(session.minZoomRatio, 1.0);
expect(session.maxZoomRatio, 3.0);
expect(session.errorMessage, isNull);
});
test('clamps requested zoom ratio before invoking native', () async {
final calls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) async {
calls.add(call);
return <String, dynamic>{
'zoomRatio': 1.0,
'minZoomRatio': 1.0,
'maxZoomRatio': 1.0,
};
},
);
final container = ProviderContainer();
addTearDown(container.dispose);
await container.read(recordingViewModelProvider.notifier).setZoomRatio(4);
expect(calls.single.arguments, <String, dynamic>{'zoomRatio': 3.0});
expect(container.read(recordingViewModelProvider).session.zoomRatio, 1.0);
});
test(
'keeps previous zoom ratio and stores error when native fails',
() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) async {
throw PlatformException(
code: 'ZOOM_FAILED',
message: 'Zoom is unavailable',
);
},
);
final container = ProviderContainer();
addTearDown(container.dispose);
await container
.read(recordingViewModelProvider.notifier)
.setZoomRatio(2);
final session = container.read(recordingViewModelProvider).session;
expect(session.zoomRatio, 1.0);
expect(session.errorMessage, 'Zoom is unavailable');
},
);
});
group('recordingFileSavePermissionsForHost', () {
test('does not request photo permission on iOS', () {
final permissions = recordingFileSavePermissionsForHost(
isIOS: true, isIOS: true,
isAndroid: false, isAndroid: false,
); );
expect(permissions, <Permission>[Permission.photosAddOnly]); expect(permissions, isEmpty);
expect(permissions, isNot(contains(Permission.photosAddOnly)));
expect(permissions, isNot(contains(Permission.photos))); expect(permissions, isNot(contains(Permission.photos)));
}); });
test('keeps Android gallery permissions unchanged', () { test('requests storage permission on Android 9 and below', () {
final permissions = recordingGalleryPermissionsForHost( final permissions = recordingFileSavePermissionsForHost(
isIOS: false, isIOS: false,
isAndroid: true, isAndroid: true,
androidSdkInt: 28,
); );
expect(permissions, <Permission>[Permission.videos, Permission.storage]); expect(permissions, <Permission>[Permission.storage]);
expect(permissions, isNot(contains(Permission.videos)));
});
test('does not request file save permission on Android 10 and above', () {
final permissions = recordingFileSavePermissionsForHost(
isIOS: false,
isAndroid: true,
androidSdkInt: 29,
);
expect(permissions, isEmpty);
}); });
}); });

View File

@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:recording_tool/features/recording/widgets/widget_recording_hud.dart';
void main() {
Future<void> pumpHud(
WidgetTester tester, {
double zoomRatio = 1.0,
double minZoomRatio = 1.0,
double maxZoomRatio = 3.0,
ValueChanged<double>? onZoomSelected,
}) async {
await tester.pumpWidget(
ScreenUtilInit(
designSize: const Size(375, 812),
builder: (context, _) {
return MaterialApp(
home: Scaffold(
backgroundColor: Colors.black,
body: RecordingHudWidget(
hasDndAccess: true,
isBatteryOptimizedIgnored: true,
notificationsGranted: true,
isRecording: false,
isStartingRecording: false,
isTouchLocked: false,
zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio,
maxZoomRatio: maxZoomRatio,
onStart: () async {},
onStop: () async {},
onOpenDnd: () {},
onOpenBattery: () {},
onToggleTouchLock: () {},
onZoomSelected: onZoomSelected ?? (_) {},
),
),
);
},
),
);
await tester.pump();
}
testWidgets('shows preset zoom buttons', (tester) async {
await pumpHud(tester);
expect(find.text('1x'), findsOneWidget);
expect(find.text('2x'), findsOneWidget);
expect(find.text('3x'), findsOneWidget);
});
testWidgets('marks current zoom ratio as selected', (tester) async {
await pumpHud(tester, zoomRatio: 2.0);
final selectedButton = tester.widget<TextButton>(
find.ancestor(of: find.text('2x'), matching: find.byType(TextButton)),
);
expect(selectedButton.enabled, isFalse);
});
testWidgets('does not expose presets beyond max zoom ratio', (tester) async {
await pumpHud(tester, maxZoomRatio: 2.0);
expect(find.text('1x'), findsOneWidget);
expect(find.text('2x'), findsOneWidget);
expect(find.text('3x'), findsNothing);
});
testWidgets('tapping zoom preset reports selected ratio', (tester) async {
double? selected;
await pumpHud(tester, onZoomSelected: (ratio) => selected = ratio);
await tester.tap(find.text('2x'));
await tester.pump();
expect(selected, 2.0);
});
}