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: