diff --git a/lib/app_version.dart b/lib/app_version.dart index 799fbff..a6bc9a0 100644 --- a/lib/app_version.dart +++ b/lib/app_version.dart @@ -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'; diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 44b29e5..65050ee 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -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 { 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), ], diff --git a/lib/screens/kiosk_qr_screen.dart b/lib/screens/kiosk_qr_screen.dart new file mode 100644 index 0000000..645dc6a --- /dev/null +++ b/lib/screens/kiosk_qr_screen.dart @@ -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 createState() => _KioskQrScreenState(); +} + +class _KioskQrScreenState extends State { + 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 _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'), + ), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/services/kiosk_qr_service.dart b/lib/services/kiosk_qr_service.dart new file mode 100644 index 0000000..0e7c0b2 --- /dev/null +++ b/lib/services/kiosk_qr_service.dart @@ -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 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 requestToken() async { + final r = await _dio.post('/kiosk/my-qr-token'); + return KioskQrToken.fromJson(r.data as Map); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index ccf6ad3..271026f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: