添加设备代码信息到 Android 和 iOS 平台信息;更新 Auth 页面以使用新的 TextEditingController 管理输入;重构推流测试页面以支持动态 RTMP URL 解析;删除不再需要的测试文件。
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## 项目结构与模块组织
|
||||
|
||||
这是一个 Flutter 移动应用项目,当前产品方向仅保留 Android,后续开发与验证不需要关注 iOS 平台。应用代码位于 `lib/`。`lib/app/` 存放启动、路由、主题和配置;`lib/core/` 存放网络、缓存、权限、日志和平台工具;`lib/shared/widgets/` 存放通用 UI 组件;`lib/features/` 按业务模块组织,例如登录、录制、扫码和弹窗。生成的资源访问代码位于 `lib/gen/`。测试文件位于 `test/`,尽量与源码目录对应。静态资源在 `pubspec.yaml` 中声明,主要放在 `assets/images/` 和 `assets/html/`。Android 原生实现位于 `android/`。
|
||||
|
||||
## 构建、测试与开发命令
|
||||
|
||||
- `flutter pub get`:安装 Dart 和 Flutter 依赖。
|
||||
- `flutter analyze`:按 `analysis_options.yaml` 执行静态检查。
|
||||
- `flutter test`:运行全部单元测试和 widget 测试。
|
||||
- `flutter run -d <device-id>`:在指定 Android 设备或模拟器上运行应用。
|
||||
- `flutter build apk --debug`:构建 Android debug APK。
|
||||
- `dart run build_runner build --delete-conflicting-outputs`:资源或生成代码变化后重新生成文件,例如 `lib/gen/assets.gen.dart`。
|
||||
|
||||
项目还包含 `build-apk.sh`、`build-apk-split.sh` 和 `clean.sh` 等辅助脚本,使用前先阅读脚本内容。
|
||||
|
||||
## 代码风格与命名规范
|
||||
|
||||
遵循 Dart 和 Flutter 常规风格,使用两个空格缩进。Analyzer 继承 `package:flutter_lints/flutter.yaml`,并启用 `prefer_single_quotes`,因此字符串优先使用单引号。文件名使用 `snake_case.dart`,类和 widget 使用 `PascalCase`,方法、变量、provider 和字段使用 `lowerCamelCase`。新增能力前优先复用现有 `shared/widgets`、`core` 服务和 feature 内已有模式,避免重复抽象。
|
||||
|
||||
## 测试规范
|
||||
|
||||
使用 `flutter_test` 编写单元测试和 widget 测试。测试文件放在 `test/` 下,并以 `_test.dart` 结尾,例如 `test/features/recording/view_model_recording_test.dart`。业务逻辑、权限判断、Android 平台通道边界、录制流程和可复用 widget 需要补充聚焦测试。提交前运行 `flutter test` 和 `flutter analyze`。
|
||||
|
||||
## 提交与 PR 规范
|
||||
|
||||
当前 Git 历史多使用简洁中文提交信息,描述实际行为变化,例如 `更新 AndroidManifest.xml 以启用明文流量`。保持提交范围单一,避免混入无关格式化。PR 应包含变更说明、测试结果、关联 issue,以及 UI、录制、扫码、WebView 或权限流程变化对应的截图或录屏。涉及原生代码时,只需说明 Android 设备或系统版本覆盖情况。
|
||||
|
||||
## Agent 特定说明
|
||||
|
||||
产品已放弃 iOS 方向,后续 agent 不需要主动检查、构建、修复或维护 `ios/` 目录内容。除非用户明确要求处理历史 iOS 文件,否则所有实现、调试和验证都应以 Android 为准。
|
||||
|
||||
## 安全与配置提示
|
||||
|
||||
不要提交私有 token、签名文件或环境专用密钥。修改 `lib/app/config/`、Android manifest、网络安全配置、摄像头、麦克风、存储或后台录制相关行为时,需要重点核对权限和运行时兼容性。
|
||||
@@ -6,6 +6,7 @@ import android.os.BatteryManager
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.os.StatFs
|
||||
import android.provider.Settings
|
||||
import androidx.camera.view.PreviewView
|
||||
import com.dronex.rec.recording.RecordingPlatformHandler
|
||||
import com.dronex.rec.recording.RecordingPreviewFactory
|
||||
@@ -111,6 +112,7 @@ class MainActivity : FlutterActivity() {
|
||||
|
||||
return mapOf(
|
||||
"platform" to "android",
|
||||
"deviceCode" to Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID).orEmpty(),
|
||||
"brand" to Build.BRAND,
|
||||
"model" to Build.MODEL,
|
||||
"systemVersion" to Build.VERSION.RELEASE,
|
||||
|
||||
@@ -62,6 +62,7 @@ final class PlatformInfoPlugin: NSObject, FlutterPlugin {
|
||||
let device = UIDevice.current
|
||||
return [
|
||||
"platform": "ios",
|
||||
"deviceCode": "",
|
||||
"brand": device.systemName,
|
||||
"model": machineIdentifier(),
|
||||
"systemVersion": device.systemVersion,
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
enum AuthApi {
|
||||
/// 获取 token
|
||||
getToken('/api/events/device/token');
|
||||
|
||||
final String path;
|
||||
const AuthApi(this.path);
|
||||
}
|
||||
@@ -32,7 +32,7 @@ class AppConfig {
|
||||
current = switch (environment) {
|
||||
AppEnvironment.dev => const EnvironmentValues(
|
||||
environment: AppEnvironment.dev,
|
||||
baseUrl: 'https://example.com/api',
|
||||
baseUrl: 'http://192.168.1.104:8000',
|
||||
enableNetworkLog: true,
|
||||
),
|
||||
AppEnvironment.staging => const EnvironmentValues(
|
||||
|
||||
@@ -5,7 +5,7 @@ class ApiResponse<T> {
|
||||
final String message;
|
||||
final T? data;
|
||||
|
||||
bool get isSuccess => code >= 200 && code < 300;
|
||||
bool get isSuccess => code == 0 || code >= 200 && code < 300;
|
||||
|
||||
factory ApiResponse.fromJson(
|
||||
Map<String, dynamic> json, {
|
||||
|
||||
@@ -28,4 +28,15 @@ class HeaderInterceptor extends Interceptor {
|
||||
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> onError(
|
||||
DioException err,
|
||||
ErrorInterceptorHandler handler,
|
||||
) async {
|
||||
if (err.response?.statusCode == 401) {
|
||||
await AppStorage.remove(StorageKeys.authToken);
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ class AppDeviceInfo {
|
||||
required this.platform,
|
||||
required this.isPhysicalDevice,
|
||||
required this.values,
|
||||
required this.deviceCode,
|
||||
});
|
||||
|
||||
factory AppDeviceInfo.fromMap(Map<Object?, Object?> map) {
|
||||
@@ -48,12 +49,14 @@ class AppDeviceInfo {
|
||||
platform: map['platform'] as String? ?? Platform.operatingSystem,
|
||||
isPhysicalDevice: isPhysicalDevice is bool ? isPhysicalDevice : true,
|
||||
values: values,
|
||||
deviceCode: values['deviceCode'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
final String platform;
|
||||
final bool isPhysicalDevice;
|
||||
final Map<String, String> values;
|
||||
final String deviceCode;
|
||||
}
|
||||
|
||||
class AppPlatformInfo {
|
||||
|
||||
@@ -28,4 +28,12 @@ class DeviceUtils {
|
||||
}
|
||||
return (await AppPlatformInfo.deviceInfo()).values;
|
||||
}
|
||||
|
||||
static Future<String> deviceCode() async {
|
||||
try {
|
||||
return (await AppPlatformInfo.deviceInfo()).deviceCode.trim();
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/// 获取 TOKEN 响应模型
|
||||
class GetTokenResModel {
|
||||
GetTokenResModel({required this.deviceAccessToken});
|
||||
|
||||
final String deviceAccessToken;
|
||||
|
||||
factory GetTokenResModel.fromJson(Map<String, dynamic> json) {
|
||||
return GetTokenResModel(
|
||||
deviceAccessToken: (json['deviceAccessToken'] ?? '').toString(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {'deviceAccessToken': deviceAccessToken};
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:recording_tool/app/router/app_navigator.dart';
|
||||
import 'package:recording_tool/features/auth/view_model_auth/view_model_auth.dart';
|
||||
import 'package:recording_tool/features/scan_qrcode/pages/page_scan_qrcode.dart';
|
||||
import 'package:recording_tool/shared/widgets/widgets.dart';
|
||||
|
||||
class AuthPageWidget extends StatefulWidget {
|
||||
class AuthPageWidget extends ConsumerStatefulWidget {
|
||||
const AuthPageWidget({super.key});
|
||||
|
||||
@override
|
||||
State<AuthPageWidget> createState() => _AuthPageWidgetState();
|
||||
ConsumerState<AuthPageWidget> createState() => _AuthPageWidgetState();
|
||||
}
|
||||
|
||||
class _AuthPageWidgetState extends ConsumerState<AuthPageWidget> {
|
||||
late TextEditingController? _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
class _AuthPageWidgetState extends State<AuthPageWidget> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authState = ref.watch(authProvider);
|
||||
|
||||
return Center(
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -26,7 +44,7 @@ class _AuthPageWidgetState extends State<AuthPageWidget> {
|
||||
SizedBox(
|
||||
width: 280.w,
|
||||
height: 80.h,
|
||||
child: AppTextField(initialValue: 'hhh'),
|
||||
child: AppTextField(initialValue: 'hhh', controller: _controller),
|
||||
),
|
||||
SizedBox(height: 20.h),
|
||||
SizedBox(
|
||||
@@ -34,8 +52,20 @@ class _AuthPageWidgetState extends State<AuthPageWidget> {
|
||||
height: 80.h,
|
||||
child: AppButton(
|
||||
label: '确定',
|
||||
onPressed: () {
|
||||
onPressed: () async {
|
||||
final code = _controller?.text;
|
||||
final success = await ref
|
||||
.read(authProvider.notifier)
|
||||
.auth(code ?? '');
|
||||
if (!mounted) return;
|
||||
if (success) {
|
||||
AppNavigator.push(const ScanQrCodePage());
|
||||
return;
|
||||
}
|
||||
final message = ref.read(authProvider).errorMessage;
|
||||
if (message != null && message.isNotEmpty) {
|
||||
AppToast.show(message);
|
||||
}
|
||||
// return;
|
||||
// AppNavigator.push(
|
||||
// const WebviewPage(
|
||||
@@ -45,6 +75,7 @@ class _AuthPageWidgetState extends State<AuthPageWidget> {
|
||||
// );
|
||||
},
|
||||
variant: AppButtonVariant.secondary,
|
||||
isLoading: authState.isLoading,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:recording_tool/app/config/api_common.dart';
|
||||
import 'package:recording_tool/core/cache/app_storage.dart';
|
||||
import 'package:recording_tool/core/cache/storage_keys.dart';
|
||||
import 'package:recording_tool/core/network/providers/dio_providers.dart';
|
||||
import 'package:recording_tool/core/utils/device_utils.dart';
|
||||
import 'package:recording_tool/features/auth/model/model_auth.dart';
|
||||
|
||||
class AuthServer {
|
||||
/// [passCode] 口令码
|
||||
static Future<GetTokenResModel> login(String passCode, Ref ref) async {
|
||||
final deviceCode = await DeviceUtils.deviceCode();
|
||||
if (deviceCode.isEmpty) {
|
||||
throw const FormatException('无法获取设备标识');
|
||||
}
|
||||
|
||||
final apiClient = ref.read(apiClientProvider);
|
||||
final data = await apiClient.post<GetTokenResModel>(
|
||||
AuthApi.getToken.path,
|
||||
data: {'deviceCode': deviceCode, 'passCode': passCode},
|
||||
parser: (json) => GetTokenResModel.fromJson(json as Map<String, dynamic>),
|
||||
);
|
||||
|
||||
if (data.deviceAccessToken.isEmpty) {
|
||||
throw const FormatException('登录响应缺少 TOKEN');
|
||||
}
|
||||
|
||||
await AppStorage.setString(StorageKeys.authToken, data.deviceAccessToken);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
class AuthState {
|
||||
const AuthState({this.isLoading = false, this.errorMessage});
|
||||
|
||||
final bool isLoading;
|
||||
final String? errorMessage;
|
||||
|
||||
AuthState copyWith({bool? isLoading, String? errorMessage}) {
|
||||
return AuthState(
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
errorMessage: errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_riverpod/legacy.dart';
|
||||
import 'package:recording_tool/core/network/api_exception.dart';
|
||||
import 'package:recording_tool/features/auth/server/server_auth.dart';
|
||||
import 'package:recording_tool/features/auth/state/state_auth.dart';
|
||||
|
||||
final authProvider = StateNotifierProvider<AuthViewModel, AuthState>((ref) {
|
||||
return AuthViewModel(ref);
|
||||
});
|
||||
|
||||
class AuthViewModel extends StateNotifier<AuthState> {
|
||||
AuthViewModel(this._ref) : super(const AuthState());
|
||||
final Ref _ref;
|
||||
|
||||
/// 鉴权获取 token
|
||||
Future<bool> auth(String code) async {
|
||||
final passCode = code.trim();
|
||||
if (passCode.isEmpty) {
|
||||
state = const AuthState(errorMessage: '请输入执裁口令');
|
||||
return false;
|
||||
}
|
||||
|
||||
state = const AuthState(isLoading: true);
|
||||
try {
|
||||
await AuthServer.login(passCode, _ref);
|
||||
state = const AuthState();
|
||||
return true;
|
||||
} on FormatException catch (error) {
|
||||
state = AuthState(errorMessage: error.message);
|
||||
return false;
|
||||
} on ApiException catch (error) {
|
||||
state = AuthState(errorMessage: error.message);
|
||||
return false;
|
||||
} catch (_) {
|
||||
state = const AuthState(errorMessage: '认证失败,请重试');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
import 'package:apivideo_live_stream/apivideo_live_stream.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:recording_tool/features/scan_qrcode/utils/rtmp_stream_target.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_bar.dart';
|
||||
import 'package:recording_tool/shared/widgets/app_toast.dart';
|
||||
|
||||
class PushSteamTestWidget extends StatefulWidget {
|
||||
const PushSteamTestWidget({super.key});
|
||||
final String steamKey = '20260708';
|
||||
final String rtmpUrl = 'rtmp://192.168.1.180:19090/live/';
|
||||
const PushSteamTestWidget({
|
||||
super.key,
|
||||
this.rtmpUrl =
|
||||
'rtmp://192.168.1.245:19090/蔡依婷vs夏志豪_空中格斗赛_高中组/蔡依婷vs夏志豪_空中格斗赛_高中组',
|
||||
});
|
||||
|
||||
final String rtmpUrl;
|
||||
|
||||
@override
|
||||
State<PushSteamTestWidget> createState() => _PushSteamTestWidgetState();
|
||||
@@ -57,19 +64,24 @@ class _PushSteamTestWidgetState extends State<PushSteamTestWidget>
|
||||
}
|
||||
}
|
||||
|
||||
// 把服务端下发的完整 rtmp:// 地址拆成 "服务器地址" + "streamKey" 两部分
|
||||
Future<void> _startPush() async {
|
||||
// final uri = Uri.parse(widget.rtmpUrl);
|
||||
final streamKey = widget.steamKey;
|
||||
// final streamKey =
|
||||
// '${DateTime.timestamp()}-${Random(1000).nextInt(1000).toString()}';
|
||||
|
||||
// debugPrint('streamKey- $streamKey ');
|
||||
// final baseUrl = widget.rtmpUrl.substring(
|
||||
// 0,
|
||||
// widget.rtmpUrl.lastIndexOf(streamKey),
|
||||
// );
|
||||
await _controller.startStreaming(streamKey: streamKey, url: widget.rtmpUrl);
|
||||
try {
|
||||
final target = RtmpStreamTarget.parse(widget.rtmpUrl);
|
||||
await _controller.startStreaming(
|
||||
streamKey: target.streamKey,
|
||||
url: target.url,
|
||||
);
|
||||
} on FormatException catch (e) {
|
||||
debugPrint('推流地址错误: ${e.message}');
|
||||
AppToast.show(e.message);
|
||||
} on PlatformException catch (e) {
|
||||
final message = e.message ?? e.code;
|
||||
debugPrint('推流失败: ${e.code} $message');
|
||||
AppToast.show('推流失败: $message');
|
||||
} catch (e) {
|
||||
debugPrint('推流失败: $e');
|
||||
AppToast.showError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _stopPush() => _controller.stopStreaming();
|
||||
|
||||
@@ -40,7 +40,7 @@ class _AuthPageWidgetState extends State<ScanQrCodePage> {
|
||||
if (result == null || result.isEmpty) return;
|
||||
debugPrint('扫码结果: $result');
|
||||
Future.delayed(const Duration(milliseconds: 1)).then((_) {
|
||||
AppNavigator.push(PushSteamTestWidget());
|
||||
AppNavigator.push(PushSteamTestWidget(rtmpUrl: result));
|
||||
// AppNavigator.push(
|
||||
// const WebviewPage(url: 'https://www.dronex.cc/'),
|
||||
// );
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
class RtmpStreamTarget {
|
||||
const RtmpStreamTarget({required this.url, required this.streamKey});
|
||||
|
||||
final String url;
|
||||
final String streamKey;
|
||||
|
||||
factory RtmpStreamTarget.parse(String value) {
|
||||
final source = value.trim();
|
||||
final uri = Uri.tryParse(source);
|
||||
if (uri == null ||
|
||||
(uri.scheme != 'rtmp' && uri.scheme != 'rtmps') ||
|
||||
uri.host.isEmpty) {
|
||||
throw const FormatException('推流地址必须是 rtmp:// 或 rtmps:// 开头的完整地址');
|
||||
}
|
||||
|
||||
final pathSegments = uri.pathSegments
|
||||
.where((segment) => segment.trim().isNotEmpty)
|
||||
.toList(growable: false);
|
||||
if (pathSegments.length < 2) {
|
||||
throw const FormatException('推流地址路径必须包含 app 和 streamKey,例如 /app/xxxx');
|
||||
}
|
||||
|
||||
final appPath = pathSegments.take(pathSegments.length - 1).join('/');
|
||||
final streamKey = pathSegments.last;
|
||||
final baseUrl = uri
|
||||
.replace(path: '/$appPath', query: null, fragment: null)
|
||||
.toString();
|
||||
|
||||
return RtmpStreamTarget(url: baseUrl, streamKey: streamKey);
|
||||
}
|
||||
|
||||
String get fullUrl => '$url/$streamKey';
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:permission_handler_platform_interface/permission_handler_platform_interface.dart';
|
||||
|
||||
import 'package:recording_tool/core/permission/permission_service.dart';
|
||||
|
||||
void main() {
|
||||
group('PermissionService.requestMissing', () {
|
||||
late PermissionHandlerPlatform originalPlatform;
|
||||
|
||||
setUp(() {
|
||||
originalPlatform = PermissionHandlerPlatform.instance;
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
PermissionHandlerPlatform.instance = originalPlatform;
|
||||
});
|
||||
|
||||
test('requests only missing permissions and skips granted ones', () async {
|
||||
final platform = FakePermissionHandlerPlatform(
|
||||
statuses: <Permission, PermissionStatus>{
|
||||
Permission.camera: PermissionStatus.granted,
|
||||
Permission.microphone: PermissionStatus.denied,
|
||||
},
|
||||
requestResults: <Permission, PermissionStatus>{
|
||||
Permission.microphone: PermissionStatus.granted,
|
||||
},
|
||||
);
|
||||
PermissionHandlerPlatform.instance = platform;
|
||||
|
||||
final result = await PermissionService.requestMissing(<Permission>[
|
||||
Permission.camera,
|
||||
Permission.microphone,
|
||||
]);
|
||||
|
||||
expect(platform.requestCalls, <List<Permission>>[
|
||||
<Permission>[Permission.microphone],
|
||||
]);
|
||||
expect(result[Permission.camera], PermissionStatus.granted);
|
||||
expect(result[Permission.microphone], PermissionStatus.granted);
|
||||
});
|
||||
|
||||
test(
|
||||
'preserves permanently denied permissions without requesting them',
|
||||
() async {
|
||||
final platform = FakePermissionHandlerPlatform(
|
||||
statuses: <Permission, PermissionStatus>{
|
||||
Permission.camera: PermissionStatus.permanentlyDenied,
|
||||
Permission.microphone: PermissionStatus.denied,
|
||||
},
|
||||
requestResults: <Permission, PermissionStatus>{
|
||||
Permission.microphone: PermissionStatus.granted,
|
||||
},
|
||||
);
|
||||
PermissionHandlerPlatform.instance = platform;
|
||||
|
||||
final result = await PermissionService.requestMissing(<Permission>[
|
||||
Permission.camera,
|
||||
Permission.microphone,
|
||||
]);
|
||||
|
||||
expect(platform.requestCalls, <List<Permission>>[
|
||||
<Permission>[Permission.microphone],
|
||||
]);
|
||||
expect(result[Permission.camera], PermissionStatus.permanentlyDenied);
|
||||
expect(result[Permission.microphone], PermissionStatus.granted);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
group('iOS permission configuration', () {
|
||||
test('Podfile enables camera and microphone permission macros only', () {
|
||||
final podfile = File('ios/Podfile').readAsStringSync();
|
||||
|
||||
expect(
|
||||
podfile,
|
||||
contains('flutter_additional_ios_build_settings(target)'),
|
||||
);
|
||||
expect(podfile, contains("'PERMISSION_CAMERA=1'"));
|
||||
expect(podfile, contains("'PERMISSION_MICROPHONE=1'"));
|
||||
expect(podfile, isNot(contains("'PERMISSION_PHOTOS=1'")));
|
||||
expect(podfile, isNot(contains("'PERMISSION_PHOTOS_ADD_ONLY=1'")));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
class FakePermissionHandlerPlatform extends PermissionHandlerPlatform {
|
||||
FakePermissionHandlerPlatform({
|
||||
required this.statuses,
|
||||
required this.requestResults,
|
||||
});
|
||||
|
||||
final Map<Permission, PermissionStatus> statuses;
|
||||
final Map<Permission, PermissionStatus> requestResults;
|
||||
final List<List<Permission>> requestCalls = <List<Permission>>[];
|
||||
|
||||
@override
|
||||
Future<PermissionStatus> checkPermissionStatus(Permission permission) async {
|
||||
return statuses[permission] ?? PermissionStatus.denied;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ServiceStatus> checkServiceStatus(Permission permission) async {
|
||||
return ServiceStatus.enabled;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> openAppSettings() async {
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<Permission, PermissionStatus>> requestPermissions(
|
||||
List<Permission> permissions,
|
||||
) async {
|
||||
requestCalls.add(List<Permission>.unmodifiable(permissions));
|
||||
return <Permission, PermissionStatus>{
|
||||
for (final permission in permissions)
|
||||
permission: requestResults[permission] ?? PermissionStatus.granted,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> shouldShowRequestPermissionRationale(
|
||||
Permission permission,
|
||||
) async {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:recording_tool/core/platform/device_health_checker.dart';
|
||||
import 'package:recording_tool/core/platform/device_health_snapshot.dart';
|
||||
|
||||
void main() {
|
||||
group('DeviceHealthChecker.warningLines', () {
|
||||
test('returns empty when battery and storage are healthy', () {
|
||||
const snapshot = DeviceHealthSnapshot(
|
||||
batteryLevelPercent: 50,
|
||||
storageAvailablePercent: 50,
|
||||
);
|
||||
|
||||
expect(DeviceHealthChecker.warningLines(snapshot), isEmpty);
|
||||
});
|
||||
|
||||
test('returns low battery message only', () {
|
||||
const snapshot = DeviceHealthSnapshot(
|
||||
batteryLevelPercent: 9,
|
||||
storageAvailablePercent: 50,
|
||||
);
|
||||
|
||||
expect(
|
||||
DeviceHealthChecker.warningLines(snapshot),
|
||||
[DeviceHealthChecker.lowBatteryMessage],
|
||||
);
|
||||
});
|
||||
|
||||
test('returns low storage message only', () {
|
||||
const snapshot = DeviceHealthSnapshot(
|
||||
batteryLevelPercent: 50,
|
||||
storageAvailablePercent: 9.9,
|
||||
);
|
||||
|
||||
expect(
|
||||
DeviceHealthChecker.warningLines(snapshot),
|
||||
[DeviceHealthChecker.lowStorageMessage],
|
||||
);
|
||||
});
|
||||
|
||||
test('returns both messages when battery and storage are low', () {
|
||||
const snapshot = DeviceHealthSnapshot(
|
||||
batteryLevelPercent: 5,
|
||||
storageAvailablePercent: 5,
|
||||
);
|
||||
|
||||
expect(
|
||||
DeviceHealthChecker.warningLines(snapshot),
|
||||
[
|
||||
DeviceHealthChecker.lowBatteryMessage,
|
||||
DeviceHealthChecker.lowStorageMessage,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('does not warn at exactly threshold percent', () {
|
||||
const snapshot = DeviceHealthSnapshot(
|
||||
batteryLevelPercent: 10,
|
||||
storageAvailablePercent: 10,
|
||||
);
|
||||
|
||||
expect(DeviceHealthChecker.warningLines(snapshot), isEmpty);
|
||||
});
|
||||
|
||||
test('skips battery warning when level is unknown', () {
|
||||
const snapshot = DeviceHealthSnapshot(
|
||||
batteryLevelPercent: null,
|
||||
storageAvailablePercent: 5,
|
||||
);
|
||||
|
||||
expect(
|
||||
DeviceHealthChecker.warningLines(snapshot),
|
||||
[DeviceHealthChecker.lowStorageMessage],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('DeviceHealthSnapshot.fromMap', () {
|
||||
test('parses native map fields', () {
|
||||
final snapshot = DeviceHealthSnapshot.fromMap({
|
||||
'batteryLevelPercent': 42,
|
||||
'storageAvailablePercent': 12.5,
|
||||
});
|
||||
|
||||
expect(snapshot.batteryLevelPercent, 42);
|
||||
expect(snapshot.storageAvailablePercent, 12.5);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:recording_tool/features/dialog/dialog-record.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/widget_recording_saved_dialog.dart';
|
||||
import 'package:recording_tool/gen/assets.gen.dart';
|
||||
|
||||
void main() {
|
||||
Future<void> pumpDialogHost(WidgetTester tester, Widget child) async {
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: const Size(375, 812),
|
||||
builder: (context, _) {
|
||||
return MaterialApp(home: Scaffold(body: child));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('single button dialog shows configured content and closes', (
|
||||
tester,
|
||||
) async {
|
||||
var tapped = false;
|
||||
|
||||
await pumpDialogHost(
|
||||
tester,
|
||||
Builder(
|
||||
builder: (context) {
|
||||
return TextButton(
|
||||
onPressed: () {
|
||||
RecordDialog.showSingle(
|
||||
context,
|
||||
title: '无选手信息!',
|
||||
buttonText: '粘贴',
|
||||
onPressed: () => tapped = true,
|
||||
);
|
||||
},
|
||||
child: const Text('show'),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('show'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(Image), findsOneWidget);
|
||||
expect(find.image(AssetImage(Assets.images.imageDialogBg.path)), findsOne);
|
||||
expect(find.text('无选手信息!'), findsOneWidget);
|
||||
expect(find.text('粘贴'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('粘贴'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tapped, isTrue);
|
||||
expect(find.text('无选手信息!'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('double button dialog dispatches each action', (tester) async {
|
||||
var leftTapped = false;
|
||||
var rightTapped = false;
|
||||
|
||||
await pumpDialogHost(
|
||||
tester,
|
||||
Builder(
|
||||
builder: (context) {
|
||||
return TextButton(
|
||||
onPressed: () {
|
||||
RecordDialog.showDouble(
|
||||
context,
|
||||
title: '本轮比赛视频已保存到文件夹\n请选择后续录制信息',
|
||||
leftText: '继续本轮',
|
||||
rightText: '录制新轮',
|
||||
onLeftPressed: () => leftTapped = true,
|
||||
onRightPressed: () => rightTapped = true,
|
||||
);
|
||||
},
|
||||
child: const Text('show'),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('show'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('继续本轮'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(leftTapped, isTrue);
|
||||
expect(rightTapped, isFalse);
|
||||
|
||||
await tester.tap(find.text('show'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('录制新轮'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(rightTapped, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('recording saved dialog follows design title only', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpDialogHost(
|
||||
tester,
|
||||
Builder(
|
||||
builder: (context) {
|
||||
return TextButton(
|
||||
onPressed: () {
|
||||
showRecordingSavedDialog(
|
||||
context,
|
||||
sessionTitle: '王东方 丨李想 空中格斗赛',
|
||||
onContinueRound: () {},
|
||||
onRecordNewRound: () {},
|
||||
);
|
||||
},
|
||||
child: const Text('show'),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('show'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('王东方 丨李想 空中格斗赛'), findsNothing);
|
||||
expect(find.text('本轮比赛视频已保存到文件夹\n请选择后续录制信息'), findsOneWidget);
|
||||
expect(find.text('继续本轮'), findsOneWidget);
|
||||
expect(find.text('录制新轮'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:recording_tool/features/recording/model/model_clipboard.dart';
|
||||
|
||||
void main() {
|
||||
group('ClipboardRecordingModel', () {
|
||||
const clipboardJson = {
|
||||
'title': '王东方 丨李想 空中格斗赛',
|
||||
'startTimestamp': 1717334400,
|
||||
'endTimestamp': 1717334400,
|
||||
'address': '广州市番禺区·粤港澳大湾区青年人才双创小镇',
|
||||
};
|
||||
|
||||
test('parses mini program clipboard JSON', () {
|
||||
final model = ClipboardRecordingModel.fromJson(clipboardJson);
|
||||
|
||||
expect(model.title, '王东方 丨李想 空中格斗赛');
|
||||
expect(model.startTimestamp, 1717334400);
|
||||
expect(model.endTimestamp, 1717334400);
|
||||
expect(model.address, '广州市番禺区·粤港澳大湾区青年人才双创小镇');
|
||||
expect(model.filename, isNull);
|
||||
expect(model.toJson(), clipboardJson);
|
||||
});
|
||||
|
||||
test('parses JSON without optional timestamps', () {
|
||||
final json = {
|
||||
'title': '郑昌梦 丨黄伟依 空中格斗赛 小学组',
|
||||
'address': '广东省汕头市番禺区青蓝街 111 号',
|
||||
'filename': '郑昌梦_黄伟依_6月3日测试-1_空中格斗赛',
|
||||
};
|
||||
final model = ClipboardRecordingModel.fromJson(json);
|
||||
|
||||
expect(model.title, json['title']);
|
||||
expect(model.address, json['address']);
|
||||
expect(model.filename, json['filename']);
|
||||
expect(model.startTimestamp, isNull);
|
||||
expect(model.endTimestamp, isNull);
|
||||
});
|
||||
|
||||
test('parses optional filename from mini program JSON', () {
|
||||
final json = {
|
||||
...clipboardJson,
|
||||
'filename': '选手名称_选手ID_赛事名称_赛项',
|
||||
};
|
||||
final model = ClipboardRecordingModel.fromJson(json);
|
||||
|
||||
expect(model.filename, '选手名称_选手ID_赛事名称_赛项');
|
||||
expect(model.toJson(), json);
|
||||
});
|
||||
|
||||
test('throws FormatException when required field is missing', () {
|
||||
final json = Map<String, dynamic>.from(clipboardJson)..remove('title');
|
||||
|
||||
expect(
|
||||
() => ClipboardRecordingModel.fromJson(json),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws FormatException when optional int field has wrong type', () {
|
||||
final json = {...clipboardJson, 'startTimestamp': '1717334400'};
|
||||
|
||||
expect(
|
||||
() => ClipboardRecordingModel.fromJson(json),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws FormatException when required address is missing', () {
|
||||
final json = Map<String, dynamic>.from(clipboardJson)..remove('address');
|
||||
|
||||
expect(
|
||||
() => ClipboardRecordingModel.fromJson(json),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:recording_tool/features/recording/utils/recording_display_name.dart';
|
||||
|
||||
void main() {
|
||||
group('sanitizeRecordingBaseName', () {
|
||||
test('removes invalid path characters', () {
|
||||
expect(
|
||||
sanitizeRecordingBaseName(r'a/b:c*d?e"f<g>h|i'),
|
||||
'a_b_c_d_e_f_g_h_i',
|
||||
);
|
||||
});
|
||||
|
||||
test('returns null for blank input', () {
|
||||
expect(sanitizeRecordingBaseName(' '), isNull);
|
||||
});
|
||||
|
||||
test('truncates overly long names', () {
|
||||
final long = 'a' * 200;
|
||||
expect(sanitizeRecordingBaseName(long)!.length, 120);
|
||||
});
|
||||
});
|
||||
|
||||
group('resolveRecordingDisplayName', () {
|
||||
test('uses sanitized clipboard filename when present', () {
|
||||
expect(
|
||||
resolveRecordingDisplayName('选手名称_选手ID_赛事名称_赛项'),
|
||||
'选手名称_选手ID_赛事名称_赛项',
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to REC_ prefix when clipboard filename is empty', () {
|
||||
expect(resolveRecordingDisplayName(null), startsWith('REC_'));
|
||||
expect(resolveRecordingDisplayName(''), startsWith('REC_'));
|
||||
});
|
||||
});
|
||||
|
||||
group('withVideoExtension', () {
|
||||
test('appends mp4 on Android', () {
|
||||
expect(
|
||||
withVideoExtension('match', isIOS: false),
|
||||
'match.mp4',
|
||||
);
|
||||
});
|
||||
|
||||
test('appends mov on iOS', () {
|
||||
expect(
|
||||
withVideoExtension('match', isIOS: true),
|
||||
'match.mov',
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps existing extension', () {
|
||||
expect(withVideoExtension('a.mp4', isIOS: false), 'a.mp4');
|
||||
expect(withVideoExtension('a.MOV', isIOS: true), 'a.MOV');
|
||||
});
|
||||
});
|
||||
|
||||
group('recordingFileNameForPlatform', () {
|
||||
test('combines clipboard name with platform extension', () {
|
||||
expect(
|
||||
recordingFileNameForPlatform(
|
||||
'选手名称_选手ID_赛事名称_赛项',
|
||||
isIOS: false,
|
||||
),
|
||||
'选手名称_选手ID_赛事名称_赛项.mp4',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
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(
|
||||
RecordingPlatform.supportsHost(isAndroid: true, isIOS: false),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
RecordingPlatform.supportsHost(isAndroid: false, isIOS: true),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
RecordingPlatform.supportsHost(isAndroid: false, isIOS: false),
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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, '保存到文件夹失败');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,595 +0,0 @@
|
||||
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() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const defaultClipboardTitle = '';
|
||||
const validClipboardText =
|
||||
'{"title":"王东方 丨李想 空中格斗赛","startTimestamp":1717334400,"endTimestamp":1717334400,"filename":"选手名称_选手ID_赛事名称_赛项","address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}';
|
||||
|
||||
Future<void> setClipboardText(String? text) async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||
if (call.method == 'Clipboard.getData') {
|
||||
return text == null ? null : <String, dynamic>{'text': text};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null);
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel(RecordingChannelNames.method),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
group('RecordingViewModel', () {
|
||||
test('initializes with default clipboard and session state', () {
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final model = container.read(recordingViewModelProvider);
|
||||
expect(model.hasValidClipboardInfo, isFalse);
|
||||
expect(model.clipboardRecordingModel.title, defaultClipboardTitle);
|
||||
expect(model.session.isPreviewReady, 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('RecordingViewModel.setZoomRatio', () {
|
||||
test('updates zoom ratio from native response', () async {
|
||||
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('passes 0.5x to native when camera capabilities allow it', () async {
|
||||
final calls = <MethodCall>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel(RecordingChannelNames.method),
|
||||
(call) async {
|
||||
calls.add(call);
|
||||
return <String, dynamic>{
|
||||
'zoomRatio': 0.5,
|
||||
'minZoomRatio': 0.5,
|
||||
'maxZoomRatio': 3.0,
|
||||
};
|
||||
},
|
||||
);
|
||||
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(
|
||||
zoomRatio: 1.0,
|
||||
minZoomRatio: 0.5,
|
||||
maxZoomRatio: 3.0,
|
||||
),
|
||||
);
|
||||
|
||||
await notifier.setZoomRatio(0.5);
|
||||
|
||||
expect(calls.single.arguments, <String, dynamic>{'zoomRatio': 0.5});
|
||||
final session = container.read(recordingViewModelProvider).session;
|
||||
expect(session.zoomRatio, 0.5);
|
||||
expect(session.minZoomRatio, 0.5);
|
||||
expect(session.maxZoomRatio, 3.0);
|
||||
});
|
||||
|
||||
test('passes 0.6x to native when camera capabilities allow it', () async {
|
||||
final calls = <MethodCall>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel(RecordingChannelNames.method),
|
||||
(call) async {
|
||||
calls.add(call);
|
||||
return <String, dynamic>{
|
||||
'zoomRatio': 0.6,
|
||||
'minZoomRatio': 0.6,
|
||||
'maxZoomRatio': 3.0,
|
||||
};
|
||||
},
|
||||
);
|
||||
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(
|
||||
zoomRatio: 1.0,
|
||||
minZoomRatio: 0.6,
|
||||
maxZoomRatio: 3.0,
|
||||
),
|
||||
);
|
||||
|
||||
await notifier.setZoomRatio(0.6);
|
||||
|
||||
expect(calls.single.arguments, <String, dynamic>{'zoomRatio': 0.6});
|
||||
final session = container.read(recordingViewModelProvider).session;
|
||||
expect(session.zoomRatio, 0.6);
|
||||
expect(session.minZoomRatio, 0.6);
|
||||
expect(session.maxZoomRatio, 3.0);
|
||||
});
|
||||
|
||||
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(
|
||||
'clamps 0.6x to 1x when camera capabilities do not allow it',
|
||||
() 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': 3.0,
|
||||
};
|
||||
},
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.setZoomRatio(0.6);
|
||||
|
||||
expect(calls.single.arguments, <String, dynamic>{'zoomRatio': 1.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, '切换镜头失败,请重试');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'maps native zoom failure to user friendly lens switch message',
|
||||
() async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(
|
||||
const MethodChannel(RecordingChannelNames.method),
|
||||
(call) async {
|
||||
throw PlatformException(
|
||||
code: 'ZOOM_FAILED',
|
||||
message: 'Cannot switch physical camera while recording',
|
||||
);
|
||||
},
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.setZoomRatio(0.6);
|
||||
|
||||
final session = container.read(recordingViewModelProvider).session;
|
||||
expect(session.zoomRatio, 1.0);
|
||||
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', () {
|
||||
test('does not request photo permission on iOS', () {
|
||||
final permissions = recordingFileSavePermissionsForHost(
|
||||
isIOS: true,
|
||||
isAndroid: false,
|
||||
);
|
||||
|
||||
expect(permissions, isEmpty);
|
||||
expect(permissions, isNot(contains(Permission.photosAddOnly)));
|
||||
expect(permissions, isNot(contains(Permission.photos)));
|
||||
});
|
||||
|
||||
test('requests storage permission on Android 9 and below', () {
|
||||
final permissions = recordingFileSavePermissionsForHost(
|
||||
isIOS: false,
|
||||
isAndroid: true,
|
||||
androidSdkInt: 28,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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', () {
|
||||
test(
|
||||
'updates state when clipboard contains valid mini program JSON',
|
||||
() async {
|
||||
await setClipboardText(validClipboardText);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final result = await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(result, ClipboardReadResult.success);
|
||||
final model = container.read(recordingViewModelProvider);
|
||||
expect(model.hasValidClipboardInfo, isTrue);
|
||||
expect(model.clipboardRecordingModel.title, '王东方 丨李想 空中格斗赛');
|
||||
expect(model.clipboardRecordingModel.startTimestamp, 1717334400);
|
||||
expect(model.clipboardRecordingModel.endTimestamp, 1717334400);
|
||||
expect(model.clipboardRecordingModel.address, '广州市番禺区·粤港澳大湾区青年人才双创小镇');
|
||||
expect(model.clipboardRecordingModel.filename, '选手名称_选手ID_赛事名称_赛项');
|
||||
},
|
||||
);
|
||||
|
||||
test('returns empty when clipboard is empty', () async {
|
||||
await setClipboardText('');
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final result = await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(result, ClipboardReadResult.empty);
|
||||
final model = container.read(recordingViewModelProvider);
|
||||
expect(model.hasValidClipboardInfo, isFalse);
|
||||
expect(model.clipboardRecordingModel.title, defaultClipboardTitle);
|
||||
});
|
||||
|
||||
test('returns invalid when clipboard is not JSON', () async {
|
||||
await setClipboardText('hello');
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final result = await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(result, ClipboardReadResult.invalid);
|
||||
expect(
|
||||
container
|
||||
.read(recordingViewModelProvider)
|
||||
.clipboardRecordingModel
|
||||
.title,
|
||||
defaultClipboardTitle,
|
||||
);
|
||||
expect(
|
||||
container.read(recordingViewModelProvider).hasValidClipboardInfo,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns invalid when clipboard JSON is not an object', () async {
|
||||
await setClipboardText('[1,2,3]');
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final result = await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(result, ClipboardReadResult.invalid);
|
||||
expect(
|
||||
container
|
||||
.read(recordingViewModelProvider)
|
||||
.clipboardRecordingModel
|
||||
.title,
|
||||
defaultClipboardTitle,
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'returns invalid when clipboard JSON misses required address',
|
||||
() async {
|
||||
await setClipboardText('{"title":"王东方 丨李想 空中格斗赛"}');
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final result = await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(result, ClipboardReadResult.invalid);
|
||||
expect(
|
||||
container
|
||||
.read(recordingViewModelProvider)
|
||||
.clipboardRecordingModel
|
||||
.title,
|
||||
defaultClipboardTitle,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('updates state when clipboard omits optional timestamps', () async {
|
||||
await setClipboardText(
|
||||
'{"title":"郑昌梦 丨黄伟依 空中格斗赛 小学组","address":"广东省汕头市番禺区青蓝街 111 号","filename":"郑昌梦_黄伟依_6月3日测试-1_空中格斗赛"}',
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final result = await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(result, ClipboardReadResult.success);
|
||||
final model = container.read(recordingViewModelProvider);
|
||||
expect(model.hasValidClipboardInfo, isTrue);
|
||||
expect(model.clipboardRecordingModel.startTimestamp, isNull);
|
||||
expect(model.clipboardRecordingModel.endTimestamp, isNull);
|
||||
expect(model.clipboardRecordingModel.filename, '郑昌梦_黄伟依_6月3日测试-1_空中格斗赛');
|
||||
});
|
||||
|
||||
test('returns invalid when clipboard JSON has wrong field type', () async {
|
||||
await setClipboardText(
|
||||
'{"title":"王东方 丨李想 空中格斗赛","startTimestamp":"1717334400","endTimestamp":1717334400,"address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}',
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final result = await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(result, ClipboardReadResult.invalid);
|
||||
expect(
|
||||
container
|
||||
.read(recordingViewModelProvider)
|
||||
.clipboardRecordingModel
|
||||
.title,
|
||||
defaultClipboardTitle,
|
||||
);
|
||||
});
|
||||
|
||||
test('returns invalid when title is blank', () async {
|
||||
await setClipboardText(
|
||||
'{"title":" ","startTimestamp":1717334400,"endTimestamp":1717334400,"address":"广州市"}',
|
||||
);
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final result = await container
|
||||
.read(recordingViewModelProvider.notifier)
|
||||
.getClipboardContent();
|
||||
|
||||
expect(result, ClipboardReadResult.invalid);
|
||||
expect(
|
||||
container.read(recordingViewModelProvider).hasValidClipboardInfo,
|
||||
isFalse,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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_record_header.dart';
|
||||
import 'package:recording_tool/gen/assets.gen.dart';
|
||||
|
||||
void main() {
|
||||
Future<void> pumpHeader(
|
||||
WidgetTester tester, {
|
||||
required bool hasValidClipboardInfo,
|
||||
String? eventTitle,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: const Size(375, 812),
|
||||
builder: (context, _) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: RecordHeaderWidget(
|
||||
hasValidClipboardInfo: hasValidClipboardInfo,
|
||||
eventTitle: eventTitle,
|
||||
isRecording: false,
|
||||
onPasteEventInfo: () async {},
|
||||
onClearEventInfo: () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('paste player info button uses copy image asset', (tester) async {
|
||||
await pumpHeader(tester, hasValidClipboardInfo: false);
|
||||
|
||||
expect(find.text('粘贴选手信息'), findsOneWidget);
|
||||
expect(find.image(AssetImage(Assets.images.imageCopy.path)), findsOne);
|
||||
});
|
||||
|
||||
testWidgets('clear player info button uses delete image asset', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpHeader(
|
||||
tester,
|
||||
hasValidClipboardInfo: true,
|
||||
eventTitle: '王东方 丨李想 空中格斗赛',
|
||||
);
|
||||
|
||||
expect(find.text('王东方 丨李想 空中格斗赛'), findsOneWidget);
|
||||
expect(find.image(AssetImage(Assets.images.imageDelete.path)), findsOne);
|
||||
});
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
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_button.dart';
|
||||
|
||||
void main() {
|
||||
const designSize = Size(375, 812);
|
||||
const morphDuration = Duration(milliseconds: 380);
|
||||
|
||||
Future<void> pumpButton(
|
||||
WidgetTester tester, {
|
||||
required bool isRecording,
|
||||
bool isStartingRecording = false,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: designSize,
|
||||
builder: (context, _) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: RecordingControlButton(
|
||||
isRecording: isRecording,
|
||||
isStartingRecording: isStartingRecording,
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Size innerCoreSize(WidgetTester tester) {
|
||||
final finder = find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is Container &&
|
||||
widget.decoration is BoxDecoration &&
|
||||
(widget.decoration! as BoxDecoration).color == Colors.red,
|
||||
);
|
||||
return tester.getSize(finder);
|
||||
}
|
||||
|
||||
testWidgets('idle state uses large circular inner core', (tester) async {
|
||||
await pumpButton(tester, isRecording: false);
|
||||
|
||||
final size = innerCoreSize(tester);
|
||||
expect(size.width, closeTo(62.r, 0.5));
|
||||
expect(size.height, closeTo(62.r, 0.5));
|
||||
});
|
||||
|
||||
testWidgets('isStartingRecording morphs to stop square before isRecording', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpButton(
|
||||
tester,
|
||||
isRecording: false,
|
||||
isStartingRecording: true,
|
||||
);
|
||||
|
||||
await tester.pump(morphDuration);
|
||||
await tester.pump();
|
||||
|
||||
final size = innerCoreSize(tester);
|
||||
expect(size.width, closeTo(22.r, 0.5));
|
||||
expect(size.height, closeTo(22.r, 0.5));
|
||||
});
|
||||
|
||||
testWidgets('isRecording forward and reverse morph without errors', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpButton(tester, isRecording: false);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: designSize,
|
||||
builder: (context, _) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: RecordingControlButton(
|
||||
isRecording: true,
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pump(morphDuration);
|
||||
await tester.pump();
|
||||
|
||||
expect(innerCoreSize(tester).width, closeTo(22.r, 0.5));
|
||||
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: designSize,
|
||||
builder: (context, _) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Center(
|
||||
child: RecordingControlButton(
|
||||
isRecording: false,
|
||||
onTap: () {},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pump(morphDuration);
|
||||
await tester.pump();
|
||||
|
||||
expect(innerCoreSize(tester).width, closeTo(62.r, 0.5));
|
||||
});
|
||||
|
||||
testWidgets('failed start rolls morph back to idle circle', (tester) async {
|
||||
await pumpButton(
|
||||
tester,
|
||||
isRecording: false,
|
||||
isStartingRecording: true,
|
||||
);
|
||||
await tester.pump(morphDuration);
|
||||
await tester.pump();
|
||||
|
||||
expect(innerCoreSize(tester).width, closeTo(22.r, 0.5));
|
||||
|
||||
await pumpButton(tester, isRecording: false, isStartingRecording: false);
|
||||
await tester.pump(morphDuration);
|
||||
await tester.pump();
|
||||
|
||||
expect(innerCoreSize(tester).width, closeTo(62.r, 0.5));
|
||||
});
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
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,
|
||||
bool isRecording = false,
|
||||
bool isSwitchingLens = false,
|
||||
Future<void> Function()? onStop,
|
||||
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: isRecording,
|
||||
isStartingRecording: false,
|
||||
isSwitchingLens: isSwitchingLens,
|
||||
isTouchLocked: false,
|
||||
zoomRatio: zoomRatio,
|
||||
minZoomRatio: minZoomRatio,
|
||||
maxZoomRatio: maxZoomRatio,
|
||||
onStart: () async {},
|
||||
onStop: onStop ?? () async {},
|
||||
onOpenDnd: () {},
|
||||
onOpenBattery: () {},
|
||||
onToggleTouchLock: () {},
|
||||
onZoomSelected: onZoomSelected ?? (_) {},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
testWidgets('shows preset zoom buttons', (tester) async {
|
||||
await pumpHud(tester);
|
||||
|
||||
expect(find.text('广角'), findsNothing);
|
||||
expect(find.text('1x'), findsOneWidget);
|
||||
expect(find.text('2x'), findsNothing);
|
||||
expect(find.text('3x'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('shows wide angle when ultra-wide camera capability is 0.5', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpHud(tester, minZoomRatio: 0.5);
|
||||
|
||||
expect(find.text('广角'), findsOneWidget);
|
||||
expect(find.text('1x'), findsOneWidget);
|
||||
expect(find.text('2x'), findsNothing);
|
||||
expect(find.text('3x'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('shows wide angle when 0.6x camera capability supports it', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpHud(tester, minZoomRatio: 0.6);
|
||||
|
||||
expect(find.text('广角'), findsOneWidget);
|
||||
expect(find.text('1x'), findsOneWidget);
|
||||
});
|
||||
|
||||
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('广角'), matching: find.byType(TextButton)),
|
||||
);
|
||||
expect(selectedButton.enabled, isFalse);
|
||||
});
|
||||
|
||||
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('广角'), matching: find.byType(TextButton)),
|
||||
);
|
||||
expect(selectedButton.enabled, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('does not expose presets beyond max zoom ratio', (tester) async {
|
||||
await pumpHud(tester, minZoomRatio: 0.5, maxZoomRatio: 0.55);
|
||||
|
||||
expect(find.text('广角'), findsOneWidget);
|
||||
expect(find.text('1x'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('tapping 0.5x reports 0.5 when camera capability is 0.5', (
|
||||
tester,
|
||||
) async {
|
||||
double? selected;
|
||||
await pumpHud(
|
||||
tester,
|
||||
minZoomRatio: 0.5,
|
||||
onZoomSelected: (ratio) => selected = ratio,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('广角'));
|
||||
await tester.pump(const Duration(milliseconds: 350));
|
||||
|
||||
expect(selected, 0.5);
|
||||
});
|
||||
|
||||
testWidgets('tapping 0.6x reports 0.6 when camera only supports 0.6x', (
|
||||
tester,
|
||||
) async {
|
||||
double? selected;
|
||||
await pumpHud(
|
||||
tester,
|
||||
minZoomRatio: 0.6,
|
||||
onZoomSelected: (ratio) => selected = ratio,
|
||||
);
|
||||
|
||||
await tester.tap(find.text('广角'));
|
||||
await tester.pump(const Duration(milliseconds: 350));
|
||||
|
||||
expect(selected, 0.6);
|
||||
});
|
||||
|
||||
testWidgets('allows 0.6x while recording on main camera after unlock', (
|
||||
tester,
|
||||
) async {
|
||||
double? selected;
|
||||
await pumpHud(
|
||||
tester,
|
||||
minZoomRatio: 0.6,
|
||||
isRecording: true,
|
||||
onZoomSelected: (ratio) => selected = ratio,
|
||||
);
|
||||
|
||||
final ultraWideButton = tester.widget<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)),
|
||||
);
|
||||
|
||||
expect(ultraWideButton.enabled, isTrue);
|
||||
expect(mainButton.enabled, isFalse);
|
||||
|
||||
await tester.tap(find.text('广角'));
|
||||
await tester.pump(const Duration(milliseconds: 350));
|
||||
|
||||
expect(selected, 0.6);
|
||||
});
|
||||
|
||||
testWidgets('allows 1x while recording on ultra-wide after unlock', (
|
||||
tester,
|
||||
) async {
|
||||
double? selected;
|
||||
await pumpHud(
|
||||
tester,
|
||||
zoomRatio: 0.5,
|
||||
minZoomRatio: 0.5,
|
||||
isRecording: true,
|
||||
onZoomSelected: (ratio) => selected = ratio,
|
||||
);
|
||||
|
||||
final ultraWideButton = tester.widget<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)),
|
||||
);
|
||||
|
||||
expect(ultraWideButton.enabled, isFalse);
|
||||
expect(mainButton.enabled, isTrue);
|
||||
|
||||
await tester.tap(find.text('1x'));
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
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_touch_lock_overlay.dart';
|
||||
|
||||
void main() {
|
||||
group('resolveRecordingTouchLockUnlockIntent', () {
|
||||
test('returns stopRecording for portrait bottom 30 percent', () {
|
||||
final intent = resolveRecordingTouchLockUnlockIntent(
|
||||
position: const Offset(120, 466.9),
|
||||
size: const Size(375, 667),
|
||||
);
|
||||
|
||||
expect(intent, RecordingTouchLockUnlockIntent.stopRecording);
|
||||
});
|
||||
|
||||
test('returns unlockOnly for portrait area outside bottom 30 percent', () {
|
||||
final intent = resolveRecordingTouchLockUnlockIntent(
|
||||
position: const Offset(120, 320),
|
||||
size: const Size(375, 667),
|
||||
);
|
||||
|
||||
expect(intent, RecordingTouchLockUnlockIntent.unlockOnly);
|
||||
});
|
||||
|
||||
test('returns stopRecording for landscape right 30 percent', () {
|
||||
final intent = resolveRecordingTouchLockUnlockIntent(
|
||||
position: const Offset(466.9, 120),
|
||||
size: const Size(667, 375),
|
||||
);
|
||||
|
||||
expect(intent, RecordingTouchLockUnlockIntent.stopRecording);
|
||||
});
|
||||
|
||||
test('returns unlockOnly for landscape area outside right 30 percent', () {
|
||||
final intent = resolveRecordingTouchLockUnlockIntent(
|
||||
position: const Offset(320, 120),
|
||||
size: const Size(667, 375),
|
||||
);
|
||||
|
||||
expect(intent, RecordingTouchLockUnlockIntent.unlockOnly);
|
||||
});
|
||||
});
|
||||
|
||||
group('RecordingTouchLockOverlayWidget', () {
|
||||
Future<void> pumpOverlay(
|
||||
WidgetTester tester, {
|
||||
required Size surfaceSize,
|
||||
required ValueChanged<RecordingTouchLockUnlockIntent> onUnlocked,
|
||||
}) async {
|
||||
await tester.binding.setSurfaceSize(surfaceSize);
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
|
||||
await tester.pumpWidget(
|
||||
ScreenUtilInit(
|
||||
designSize: const Size(375, 812),
|
||||
builder: (context, _) {
|
||||
return MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
RecordingTouchLockOverlayWidget(
|
||||
enabled: true,
|
||||
unlockHoldDuration: const Duration(seconds: 2),
|
||||
onUnlocked: onUnlocked,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('long press in portrait bottom 30 percent stops recording', (
|
||||
tester,
|
||||
) async {
|
||||
RecordingTouchLockUnlockIntent? receivedIntent;
|
||||
await pumpOverlay(
|
||||
tester,
|
||||
surfaceSize: const Size(375, 667),
|
||||
onUnlocked: (intent) => receivedIntent = intent,
|
||||
);
|
||||
|
||||
final gesture = await tester.startGesture(const Offset(120, 600));
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
await gesture.up();
|
||||
|
||||
expect(receivedIntent, RecordingTouchLockUnlockIntent.stopRecording);
|
||||
});
|
||||
|
||||
testWidgets('long press outside stop area only unlocks', (tester) async {
|
||||
RecordingTouchLockUnlockIntent? receivedIntent;
|
||||
await pumpOverlay(
|
||||
tester,
|
||||
surfaceSize: const Size(375, 667),
|
||||
onUnlocked: (intent) => receivedIntent = intent,
|
||||
);
|
||||
|
||||
final gesture = await tester.startGesture(const Offset(120, 320));
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
await gesture.up();
|
||||
|
||||
expect(receivedIntent, RecordingTouchLockUnlockIntent.unlockOnly);
|
||||
});
|
||||
|
||||
testWidgets('releasing before hold duration does not unlock', (
|
||||
tester,
|
||||
) async {
|
||||
RecordingTouchLockUnlockIntent? receivedIntent;
|
||||
await pumpOverlay(
|
||||
tester,
|
||||
surfaceSize: const Size(375, 667),
|
||||
onUnlocked: (intent) => receivedIntent = intent,
|
||||
);
|
||||
|
||||
final gesture = await tester.startGesture(const Offset(120, 600));
|
||||
await tester.pump(const Duration(milliseconds: 1500));
|
||||
await gesture.up();
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
|
||||
expect(receivedIntent, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:recording_tool/app/app.dart';
|
||||
import 'package:recording_tool/features/recording/widgets/widget_recording_button.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
const validClipboardText =
|
||||
'{"title":"王东方 丨李想 空中格斗赛","startTimestamp":1717334400,"endTimestamp":1717334400,"filename":"选手名称_选手ID_赛事名称_赛项","address":"广州市番禺区·粤港澳大湾区青年人才双创小镇"}';
|
||||
|
||||
String? clipboardText;
|
||||
|
||||
setUp(() {
|
||||
clipboardText = null;
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||
if (call.method == 'Clipboard.getData') {
|
||||
return clipboardText == null
|
||||
? null
|
||||
: <String, dynamic>{'text': clipboardText};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null);
|
||||
});
|
||||
|
||||
Future<void> pumpRecordingApp(WidgetTester tester) async {
|
||||
await tester.pumpWidget(const ProviderScope(child: FlutterTemplateApp()));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 500));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
}
|
||||
|
||||
testWidgets('recording app renders recording page', (tester) async {
|
||||
await pumpRecordingApp(tester);
|
||||
|
||||
final recordButton = find.byType(RecordingControlButton);
|
||||
|
||||
expect(recordButton, findsOneWidget);
|
||||
expect(
|
||||
tester.getCenter(recordButton).dx,
|
||||
closeTo(tester.getCenter(find.byType(Scaffold)).dx, 0.5),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('shows paste event info button when title is empty', (
|
||||
tester,
|
||||
) async {
|
||||
clipboardText = '';
|
||||
|
||||
await pumpRecordingApp(tester);
|
||||
|
||||
expect(find.text('粘贴选手信息'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('pastes valid event info from clipboard', (tester) async {
|
||||
clipboardText = '';
|
||||
|
||||
await pumpRecordingApp(tester);
|
||||
|
||||
clipboardText = validClipboardText;
|
||||
await tester.tap(find.text('粘贴选手信息'));
|
||||
await tester.pump(const Duration(milliseconds: 700));
|
||||
|
||||
expect(find.text('王东方 丨李想 空中格斗赛'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows no event info toast when pasted clipboard is invalid', (
|
||||
tester,
|
||||
) async {
|
||||
clipboardText = '';
|
||||
|
||||
await pumpRecordingApp(tester);
|
||||
|
||||
clipboardText = 'hello';
|
||||
await tester.tap(find.text('粘贴选手信息'));
|
||||
await tester.pump();
|
||||
|
||||
expect(find.text('王东方 丨李想 空中格斗赛'), findsNothing);
|
||||
expect(find.text('无选手信息'), findsOneWidget);
|
||||
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user