添加设备代码信息到 Android 和 iOS 平台信息;更新 Auth 页面以使用新的 TextEditingController 管理输入;重构推流测试页面以支持动态 RTMP URL 解析;删除不再需要的测试文件。

This commit is contained in:
2026-07-09 12:26:41 +08:00
parent 98a3381716
commit a1f6ea14cf
29 changed files with 267 additions and 1797 deletions
+36
View File
@@ -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.Build
import android.os.Environment import android.os.Environment
import android.os.StatFs import android.os.StatFs
import android.provider.Settings
import androidx.camera.view.PreviewView import androidx.camera.view.PreviewView
import com.dronex.rec.recording.RecordingPlatformHandler import com.dronex.rec.recording.RecordingPlatformHandler
import com.dronex.rec.recording.RecordingPreviewFactory import com.dronex.rec.recording.RecordingPreviewFactory
@@ -111,6 +112,7 @@ class MainActivity : FlutterActivity() {
return mapOf( return mapOf(
"platform" to "android", "platform" to "android",
"deviceCode" to Settings.Secure.getString(contentResolver, Settings.Secure.ANDROID_ID).orEmpty(),
"brand" to Build.BRAND, "brand" to Build.BRAND,
"model" to Build.MODEL, "model" to Build.MODEL,
"systemVersion" to Build.VERSION.RELEASE, "systemVersion" to Build.VERSION.RELEASE,
+1
View File
@@ -62,6 +62,7 @@ final class PlatformInfoPlugin: NSObject, FlutterPlugin {
let device = UIDevice.current let device = UIDevice.current
return [ return [
"platform": "ios", "platform": "ios",
"deviceCode": "",
"brand": device.systemName, "brand": device.systemName,
"model": machineIdentifier(), "model": machineIdentifier(),
"systemVersion": device.systemVersion, "systemVersion": device.systemVersion,
+7
View File
@@ -0,0 +1,7 @@
enum AuthApi {
/// 获取 token
getToken('/api/events/device/token');
final String path;
const AuthApi(this.path);
}
+1 -1
View File
@@ -32,7 +32,7 @@ class AppConfig {
current = switch (environment) { current = switch (environment) {
AppEnvironment.dev => const EnvironmentValues( AppEnvironment.dev => const EnvironmentValues(
environment: AppEnvironment.dev, environment: AppEnvironment.dev,
baseUrl: 'https://example.com/api', baseUrl: 'http://192.168.1.104:8000',
enableNetworkLog: true, enableNetworkLog: true,
), ),
AppEnvironment.staging => const EnvironmentValues( AppEnvironment.staging => const EnvironmentValues(
+1 -1
View File
@@ -5,7 +5,7 @@ class ApiResponse<T> {
final String message; final String message;
final T? data; final T? data;
bool get isSuccess => code >= 200 && code < 300; bool get isSuccess => code == 0 || code >= 200 && code < 300;
factory ApiResponse.fromJson( factory ApiResponse.fromJson(
Map<String, dynamic> json, { Map<String, dynamic> json, {
+11
View File
@@ -28,4 +28,15 @@ class HeaderInterceptor extends Interceptor {
handler.next(options); 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);
}
} }
+3
View File
@@ -31,6 +31,7 @@ class AppDeviceInfo {
required this.platform, required this.platform,
required this.isPhysicalDevice, required this.isPhysicalDevice,
required this.values, required this.values,
required this.deviceCode,
}); });
factory AppDeviceInfo.fromMap(Map<Object?, Object?> map) { factory AppDeviceInfo.fromMap(Map<Object?, Object?> map) {
@@ -48,12 +49,14 @@ class AppDeviceInfo {
platform: map['platform'] as String? ?? Platform.operatingSystem, platform: map['platform'] as String? ?? Platform.operatingSystem,
isPhysicalDevice: isPhysicalDevice is bool ? isPhysicalDevice : true, isPhysicalDevice: isPhysicalDevice is bool ? isPhysicalDevice : true,
values: values, values: values,
deviceCode: values['deviceCode'] ?? '',
); );
} }
final String platform; final String platform;
final bool isPhysicalDevice; final bool isPhysicalDevice;
final Map<String, String> values; final Map<String, String> values;
final String deviceCode;
} }
class AppPlatformInfo { class AppPlatformInfo {
+8
View File
@@ -28,4 +28,12 @@ class DeviceUtils {
} }
return (await AppPlatformInfo.deviceInfo()).values; return (await AppPlatformInfo.deviceInfo()).values;
} }
static Future<String> deviceCode() async {
try {
return (await AppPlatformInfo.deviceInfo()).deviceCode.trim();
} catch (_) {
return '';
}
}
} }
+16
View File
@@ -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};
}
}
+36 -5
View File
@@ -1,19 +1,37 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/app/router/app_navigator.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/features/scan_qrcode/pages/page_scan_qrcode.dart';
import 'package:recording_tool/shared/widgets/widgets.dart'; import 'package:recording_tool/shared/widgets/widgets.dart';
class AuthPageWidget extends StatefulWidget { class AuthPageWidget extends ConsumerStatefulWidget {
const AuthPageWidget({super.key}); const AuthPageWidget({super.key});
@override @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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
return Center( return Center(
child: Column( child: Column(
children: [ children: [
@@ -26,7 +44,7 @@ class _AuthPageWidgetState extends State<AuthPageWidget> {
SizedBox( SizedBox(
width: 280.w, width: 280.w,
height: 80.h, height: 80.h,
child: AppTextField(initialValue: 'hhh'), child: AppTextField(initialValue: 'hhh', controller: _controller),
), ),
SizedBox(height: 20.h), SizedBox(height: 20.h),
SizedBox( SizedBox(
@@ -34,8 +52,20 @@ class _AuthPageWidgetState extends State<AuthPageWidget> {
height: 80.h, height: 80.h,
child: AppButton( child: AppButton(
label: '确定', 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()); AppNavigator.push(const ScanQrCodePage());
return;
}
final message = ref.read(authProvider).errorMessage;
if (message != null && message.isNotEmpty) {
AppToast.show(message);
}
// return; // return;
// AppNavigator.push( // AppNavigator.push(
// const WebviewPage( // const WebviewPage(
@@ -45,6 +75,7 @@ class _AuthPageWidgetState extends State<AuthPageWidget> {
// ); // );
}, },
variant: AppButtonVariant.secondary, variant: AppButtonVariant.secondary,
isLoading: authState.isLoading,
), ),
), ),
], ],
+31
View File
@@ -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;
}
}
+13
View File
@@ -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:apivideo_live_stream/apivideo_live_stream.dart';
import 'package:flutter/material.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_bar.dart';
import 'package:recording_tool/shared/widgets/app_toast.dart';
class PushSteamTestWidget extends StatefulWidget { class PushSteamTestWidget extends StatefulWidget {
const PushSteamTestWidget({super.key}); const PushSteamTestWidget({
final String steamKey = '20260708'; super.key,
final String rtmpUrl = 'rtmp://192.168.1.180:19090/live/'; this.rtmpUrl =
'rtmp://192.168.1.245:19090/蔡依婷vs夏志豪_空中格斗赛_高中组/蔡依婷vs夏志豪_空中格斗赛_高中组',
});
final String rtmpUrl;
@override @override
State<PushSteamTestWidget> createState() => _PushSteamTestWidgetState(); State<PushSteamTestWidget> createState() => _PushSteamTestWidgetState();
@@ -57,19 +64,24 @@ class _PushSteamTestWidgetState extends State<PushSteamTestWidget>
} }
} }
// 把服务端下发的完整 rtmp:// 地址拆成 "服务器地址" + "streamKey" 两部分
Future<void> _startPush() async { Future<void> _startPush() async {
// final uri = Uri.parse(widget.rtmpUrl); try {
final streamKey = widget.steamKey; final target = RtmpStreamTarget.parse(widget.rtmpUrl);
// final streamKey = await _controller.startStreaming(
// '${DateTime.timestamp()}-${Random(1000).nextInt(1000).toString()}'; streamKey: target.streamKey,
url: target.url,
// debugPrint('streamKey- $streamKey '); );
// final baseUrl = widget.rtmpUrl.substring( } on FormatException catch (e) {
// 0, debugPrint('推流地址错误: ${e.message}');
// widget.rtmpUrl.lastIndexOf(streamKey), AppToast.show(e.message);
// ); } on PlatformException catch (e) {
await _controller.startStreaming(streamKey: streamKey, url: widget.rtmpUrl); 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(); Future<void> _stopPush() => _controller.stopStreaming();
@@ -40,7 +40,7 @@ class _AuthPageWidgetState extends State<ScanQrCodePage> {
if (result == null || result.isEmpty) return; if (result == null || result.isEmpty) return;
debugPrint('扫码结果: $result'); debugPrint('扫码结果: $result');
Future.delayed(const Duration(milliseconds: 1)).then((_) { Future.delayed(const Duration(milliseconds: 1)).then((_) {
AppNavigator.push(PushSteamTestWidget()); AppNavigator.push(PushSteamTestWidget(rtmpUrl: result));
// AppNavigator.push( // AppNavigator.push(
// const WebviewPage(url: 'https://www.dronex.cc/'), // 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);
});
});
}
-91
View File
@@ -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));
});
}