Author SHA1 Message Date
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
linfeng 26574808df Merge branch 'linfeng/run/dev/20260630' into linfeng/drone/dev/2026630 2026-07-01 12:18:58 +08:00
linfeng 4a85b9f83e Merge run recording updates into drone branch 2026-07-01 11:54:06 +08:00
linfeng e4057c3d6a 添加 CLAUDE.md 到 .gitignore 文件 2026-07-01 11:32:22 +08:00
51 changed files with 735 additions and 129 deletions
-1
View File
@@ -49,4 +49,3 @@ app.*.map.json
/android/.kotlin
CLAUDE.md
+1 -1
View File
@@ -4,7 +4,7 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
val appPackageName = "com.run.sportsx"
val appPackageName = "com.dronex.rec"
android {
namespace = appPackageName
+2 -2
View File
@@ -1,5 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.run.sportsx">
package="com.dronex.rec">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
@@ -19,7 +19,7 @@
android:required="true" />
<application
android:label="酷跑录像工作台"
android:label="飞行极控录像工作台"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
@@ -1,4 +1,4 @@
package com.run.sportsx
package com.dronex.rec
object AppConstants {
private const val CHANNEL_NAMESPACE = "app.record_tool"
@@ -1,4 +1,4 @@
package com.run.sportsx
package com.dronex.rec
import android.content.Context
import android.content.pm.ApplicationInfo
@@ -7,8 +7,8 @@ import android.os.Build
import android.os.Environment
import android.os.StatFs
import androidx.camera.view.PreviewView
import com.run.sportsx.recording.RecordingPlatformHandler
import com.run.sportsx.recording.RecordingPreviewFactory
import com.dronex.rec.recording.RecordingPlatformHandler
import com.dronex.rec.recording.RecordingPreviewFactory
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
@@ -1,4 +1,4 @@
package com.run.sportsx.recording
package com.dronex.rec.recording
import android.content.Context
import android.content.Intent
@@ -1,4 +1,4 @@
package com.run.sportsx.recording
package com.dronex.rec.recording
import android.app.NotificationManager
import android.content.Context
@@ -1,4 +1,4 @@
package com.run.sportsx.recording
package com.dronex.rec.recording
import android.content.Context
import android.hardware.camera2.CameraCharacteristics
@@ -1,4 +1,4 @@
package com.run.sportsx.recording
package com.dronex.rec.recording
import android.app.Notification
import android.app.NotificationChannel
@@ -14,8 +14,8 @@ import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.LifecycleService
import com.run.sportsx.AppConstants
import com.run.sportsx.MainActivity
import com.dronex.rec.AppConstants
import com.dronex.rec.MainActivity
class RecordingForegroundService : LifecycleService() {
private var wakeLock: PowerManager.WakeLock? = null
@@ -1,4 +1,4 @@
package com.run.sportsx.recording
package com.dronex.rec.recording
import android.content.ContentValues
import android.content.Context
@@ -12,7 +12,7 @@ import java.util.Date
import java.util.Locale
object RecordingOutputFactory {
private const val RELATIVE_PATH = "Movies/酷跑录像工作台"
private const val RELATIVE_PATH = "Movies/飞行极控录像工作台"
private const val MIME_TYPE = "video/mp4"
fun buildSegmentOutputOptions(segmentFile: File): FileOutputOptions {
@@ -1,12 +1,12 @@
package com.run.sportsx.recording
package com.dronex.rec.recording
import android.os.Handler
import android.os.Looper
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.run.sportsx.AppConstants
import com.run.sportsx.MainActivity
import com.dronex.rec.AppConstants
import com.dronex.rec.MainActivity
import io.flutter.plugin.common.BinaryMessenger
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
@@ -1,9 +1,9 @@
package com.run.sportsx.recording
package com.dronex.rec.recording
import android.content.Context
import android.view.View
import androidx.camera.view.PreviewView
import com.run.sportsx.MainActivity
import com.dronex.rec.MainActivity
import io.flutter.plugin.common.StandardMessageCodec
import io.flutter.plugin.platform.PlatformView
import io.flutter.plugin.platform.PlatformViewFactory
@@ -1,4 +1,4 @@
package com.run.sportsx.recording
package com.dronex.rec.recording
import android.media.MediaCodec
import android.media.MediaExtractor
@@ -1,4 +1,4 @@
package com.run.sportsx.recording
package com.dronex.rec.recording
import android.content.Context
import androidx.lifecycle.LifecycleService
@@ -1,4 +1,4 @@
package com.run.sportsx.recording
package com.dronex.rec.recording
enum class RecordingState {
IDLE,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

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

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 761 B

After

Width:  |  Height:  |  Size: 976 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 20 KiB

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

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 262 KiB

+71 -71
View File
@@ -1,78 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>飞行极控录像工作台</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>飞行极控录像工作台</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>需要访问相机以显示预览并录制视频。</string>
<key>NSMicrophoneUsageDescription</key>
<string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string>
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true />
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>酷跑录像工作台</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>酷跑录像工作台</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true />
<key>NSCameraUsageDescription</key>
<string>需要访问相机以显示预览并录制视频。</string>
<key>NSMicrophoneUsageDescription</key>
<string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string>
<key>UIFileSharingEnabled</key>
<true />
<key>LSSupportsOpeningDocumentsInPlace</key>
<true />
<key>UIApplicationSceneManifest</key>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false />
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>FlutterSceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true />
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
+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(),
),
);
},
+1 -1
View File
@@ -21,7 +21,7 @@ class AppConfig {
static late EnvironmentValues current;
static AppPackageInfo? packageInfo;
static const appName = '酷跑录像工作台';
static const appName = '飞行极控录像工作台';
static const designSize = Size(375, 812);
static void configure({
+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:
@@ -50,48 +50,50 @@ void main() {
testWidgets('shows preset zoom buttons', (tester) async {
await pumpHud(tester);
expect(find.text('0.5x'), findsNothing);
expect(find.text('0.6x'), findsNothing);
expect(find.text('广角'), findsNothing);
expect(find.text('1x'), findsOneWidget);
expect(find.text('2x'), findsNothing);
expect(find.text('3x'), findsNothing);
});
testWidgets('shows 0.5x when ultra-wide camera capability is 0.5', (
testWidgets('shows wide angle when ultra-wide camera capability is 0.5', (
tester,
) async {
await pumpHud(tester, minZoomRatio: 0.5);
expect(find.text('0.5x'), findsOneWidget);
expect(find.text('0.6x'), findsNothing);
expect(find.text('广角'), findsOneWidget);
expect(find.text('1x'), findsOneWidget);
expect(find.text('2x'), findsNothing);
expect(find.text('3x'), findsNothing);
});
testWidgets('shows 0.6x when 0.6x camera capability supports it', (
testWidgets('shows wide angle when 0.6x camera capability supports it', (
tester,
) async {
await pumpHud(tester, minZoomRatio: 0.6);
expect(find.text('0.6x'), findsOneWidget);
expect(find.text('广角'), findsOneWidget);
expect(find.text('1x'), findsOneWidget);
});
testWidgets('marks current 0.5x zoom ratio as selected', (tester) async {
testWidgets('marks current 0.5x wide angle ratio as selected', (
tester,
) async {
await pumpHud(tester, zoomRatio: 0.5, minZoomRatio: 0.5);
final selectedButton = tester.widget<TextButton>(
find.ancestor(of: find.text('0.5x'), matching: find.byType(TextButton)),
find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)),
);
expect(selectedButton.enabled, isFalse);
});
testWidgets('marks current 0.6x zoom ratio as selected', (tester) async {
testWidgets('marks current 0.6x wide angle ratio as selected', (
tester,
) async {
await pumpHud(tester, zoomRatio: 0.6, minZoomRatio: 0.6);
final selectedButton = tester.widget<TextButton>(
find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)),
find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)),
);
expect(selectedButton.enabled, isFalse);
});
@@ -99,8 +101,7 @@ void main() {
testWidgets('does not expose presets beyond max zoom ratio', (tester) async {
await pumpHud(tester, minZoomRatio: 0.5, maxZoomRatio: 0.55);
expect(find.text('0.5x'), findsOneWidget);
expect(find.text('0.6x'), findsNothing);
expect(find.text('广角'), findsOneWidget);
expect(find.text('1x'), findsNothing);
});
@@ -114,7 +115,7 @@ void main() {
onZoomSelected: (ratio) => selected = ratio,
);
await tester.tap(find.text('0.5x'));
await tester.tap(find.text('广角'));
await tester.pump(const Duration(milliseconds: 350));
expect(selected, 0.5);
@@ -130,7 +131,7 @@ void main() {
onZoomSelected: (ratio) => selected = ratio,
);
await tester.tap(find.text('0.6x'));
await tester.tap(find.text('广角'));
await tester.pump(const Duration(milliseconds: 350));
expect(selected, 0.6);
@@ -148,7 +149,7 @@ void main() {
);
final ultraWideButton = tester.widget<TextButton>(
find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)),
find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)),
);
final mainButton = tester.widget<TextButton>(
find.ancestor(of: find.text('1x'), matching: find.byType(TextButton)),
@@ -157,7 +158,7 @@ void main() {
expect(ultraWideButton.enabled, isTrue);
expect(mainButton.enabled, isFalse);
await tester.tap(find.text('0.6x'));
await tester.tap(find.text('广角'));
await tester.pump(const Duration(milliseconds: 350));
expect(selected, 0.6);
@@ -176,7 +177,7 @@ void main() {
);
final ultraWideButton = tester.widget<TextButton>(
find.ancestor(of: find.text('0.5x'), matching: find.byType(TextButton)),
find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)),
);
final mainButton = tester.widget<TextButton>(
find.ancestor(of: find.text('1x'), matching: find.byType(TextButton)),
@@ -218,11 +219,11 @@ void main() {
);
final ultraWideButton = tester.widget<TextButton>(
find.ancestor(of: find.text('0.6x'), matching: find.byType(TextButton)),
find.ancestor(of: find.text('广角'), matching: find.byType(TextButton)),
);
expect(ultraWideButton.enabled, isFalse);
await tester.tap(find.text('0.6x'));
await tester.tap(find.text('广角'));
await tester.pump(const Duration(milliseconds: 350));
expect(selected, isNull);