Author SHA1 Message Date
linfeng 613bd90bad update 2026-07-16 11:58:58 +08:00
linfeng 9818a56fe1 删除单例测试文件 2026-07-16 11:58:43 +08:00
linfeng f6c477c9be 添加webview_flutter及相关依赖
添加公共的 webview 组件
2026-07-07 17:40:24 +08:00
linfeng 1246e41e4b 添加mobile_scanner包,
添加AuthPageWidget
添加全局扫码组件
2026-07-07 17:16:51 +08:00
20 changed files with 618 additions and 1775 deletions
+2
View File
@@ -49,3 +49,5 @@ app.*.map.json
/android/.kotlin
CLAUDE.md
AGENTS.md
test/
+2 -2
View File
@@ -7,7 +7,7 @@ import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:recording_tool/app/config/app_config.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/app/theme/app_theme.dart';
import 'package:recording_tool/features/recording/pages/page_record.dart';
import 'package:recording_tool/features/auth/pages/page_auth.dart';
import 'package:recording_tool/features/recording/view-model/view_model_recording.dart';
class FlutterTemplateApp extends ConsumerStatefulWidget {
@@ -74,7 +74,7 @@ class _FlutterTemplateAppState extends ConsumerState<FlutterTemplateApp>
home: RefreshConfiguration(
enableLoadingWhenNoData: false,
headerTriggerDistance: 80.h,
child: const RecordingPage(),
child: const AuthPageWidget(),
),
);
},
+50
View File
@@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/app/router/app_navigator.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 {
const AuthPageWidget({super.key});
@override
State<AuthPageWidget> createState() => _AuthPageWidgetState();
}
class _AuthPageWidgetState extends State<AuthPageWidget> {
@override
Widget build(BuildContext context) {
return Center(
child: Column(
children: [
SizedBox(height: 180.h),
Text('裁判工作台', style: TextStyle(fontSize: 50, color: Colors.black)),
SizedBox(height: 20.h),
Text(
'Auth Page',
style: TextStyle(fontSize: 30, color: Colors.black),
),
SizedBox(height: 20.h),
SizedBox(
width: 280.w,
height: 80.h,
child: AppTextField(initialValue: 'hhh'),
),
SizedBox(height: 20.h),
SizedBox(
width: 280.w,
height: 80.h,
child: AppButton(
label: '确定',
onPressed: () {
AppNavigator.push(const ScanQrCodePage());
},
variant: AppButtonVariant.secondary,
),
),
],
),
);
}
}
@@ -0,0 +1,64 @@
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:recording_tool/app/router/app_navigator.dart';
import 'package:recording_tool/shared/widgets/app_button.dart';
import 'package:recording_tool/shared/widgets/app_qr_scanner_dialog.dart';
import 'package:recording_tool/shared/widgets/app_webview.dart';
class ScanQrCodePage extends StatefulWidget {
const ScanQrCodePage({super.key});
@override
State<ScanQrCodePage> createState() => _AuthPageWidgetState();
}
class _AuthPageWidgetState extends State<ScanQrCodePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
Navigator.of(context).maybePop();
},
),
centerTitle: true,
elevation: 0,
backgroundColor: Colors.transparent,
foregroundColor: Colors.black,
),
body: Center(
child: Column(
children: [
SizedBox(height: 100.h),
Text('裁判工作台', style: TextStyle(fontSize: 30, color: Colors.black)),
SizedBox(height: 100.h),
Text(
'扫描选手参赛凭证进行执裁',
style: TextStyle(fontSize: 30, color: Colors.black),
),
SizedBox(height: 20.h),
SizedBox(
width: 280.w,
height: 80.h,
child: AppButton(
label: '扫码',
onPressed: () async {
final result = await AppQrScannerDialog.show(context);
if (result == null || result.isEmpty) return;
debugPrint('扫码结果: $result');
AppNavigator.push(
const WebviewPage(url: 'https://www.dronex.cc/'),
);
},
variant: AppButtonVariant.secondary,
),
),
],
),
),
);
}
}
@@ -0,0 +1,331 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
class AppQrScannerDialog extends StatefulWidget {
const AppQrScannerDialog({super.key});
static Future<String?> show(BuildContext context) {
return showGeneralDialog<String>(
context: context,
barrierDismissible: false,
barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel,
barrierColor: Colors.black,
transitionDuration: const Duration(milliseconds: 180),
pageBuilder: (dialogContext, animation, secondaryAnimation) {
return const AppQrScannerDialog();
},
transitionBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(opacity: animation, child: child);
},
);
}
@override
State<AppQrScannerDialog> createState() => _AppQrScannerDialogState();
}
class _AppQrScannerDialogState extends State<AppQrScannerDialog>
with SingleTickerProviderStateMixin {
late final MobileScannerController _controller;
late final AnimationController _scanLineController;
bool _hasScanned = false;
@override
void initState() {
super.initState();
_controller = MobileScannerController(
detectionSpeed: DetectionSpeed.noDuplicates,
facing: CameraFacing.back,
formats: const [BarcodeFormat.qrCode],
);
_scanLineController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1800),
)..repeat(reverse: true);
}
@override
void dispose() {
_scanLineController.dispose();
unawaited(_controller.dispose());
super.dispose();
}
Future<void> _handleDetect(BarcodeCapture capture) async {
if (_hasScanned || capture.barcodes.isEmpty) return;
final value = capture.barcodes.first.rawValue;
if (value == null || value.isEmpty) return;
_hasScanned = true;
final navigator = Navigator.of(context, rootNavigator: true);
try {
await _controller.stop();
} catch (error) {
debugPrint('停止扫码相机失败: $error');
}
if (!mounted) return;
navigator.pop(value);
}
void _close() {
Navigator.of(context, rootNavigator: true).pop();
}
Rect _scanWindowFor(Size size) {
final scanSize = math.min(size.width * 0.72, 300.w);
final center = Offset(size.width / 2, size.height * 0.46);
return Rect.fromCenter(center: center, width: scanSize, height: scanSize);
}
@override
Widget build(BuildContext context) {
return Material(
color: Colors.black,
child: LayoutBuilder(
builder: (context, constraints) {
final size = constraints.biggest;
final scanWindow = _scanWindowFor(size);
return Stack(
children: [
Positioned.fill(
child: MobileScanner(
controller: _controller,
fit: BoxFit.cover,
scanWindow: scanWindow,
onDetect: _handleDetect,
errorBuilder: _buildCameraError,
placeholderBuilder: (_) => const ColoredBox(
color: Colors.black,
child: Center(
child: CircularProgressIndicator(color: Colors.white),
),
),
),
),
Positioned.fill(
child: CustomPaint(
painter: _ScannerOverlayPainter(scanWindow: scanWindow),
),
),
_ScanLine(animation: _scanLineController, scanWindow: scanWindow),
Positioned(
left: 0,
right: 0,
top: 0,
child: SafeArea(
bottom: false,
child: SizedBox(
height: 56.h,
child: Stack(
alignment: Alignment.center,
children: [
Align(
alignment: Alignment.centerLeft,
child: IconButton(
onPressed: _close,
icon: const Icon(Icons.close),
color: Colors.white,
tooltip: '关闭',
),
),
Text(
'扫一扫',
style: TextStyle(
color: Colors.white,
fontSize: 18.sp,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
),
Positioned(
top: scanWindow.bottom + 28.h,
left: 24.w,
right: 24.w,
child: Text(
'将二维码放入框内,即可自动扫描',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.86),
fontSize: 15.sp,
height: 1.4,
),
),
),
],
);
},
),
);
}
Widget _buildCameraError(BuildContext context, MobileScannerException error) {
return ColoredBox(
color: Colors.black,
child: Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 32.w),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.camera_alt_outlined,
color: Colors.white.withValues(alpha: 0.82),
size: 44.r,
),
SizedBox(height: 16.h),
Text(
'无法启动相机,请检查相机权限后重试',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white,
fontSize: 16.sp,
height: 1.45,
),
),
SizedBox(height: 8.h),
Text(
error.errorDetails?.message ?? error.errorCode.message,
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.58),
fontSize: 12.sp,
height: 1.35,
),
),
SizedBox(height: 24.h),
TextButton(
onPressed: _close,
style: TextButton.styleFrom(
foregroundColor: Colors.white,
side: BorderSide(color: Colors.white.withValues(alpha: 0.44)),
),
child: const Text('关闭'),
),
],
),
),
),
);
}
}
class _ScanLine extends StatelessWidget {
const _ScanLine({required this.animation, required this.scanWindow});
final Animation<double> animation;
final Rect scanWindow;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
final inset = 14.r;
final top =
scanWindow.top +
inset +
(scanWindow.height - inset * 2) * animation.value;
return Positioned(
left: scanWindow.left + inset,
top: top,
width: scanWindow.width - inset * 2,
child: child!,
);
},
child: Container(
height: 2.h,
decoration: BoxDecoration(
color: const Color(0xFF1BD760),
borderRadius: BorderRadius.circular(2.r),
boxShadow: [
BoxShadow(
color: const Color(0xFF1BD760).withValues(alpha: 0.65),
blurRadius: 12.r,
spreadRadius: 2.r,
),
],
),
),
);
}
}
class _ScannerOverlayPainter extends CustomPainter {
const _ScannerOverlayPainter({required this.scanWindow});
final Rect scanWindow;
@override
void paint(Canvas canvas, Size size) {
final overlayPaint = Paint()..color = Colors.black.withValues(alpha: 0.58);
final outerPath = Path()..addRect(Offset.zero & size);
final innerPath = Path()
..addRRect(RRect.fromRectAndRadius(scanWindow, Radius.circular(8.r)));
final overlayPath = Path.combine(
PathOperation.difference,
outerPath,
innerPath,
);
canvas.drawPath(overlayPath, overlayPaint);
final borderPaint = Paint()
..color = Colors.white.withValues(alpha: 0.38)
..style = PaintingStyle.stroke
..strokeWidth = 1.r;
canvas.drawRRect(
RRect.fromRectAndRadius(scanWindow, Radius.circular(8.r)),
borderPaint,
);
final cornerPaint = Paint()
..color = const Color(0xFF1BD760)
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeWidth = 4.r;
final cornerLength = 28.r;
void drawCorner(Offset start, Offset horizontalEnd, Offset verticalEnd) {
canvas
..drawLine(start, horizontalEnd, cornerPaint)
..drawLine(start, verticalEnd, cornerPaint);
}
drawCorner(
scanWindow.topLeft,
scanWindow.topLeft.translate(cornerLength, 0),
scanWindow.topLeft.translate(0, cornerLength),
);
drawCorner(
scanWindow.topRight,
scanWindow.topRight.translate(-cornerLength, 0),
scanWindow.topRight.translate(0, cornerLength),
);
drawCorner(
scanWindow.bottomLeft,
scanWindow.bottomLeft.translate(cornerLength, 0),
scanWindow.bottomLeft.translate(0, -cornerLength),
);
drawCorner(
scanWindow.bottomRight,
scanWindow.bottomRight.translate(-cornerLength, 0),
scanWindow.bottomRight.translate(0, -cornerLength),
);
}
@override
bool shouldRepaint(covariant _ScannerOverlayPainter oldDelegate) {
return oldDelegate.scanWindow != scanWindow;
}
}
+164
View File
@@ -0,0 +1,164 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:webview_flutter_android/webview_flutter_android.dart';
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
class WebviewPage extends StatefulWidget {
final String url;
final String? title;
const WebviewPage({super.key, required this.url, this.title});
@override
State<WebviewPage> createState() => WebviewPageState();
}
class WebviewPageState extends State<WebviewPage> {
WebViewController? _controller;
bool _isLoading = true;
String? _errorMessage;
@override
void initState() {
super.initState();
_initController();
}
Future<void> _initController() async {
if (widget.url?.isEmpty ?? true) {
return;
}
final params = _createPlatformParams();
final controller = WebViewController.fromPlatformCreationParams(params)
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.transparent)
..setNavigationDelegate(
NavigationDelegate(
onProgress: (int progress) {
debugPrint('webview - progress: $progress');
},
onPageStarted: (String url) {
debugPrint('webview - page started: $url');
if (!mounted) return;
setState(() {
_isLoading = true;
_errorMessage = null;
});
},
onPageFinished: (String url) {
debugPrint('webview - page finished: $url');
if (!mounted) return;
setState(() => _isLoading = false);
},
onHttpError: (HttpResponseError error) {
debugPrint(
'webview - http error: ${error.response?.uri}, code: ${error.response?.statusCode}',
);
},
onWebResourceError: (WebResourceError error) {
debugPrint(
'webview error: ${error.description}, code: ${error.errorCode}, url: ${error.url}',
);
if (!mounted || error.isForMainFrame != true) return;
setState(() {
_isLoading = false;
_errorMessage = error.description;
});
},
onNavigationRequest: (NavigationRequest request) {
return NavigationDecision.navigate;
},
),
);
if (controller.platform is AndroidWebViewController) {
AndroidWebViewController.enableDebugging(kDebugMode);
await (controller.platform as AndroidWebViewController)
.setMediaPlaybackRequiresUserGesture(false);
}
final targetUrl = widget.url ?? '';
try {
await controller.loadRequest(Uri.parse(targetUrl));
} catch (e) {
debugPrint('webview - loadRequest failed: $e');
if (!mounted) return;
setState(() {
_isLoading = false;
_errorMessage = e.toString();
});
return;
}
if (!mounted) return;
setState(() => _controller = controller);
}
PlatformWebViewControllerCreationParams _createPlatformParams() {
if (WebViewPlatform.instance is WebKitWebViewPlatform) {
return WebKitWebViewControllerCreationParams(
allowsInlineMediaPlayback: true,
mediaTypesRequiringUserAction: const <PlaybackMediaTypes>{},
);
}
return const PlatformWebViewControllerCreationParams();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.transparent,
extendBodyBehindAppBar: true,
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
title: Text(
widget.title ?? '',
style: const TextStyle(color: Colors.white),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () async {
final controller = _controller;
if (controller != null && await controller.canGoBack()) {
await controller.goBack();
} else {
if (context.mounted) Navigator.of(context).maybePop();
}
},
),
iconTheme: const IconThemeData(color: Colors.white),
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_errorMessage != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
'页面加载失败\n$_errorMessage',
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 14),
),
),
);
}
final controller = _controller;
if (controller == null) {
return const Center(child: CircularProgressIndicator());
}
return Stack(
children: [
Positioned.fill(child: WebViewWidget(controller: controller)),
if (_isLoading) const Center(child: CircularProgressIndicator()),
],
);
}
}
+1
View File
@@ -6,6 +6,7 @@ export 'app_empty_view.dart';
export 'app_error_view.dart';
export 'app_loading_view.dart';
export 'app_network_image.dart';
export 'app_qr_scanner_dialog.dart';
export 'app_refresh_list.dart';
export 'app_search_bar.dart';
export 'app_status_view.dart';
+4
View File
@@ -47,6 +47,10 @@ dependencies:
shared_preferences: ^2.5.5
url_launcher: ^6.3.2
uuid: ^4.5.3
mobile_scanner: ^7.2.0
webview_flutter_android: ^4.13.0
webview_flutter_wkwebview: ^3.26.0
webview_flutter: ^4.14.0
dev_dependencies:
flutter_test:
@@ -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));
});
}