feat: QR-Login mit der Employee-App

Neuer 4. Login-Modus "QR" im LoginScreen: scannt den Token, den die
Employee-App unter "Am Kiosk anmelden" anzeigt (POST /kiosk/my-qr-token im
Haupt-Repo), meldet den Mitarbeiter per POST /kiosk/auth/qr an. Eigener
QrLoginScanScreen (rohe Token-String statt JSON wie beim Geräte-Enrollment-
QR). v0.1.11+12.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dzdm3EWeNdYFUuXFRJryA1
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-10 14:53:54 +02:00
1 parent 5b8bfeb4b0
commit d10b5d572d
5 files changed
+127 -3

No files matched your search

+1 -1
View File
@@ -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';
+44 -1
View File
@@ -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<LoginScreen> {
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<LoginScreen> {
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<LoginScreen> {
),
];
}
Future<void> _scanAndLoginQr() async {
final token = await Navigator.of(context).push<String>(
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<Widget> _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'),
),
),
];
}
}
+76
View File
@@ -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<QrLoginScanScreen> createState() => _QrLoginScanScreenState();
}
class _QrLoginScanScreenState extends State<QrLoginScanScreen> {
// 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<T> on List<T> {
T? get firstOrNull => isEmpty ? null : first;
}
+5
View File
@@ -24,6 +24,11 @@ class KioskService {
return KioskSession.fromJson(r.data as Map<String, dynamic>);
}
Future<KioskSession> loginQr(String qrToken) async {
final r = await _dio.post('/kiosk/auth/qr', data: {'qr_token': qrToken});
return KioskSession.fromJson(r.data as Map<String, dynamic>);
}
Future<KioskSession> loginList(String userId) async {
final r = await _dio.post('/kiosk/auth/list', data: {'user_id': userId});
return KioskSession.fromJson(r.data as Map<String, dynamic>);
+1 -1
View File
@@ -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'