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