55 changed files with 377 additions and 162 deletions
-1
View File
@@ -49,4 +49,3 @@ app.*.map.json
/android/.kotlin
CLAUDE.md
+1 -1
View File
@@ -4,7 +4,7 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
val appPackageName = "com.run.sportsx"
val appPackageName = "com.dronex.rec"
android {
namespace = appPackageName
+3 -4
View File
@@ -1,5 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.run.sportsx">
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" />
@@ -20,7 +19,7 @@
android:required="true" />
<application
android:label="酷跑录像工作台"
android:label="飞行极控录像工作台"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@@ -57,4 +56,4 @@
<data android:mimeType="text/plain" />
</intent>
</queries>
</manifest>
</manifest>
@@ -0,0 +1,10 @@
package com.dronex.rec
object AppConstants {
private const val CHANNEL_NAMESPACE = "app.record_tool"
const val PLATFORM_INFO_CHANNEL = "$CHANNEL_NAMESPACE/platform_info"
const val RECORDING_METHOD_CHANNEL = "$CHANNEL_NAMESPACE/recording"
const val RECORDING_EVENT_CHANNEL = "$CHANNEL_NAMESPACE/recording_events"
const val RECORDING_ACTION_START = "$CHANNEL_NAMESPACE.recording.START"
const val RECORDING_ACTION_STOP = "$CHANNEL_NAMESPACE.recording.STOP"
}
@@ -1,4 +1,4 @@
package com.run.sportsx
package com.dronex.rec
import android.content.Context
import android.content.pm.ApplicationInfo
@@ -7,8 +7,8 @@ import android.os.Build
import android.os.Environment
import android.os.StatFs
import androidx.camera.view.PreviewView
import com.run.sportsx.recording.RecordingPlatformHandler
import com.run.sportsx.recording.RecordingPreviewFactory
import com.dronex.rec.recording.RecordingPlatformHandler
import com.dronex.rec.recording.RecordingPreviewFactory
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
@@ -1,4 +1,4 @@
package com.run.sportsx.recording
package com.dronex.rec.recording
import android.content.Context
import android.content.Intent
@@ -1,4 +1,4 @@
package com.run.sportsx.recording
package com.dronex.rec.recording
import android.app.NotificationManager
import android.content.Context
@@ -1,4 +1,4 @@
package com.run.sportsx.recording
package com.dronex.rec.recording
import android.content.Context
import android.hardware.camera2.CameraCharacteristics
@@ -1,4 +1,4 @@
package com.run.sportsx.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.run.sportsx.AppConstants
import com.run.sportsx.MainActivity
import com.dronex.rec.AppConstants
import com.dronex.rec.MainActivity
class RecordingForegroundService : LifecycleService() {
private var wakeLock: PowerManager.WakeLock? = null
@@ -1,4 +1,4 @@
package com.run.sportsx.recording
package com.dronex.rec.recording
import android.content.ContentValues
import android.content.Context
@@ -12,7 +12,7 @@ import java.util.Date
import java.util.Locale
object RecordingOutputFactory {
private const val RELATIVE_PATH = "Movies/酷跑录像工作台"
private const val RELATIVE_PATH = "Movies/飞行极控录像工作台"
private const val MIME_TYPE = "video/mp4"
fun buildSegmentOutputOptions(segmentFile: File): FileOutputOptions {
@@ -1,12 +1,12 @@
package com.run.sportsx.recording
package com.dronex.rec.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.run.sportsx.AppConstants
import com.run.sportsx.MainActivity
import com.dronex.rec.AppConstants
import com.dronex.rec.MainActivity
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
@@ -1,9 +1,9 @@
package com.run.sportsx.recording
package com.dronex.rec.recording
import android.content.Context
import android.view.View
import androidx.camera.view.PreviewView
import com.run.sportsx.MainActivity
import com.dronex.rec.MainActivity
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory
@@ -1,4 +1,4 @@
package com.run.sportsx.recording
package com.dronex.rec.recording
import android.media.MediaCodec
import android.media.MediaExtractor
@@ -1,4 +1,4 @@
package com.run.sportsx.recording
package com.dronex.rec.recording
import android.content.Context
import androidx.lifecycle.LifecycleService
@@ -1,4 +1,4 @@
package com.run.sportsx.recording
package com.dronex.rec.recording
enum class RecordingState {
IDLE,
@@ -1,10 +0,0 @@
package com.run.sportsx
object AppConstants {
const val PACKAGE_NAME = "com.run.sportsx"
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"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

+3 -3
View File
@@ -507,7 +507,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.run.sportsx;
PRODUCT_BUNDLE_IDENTIFIER = com.dronex.rec;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "dev-profile-dronex";
@@ -696,7 +696,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.run.sportsx;
PRODUCT_BUNDLE_IDENTIFIER = com.dronex.rec;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "dev-profile-dronex";
@@ -725,7 +725,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.run.sportsx;
PRODUCT_BUNDLE_IDENTIFIER = com.dronex.rec;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "dev-profile-dronex";
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 761 B

After

Width:  |  Height:  |  Size: 976 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 20 KiB

@@ -4,14 +4,6 @@
"filename" : "startup_background.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 262 KiB

+71 -71
View File
@@ -1,78 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>飞行极控录像工作台</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>飞行极控录像工作台</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>需要访问相机以显示预览并录制视频。</string>
<key>NSMicrophoneUsageDescription</key>
<string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string>
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true />
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>酷跑录像工作台</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>酷跑录像工作台</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true />
<key>NSCameraUsageDescription</key>
<string>需要访问相机以显示预览并录制视频。</string>
<key>NSMicrophoneUsageDescription</key>
<string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string>
<key>UIFileSharingEnabled</key>
<true />
<key>LSSupportsOpeningDocumentsInPlace</key>
<true />
<key>UIApplicationSceneManifest</key>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false />
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true />
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
+1 -1
View File
@@ -4,7 +4,7 @@ import UIKit
final class PlatformInfoPlugin: NSObject, FlutterPlugin {
static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(
name: "com.run.sportsx/platform_info",
name: "app.record_tool/platform_info",
binaryMessenger: registrar.messenger()
)
let plugin = PlatformInfoPlugin()
+3 -3
View File
@@ -633,9 +633,9 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
}
private enum RecordingChannelNames {
static let packageName = "com.run.sportsx"
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 {
+1 -1
View File
@@ -21,7 +21,7 @@ class AppConfig {
static late EnvironmentValues current;
static AppPackageInfo? packageInfo;
static const appName = '酷跑录像工作台';
static const appName = '飞行极控录像工作台';
static const designSize = Size(375, 812);
static void configure({
+1 -1
View File
@@ -60,7 +60,7 @@ class AppPlatformInfo {
AppPlatformInfo._();
static const MethodChannel _channel = MethodChannel(
'com.run.sportsx/platform_info',
'app.record_tool/platform_info',
);
static Future<AppPackageInfo> packageInfo() async {
@@ -7,6 +7,7 @@ class RecordingSessionState {
this.isTouchLocked = true,
this.isPreviewReady = false,
this.isStartingRecording = false,
this.isSwitchingLens = false,
this.hasDndAccess = false,
this.isBatteryOptimizedIgnored = true,
this.notificationsGranted = true,
@@ -19,12 +20,14 @@ class RecordingSessionState {
this.errorMessage,
this.permissionWarning,
this.fileSaveFailed = false,
this.segmentOutputPaths = const [],
});
final RecordingStatus status;
final bool isTouchLocked;
final bool isPreviewReady;
final bool isStartingRecording;
final bool isSwitchingLens;
final bool hasDndAccess;
final bool isBatteryOptimizedIgnored;
final bool notificationsGranted;
@@ -37,6 +40,7 @@ class RecordingSessionState {
final String? errorMessage;
final String? permissionWarning;
final bool fileSaveFailed;
final List<String> segmentOutputPaths;
bool get isRecording => status.isRecording;
@@ -53,6 +57,7 @@ class RecordingSessionState {
bool? isTouchLocked,
bool? isPreviewReady,
bool? isStartingRecording,
bool? isSwitchingLens,
bool? hasDndAccess,
bool? isBatteryOptimizedIgnored,
bool? notificationsGranted,
@@ -65,6 +70,7 @@ class RecordingSessionState {
String? errorMessage,
String? permissionWarning,
bool? fileSaveFailed,
List<String>? segmentOutputPaths,
bool clearPermissionWarning = false,
bool clearLastSaved = false,
}) {
@@ -73,6 +79,7 @@ class RecordingSessionState {
isTouchLocked: isTouchLocked ?? this.isTouchLocked,
isPreviewReady: isPreviewReady ?? this.isPreviewReady,
isStartingRecording: isStartingRecording ?? this.isStartingRecording,
isSwitchingLens: isSwitchingLens ?? this.isSwitchingLens,
hasDndAccess: hasDndAccess ?? this.hasDndAccess,
isBatteryOptimizedIgnored:
isBatteryOptimizedIgnored ?? this.isBatteryOptimizedIgnored,
@@ -90,6 +97,7 @@ class RecordingSessionState {
? null
: (permissionWarning ?? this.permissionWarning),
fileSaveFailed: fileSaveFailed ?? this.fileSaveFailed,
segmentOutputPaths: segmentOutputPaths ?? this.segmentOutputPaths,
);
}
}
@@ -173,7 +173,11 @@ class _RecordingPageState extends ConsumerState<RecordingPage> {
if (!mounted) return;
final latest = ref.read(recordingViewModelProvider).session;
if (latest.fileSaveFailed) {
AppToast.show(latest.errorMessage ?? '保存到文件夹失败,请检查文件保存权限');
if (latest.segmentOutputPaths.isNotEmpty) {
AppToast.show('视频合并失败,已为你保存分段文件,可在相册中查看');
} else {
AppToast.show(latest.errorMessage ?? '保存到文件夹失败,请检查文件保存权限');
}
return;
}
await _showRecordingSavedDialogIfNeeded();
@@ -374,6 +378,7 @@ class _RecordingHudLayer extends ConsumerWidget {
m.session.notificationsGranted,
m.session.isRecording,
m.session.isStartingRecording,
m.session.isSwitchingLens,
m.session.isTouchLocked,
m.session.zoomRatio,
m.session.minZoomRatio,
@@ -391,6 +396,7 @@ class _RecordingHudLayer extends ConsumerWidget {
notificationsGranted,
isRecording,
isStartingRecording,
isSwitchingLens,
isTouchLocked,
zoomRatio,
minZoomRatio,
@@ -408,6 +414,7 @@ class _RecordingHudLayer extends ConsumerWidget {
notificationsGranted: notificationsGranted,
isRecording: isRecording,
isStartingRecording: isStartingRecording,
isSwitchingLens: isSwitchingLens,
isTouchLocked: isTouchLocked,
zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio,
@@ -1,5 +1,5 @@
abstract final class RecordingChannelNames {
static const packageName = 'com.run.sportsx';
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';
}
@@ -41,10 +41,10 @@ List<Permission> recordingFileSavePermissionsForHost({
return const [];
}
if (isAndroid) {
if (androidSdkInt != null && androidSdkInt >= 29) {
return const [];
if (androidSdkInt != null && androidSdkInt <= 28) {
return [Permission.storage];
}
return [Permission.storage];
return const [];
}
return const [];
}
@@ -349,10 +349,14 @@ class RecordingViewModel extends Notifier<RecordingModel> {
/// 设置相机倍距,原生层会返回设备实际应用后的倍距范围与当前值。
Future<void> setZoomRatio(double ratio) async {
final session = state.session;
if (session.isSwitchingLens) {
return;
}
final clamped = ratio
.clamp(session.minZoomRatio, session.maxZoomRatio)
.toDouble();
_updateSession((s) => s.copyWith(isSwitchingLens: true));
try {
final zoom = await RecordingPlatform.setZoomRatio(clamped);
_updateSession(
@@ -368,13 +372,19 @@ class RecordingViewModel extends Notifier<RecordingModel> {
? '切换镜头失败,请重试'
: (error.message ?? '相机倍距设置失败');
_updateSession((s) => s.copyWith(errorMessage: message));
} finally {
_updateSession(
(s) => s.copyWith(isSwitchingLens: false, errorMessage: s.errorMessage),
);
}
}
/// 开始录制,可选开启勿扰模式。
Future<void> startRecording({bool enableDoNotDisturb = true}) async {
final session = state.session;
if (session.isRecording || session.isStartingRecording) {
if (session.isRecording ||
session.isStartingRecording ||
session.isSwitchingLens) {
return;
}
if (!session.isPreviewReady) {
@@ -401,6 +411,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
isTouchLocked: true,
errorMessage: null,
fileSaveFailed: false,
segmentOutputPaths: const [],
clearLastSaved: true,
),
);
@@ -415,7 +426,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
/// 停止录制、保存到文件夹,并恢复相机预览。
Future<void> stopRecording() async {
if (!state.session.isRecording) return;
if (!state.session.isRecording || state.session.isSwitchingLens) return;
try {
final result = await RecordingPlatform.stopRecording();
@@ -432,6 +443,7 @@ class RecordingViewModel extends Notifier<RecordingModel> {
? (result.fileErrorMessage ?? '保存到文件夹失败,请检查文件保存权限')
: null,
fileSaveFailed: fileFailed,
segmentOutputPaths: result.segmentOutputPaths,
),
);
} on PlatformException catch (error) {
@@ -18,6 +18,7 @@ class RecordingHudWidget extends StatelessWidget {
required this.notificationsGranted,
required this.isRecording,
required this.isStartingRecording,
required this.isSwitchingLens,
required this.isTouchLocked,
this.showClipboardHint = false,
this.clipboardAddress = '',
@@ -39,6 +40,7 @@ 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;
@@ -143,8 +145,9 @@ class RecordingHudWidget extends StatelessWidget {
),
Positioned(
right: 16.r,
bottom: _recordButtonBottom + _recordButtonSize + 14.h,
bottom: 260.r,
child: _ZoomPresetControl(
enabled: !isSwitchingLens,
zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio,
maxZoomRatio: maxZoomRatio,
@@ -160,7 +163,7 @@ class RecordingHudWidget extends StatelessWidget {
child: RecordingControlButton(
isRecording: isRecording,
isStartingRecording: isStartingRecording,
enabled: !isStartingRecording,
enabled: !isStartingRecording && !isSwitchingLens,
size: _recordButtonSize,
onTap: () {
if (isRecording) {
@@ -197,6 +200,7 @@ class RecordingHudWidget extends StatelessWidget {
class _ZoomPresetControl extends StatelessWidget {
const _ZoomPresetControl({
required this.enabled,
required this.zoomRatio,
required this.minZoomRatio,
required this.maxZoomRatio,
@@ -204,6 +208,7 @@ class _ZoomPresetControl extends StatelessWidget {
required this.onSelected,
});
final bool enabled;
final double zoomRatio;
final double minZoomRatio;
final double maxZoomRatio;
@@ -235,7 +240,7 @@ class _ZoomPresetControl extends StatelessWidget {
displayRatio: preset,
requestRatio: preset,
selected: _isPresetSelected(preset),
enabled: true,
enabled: enabled,
onSelected: onSelected,
),
],
@@ -303,7 +308,7 @@ class _ZoomPresetButton extends StatelessWidget {
),
),
child: Text(
'${_formatZoomRatio(displayRatio)}x',
_formatZoomRatio(displayRatio),
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w700,
@@ -315,9 +320,12 @@ class _ZoomPresetButton extends StatelessWidget {
}
String _formatZoomRatio(double ratio) {
if (ratio == ratio.roundToDouble()) {
return ratio.toStringAsFixed(0);
if (ratio < 1.0) {
return '广角';
}
return ratio.toStringAsFixed(1);
if (ratio == ratio.roundToDouble()) {
return '${ratio.toStringAsFixed(0)}x';
}
return '${ratio.toStringAsFixed(1)}x';
}
}
@@ -1,7 +1,15 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:recording_tool/features/recording/platform/recording_channel_names.dart';
import 'package:recording_tool/features/recording/platform/recording_platform.dart';
void main() {
group('RecordingChannelNames', () {
test('uses stable bridge names without package binding', () {
expect(RecordingChannelNames.method, 'app.record_tool/recording');
expect(RecordingChannelNames.events, 'app.record_tool/recording_events');
});
});
group('RecordingPlatform support', () {
test('supports Android and iOS hosts only', () {
expect(
@@ -1,9 +1,12 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:recording_tool/features/recording/model/model_recording_session.dart';
import 'package:recording_tool/features/recording/platform/recording_channel_names.dart';
import 'package:recording_tool/features/recording/platform/recording_platform.dart';
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
void main() {
@@ -255,6 +258,125 @@ void main() {
expect(session.errorMessage, '切换镜头失败,请重试');
},
);
test('sets switching lens while native zoom request is pending', () async {
final completer = Completer<Map<String, dynamic>>();
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) => completer.future,
);
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(recordingViewModelProvider.notifier);
final future = notifier.setZoomRatio(2);
await Future<void>.delayed(Duration.zero);
expect(
container.read(recordingViewModelProvider).session.isSwitchingLens,
isTrue,
);
completer.complete(<String, dynamic>{
'zoomRatio': 2.0,
'minZoomRatio': 1.0,
'maxZoomRatio': 3.0,
});
await future;
expect(
container.read(recordingViewModelProvider).session.isSwitchingLens,
isFalse,
);
});
});
group('RecordingViewModel.stopRecording', () {
test('does not call native stop while switching lens', () async {
final calls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) async {
calls.add(call);
return <String, dynamic>{};
},
);
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(recordingViewModelProvider.notifier);
// ignore: invalid_use_of_protected_member
notifier.state = container
.read(recordingViewModelProvider)
.copyWith(
session: const RecordingSessionState(
status: RecordingStatus(state: RecordingState.recording),
isSwitchingLens: true,
),
);
await notifier.stopRecording();
expect(calls, isEmpty);
});
test(
'stores segment output paths when native save falls back to parts',
() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel(RecordingChannelNames.method),
(call) async {
switch (call.method) {
case 'stopRecording':
return <String, dynamic>{
'outputPath': 'content://recordings/part1',
'status': <String, dynamic>{
'state': 'error',
'message': 'Merge failed',
},
'fileSaved': false,
'fileErrorMessage': 'Merge failed',
'segmentOutputPaths': <String>[
'content://recordings/part1',
'content://recordings/part2',
],
};
case 'initializePreview':
return <String, dynamic>{'state': 'previewing'};
case 'getZoomCapabilities':
return <String, dynamic>{
'zoomRatio': 1.0,
'minZoomRatio': 1.0,
'maxZoomRatio': 3.0,
};
}
return <String, dynamic>{};
},
);
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(recordingViewModelProvider.notifier);
// ignore: invalid_use_of_protected_member
notifier.state = container
.read(recordingViewModelProvider)
.copyWith(
session: const RecordingSessionState(
status: RecordingStatus(state: RecordingState.recording),
),
);
await notifier.stopRecording();
final session = container.read(recordingViewModelProvider).session;
expect(session.fileSaveFailed, isTrue);
expect(session.segmentOutputPaths, <String>[
'content://recordings/part1',
'content://recordings/part2',
]);
},
);
});
group('recordingFileSavePermissionsForHost', () {
@@ -289,6 +411,25 @@ void main() {
expect(permissions, isEmpty);
});
test('does not request file save permission on Android 13 and above', () {
final permissions = recordingFileSavePermissionsForHost(
isIOS: false,
isAndroid: true,
androidSdkInt: 33,
);
expect(permissions, isEmpty);
});
test('does not request file save permission when Android SDK is unknown', () {
final permissions = recordingFileSavePermissionsForHost(
isIOS: false,
isAndroid: true,
);
expect(permissions, isEmpty);
});
});
group('RecordingViewModel.getClipboardContent', () {
@@ -10,6 +10,8 @@ void main() {
double minZoomRatio = 1.0,
double maxZoomRatio = 3.0,
bool isRecording = false,
bool isSwitchingLens = false,
Future<void> Function()? onStop,
ValueChanged<double>? onZoomSelected,
}) async {
await tester.pumpWidget(
@@ -25,12 +27,13 @@ void main() {
notificationsGranted: true,
isRecording: isRecording,
isStartingRecording: false,
isSwitchingLens: isSwitchingLens,
isTouchLocked: false,
zoomRatio: zoomRatio,
minZoomRatio: minZoomRatio,
maxZoomRatio: maxZoomRatio,
onStart: () async {},
onStop: () async {},
onStop: onStop ?? () async {},
onOpenDnd: () {},
onOpenBattery: () {},
onToggleTouchLock: () {},
@@ -47,48 +50,50 @@ void main() {
testWidgets('shows preset zoom buttons', (tester) async {
await pumpHud(tester);
expect(find.text('0.5x'), findsNothing);
expect(find.text('0.6x'), findsNothing);
expect(find.text('广角'), findsNothing);
expect(find.text('1x'), findsOneWidget);
expect(find.text('2x'), findsNothing);
expect(find.text('3x'), findsNothing);
});
testWidgets('shows 0.5x when ultra-wide camera capability is 0.5', (
testWidgets('shows wide angle when ultra-wide camera capability is 0.5', (
tester,
) async {
await pumpHud(tester, minZoomRatio: 0.5);
expect(find.text('0.5x'), findsOneWidget);
expect(find.text('0.6x'), findsNothing);
expect(find.text('广角'), findsOneWidget);
expect(find.text('1x'), findsOneWidget);
expect(find.text('2x'), findsNothing);
expect(find.text('3x'), findsNothing);
});
testWidgets('shows 0.6x when 0.6x camera capability supports it', (
testWidgets('shows wide angle when 0.6x camera capability supports it', (
tester,
) async {
await pumpHud(tester, minZoomRatio: 0.6);
expect(find.text('0.6x'), findsOneWidget);
expect(find.text('广角'), findsOneWidget);
expect(find.text('1x'), findsOneWidget);
});
testWidgets('marks current 0.5x zoom ratio as selected', (tester) async {
testWidgets('marks current 0.5x wide angle ratio as selected', (
tester,
) async {
await pumpHud(tester, zoomRatio: 0.5, minZoomRatio: 0.5);
final selectedButton = tester.widget<TextButton>(
find.ancestor(of: find.text('0.5x'), matching: find.byType(TextButton)),
find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)),
);
expect(selectedButton.enabled, isFalse);
});
testWidgets('marks current 0.6x zoom ratio as selected', (tester) async {
testWidgets('marks current 0.6x wide angle ratio as selected', (
tester,
) async {
await pumpHud(tester, zoomRatio: 0.6, minZoomRatio: 0.6);
final selectedButton = tester.widget<TextButton>(
find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)),
find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)),
);
expect(selectedButton.enabled, isFalse);
});
@@ -96,8 +101,7 @@ void main() {
testWidgets('does not expose presets beyond max zoom ratio', (tester) async {
await pumpHud(tester, minZoomRatio: 0.5, maxZoomRatio: 0.55);
expect(find.text('0.5x'), findsOneWidget);
expect(find.text('0.6x'), findsNothing);
expect(find.text('广角'), findsOneWidget);
expect(find.text('1x'), findsNothing);
});
@@ -111,8 +115,8 @@ void main() {
onZoomSelected: (ratio) => selected = ratio,
);
await tester.tap(find.text('0.5x'));
await tester.pump();
await tester.tap(find.text('广角'));
await tester.pump(const Duration(milliseconds: 350));
expect(selected, 0.5);
});
@@ -127,8 +131,8 @@ void main() {
onZoomSelected: (ratio) => selected = ratio,
);
await tester.tap(find.text('0.6x'));
await tester.pump();
await tester.tap(find.text('广角'));
await tester.pump(const Duration(milliseconds: 350));
expect(selected, 0.6);
});
@@ -145,7 +149,7 @@ void main() {
);
final ultraWideButton = tester.widget<TextButton>(
find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)),
find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)),
);
final mainButton = tester.widget<TextButton>(
find.ancestor(of: find.text('1x'), matching: find.byType(TextButton)),
@@ -154,8 +158,8 @@ void main() {
expect(ultraWideButton.enabled, isTrue);
expect(mainButton.enabled, isFalse);
await tester.tap(find.text('0.6x'));
await tester.pump();
await tester.tap(find.text('广角'));
await tester.pump(const Duration(milliseconds: 350));
expect(selected, 0.6);
});
@@ -173,7 +177,7 @@ void main() {
);
final ultraWideButton = tester.widget<TextButton>(
find.ancestor(of: find.text('0.5x'), matching: find.byType(TextButton)),
find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)),
);
final mainButton = tester.widget<TextButton>(
find.ancestor(of: find.text('1x'), matching: find.byType(TextButton)),
@@ -183,8 +187,45 @@ void main() {
expect(mainButton.enabled, isTrue);
await tester.tap(find.text('1x'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 350));
expect(selected, 1.0);
});
testWidgets('disables stop button while switching lens', (tester) async {
var stopped = false;
await pumpHud(
tester,
minZoomRatio: 0.6,
isRecording: true,
isSwitchingLens: true,
onStop: () async => stopped = true,
);
await tester.tap(find.byType(GestureDetector).last);
await tester.pump();
expect(stopped, isFalse);
});
testWidgets('disables zoom buttons while switching lens', (tester) async {
double? selected;
await pumpHud(
tester,
minZoomRatio: 0.6,
isRecording: true,
isSwitchingLens: true,
onZoomSelected: (ratio) => selected = ratio,
);
final ultraWideButton = tester.widget<TextButton>(
find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)),
);
expect(ultraWideButton.enabled, isFalse);
await tester.tap(find.text('广角'));
await tester.pump(const Duration(milliseconds: 350));
expect(selected, isNull);
});
}