From 1246e41e4b44ad652f4e3edeb0a0ea670d9195d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9E=97=E9=94=8B?= <2535831261@qq.com> Date: Tue, 7 Jul 2026 17:16:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0mobile=5Fscanner=E5=8C=85?= =?UTF-8?q?=EF=BC=8C=20=E6=B7=BB=E5=8A=A0AuthPageWidget=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E5=85=A8=E5=B1=80=E6=89=AB=E7=A0=81=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app/app.dart | 4 +- lib/features/auth/pages/page_auth.dart | 50 +++ .../scan_qrcode/pages/page_scan_qrcode.dart | 59 ++++ lib/shared/widgets/app_qr_scanner_dialog.dart | 331 ++++++++++++++++++ lib/shared/widgets/widgets.dart | 1 + pubspec.yaml | 1 + 6 files changed, 444 insertions(+), 2 deletions(-) create mode 100644 lib/features/auth/pages/page_auth.dart create mode 100644 lib/features/scan_qrcode/pages/page_scan_qrcode.dart create mode 100644 lib/shared/widgets/app_qr_scanner_dialog.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index fe0d984..5d3ffda 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -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 home: RefreshConfiguration( enableLoadingWhenNoData: false, headerTriggerDistance: 80.h, - child: const RecordingPage(), + child: const AuthPageWidget(), ), ); }, diff --git a/lib/features/auth/pages/page_auth.dart b/lib/features/auth/pages/page_auth.dart new file mode 100644 index 0000000..63e3dac --- /dev/null +++ b/lib/features/auth/pages/page_auth.dart @@ -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 createState() => _AuthPageWidgetState(); +} + +class _AuthPageWidgetState extends State { + @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, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/scan_qrcode/pages/page_scan_qrcode.dart b/lib/features/scan_qrcode/pages/page_scan_qrcode.dart new file mode 100644 index 0000000..f4f951b --- /dev/null +++ b/lib/features/scan_qrcode/pages/page_scan_qrcode.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:recording_tool/shared/widgets/app_button.dart'; +import 'package:recording_tool/shared/widgets/app_qr_scanner_dialog.dart'; + +class ScanQrCodePage extends StatefulWidget { + const ScanQrCodePage({super.key}); + + @override + State createState() => _AuthPageWidgetState(); +} + +class _AuthPageWidgetState extends State { + @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'); + }, + variant: AppButtonVariant.secondary, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/shared/widgets/app_qr_scanner_dialog.dart b/lib/shared/widgets/app_qr_scanner_dialog.dart new file mode 100644 index 0000000..dde2fbe --- /dev/null +++ b/lib/shared/widgets/app_qr_scanner_dialog.dart @@ -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 show(BuildContext context) { + return showGeneralDialog( + 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 createState() => _AppQrScannerDialogState(); +} + +class _AppQrScannerDialogState extends State + 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 _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 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; + } +} diff --git a/lib/shared/widgets/widgets.dart b/lib/shared/widgets/widgets.dart index 9504723..f457f49 100644 --- a/lib/shared/widgets/widgets.dart +++ b/lib/shared/widgets/widgets.dart @@ -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'; diff --git a/pubspec.yaml b/pubspec.yaml index 3eaa74f..00eae6b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,6 +47,7 @@ dependencies: shared_preferences: ^2.5.5 url_launcher: ^6.3.2 uuid: ^4.5.3 + mobile_scanner: ^7.2.0 dev_dependencies: flutter_test: