Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4c7d7180e | ||
|
|
90e8966528 | ||
|
|
c8a7494344 | ||
|
|
02614c2817 | ||
|
|
9f49f73a31 |
@@ -48,3 +48,5 @@ app.*.map.json
|
||||
/android/app/release
|
||||
/android/.kotlin
|
||||
|
||||
CLAUDE.md
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
本文件为 Claude Code (claude.ai/code) 在此仓库中工作提供指引。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 运行全部测试
|
||||
flutter test
|
||||
|
||||
# 运行单个测试文件
|
||||
flutter test test/features/recording/view_model_recording_test.dart
|
||||
|
||||
# 静态分析
|
||||
flutter analyze
|
||||
|
||||
# 构建 Android release APK
|
||||
flutter build apk --release
|
||||
|
||||
# 构建 Android release APK(按 ABI 拆分)
|
||||
./build-apk-split.sh
|
||||
|
||||
# 清理并重装 iOS pods
|
||||
./clean.sh
|
||||
|
||||
# 在指定设备上运行
|
||||
flutter run -d <device-id>
|
||||
```
|
||||
|
||||
## 架构
|
||||
|
||||
这是一个 Flutter 视频录制工具(酷跑录像工作台),支持 Android 和 iOS。应用从系统剪贴板读取赛事信息(来自小程序的 JSON),初始化 CameraX/AVFoundation 相机预览,录制视频并保存到文件系统。
|
||||
|
||||
### 状态管理:Riverpod
|
||||
|
||||
使用 `NotifierProvider` 模式。主状态 provider 为 `recordingViewModelProvider`,位于 `lib/features/recording/view-model/view_model_recording.dart`。UI 通过 `ref.watch(provider.select(...))` 细粒度读取状态,通过 `ref.read(provider.notifier).method()` 调用操作。
|
||||
|
||||
### 状态模型(`RecordingSessionState`)
|
||||
|
||||
`lib/features/recording/model/model_recording_session.dart` 中的关键字段:
|
||||
- `isTouchLocked` — 防误触状态(默认 `true`,开始录制时置为 `true`)
|
||||
- `zoomRatio`, `minZoomRatio`, `maxZoomRatio` — 超广角(<1.0)与主摄(1.0)切换
|
||||
- `isRecording`, `isPreviewReady`, `isStartingRecording`
|
||||
- `status` — 原生端 `RecordingState` 流
|
||||
|
||||
### 原生桥接
|
||||
|
||||
`lib/features/recording/platform/recording_platform.dart` 封装了所有与 Android(Kotlin/CameraX)和 iOS(Swift/AVFoundation)通信的 MethodChannel/EventChannel 调用。禁止直接调用 channel,统一通过 `RecordingPlatform`。
|
||||
|
||||
### 原生关键文件
|
||||
|
||||
- Android: `android/app/src/main/kotlin/com/run/sportsx/recording/RecordingCameraController.kt`
|
||||
- iOS: `ios/Runner/RecordingPlugin.swift`
|
||||
- 两个平台均实现 `lib/features/recording/platform/recording_channel_names.dart` 中定义的 channel 名称
|
||||
|
||||
### 网络层
|
||||
|
||||
`lib/core/network/` — 基于 Dio,包含 `ApiClient`、`ApiResponse<T>`、`ApiException`、请求头拦截器,以及离线队列(当前未启用)。网络 provider 定义在 `lib/core/network/providers/dio_providers.dart`。
|
||||
|
||||
### 相机倍距/镜头切换逻辑
|
||||
|
||||
`lib/features/recording/widgets/widget_recording_hud.dart:199` 中的 `_ZoomPresetControl` 组件渲染倍距预设按钮(超广角 = `<1.0`,主摄 = `1.0`)。`_wouldSwitchPhysicalCamera` 方法在录制过程中禁止切换物理镜头。组件接收 `isRecording` 和倍距值;`isTouchLocked` 已存在于父级 `RecordingHudWidget` 但未传入 `_ZoomPresetControl`。
|
||||
|
||||
### 防误触
|
||||
|
||||
`widget_recording_touch_lock_overlay.dart` — 全屏遮罩,`isTouchLocked` 为 `true` 时显示"防误触已开启"。需长按 2 秒解锁。防误触默认 `true`,开始录制时也会置为 `true`(`view_model_recording.dart:400`)。
|
||||
|
||||
### 路由
|
||||
|
||||
`lib/app/router/app_navigator.dart` — 单例导航器,支持路由去重和自定义滑动过渡动画。
|
||||
@@ -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" />
|
||||
@@ -57,4 +56,4 @@
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
</manifest>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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"
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -170,7 +170,7 @@ class _HeaderPasteActions extends StatelessWidget {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
_HeaderActionButton(label: 'mock', onPressed: onMockCopy),
|
||||
// _HeaderActionButton(label: 'mock', onPressed: onMockCopy),
|
||||
_HeaderActionButton(
|
||||
label: '粘贴选手信息',
|
||||
onPressed: () => onPasteEventInfo(),
|
||||
|
||||
@@ -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: () {},
|
||||
@@ -112,7 +115,7 @@ void main() {
|
||||
);
|
||||
|
||||
await tester.tap(find.text('0.5x'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 350));
|
||||
|
||||
expect(selected, 0.5);
|
||||
});
|
||||
@@ -128,7 +131,7 @@ void main() {
|
||||
);
|
||||
|
||||
await tester.tap(find.text('0.6x'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 350));
|
||||
|
||||
expect(selected, 0.6);
|
||||
});
|
||||
@@ -155,7 +158,7 @@ void main() {
|
||||
expect(mainButton.enabled, isFalse);
|
||||
|
||||
await tester.tap(find.text('0.6x'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 350));
|
||||
|
||||
expect(selected, 0.6);
|
||||
});
|
||||
@@ -183,8 +186,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('0.6x'), matching: find.byType(TextButton)),
|
||||
);
|
||||
|
||||
expect(ultraWideButton.enabled, isFalse);
|
||||
await tester.tap(find.text('0.6x'));
|
||||
await tester.pump(const Duration(milliseconds: 350));
|
||||
|
||||
expect(selected, isNull);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user