diff --git a/lib/app_version.dart b/lib/app_version.dart index f0dd794..17afb29 100644 --- a/lib/app_version.dart +++ b/lib/app_version.dart @@ -1,3 +1,3 @@ /// Manuell mit pubspec.yaml synchron halten (siehe timemaster-mobile, /// gleiche Konvention – kein package_info_plus während aktiver Feldtests). -const String kAppVersion = '0.1.10+11'; +const String kAppVersion = '0.1.11+12'; diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 646e00b..3f4569f 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -9,9 +9,10 @@ import '../services/api_client.dart'; import '../services/kiosk_service.dart'; import '../services/update_check_service.dart'; import 'enrollment_screen.dart'; +import 'qr_login_scan_screen.dart'; import 'stamp_screen.dart'; -enum _LoginMode { pin, nfc, list } +enum _LoginMode { pin, nfc, list, qr } class LoginScreen extends StatefulWidget { const LoginScreen({super.key}); @@ -350,6 +351,7 @@ class _LoginScreenState extends State { ButtonSegment(value: _LoginMode.pin, label: Text('PIN'), icon: Icon(Icons.pin)), ButtonSegment(value: _LoginMode.nfc, label: Text('NFC'), icon: Icon(Icons.nfc)), ButtonSegment(value: _LoginMode.list, label: Text('Liste'), icon: Icon(Icons.people)), + ButtonSegment(value: _LoginMode.qr, label: Text('QR'), icon: Icon(Icons.qr_code_scanner)), ], selected: {_mode}, onSelectionChanged: (s) { @@ -373,6 +375,7 @@ class _LoginScreenState extends State { if (_mode == _LoginMode.pin) ..._pinForm(), if (_mode == _LoginMode.nfc) ..._nfcForm(), if (_mode == _LoginMode.list) ..._listForm(), + if (_mode == _LoginMode.qr) ..._qrForm(), if (_error != null) ...[ const SizedBox(height: 16), Text(_error!, style: const TextStyle(color: Colors.red), textAlign: TextAlign.center), @@ -479,4 +482,44 @@ class _LoginScreenState extends State { ), ]; } + + Future _scanAndLoginQr() async { + final token = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const QrLoginScanScreen()), + ); + if (token == null || !mounted) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final session = await _service.loginQr(token); + if (!mounted) return; + _goToStamp(session); + } on DioException catch (e) { + setState(() => _error = _dioMessage(e)); + } catch (_) { + if (mounted) setState(() => _error = 'Unerwartete Antwort vom Server.'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + List _qrForm() { + return [ + const Icon(Icons.qr_code_2, size: 80, color: Colors.indigo), + const SizedBox(height: 16), + const Text('QR-Code aus der TimeMaster-App scannen ("Am Kiosk anmelden").'), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 56, + child: FilledButton.icon( + onPressed: _loading ? null : _scanAndLoginQr, + icon: const Icon(Icons.qr_code_scanner), + label: _loading ? const Text('Prüfe…') : const Text('QR-Code scannen'), + ), + ), + ]; + } } diff --git a/lib/screens/qr_login_scan_screen.dart b/lib/screens/qr_login_scan_screen.dart new file mode 100644 index 0000000..c323955 --- /dev/null +++ b/lib/screens/qr_login_scan_screen.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +/// Scannt den QR-Login-Code aus der Employee-App (POST /kiosk/my-qr-token). +/// Inhalt ist der rohe Token-String (kein JSON, anders als beim Geräte- +/// Enrollment-QR in qr_enrollment_scan_screen.dart). +class QrLoginScanScreen extends StatefulWidget { + const QrLoginScanScreen({super.key}); + + @override + State createState() => _QrLoginScanScreenState(); +} + +class _QrLoginScanScreenState extends State { + // Gleiche feste Auflösung wie beim Enrollment-Scan – siehe dortiger + // Kommentar (R8/ProGuard war die eigentliche Ursache für Scan-Abstürze, + // die feste Auflösung bleibt trotzdem als sinnvolle Optimierung). + MobileScannerController _buildController() => MobileScannerController( + formats: const [BarcodeFormat.qrCode], + cameraResolution: const Size(1280, 720), + ); + + late MobileScannerController _controller = _buildController(); + bool _handled = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _onDetect(BarcodeCapture capture) { + if (_handled) return; + final raw = capture.barcodes.firstOrNull?.rawValue; + if (raw == null || raw.isEmpty) return; + _handled = true; + Navigator.of(context).pop(raw); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('QR-Login scannen')), + body: MobileScanner( + controller: _controller, + onDetect: _onDetect, + errorBuilder: (context, error, child) { + return Container( + color: Colors.black, + padding: const EdgeInsets.all(24), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, color: Colors.red, size: 48), + const SizedBox(height: 16), + Text('Kamera-Fehler: ${error.errorCode}', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + const SizedBox(height: 24), + OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Zurück', style: TextStyle(color: Colors.white)), + ), + ], + ), + ), + ); + }, + ), + ); + } +} + +extension _FirstOrNull on List { + T? get firstOrNull => isEmpty ? null : first; +} diff --git a/lib/services/kiosk_service.dart b/lib/services/kiosk_service.dart index aa71942..55cb333 100644 --- a/lib/services/kiosk_service.dart +++ b/lib/services/kiosk_service.dart @@ -24,6 +24,11 @@ class KioskService { return KioskSession.fromJson(r.data as Map); } + Future loginQr(String qrToken) async { + final r = await _dio.post('/kiosk/auth/qr', data: {'qr_token': qrToken}); + return KioskSession.fromJson(r.data as Map); + } + Future loginList(String userId) async { final r = await _dio.post('/kiosk/auth/list', data: {'user_id': userId}); return KioskSession.fromJson(r.data as Map); diff --git a/pubspec.yaml b/pubspec.yaml index 7a9579c..a5d4a4c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: timemaster_kiosk description: TimeMaster Kiosk – Stempeluhr für Android-Tablets hinter VPN publish_to: 'none' -version: 0.1.10+11 +version: 0.1.11+12 environment: sdk: '>=3.3.0 <4.0.0'