feat: QR-Code für Kiosk-Login anzeigen

Neuer KioskQrScreen (erreichbar über QR-Icon im HomeScreen-AppBar) fordert
über POST /kiosk/my-qr-token einen 5min gültigen, einmalig einlösbaren
Login-Token an und zeigt ihn als QR-Code (qr_flutter). Mitarbeiter scannt
ihn am Kiosk-Tablet statt PIN einzutippen. Live-Countdown, Neu-Erzeugen bei
Ablauf. 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:56 +02:00
1 parent d6664c582c
commit ffd972392c
5 files changed
+159 -2

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
/// Manuell mit `pubspec.yaml` synchron halten (kein package_info_plus, um
/// keine zusätzliche Native-Plugin-Fläche in dieser Debugging-Phase
/// einzuführen). Bei jedem Release beide Stellen bumpen.
const String kAppVersion = '0.1.10+11';
const String kAppVersion = '0.1.11+12';
+8
View File
@@ -7,6 +7,7 @@ import '../models/time_entry.dart';
import '../services/auth_service.dart';
import '../services/time_service.dart';
import '../services/update_check_service.dart';
import 'kiosk_qr_screen.dart';
import 'login_screen.dart';
class HomeScreen extends StatefulWidget {
@@ -244,6 +245,13 @@ class _HomeScreenState extends State<HomeScreen> {
appBar: AppBar(
title: const Text('Zeiterfassung · v$kAppVersion'),
actions: [
IconButton(
icon: const Icon(Icons.qr_code),
tooltip: 'Am Kiosk anmelden',
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const KioskQrScreen()),
),
),
IconButton(icon: const Icon(Icons.info_outline), onPressed: _showVersionInfo, tooltip: 'Version'),
IconButton(icon: const Icon(Icons.logout), onPressed: _logout),
],
+121
View File
@@ -0,0 +1,121 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../services/kiosk_qr_service.dart';
/// Zeigt einen QR-Code, den der Mitarbeiter am Kiosk-Tablet scannen kann,
/// um sich dort anzumelden (kein Passwort-Eintippen am Shared-Device).
/// Token ist nur 5 min gültig und einmalig einlösbar.
class KioskQrScreen extends StatefulWidget {
const KioskQrScreen({super.key});
@override
State<KioskQrScreen> createState() => _KioskQrScreenState();
}
class _KioskQrScreenState extends State<KioskQrScreen> {
final _service = KioskQrService();
String? _token;
int _remainingSeconds = 0;
Timer? _countdown;
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_requestToken();
}
@override
void dispose() {
_countdown?.cancel();
super.dispose();
}
Future<void> _requestToken() async {
_countdown?.cancel();
setState(() {
_loading = true;
_error = null;
});
try {
final result = await _service.requestToken();
if (!mounted) return;
setState(() {
_token = result.qrToken;
_remainingSeconds = result.expiresInSeconds;
_loading = false;
});
_countdown = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
setState(() {
_remainingSeconds--;
if (_remainingSeconds <= 0) {
_countdown?.cancel();
_token = null; // abgelaufen, User muss neu generieren
}
});
});
} on DioException catch (e) {
final detail = e.response?.data is Map ? e.response?.data['detail'] : null;
if (mounted) setState(() => _error = detail is String ? detail : 'Verbindung fehlgeschlagen.');
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Am Kiosk anmelden')),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'QR-Code am Kiosk-Tablet scannen, um dich dort anzumelden.',
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
if (_loading)
const CircularProgressIndicator()
else if (_error != null) ...[
Text(_error!, style: const TextStyle(color: Colors.red)),
const SizedBox(height: 16),
FilledButton(onPressed: _requestToken, child: const Text('Erneut versuchen')),
] else if (_token != null) ...[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
),
child: QrImageView(data: _token!, size: 240),
),
const SizedBox(height: 16),
Text('Gültig für $_remainingSeconds s', style: TextStyle(color: Colors.grey[600])),
] else ...[
const Icon(Icons.timer_off, size: 48, color: Colors.grey),
const SizedBox(height: 8),
const Text('Abgelaufen.'),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _requestToken,
icon: const Icon(Icons.refresh),
label: const Text('Neuen Code erzeugen'),
),
],
],
),
),
),
),
);
}
}
+27
View File
@@ -0,0 +1,27 @@
import 'api_client.dart';
class KioskQrToken {
final String qrToken;
final int expiresInSeconds;
KioskQrToken({required this.qrToken, required this.expiresInSeconds});
factory KioskQrToken.fromJson(Map<String, dynamic> json) {
return KioskQrToken(
qrToken: json['qr_token'] as String,
expiresInSeconds: json['expires_in_seconds'] as int,
);
}
}
/// Erzeugt einmalige QR-Login-Tokens für den Kiosk-Terminal-Login (kein
/// Passwort am gemeinsam genutzten Gerät nötig). Token ist 5 min gültig
/// und einmalig einlösbar (siehe backend/app/routers/kiosk.py::my-qr-token).
class KioskQrService {
final _dio = ApiClient.instance.dio;
Future<KioskQrToken> requestToken() async {
final r = await _dio.post('/kiosk/my-qr-token');
return KioskQrToken.fromJson(r.data as Map<String, dynamic>);
}
}
+2 -1
View File
@@ -1,7 +1,7 @@
name: timemaster_mobile
description: TimeMaster Zeiterfassung – Employee App
publish_to: 'none'
version: 0.1.10+11
version: 0.1.11+12
environment:
sdk: '>=3.3.0 <4.0.0'
@@ -19,6 +19,7 @@ dependencies:
open_filex: ^4.5.0
provider: ^6.1.2
intl: ^0.19.0
qr_flutter: ^4.1.0
dev_dependencies:
flutter_test: