From cb6edb850abfdade37101871f14db398377545b2 Mon Sep 17 00:00:00 2001 From: Lectom C Han Date: Tue, 24 Feb 2026 10:50:17 +0900 Subject: [PATCH] =?UTF-8?q?iOS=20QR=20js=EA=B8=B0=EB=B0=98=20=EB=B0=94?= =?UTF-8?q?=EC=9D=B4=ED=8C=A8=EC=8A=A4,=20QR=EC=8A=B9=EC=9D=B8=20=ED=81=B4?= =?UTF-8?q?=EB=A6=AD=EC=A0=88=EC=B0=A8=20=EC=83=9D=EB=9E=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/presentation/approve_qr_screen.dart | 66 +++- .../auth/presentation/qr_scan_screen_web.dart | 307 +++++++----------- userfront/pubspec.lock | 8 + userfront/pubspec.yaml | 1 + 4 files changed, 191 insertions(+), 191 deletions(-) diff --git a/userfront/lib/features/auth/presentation/approve_qr_screen.dart b/userfront/lib/features/auth/presentation/approve_qr_screen.dart index 01dca34c..78c97aa2 100644 --- a/userfront/lib/features/auth/presentation/approve_qr_screen.dart +++ b/userfront/lib/features/auth/presentation/approve_qr_screen.dart @@ -18,11 +18,15 @@ class _ApproveQrScreenState extends State { bool _success = false; bool _isCheckingSession = false; bool _redirectingToLogin = false; + bool _autoApproveTriggered = false; @override void initState() { super.initState(); - _bootstrapCookieSession().then((_) => _redirectIfNotLoggedIn()); + _bootstrapCookieSession().then((_) { + _redirectIfNotLoggedIn(); + _maybeAutoApprove(); + }); } Future _bootstrapCookieSession() async { @@ -61,6 +65,31 @@ class _ApproveQrScreenState extends State { } } + void _maybeAutoApprove() { + if (!mounted || _autoApproveTriggered) return; + if (widget.pendingRef == null || widget.pendingRef!.trim().isEmpty) { + if (_message == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() { + _message = 'Error: pendingRef is missing.'; + }); + }); + } + return; + } + + final hasStoredToken = AuthTokenStore.getToken()?.isNotEmpty ?? false; + final usesCookie = AuthTokenStore.usesCookie(); + final isLoggedIn = hasStoredToken || usesCookie || _isCheckingSession; + if (!isLoggedIn || _isLoading || _success) { + return; + } + + _autoApproveTriggered = true; + _handleApprove(); + } + Future _handleApprove() async { if (widget.pendingRef == null) return; @@ -115,6 +144,9 @@ class _ApproveQrScreenState extends State { if (!isLoggedIn && !_redirectingToLogin) { _redirectIfNotLoggedIn(); } + if (isLoggedIn && !_success && !_isLoading) { + _maybeAutoApprove(); + } return Scaffold( appBar: AppBar(title: const Text("QR Login Approval")), @@ -151,15 +183,17 @@ class _ApproveQrScreenState extends State { ), ), - if (!_success) - FilledButton.icon( - onPressed: _isLoading || !isLoggedIn ? null : _handleApprove, - icon: const Icon(Icons.check_circle), - label: const Text("Approve Login"), - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(60), - backgroundColor: Colors.blue, - ), + if (_isLoading) + const Padding( + padding: EdgeInsets.only(bottom: 16), + child: CircularProgressIndicator(), + ), + + if (!_success && !_isLoading) + Text( + "Approving login request automatically...", + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey.shade700), ), if (!isLoggedIn && !_success) @@ -172,6 +206,18 @@ class _ApproveQrScreenState extends State { ), ), + if (!_success && !_isLoading && _message != null) + FilledButton.icon( + onPressed: !isLoggedIn + ? null + : () { + _autoApproveTriggered = false; + _handleApprove(); + }, + icon: const Icon(Icons.refresh), + label: const Text("Retry Approval"), + ), + if (_success) FilledButton( onPressed: () => context.go(buildLocalizedHomePath(Uri.base)), diff --git a/userfront/lib/features/auth/presentation/qr_scan_screen_web.dart b/userfront/lib/features/auth/presentation/qr_scan_screen_web.dart index 40d3f962..c3e5291e 100644 --- a/userfront/lib/features/auth/presentation/qr_scan_screen_web.dart +++ b/userfront/lib/features/auth/presentation/qr_scan_screen_web.dart @@ -1,26 +1,12 @@ import 'dart:async'; -import 'dart:js_interop'; -import 'dart:js_interop_unsafe'; -import 'dart:ui_web' as ui_web; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:userfront/i18n.dart'; -import 'package:web/web.dart' as web; -import 'qr_camera_bootstrap_policy.dart'; import 'qr_scan_route.dart'; -@JS('BarcodeDetector') -extension type _BarcodeDetector._(JSObject _) implements JSObject { - external factory _BarcodeDetector(); - external JSPromise> detect(JSObject source); -} - -extension type _DetectedBarcode._(JSObject _) implements JSObject { - external String? get rawValue; -} - class QRScanScreen extends StatefulWidget { const QRScanScreen({super.key}); @@ -29,195 +15,120 @@ class QRScanScreen extends StatefulWidget { } class _QRScanScreenState extends State { - late final web.HTMLVideoElement _videoElement; - late final String _viewType; + final MobileScannerController _scannerController = MobileScannerController( + autoStart: true, + detectionSpeed: DetectionSpeed.noDuplicates, + facing: CameraFacing.back, + formats: const [BarcodeFormat.qrCode], + ); final TextEditingController _manualController = TextEditingController(); - web.MediaStream? _stream; - _BarcodeDetector? _detector; - Timer? _scanTimer; - bool _scanInFlight = false; - bool _navigated = false; - bool _initializing = true; + bool _isProcessing = false; String? _error; String? _status; @override void initState() { super.initState(); - _viewType = 'qr-camera-${DateTime.now().microsecondsSinceEpoch}'; - _videoElement = web.HTMLVideoElement() - ..autoplay = true - ..muted = true - ..playsInline = true - ..style.border = '0' - ..style.width = '100%' - ..style.height = '100%' - ..style.objectFit = 'cover'; - - ui_web.platformViewRegistry.registerViewFactory(_viewType, (int viewId) { - return _videoElement; - }); - - unawaited(_initializeScanner()); + _status = tr( + 'msg.userfront.login.qr.scan_hint', + fallback: 'QR 코드를 카메라 중앙에 맞춰주세요.', + ); } @override void dispose() { - _scanTimer?.cancel(); - _stopCamera(); _manualController.dispose(); + _scannerController.dispose(); super.dispose(); } - Future _initializeScanner() async { - setState(() { - _initializing = true; - _error = null; - _status = tr('ui.userfront.qr.title', fallback: 'Scan QR Code'); - }); - final hasBarcodeDetector = globalContext - .hasProperty('BarcodeDetector'.toJS) - .toDart; + Future _navigateToApprove(String rawPayload) async { + final payload = rawPayload.trim(); + if (payload.isEmpty || _isProcessing || !mounted) { + return; + } - final result = await bootstrapQrCamera( - hasBarcodeDetector: hasBarcodeDetector, - openCameraAndPlay: () async { - final constraints = web.MediaStreamConstraints( - video: web.MediaTrackConstraints(facingMode: 'environment'.toJS), - audio: false.toJS, - ); - _stream = await web.window.navigator.mediaDevices - .getUserMedia(constraints) - .toDart; - _videoElement.srcObject = _stream; - await _videoElement.play().toDart; - }, - stopCamera: () async { - _stopCamera(); - }, - ); + setState(() { + _isProcessing = true; + _error = null; + _status = tr( + 'ui.userfront.qr.result_success', + fallback: '승인 화면으로 이동 중...', + ); + }); + + try { + await _scannerController.stop(); + } catch (_) {} if (!mounted) { return; } - - switch (result.status) { - case QrCameraBootstrapStatus.ready: - _detector = _BarcodeDetector(); - setState(() { - _initializing = false; - _status = tr( - 'msg.userfront.login.qr.scan_hint', - fallback: 'QR 코드를 카메라 중앙에 맞춰주세요.', - ); - }); - _startScanLoop(); - break; - case QrCameraBootstrapStatus.detectorUnsupported: - setState(() { - _initializing = false; - _status = tr( - 'msg.userfront.login.qr.scan_hint', - fallback: 'QR 코드를 카메라 중앙에 맞춰주세요.', - ); - _error = tr( - 'msg.userfront.qr.camera_error', - fallback: 'Camera error: {{error}}', - params: {'error': result.errorDetail}, - ); - }); - break; - case QrCameraBootstrapStatus.permissionError: - setState(() { - _initializing = false; - _error = tr( - 'msg.userfront.qr.permission_error', - fallback: 'Permission request failed. Check browser/OS settings.', - ); - }); - break; - case QrCameraBootstrapStatus.cameraError: - setState(() { - _initializing = false; - _error = tr( - 'msg.userfront.qr.camera_error', - fallback: 'Camera error: {{error}}', - params: {'error': result.errorDetail}, - ); - }); - break; - } - } - - void _startScanLoop() { - _scanTimer?.cancel(); - _scanTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async { - if (!mounted || _scanInFlight || _navigated) { - return; - } - final detector = _detector; - if (detector == null) { - return; - } - if (_videoElement.readyState < web.HTMLMediaElement.HAVE_CURRENT_DATA) { - return; - } - - _scanInFlight = true; - try { - final detected = await detector.detect(_videoElement).toDart; - final items = detected.toDart; - for (final item in items) { - final raw = item.rawValue?.trim(); - if (raw != null && raw.isNotEmpty) { - _handleScanSuccess(raw); - break; - } - } - } catch (_) { - } finally { - _scanInFlight = false; - } - }); - } - - void _stopCamera() { - final stream = _stream; - if (stream == null) { - return; - } - final tracks = stream.getTracks().toDart; - for (final track in tracks) { - track.stop(); - } - _videoElement.srcObject = null; - _stream = null; - } - - void _handleScanSuccess(String payload) { - if (_navigated || !mounted) { - return; - } - _navigated = true; - _scanTimer?.cancel(); - _stopCamera(); context.go(buildQrApprovePath(payload)); } - void _submitManual() { - final payload = _manualController.text.trim(); - if (payload.isEmpty) { - return; + void _onDetect(BarcodeCapture capture) { + for (final barcode in capture.barcodes) { + final raw = barcode.rawValue?.trim(); + if (raw != null && raw.isNotEmpty) { + unawaited(_navigateToApprove(raw)); + return; + } } - _handleScanSuccess(payload); + } + + String _toScannerErrorMessage(MobileScannerException error) { + switch (error.errorCode) { + case MobileScannerErrorCode.permissionDenied: + return tr( + 'msg.userfront.qr.permission_error', + fallback: '카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.', + ); + case MobileScannerErrorCode.unsupported: + return tr( + 'msg.userfront.qr.camera_error', + fallback: '카메라 오류: {{error}}', + params: {'error': 'QR scanner is not supported in this browser.'}, + ); + default: + final detail = error.errorDetails?.message; + return tr( + 'msg.userfront.qr.camera_error', + fallback: '카메라 오류: {{error}}', + params: {'error': detail ?? error.errorCode.message}, + ); + } + } + + void _submitManual() { + unawaited(_navigateToApprove(_manualController.text)); } Future _retry() async { - _scanTimer?.cancel(); - _stopCamera(); - _detector = null; - await _initializeScanner(); + setState(() { + _isProcessing = false; + _error = null; + _status = tr( + 'msg.userfront.login.qr.scan_hint', + fallback: 'QR 코드를 카메라 중앙에 맞춰주세요.', + ); + }); + + try { + await _scannerController.start(); + } catch (e) { + if (!mounted) { + return; + } + setState(() { + _error = tr( + 'msg.userfront.qr.camera_error', + fallback: '카메라 오류: {{error}}', + params: {'error': '$e'}, + ); + }); + } } @override @@ -244,9 +155,43 @@ class _QRScanScreenState extends State { ), child: ClipRRect( borderRadius: BorderRadius.circular(12), - child: _initializing - ? const Center(child: CircularProgressIndicator()) - : HtmlElementView(viewType: _viewType), + child: Stack( + fit: StackFit.expand, + children: [ + MobileScanner( + controller: _scannerController, + onDetect: _onDetect, + errorBuilder: (context, error) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) { + return; + } + setState(() { + _error = _toScannerErrorMessage(error); + }); + }); + + return Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + _toScannerErrorMessage(error), + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white), + ), + ), + ); + }, + ), + if (_isProcessing) + Container( + color: Colors.black45, + child: const Center( + child: CircularProgressIndicator(), + ), + ), + ], + ), ), ), ), @@ -262,7 +207,7 @@ class _QRScanScreenState extends State { ], const SizedBox(height: 12), FilledButton.icon( - onPressed: _initializing ? null : _retry, + onPressed: _isProcessing ? null : _retry, icon: const Icon(Icons.refresh), label: Text(tr('ui.userfront.qr.rescan', fallback: '다시 스캔')), ), @@ -279,7 +224,7 @@ class _QRScanScreenState extends State { const SizedBox(height: 8), FilledButton.icon( key: const ValueKey('qr_scan_submit_button'), - onPressed: _submitManual, + onPressed: _isProcessing ? null : _submitManual, icon: const Icon(Icons.check_circle), label: Text( tr('ui.userfront.qr.result_success', fallback: '승인 화면으로 이동'), diff --git a/userfront/pubspec.lock b/userfront/pubspec.lock index 9e031c34..fecd33f1 100644 --- a/userfront/pubspec.lock +++ b/userfront/pubspec.lock @@ -356,6 +356,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: c92c26bf2231695b6d3477c8dcf435f51e28f87b1745966b1fe4c47a286171ce + url: "https://pub.dev" + source: hosted + version: "7.2.0" node_preamble: dependency: transitive description: diff --git a/userfront/pubspec.yaml b/userfront/pubspec.yaml index 35e46fe6..270c2fb4 100644 --- a/userfront/pubspec.yaml +++ b/userfront/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: logging: ^1.2.0 logger: ^2.0.0 qr_flutter: ^4.1.0 + mobile_scanner: ^7.1.4 easy_localization: ^3.0.7 toml: ^0.15.0 web: ^1.1.0