diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 2fbd078..1bc651f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,13 +1,16 @@ - + + + android:usesCleartextTraffic="true" + tools:replace="android:label"> - + + + + - + + + + + + + + - - + \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/example/qrscanner/HoneywellScanManager.kt b/android/app/src/main/kotlin/com/example/qrscanner/HoneywellScanManager.kt new file mode 100644 index 0000000..2ce43f7 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/qrscanner/HoneywellScanManager.kt @@ -0,0 +1,160 @@ +package com.example.qrscanner + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import io.flutter.plugin.common.EventChannel + +object HoneywellScanManager { + const val ACTION_BARCODE_DATA = "com.example.qrscanner.action.BARCODE_DATA" + const val EXTRA_SCAN_DATA = "scan_data" + + private const val PREFS = "honeywell_scan_bridge" + private const val PENDING_KEY = "pending_scan" + + private const val ACTION_CLAIM_SCANNER = "com.honeywell.aidc.action.ACTION_CLAIM_SCANNER" + private const val ACTION_RELEASE_SCANNER = "com.honeywell.aidc.action.ACTION_RELEASE_SCANNER" + private const val EXTRA_SCANNER = "com.honeywell.aidc.extra.EXTRA_SCANNER" + private const val EXTRA_PROPERTIES = "com.honeywell.aidc.extra.EXTRA_PROPERTIES" + private const val SCANNER_PACKAGE = "com.intermec.datacollectionservice" + + @Volatile + private var eventSink: EventChannel.EventSink? = null + + fun setEventSink(sink: EventChannel.EventSink?) { + eventSink = sink + } + + fun isSupported(context: Context): Boolean { + return try { + context.packageManager.getPackageInfo(SCANNER_PACKAGE, 0) + true + } catch (_: PackageManager.NameNotFoundException) { + false + } + } + + fun claimScanner(context: Context) { + if (!isSupported(context)) return + + val properties = Bundle().apply { + putBoolean("DPR_DATA_INTENT", true) + putString("DPR_DATA_INTENT_ACTION", ACTION_BARCODE_DATA) + putBoolean("DPR_LAUNCH_BROWSER", false) + putBoolean("DEC_QR_ENABLED", true) + putBoolean("DEC_AZTEC_ENABLED", false) + putBoolean("DEC_CODABAR_ENABLED", false) + putBoolean("DEC_CODE39_ENABLED", false) + putBoolean("DEC_CODE93_ENABLED", false) + putBoolean("DEC_CODE128_ENABLED", false) + putBoolean("DEC_DATAMATRIX_ENABLED", false) + putBoolean("DEC_EAN8_ENABLED", false) + putBoolean("DEC_EAN13_ENABLED", false) + putBoolean("DEC_MAXICODE_ENABLED", false) + putBoolean("DEC_PDF417_ENABLED", false) + putBoolean("DEC_RSS_14_ENABLED", false) + putBoolean("DEC_RSS_EXPANDED_ENABLED", false) + putBoolean("DEC_UPCA_ENABLE", false) + putBoolean("DEC_UPCE0_ENABLED", false) + } + + sendScannerBroadcast( + context, + Intent(ACTION_CLAIM_SCANNER) + .setPackage(SCANNER_PACKAGE) + .putExtra(EXTRA_SCANNER, "dcs.scanner.imager") + .putExtra(EXTRA_PROPERTIES, properties), + ) + } + + fun releaseScanner(context: Context) { + if (!isSupported(context)) return + sendScannerBroadcast(context, Intent(ACTION_RELEASE_SCANNER)) + } + + fun handleBarcodeIntent(context: Context, intent: Intent?) { + val data = extractScanData(intent) ?: return + deliverScan(context, data) + } + + fun deliverScan(context: Context, data: String) { + val trimmed = data.trim() + if (trimmed.isEmpty()) return + + storePendingScan(context, trimmed) + eventSink?.success(trimmed) + } + + fun launchAppWithScan(context: Context, data: String) { + val trimmed = data.trim() + if (trimmed.isEmpty()) return + + storePendingScan(context, trimmed) + + val launchIntent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_CLEAR_TOP + putExtra(EXTRA_SCAN_DATA, trimmed) + } + context.startActivity(launchIntent) + } + + fun consumePendingScan(context: Context): String? { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val stored = prefs.getString(PENDING_KEY, null)?.trim() + if (!stored.isNullOrEmpty()) { + prefs.edit().remove(PENDING_KEY).apply() + return stored + } + return null + } + + private fun storePendingScan(context: Context, data: String) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putString(PENDING_KEY, data) + .apply() + } + + private fun extractScanData(intent: Intent?): String? { + if (intent == null) return null + + intent.getStringExtra(EXTRA_SCAN_DATA)?.let { return it } + + if (intent.action == ACTION_BARCODE_DATA) { + val version = intent.getIntExtra("version", 0) + if (version >= 1) { + return intent.getStringExtra("data") + } + } + + return intent.getStringExtra("data") + } + + private fun sendScannerBroadcast(context: Context, intent: Intent) { + if (Build.VERSION.SDK_INT < 26) { + context.sendBroadcast(intent) + return + } + + val matches = context.packageManager.queryBroadcastReceivers(intent, PackageManager.MATCH_DEFAULT_ONLY) + if (matches.isEmpty()) { + context.sendBroadcast(intent) + return + } + + for (resolveInfo in matches) { + val explicit = Intent(intent).apply { + component = ComponentName( + resolveInfo.activityInfo.applicationInfo.packageName, + resolveInfo.activityInfo.name, + ) + } + context.sendBroadcast(explicit) + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/example/qrscanner/HoneywellScanReceiver.kt b/android/app/src/main/kotlin/com/example/qrscanner/HoneywellScanReceiver.kt new file mode 100644 index 0000000..66a54e6 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/qrscanner/HoneywellScanReceiver.kt @@ -0,0 +1,19 @@ +package com.example.qrscanner + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent + +class HoneywellScanReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != HoneywellScanManager.ACTION_BARCODE_DATA) return + + val version = intent.getIntExtra("version", 0) + if (version < 1) return + + val data = intent.getStringExtra("data")?.trim().orEmpty() + if (data.isEmpty()) return + + HoneywellScanManager.launchAppWithScan(context, data) + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/example/qrscanner/MainActivity.kt b/android/app/src/main/kotlin/com/example/qrscanner/MainActivity.kt index ca0660c..3eb974e 100644 --- a/android/app/src/main/kotlin/com/example/qrscanner/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/qrscanner/MainActivity.kt @@ -1,5 +1,59 @@ package com.example.qrscanner import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodChannel -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + companion object { + private const val METHOD_CHANNEL = "com.example.qrscanner/scan_launch" + private const val EVENT_CHANNEL = "com.example.qrscanner/scan_events" + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL) + .setMethodCallHandler { call, result -> + when (call.method) { + "isSupported" -> result.success(HoneywellScanManager.isSupported(this)) + "claimScanner" -> { + HoneywellScanManager.claimScanner(this) + result.success(true) + } + "releaseScanner" -> { + HoneywellScanManager.releaseScanner(this) + result.success(true) + } + "consumePendingScan" -> { + result.success(HoneywellScanManager.consumePendingScan(this)) + } + else -> result.notImplemented() + } + } + + EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL) + .setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + HoneywellScanManager.setEventSink(events) + } + + override fun onCancel(arguments: Any?) { + HoneywellScanManager.setEventSink(null) + } + }) + } + + override fun onNewIntent(intent: android.content.Intent) { + super.onNewIntent(intent) + setIntent(intent) + HoneywellScanManager.handleBarcodeIntent(this, intent) + } + + override fun onResume() { + super.onResume() + HoneywellScanManager.claimScanner(this) + HoneywellScanManager.handleBarcodeIntent(this, intent) + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/example/qrscanner/QrScannerApplication.kt b/android/app/src/main/kotlin/com/example/qrscanner/QrScannerApplication.kt new file mode 100644 index 0000000..cf6376d --- /dev/null +++ b/android/app/src/main/kotlin/com/example/qrscanner/QrScannerApplication.kt @@ -0,0 +1,10 @@ +package com.example.qrscanner + +import android.app.Application + +class QrScannerApplication : Application() { + override fun onCreate() { + super.onCreate() + HoneywellScanManager.claimScanner(this) + } +} \ No newline at end of file diff --git a/android/honeywell/build.gradle b/android/honeywell/build.gradle new file mode 100644 index 0000000..073f934 --- /dev/null +++ b/android/honeywell/build.gradle @@ -0,0 +1,2 @@ +configurations.maybeCreate("default") +artifacts.add("default", file('honeywell.aar')) \ No newline at end of file diff --git a/android/honeywell/honeywell.aar b/android/honeywell/honeywell.aar new file mode 100644 index 0000000..0b267ac Binary files /dev/null and b/android/honeywell/honeywell.aar differ diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index c21f0c5..ea6d5e3 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -24,3 +24,4 @@ plugins { } include(":app") +include(":honeywell") diff --git a/assets/sounds/scan.wav b/assets/sounds/scan.wav index 298f989..1adcbcf 100644 Binary files a/assets/sounds/scan.wav and b/assets/sounds/scan.wav differ diff --git a/assets/sounds/success.wav b/assets/sounds/success.wav index f30e8f1..26fe85c 100644 Binary files a/assets/sounds/success.wav and b/assets/sounds/success.wav differ diff --git a/assets/sounds/warning.wav b/assets/sounds/warning.wav index c4c3550..b41f18e 100644 Binary files a/assets/sounds/warning.wav and b/assets/sounds/warning.wav differ diff --git a/lib/main.dart b/lib/main.dart index 3261d47..e68e7de 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,20 +1,42 @@ import 'package:flutter/material.dart'; +import 'package:qrscanner/screens/home_screen.dart'; import 'package:qrscanner/screens/login_screen.dart'; +import 'package:qrscanner/services/scan_navigation_service.dart'; import 'package:qrscanner/theme/app_theme.dart'; void main() { + WidgetsFlutterBinding.ensureInitialized(); runApp(const MyApp()); } -class MyApp extends StatelessWidget { +class MyApp extends StatefulWidget { const MyApp({super.key}); + @override + State createState() => _MyAppState(); +} + +class _MyAppState extends State { + @override + void initState() { + super.initState(); + ScanNavigationService.instance.initialize(); + } + + @override + void dispose() { + ScanNavigationService.instance.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return MaterialApp( title: 'QR Scanner', debugShowCheckedModeBanner: false, theme: AppTheme.darkTheme, + navigatorKey: ScanNavigationService.instance.navigatorKey, + navigatorObservers: [homeRouteObserver], home: const LoginScreen(), ); } diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 6555771..c293b54 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -3,9 +3,14 @@ import 'package:qrscanner/models/auth_session.dart'; import 'package:qrscanner/screens/login_screen.dart'; import 'package:qrscanner/screens/scanner_screen.dart'; import 'package:qrscanner/services/auth_service.dart'; +import 'package:qrscanner/services/hardware_scanner_service.dart'; +import 'package:qrscanner/services/scan_launch_service.dart'; import 'package:qrscanner/theme/app_theme.dart'; -class HomeScreen extends StatelessWidget { +final RouteObserver> homeRouteObserver = + RouteObserver>(); + +class HomeScreen extends StatefulWidget { const HomeScreen({ super.key, required this.session, @@ -13,7 +18,29 @@ class HomeScreen extends StatelessWidget { final AuthSession session; + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + bool _hardwareAvailable = false; + + @override + void initState() { + super.initState(); + _checkHardware(); + } + + Future _checkHardware() async { + final available = await ScanLaunchService.instance.isSupported(); + if (mounted) { + setState(() => _hardwareAvailable = available); + } + } + Future _logout(BuildContext context) async { + await HardwareScannerService.instance.ensureStopped(); + await ScanLaunchService.instance.releaseScanner(); await AuthService().clearSession(); if (!context.mounted) return; @@ -30,11 +57,12 @@ class HomeScreen extends StatelessWidget { ); } - void _openScanner(BuildContext context) { - Navigator.of(context).push( + Future _openScanner(BuildContext context) async { + await Navigator.of(context).push( PageRouteBuilder( + settings: const RouteSettings(name: '/scanner'), pageBuilder: (context, animation, secondaryAnimation) => - ScannerScreen(session: session), + ScannerScreen(session: widget.session), transitionsBuilder: (context, animation, secondaryAnimation, child) { return FadeTransition( opacity: animation, @@ -57,7 +85,7 @@ class HomeScreen extends StatelessWidget { @override Widget build(BuildContext context) { - final expiresHours = (session.expiresIn / 3600).round(); + final expiresHours = (widget.session.expiresIn / 3600).round(); return Scaffold( body: Stack( @@ -140,7 +168,7 @@ class HomeScreen extends StatelessWidget { ), const SizedBox(height: 28), Text( - 'Hola, ${session.username}', + 'Hola, ${widget.session.username}', style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.bold, @@ -175,6 +203,40 @@ class HomeScreen extends StatelessWidget { ), ), ), + if (_hardwareAvailable) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 8, + ), + decoration: BoxDecoration( + color: AppTheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: AppTheme.primary.withValues(alpha: 0.25), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.barcode_reader, + size: 16, + color: AppTheme.primary.withValues(alpha: 0.9), + ), + const SizedBox(width: 8), + Text( + 'Escáner activo · abre la app al escanear', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.7), + fontSize: 12, + ), + ), + ], + ), + ), + ], const SizedBox(height: 48), _ScanButton(onPressed: () => _openScanner(context)), ], diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 57353b1..c7489f4 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -1,8 +1,9 @@ import 'package:flutter/material.dart'; import 'package:qrscanner/config/api_config.dart'; import 'package:qrscanner/models/auth_session.dart'; -import 'package:qrscanner/screens/home_screen.dart'; import 'package:qrscanner/services/auth_service.dart'; +import 'package:qrscanner/services/scan_launch_service.dart'; +import 'package:qrscanner/services/scan_navigation_service.dart'; import 'package:qrscanner/theme/app_theme.dart'; class LoginScreen extends StatefulWidget { @@ -47,6 +48,7 @@ class _LoginScreenState extends State curve: Curves.easeOutCubic, )); _animationController.forward(); + ScanLaunchService.instance.claimScanner(); _checkStoredSession(); } @@ -54,7 +56,7 @@ class _LoginScreenState extends State final session = await _authService.loadStoredSession(); if (!mounted || session == null) return; - _goToHome(session); + await ScanNavigationService.instance.navigateToHome(session); } @override @@ -83,7 +85,7 @@ class _LoginScreenState extends State if (!mounted) return; if (response.success && response.accessToken != null) { - _goToHome( + await ScanNavigationService.instance.navigateToHome( AuthSession.fromLoginResponse( response.copyWith( username: response.username ?? _usernameController.text.trim(), @@ -109,19 +111,6 @@ class _LoginScreenState extends State } } - void _goToHome(AuthSession session) { - Navigator.of(context).pushReplacement( - PageRouteBuilder( - pageBuilder: (context, animation, secondaryAnimation) => - HomeScreen(session: session), - transitionsBuilder: (context, animation, secondaryAnimation, child) { - return FadeTransition(opacity: animation, child: child); - }, - transitionDuration: const Duration(milliseconds: 400), - ), - ); - } - @override Widget build(BuildContext context) { return Scaffold( diff --git a/lib/screens/scanner_screen.dart b/lib/screens/scanner_screen.dart index e7aa8db..05ce8c9 100644 --- a/lib/screens/scanner_screen.dart +++ b/lib/screens/scanner_screen.dart @@ -1,25 +1,33 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; import 'package:qrscanner/models/auth_session.dart'; import 'package:qrscanner/models/pass_code.dart'; import 'package:qrscanner/models/scan_response.dart'; import 'package:qrscanner/services/feedback_service.dart'; +import 'package:qrscanner/services/hardware_scanner_service.dart'; import 'package:qrscanner/services/pass_code_service.dart'; import 'package:qrscanner/theme/app_theme.dart'; enum _ScanPhase { scanning, processing, success, warning } +enum _ScannerInput { loading, camera, hardware } + const double _scanWindowSize = 260; const double _scanWindowRadius = 28; const Alignment _scanWindowAlignment = Alignment(0, -0.12); +const Duration _resultDisplayDuration = Duration(seconds: 5); class ScannerScreen extends StatefulWidget { const ScannerScreen({ super.key, required this.session, + this.initialCode, }); final AuthSession session; + final String? initialCode; @override State createState() => _ScannerScreenState(); @@ -27,21 +35,21 @@ class ScannerScreen extends StatefulWidget { class _ScannerScreenState extends State with TickerProviderStateMixin { - final _scannerController = MobileScannerController( - detectionSpeed: DetectionSpeed.normal, - detectionTimeoutMs: 500, - facing: CameraFacing.back, - formats: const [BarcodeFormat.qrCode], - autoZoom: true, - ); + MobileScannerController? _cameraController; + final _hardwareScanner = HardwareScannerService.instance; final _passCodeService = PassCodeService(); final _feedbackService = FeedbackService(); + _ScannerInput _inputMode = _ScannerInput.loading; + bool _hardwareAvailable = false; + bool _isInitialSetup = true; + bool _isSwitchingInput = false; _ScanPhase _phase = _ScanPhase.scanning; bool _isHandlingDetection = false; String? _lastScannedCode; String? _message; PassCode? _passCode; + Timer? _resultAutoDismissTimer; late final AnimationController _scanLineController; late final AnimationController _resultController; @@ -76,8 +84,158 @@ class _ScannerScreenState extends State parent: _resultController, curve: Curves.easeOutBack, )); + _initializeScannerInput(); } + Future _initializeScannerInput() async { + _hardwareAvailable = await HardwareScannerService.isAvailable(); + if (!mounted) return; + + if (_hardwareAvailable) { + await _activateHardwareMode(showLoading: true); + } else { + await _activateCameraMode(showLoading: true); + } + + final pendingCode = widget.initialCode?.trim(); + if (!mounted || pendingCode == null || pendingCode.isEmpty) return; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _processScan(pendingCode); + }); + } + + MobileScannerController _createCameraController() { + return MobileScannerController( + autoStart: false, + detectionSpeed: DetectionSpeed.normal, + detectionTimeoutMs: 500, + facing: CameraFacing.back, + formats: const [BarcodeFormat.qrCode], + autoZoom: true, + ); + } + + Future _activateHardwareMode({bool showLoading = false}) async { + if (!mounted) return; + if (showLoading) { + setState(() => _inputMode = _ScannerInput.loading); + } + + try { + try { + await _cameraController?.stop(); + } catch (_) {} + await _cameraController?.dispose(); + _cameraController = null; + _scanLineController.stop(); + + await Future.delayed(const Duration(milliseconds: 300)); + + final started = await _hardwareScanner.activate(onScan: _onRawScan); + if (!mounted) return; + + if (started) { + setState(() { + _inputMode = _ScannerInput.hardware; + _isInitialSetup = false; + }); + return; + } + + await _activateCameraMode( + showLoading: showLoading, + fallbackFromHardware: true, + ); + } finally { + if (mounted && _inputMode == _ScannerInput.loading) { + setState(() { + _inputMode = _ScannerInput.hardware; + _isInitialSetup = false; + }); + } + } + } + + Future _activateCameraMode({ + bool showLoading = false, + bool fallbackFromHardware = false, + }) async { + if (!mounted) return; + if (showLoading) { + setState(() => _inputMode = _ScannerInput.loading); + } + + try { + await _hardwareScanner.pause(); + await Future.delayed(const Duration(milliseconds: 300)); + + try { + await _cameraController?.stop(); + } catch (_) {} + await _cameraController?.dispose(); + _cameraController = _createCameraController(); + + if (!_scanLineController.isAnimating) { + _scanLineController.repeat(); + } + + if (!mounted) return; + setState(() { + _inputMode = _ScannerInput.camera; + _isInitialSetup = false; + }); + + await Future.delayed(const Duration(milliseconds: 200)); + + try { + await _cameraController!.start(); + } catch (_) { + if (!mounted) return; + if (!fallbackFromHardware) { + await _activateHardwareMode(); + } + } + } finally { + if (mounted && _inputMode == _ScannerInput.loading) { + setState(() { + _inputMode = _ScannerInput.camera; + _isInitialSetup = false; + }); + } + } + } + + Future _toggleScannerInput() async { + if (!_hardwareAvailable || + _phase != _ScanPhase.scanning || + _isSwitchingInput || + _inputMode == _ScannerInput.loading) { + return; + } + + setState(() => _isSwitchingInput = true); + try { + if (_inputMode == _ScannerInput.hardware) { + await _activateCameraMode(); + } else if (_inputMode == _ScannerInput.camera) { + await _activateHardwareMode(); + } + } finally { + if (mounted) { + setState(() => _isSwitchingInput = false); + } else { + _isSwitchingInput = false; + } + } + } + + bool get _showsScannerInput => + _phase == _ScanPhase.scanning || + _phase == _ScanPhase.processing || + _phase == _ScanPhase.success || + _phase == _ScanPhase.warning; + Rect _scanWindowRect(Size size) { final center = _scanWindowAlignment.alongSize(size); return Rect.fromCenter( @@ -87,23 +245,58 @@ class _ScannerScreenState extends State ); } + void _cancelAutoDismiss() { + _resultAutoDismissTimer?.cancel(); + _resultAutoDismissTimer = null; + } + + void _scheduleAutoDismiss() { + _cancelAutoDismiss(); + _resultAutoDismissTimer = Timer(_resultDisplayDuration, () { + if (!mounted) return; + if (_phase == _ScanPhase.success || _phase == _ScanPhase.warning) { + unawaited(_scanAgain()); + } + }); + } + @override void dispose() { + _cancelAutoDismiss(); _scanLineController.dispose(); _resultController.dispose(); - _scannerController.dispose(); + _cameraController?.dispose(); + _hardwareScanner.deactivate(); _feedbackService.dispose(); super.dispose(); } - Future _onDetect(BarcodeCapture capture) async { - if (_phase != _ScanPhase.scanning || _isHandlingDetection) return; + void _onRawScan(String rawValue) { + _processScan(rawValue); + } + Future _onDetect(BarcodeCapture capture) async { final rawValue = capture.barcodes.firstOrNull?.rawValue?.trim(); if (rawValue == null || rawValue.isEmpty) return; + await _processScan(rawValue); + } + + Future _processScan(String rawValue) async { + if (_isHandlingDetection || _phase == _ScanPhase.processing) return; final code = _extractCode(rawValue); - if (code.isEmpty || code == _lastScannedCode) return; + if (code.isEmpty) return; + + final showingResult = + _phase == _ScanPhase.success || _phase == _ScanPhase.warning; + + if (showingResult) { + _cancelAutoDismiss(); + _resultController.reset(); + } else { + if (_phase != _ScanPhase.scanning) return; + if (code == _lastScannedCode) return; + } _isHandlingDetection = true; setState(() { @@ -114,7 +307,9 @@ class _ScannerScreenState extends State }); _scanLineController.stop(); - await _scannerController.stop(); + if (_inputMode == _ScannerInput.camera) { + await _cameraController?.stop(); + } await _feedbackService.onCodeDetected(); try { @@ -166,9 +361,15 @@ class _ScannerScreenState extends State } await _resultController.forward(); + _scheduleAutoDismiss(); + + if (_inputMode == _ScannerInput.camera) { + await _cameraController?.start(); + } } Future _scanAgain() async { + _cancelAutoDismiss(); _resultController.reset(); _isHandlingDetection = false; setState(() { @@ -177,8 +378,10 @@ class _ScannerScreenState extends State _message = null; _passCode = null; }); - _scanLineController.repeat(); - await _scannerController.start(); + if (_inputMode == _ScannerInput.camera) { + _scanLineController.repeat(); + await _cameraController?.start(); + } } @override @@ -203,16 +406,45 @@ class _ScannerScreenState extends State ), title: const Text('Escanear pase'), centerTitle: true, + actions: [ + if (_hardwareAvailable && _phase == _ScanPhase.scanning) + IconButton( + tooltip: _inputMode == _ScannerInput.hardware + ? 'Usar cámara' + : 'Usar escáner Honeywell', + icon: _isSwitchingInput + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : Icon( + _inputMode == _ScannerInput.hardware + ? Icons.camera_alt_outlined + : Icons.barcode_reader, + ), + onPressed: _isSwitchingInput ? null : _toggleScannerInput, + ), + ], ), body: Stack( fit: StackFit.expand, children: [ - if (_phase == _ScanPhase.scanning || _phase == _ScanPhase.processing) + if (_inputMode == _ScannerInput.loading && _isInitialSetup) + const Center( + child: CircularProgressIndicator(color: AppTheme.primary), + ), + if (_showsScannerInput && + _inputMode == _ScannerInput.camera && + _cameraController != null) LayoutBuilder( builder: (context, constraints) { final scanRect = _scanWindowRect(constraints.biggest); return MobileScanner( - controller: _scannerController, + controller: _cameraController!, onDetect: _onDetect, scanWindow: scanRect, overlayBuilder: (context, constraints) { @@ -236,8 +468,8 @@ class _ScannerScreenState extends State ); }, ), - if (_phase != _ScanPhase.scanning && _phase != _ScanPhase.processing) - Container(color: AppTheme.backgroundDark), + if (_showsScannerInput && _inputMode == _ScannerInput.hardware) + _buildHardwareScannerView(), AnimatedContainer( duration: const Duration(milliseconds: 450), curve: Curves.easeOutCubic, @@ -256,7 +488,7 @@ class _ScannerScreenState extends State ), ), ), - if (_phase == _ScanPhase.scanning) + if (_phase == _ScanPhase.scanning && _inputMode != _ScannerInput.loading) SafeArea( child: Align( alignment: Alignment.bottomCenter, @@ -452,11 +684,92 @@ class _ScannerScreenState extends State ); } + Widget _buildHardwareScannerView() { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppTheme.backgroundDark, + Color(0xFF0D1B2A), + Color(0xFF12182B), + ], + ), + ), + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 36), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 120, + height: 120, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(28), + color: AppTheme.primary.withValues(alpha: 0.12), + border: Border.all( + color: AppTheme.primary.withValues(alpha: 0.35), + ), + ), + child: const Icon( + Icons.barcode_reader, + size: 56, + color: AppTheme.primary, + ), + ), + const SizedBox(height: 28), + Text( + 'Escáner Honeywell', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Text( + 'Presiona el botón lateral del EDA51 para leer el código QR del pase.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Colors.white.withValues(alpha: 0.6), + height: 1.5, + ), + ), + const SizedBox(height: 20), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Colors.white.withValues(alpha: 0.08), + ), + ), + child: Text( + 'Sensor dedicado · Honeywell ScanPal EDA51', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.45), + fontSize: 12, + ), + ), + ), + ], + ), + ), + ), + ); + } + Widget _buildHint() { + final message = _inputMode == _ScannerInput.hardware + ? 'Presiona el botón lateral para escanear' + : 'Apunta la cámara al código QR del pase'; + return Padding( padding: const EdgeInsets.symmetric(horizontal: 32), child: Text( - 'Apunta la cámara al código QR del pase', + message, textAlign: TextAlign.center, style: TextStyle( color: Colors.white.withValues(alpha: 0.65), diff --git a/lib/services/feedback_service.dart b/lib/services/feedback_service.dart index e53aa55..0bcf3c5 100644 --- a/lib/services/feedback_service.dart +++ b/lib/services/feedback_service.dart @@ -1,43 +1,92 @@ +import 'dart:io'; + import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:vibration/vibration.dart'; class FeedbackService { - FeedbackService() : _player = AudioPlayer() { - _player.setReleaseMode(ReleaseMode.stop); + FeedbackService() { + _configurePlayers(); } - final AudioPlayer _player; + final AudioPlayer _scanPlayer = AudioPlayer(); + final AudioPlayer _successPlayer = AudioPlayer(); + final AudioPlayer _warningPlayer = AudioPlayer(); + + static final AudioContext _loudContext = AudioContext( + android: AudioContextAndroid( + isSpeakerphoneOn: true, + audioMode: AndroidAudioMode.normal, + contentType: AndroidContentType.sonification, + usageType: AndroidUsageType.alarm, + audioFocus: AndroidAudioFocus.gain, + ), + iOS: AudioContextIOS( + category: AVAudioSessionCategory.playback, + options: { + AVAudioSessionOptions.mixWithOthers, + AVAudioSessionOptions.duckOthers, + }, + ), + ); + + Future _configurePlayers() async { + for (final player in [_scanPlayer, _successPlayer, _warningPlayer]) { + await player.setReleaseMode(ReleaseMode.stop); + await player.setPlayerMode(PlayerMode.lowLatency); + await player.setVolume(1.0); + await player.setAudioContext(_loudContext); + } + } Future onCodeDetected() async { await Future.wait([ - _play('sounds/scan.wav'), - _vibratePattern(const [0, 40]), + _play(_scanPlayer, 'sounds/scan.wav'), + _vibratePattern(const [0, 80]), + HapticFeedback.mediumImpact(), ]); } Future onSuccess() async { await Future.wait([ - _play('sounds/success.wav'), - _vibratePattern(const [0, 90, 70, 140]), + _playSuccessSequence(), + _vibratePattern(const [0, 120, 60, 180]), + HapticFeedback.heavyImpact(), ]); - await HapticFeedback.heavyImpact(); } Future onWarning() async { await Future.wait([ - _play('sounds/warning.wav'), - _vibratePattern(const [0, 120, 80, 120, 80, 120]), + _playWarningSequence(), + _vibratePattern(const [0, 180, 80, 180, 80, 220]), + HapticFeedback.heavyImpact(), ]); - await HapticFeedback.mediumImpact(); } - Future _play(String asset) async { + Future _playSuccessSequence() async { + await _play(_successPlayer, 'sounds/success.wav'); + if (!kIsWeb && Platform.isAndroid) { + await Future.delayed(const Duration(milliseconds: 120)); + await _play(_successPlayer, 'sounds/success.wav'); + } + } + + Future _playWarningSequence() async { + for (var i = 0; i < 2; i++) { + await _play(_warningPlayer, 'sounds/warning.wav'); + if (i == 0) { + await Future.delayed(const Duration(milliseconds: 140)); + } + } + } + + Future _play(AudioPlayer player, String asset) async { try { - await _player.stop(); - await _player.play(AssetSource(asset)); + await player.stop(); + await player.play(AssetSource(asset)); } catch (_) { - // Ignore audio errors on devices without speaker routing. + await SystemSound.play(SystemSoundType.alert); } } @@ -51,7 +100,7 @@ class FeedbackService { final intensities = pattern .asMap() .entries - .map((entry) => entry.key.isOdd ? 180 : 0) + .map((entry) => entry.key.isOdd ? 255 : 0) .toList(); await Vibration.vibrate( pattern: pattern, @@ -62,11 +111,15 @@ class FeedbackService { await Vibration.vibrate(pattern: pattern); } catch (_) { - await HapticFeedback.selectionClick(); + await HapticFeedback.heavyImpact(); } } Future dispose() async { - await _player.dispose(); + await Future.wait([ + _scanPlayer.dispose(), + _successPlayer.dispose(), + _warningPlayer.dispose(), + ]); } } \ No newline at end of file diff --git a/lib/services/hardware_scanner_service.dart b/lib/services/hardware_scanner_service.dart new file mode 100644 index 0000000..800aba1 --- /dev/null +++ b/lib/services/hardware_scanner_service.dart @@ -0,0 +1,62 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:qrscanner/services/scan_launch_service.dart'; + +typedef HardwareScanCallback = void Function(String rawValue); + +/// Integración con el escáner físico Honeywell vía Intent API nativa (EDA51). +class HardwareScannerService { + HardwareScannerService._(); + + static final HardwareScannerService instance = HardwareScannerService._(); + + final _scanLaunch = ScanLaunchService.instance; + + HardwareScanCallback? _onScan; + void Function(Exception error)? _onError; + StreamSubscription? _scanSubscription; + bool _active = false; + + bool get isActive => _active; + + static Future isAvailable() => instance._scanLaunch.isSupported(); + + Future activate({ + required HardwareScanCallback onScan, + void Function(Exception error)? onError, + }) async { + if (kIsWeb || !Platform.isAndroid) return false; + + _onScan = onScan; + _onError = onError; + + await _scanLaunch.claimScanner(); + await _scanSubscription?.cancel(); + _scanSubscription = _scanLaunch.scanStream.listen( + (code) => _onScan?.call(code), + onError: (Object error) => _onError?.call(Exception(error.toString())), + ); + + _active = true; + return _scanLaunch.isSupported(); + } + + Future pause() async { + _active = false; + } + + Future deactivate() async { + await _scanSubscription?.cancel(); + _scanSubscription = null; + _onScan = null; + _onError = null; + _active = false; + } + + Future ensureStopped() async { + await deactivate(); + await _scanLaunch.releaseScanner(); + } +} \ No newline at end of file diff --git a/lib/services/scan_launch_service.dart b/lib/services/scan_launch_service.dart new file mode 100644 index 0000000..43971fd --- /dev/null +++ b/lib/services/scan_launch_service.dart @@ -0,0 +1,62 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +class ScanLaunchService { + ScanLaunchService._(); + + static final ScanLaunchService instance = ScanLaunchService._(); + + static const _methodChannel = MethodChannel('com.example.qrscanner/scan_launch'); + static const _eventChannel = EventChannel('com.example.qrscanner/scan_events'); + + Stream? _scanStream; + + bool get isAndroid => !kIsWeb && Platform.isAndroid; + + Stream get scanStream { + _scanStream ??= _eventChannel + .receiveBroadcastStream() + .where((event) => event is String && event.trim().isNotEmpty) + .map((event) => (event as String).trim()); + return _scanStream!; + } + + Future isSupported() async { + if (!isAndroid) return false; + try { + final supported = await _methodChannel.invokeMethod('isSupported'); + return supported ?? false; + } catch (_) { + return false; + } + } + + Future claimScanner() async { + if (!isAndroid) return; + try { + await _methodChannel.invokeMethod('claimScanner'); + } catch (_) {} + } + + Future releaseScanner() async { + if (!isAndroid) return; + try { + await _methodChannel.invokeMethod('releaseScanner'); + } catch (_) {} + } + + Future consumePendingScan() async { + if (!isAndroid) return null; + try { + final code = await _methodChannel.invokeMethod('consumePendingScan'); + final trimmed = code?.trim(); + if (trimmed == null || trimmed.isEmpty) return null; + return trimmed; + } catch (_) { + return null; + } + } +} \ No newline at end of file diff --git a/lib/services/scan_navigation_service.dart b/lib/services/scan_navigation_service.dart new file mode 100644 index 0000000..5b99d36 --- /dev/null +++ b/lib/services/scan_navigation_service.dart @@ -0,0 +1,137 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:qrscanner/models/auth_session.dart'; +import 'package:qrscanner/screens/home_screen.dart'; +import 'package:qrscanner/screens/scanner_screen.dart'; +import 'package:qrscanner/services/auth_service.dart'; +import 'package:qrscanner/services/scan_launch_service.dart'; + +class ScanNavigationService { + ScanNavigationService._(); + + static final ScanNavigationService instance = ScanNavigationService._(); + + final GlobalKey navigatorKey = GlobalKey(); + + final AuthService _authService = AuthService(); + StreamSubscription? _scanSubscription; + String? _pendingCode; + bool _handlingScan = false; + bool _scannerOpen = false; + + String? get pendingCode => _pendingCode; + + void setPendingCode(String code) { + final trimmed = code.trim(); + if (trimmed.isEmpty) return; + _pendingCode = trimmed; + } + + String? takePendingCode() { + final code = _pendingCode; + _pendingCode = null; + return code; + } + + Future initialize() async { + await ScanLaunchService.instance.claimScanner(); + + final pending = await ScanLaunchService.instance.consumePendingScan(); + if (pending != null) { + setPendingCode(pending); + } + + await _scanSubscription?.cancel(); + _scanSubscription = ScanLaunchService.instance.scanStream.listen( + (code) => unawaited(handleScan(code)), + ); + } + + Future dispose() async { + await _scanSubscription?.cancel(); + _scanSubscription = null; + } + + Future handleScan(String code, {AuthSession? session}) async { + final trimmed = code.trim(); + if (trimmed.isEmpty || _handlingScan) return; + + _handlingScan = true; + try { + setPendingCode(trimmed); + + final activeSession = session ?? + _authService.currentSession ?? + await _authService.loadStoredSession(); + + if (activeSession == null) return; + + final navigator = navigatorKey.currentState; + if (navigator == null) return; + + takePendingCode(); + + if (_scannerOpen) return; + + _scannerOpen = true; + try { + await navigator.push( + PageRouteBuilder( + settings: const RouteSettings(name: '/scanner'), + pageBuilder: (context, animation, secondaryAnimation) => + ScannerScreen( + session: activeSession, + initialCode: trimmed, + ), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.06), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + )), + child: child, + ), + ); + }, + transitionDuration: const Duration(milliseconds: 380), + ), + ); + } finally { + _scannerOpen = false; + } + } finally { + _handlingScan = false; + } + } + + Future openPendingScanAfterAuth(AuthSession session) async { + final code = takePendingCode(); + if (code == null) return; + await handleScan(code, session: session); + } + + Future navigateToHome(AuthSession session) async { + final navigator = navigatorKey.currentState; + if (navigator == null) return; + + await navigator.pushAndRemoveUntil( + PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => + HomeScreen(session: session), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition(opacity: animation, child: child); + }, + transitionDuration: const Duration(milliseconds: 400), + ), + (route) => false, + ); + + await openPendingScanAfterAuth(session); + } +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index b231bc5..16dd122 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -208,6 +208,14 @@ packages: description: flutter source: sdk version: "0.0.0" + honeywell_scanner: + dependency: "direct main" + description: + name: honeywell_scanner + sha256: a2bf8600e8e33887e268df20d300d9341aed708179a3be74bc2acc9f8806a7c1 + url: "https://pub.dev" + source: hosted + version: "8.0.0" hooks: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e0bbd60..c882168 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: mobile_scanner: ^7.0.1 shared_preferences: ^2.3.3 vibration: ^3.1.3 + honeywell_scanner: ^8.0.0 dev_dependencies: flutter_test: