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: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}); @override State createState() => _QRScanScreenState(); } class _QRScanScreenState extends State { late final web.HTMLVideoElement _videoElement; late final String _viewType; final TextEditingController _manualController = TextEditingController(); web.MediaStream? _stream; _BarcodeDetector? _detector; Timer? _scanTimer; bool _scanInFlight = false; bool _navigated = false; bool _initializing = true; 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()); } @override void dispose() { _scanTimer?.cancel(); _stopCamera(); _manualController.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; 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(); }, ); 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; } _handleScanSuccess(payload); } Future _retry() async { _scanTimer?.cancel(); _stopCamera(); _detector = null; await _initializeScanner(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(tr('ui.userfront.qr.title', fallback: 'Scan QR Code')), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => context.pop(), ), ), body: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ AspectRatio( aspectRatio: 3 / 4, child: DecoratedBox( decoration: BoxDecoration( color: Colors.black, borderRadius: BorderRadius.circular(12), ), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: _initializing ? const Center(child: CircularProgressIndicator()) : HtmlElementView(viewType: _viewType), ), ), ), const SizedBox(height: 12), if (_status != null) Text(_status!, textAlign: TextAlign.center), if (_error != null) ...[ const SizedBox(height: 8), Text( _error!, textAlign: TextAlign.center, style: const TextStyle(color: Colors.red), ), ], const SizedBox(height: 12), FilledButton.icon( onPressed: _initializing ? null : _retry, icon: const Icon(Icons.refresh), label: Text(tr('ui.userfront.qr.rescan', fallback: '다시 스캔')), ), const SizedBox(height: 12), TextField( key: const ValueKey('qr_scan_manual_input'), controller: _manualController, decoration: const InputDecoration( labelText: 'QR Payload', hintText: 'https://.../ql/{ref} 또는 ref', ), onSubmitted: (_) => _submitManual(), ), const SizedBox(height: 8), FilledButton.icon( key: const ValueKey('qr_scan_submit_button'), onPressed: _submitManual, icon: const Icon(Icons.check_circle), label: Text( tr('ui.userfront.qr.result_success', fallback: '승인 화면으로 이동'), ), ), ], ), ), ); } }