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:
1 parent
5b8bfeb4b0
commit
d10b5d572d
5 files changed
+127
-3
No files matched your search
@@ -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';
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user