commit e42cae74f33da2e2ce25a90bed1de4e550c2699c Author: patrick Date: Thu Sep 10 02:23:59 2026 +0200 feat: TimeMaster Kiosk Grundgerüst (Login + Stempeln) Android-Tablet-Kiosk-App, gedacht für Betrieb hinter WireGuard-VPN. Bearer-Token-Geräte-Auth statt Ed25519-Request-Signing (siehe Backend- Commit f4ff11a im Haupt-Repo). Ersteinrichtung (Server-URL+Token), PIN/ NFC/Listen-Login, Stempeln Ein/Aus/Pause, Heartbeat-Loop. Bekannte Einschränkung: Android-NFC (13,56 MHz) ist inkompatibel zu den 125-kHz-EM4100-Karten der bestehenden PC-Terminals – separate Karten nötig, siehe README. Vollständiger Plan: /home/sysops/.claude/plans/staged-marinating-dove.md Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Dzdm3EWeNdYFUuXFRJryA1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..63edbbf --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +**/android/app/debug +**/android/app/profile +**/android/app/release +**/android/.gradle +**/android/local.properties +**/android/.kotlin/ +*.iml +.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..224fe04 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# TimeMaster Kiosk (Flutter) + +Stempeluhr-App für Android-Tablets, gedacht für den Betrieb hinter WireGuard- +VPN (feste Geräte-IP). Kein Ed25519-Request-Signing wie beim Browser-PC- +Terminal – Geräte-Auth läuft über ein einfaches Bearer-Token, siehe +`backend/app/core/kiosk_security.py::_verify_bearer_device` im Haupt-Repo. + +Vollständiger Architektur-Plan: `/home/sysops/.claude/plans/staged-marinating-dove.md`. + +## Ersteinrichtung + +1. Auf dem Server: `python cli.py kiosk add --company "Acme GmbH" --name "Eingang" --auth-mode bearer [--ip-whitelist 10.8.0.0/24]` +2. CLI gibt Token **einmalig** aus – in der App unter „Ersteinrichtung" zusammen mit der Server-URL eintragen. +3. Gerät erscheint in `KioskDevicesPage` als „pending" – Admin genehmigt. + +## Login-Modi + +- **PIN**: Personalnummer + PIN (`POST /kiosk/auth/pin`) +- **NFC**: Kartenscan (Android-eigenes NFC, 13,56 MHz/ISO14443) + Pflicht-PIN + als Zweitfaktor (`POST /kiosk/auth/nfc`) +- **Liste**: Mitarbeiter aus Liste tippen (`POST /kiosk/auth/list`) + +**Wichtig – Hardware-Inkompatibilität:** Die PC-Terminals nutzen einen +125-kHz-EM4100-Leser (HID-Keyboard-Wedge, siehe `project_kiosk_hardening`- +Memory). Android-NFC liest nur 13,56-MHz-Karten (ISO14443/Mifare/NTAG). +**Bestehende Mitarbeiterkarten für die PC-Terminals funktionieren NICHT am +Tablet** und umgekehrt – für Tablet-NFC-Login werden separate 13,56-MHz- +Karten/Tags benötigt, die zusätzlich über `POST /users/{id}/kiosk-nfc` +hinterlegt werden müssen (gleiches Feld, zwei unterschiedliche physische +Kartentypen). + +## Build + +Kein lokales Flutter-SDK nötig – Build läuft auf root@192.168.1.236 +(gleiche Umgebung wie `timemaster-mobile`, siehe dortige Memory-Notiz +`project_android_app.md` für Setup-Details: `flutteruser`, `/opt/flutter`, +`/opt/android-sdk`). + +```bash +# Auf 236, als flutteruser: +cd /opt/timemaster-kiosk +flutter build apk --release --target-platform android-arm64 +``` + +## Umfang v1 + +Nur Login + Stempeln + Heartbeat. Kein Offline-Modus, keine NTAG424- +Kryptografie, kein Android-Lock-Task/MDM (App läuft im normalen Vollbild, +kein Geräte-Pinning). diff --git a/lib/app_version.dart b/lib/app_version.dart new file mode 100644 index 0000000..c5fffa0 --- /dev/null +++ b/lib/app_version.dart @@ -0,0 +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.0+1'; diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..6596f89 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,54 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'app_version.dart'; +import 'services/api_client.dart'; +import 'screens/enrollment_screen.dart'; +import 'screens/login_screen.dart'; + +Future main() async { + // Release-Build zeigt bei unbehandelten Fehlern sonst nur eine leere + // Fläche ohne Text – siehe timemaster-mobile v0.1.1+2 (gleiches Problem, + // gleicher Fix hier von Anfang an). + ErrorWidget.builder = (FlutterErrorDetails details) { + return Material( + color: Colors.red[900], + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Fehler (Kiosk $kAppVersion)', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16)), + const SizedBox(height: 8), + Text(details.exceptionAsString(), style: const TextStyle(color: Colors.white70, fontSize: 12)), + ], + ), + ), + ), + ), + ); + }; + + runZonedGuarded(() async { + WidgetsFlutterBinding.ensureInitialized(); + await ApiClient.instance.init(); + runApp(const TimeMasterKioskApp()); + }, (error, stack) { + debugPrint('Unbehandelter Fehler (Kiosk $kAppVersion): $error\n$stack'); + }); +} + +class TimeMasterKioskApp extends StatelessWidget { + const TimeMasterKioskApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'TimeMaster Kiosk', + theme: ThemeData(colorSchemeSeed: Colors.indigo, useMaterial3: true), + home: ApiClient.instance.isEnrolled ? const LoginScreen() : const EnrollmentScreen(), + ); + } +} diff --git a/lib/models/kiosk_session.dart b/lib/models/kiosk_session.dart new file mode 100644 index 0000000..e3fb32b --- /dev/null +++ b/lib/models/kiosk_session.dart @@ -0,0 +1,78 @@ +/// Spiegelt backend/app/schemas/kiosk_auth.py::KioskSessionResponse. +class KioskSession { + final String sessionToken; + final String userId; + final String userName; + final int expiresInSeconds; + final String authMethod; + + KioskSession({ + required this.sessionToken, + required this.userId, + required this.userName, + required this.expiresInSeconds, + required this.authMethod, + }); + + factory KioskSession.fromJson(Map json) { + return KioskSession( + sessionToken: json['session_token'] as String, + userId: json['user_id'] as String, + userName: json['user_name'] as String, + expiresInSeconds: json['expires_in_seconds'] as int, + authMethod: json['auth_method'] as String, + ); + } +} + +class KioskEmployee { + final String id; + final String displayName; + + KioskEmployee({required this.id, required this.displayName}); + + factory KioskEmployee.fromJson(Map json) { + return KioskEmployee( + id: json['id'] as String, + displayName: json['display_name'] as String, + ); + } + + /// Backend gibt {"users": [...]} zurück (KioskUserListResponse). + static List listFromResponse(Map json) { + return (json['users'] as List) + .map((e) => KioskEmployee.fromJson(e as Map)) + .toList(); + } +} + +/// Spiegelt die Antwort von POST /kiosk/stamp/status. +class StampStatus { + final bool stampedIn; + final bool onBreak; + final String? startTime; + + StampStatus({required this.stampedIn, required this.onBreak, this.startTime}); + + factory StampStatus.fromJson(Map json) { + return StampStatus( + stampedIn: json['stamped_in'] as bool, + onBreak: json['on_break'] as bool, + startTime: json['start_time'] as String?, + ); + } +} + +class StampResult { + final String status; + final List warnings; + + StampResult({required this.status, required this.warnings}); + + factory StampResult.fromJson(Map json) { + return StampResult( + status: json['status'] as String? ?? '', + warnings: (json['warnings'] as List? ?? []).cast(), + ); + } +} diff --git a/lib/screens/enrollment_screen.dart b/lib/screens/enrollment_screen.dart new file mode 100644 index 0000000..ba22c2b --- /dev/null +++ b/lib/screens/enrollment_screen.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; +import '../app_version.dart'; +import '../services/api_client.dart'; +import 'login_screen.dart'; + +/// Einmalige Ersteinrichtung: Server-URL + Bearer-Token eintragen, die per +/// `timemaster kiosk add --auth-mode bearer` auf dem Server generiert wurden. +/// Kein Keypair, kein QR-Scan – bewusst simpel (VPN übernimmt Transport- +/// Sicherheit, siehe Plan Android-Tablet-Kiosk 2026-09-10). +class EnrollmentScreen extends StatefulWidget { + const EnrollmentScreen({super.key}); + + @override + State createState() => _EnrollmentScreenState(); +} + +class _EnrollmentScreenState extends State { + final _urlCtrl = TextEditingController(text: ApiClient.instance.baseUrl); + final _tokenCtrl = TextEditingController(); + bool _saving = false; + String? _error; + + Future _save() async { + if (_urlCtrl.text.trim().isEmpty || _tokenCtrl.text.trim().isEmpty) { + setState(() => _error = 'Server-URL und Token sind Pflichtfelder.'); + return; + } + setState(() { + _saving = true; + _error = null; + }); + await ApiClient.instance.enroll( + baseUrl: _urlCtrl.text.trim(), + deviceToken: _tokenCtrl.text.trim(), + ); + if (!mounted) return; + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const LoginScreen()), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text('TimeMaster Kiosk · v$kAppVersion')), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.tablet_mac, size: 64, color: Colors.indigo), + const SizedBox(height: 16), + Text('Ersteinrichtung', style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: 8), + const Text( + 'Bearer-Token vom Admin einfügen (per "timemaster kiosk add --auth-mode bearer" erzeugt).', + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + TextField( + controller: _urlCtrl, + decoration: const InputDecoration(labelText: 'Server-URL', hintText: 'https://.../api/v1'), + ), + const SizedBox(height: 12), + TextField( + controller: _tokenCtrl, + decoration: const InputDecoration(labelText: 'Bearer-Token'), + obscureText: true, + ), + const SizedBox(height: 24), + if (_error != null) ...[ + Text(_error!, style: const TextStyle(color: Colors.red)), + const SizedBox(height: 12), + ], + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: _saving ? null : _save, + child: const Text('Einrichten'), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart new file mode 100644 index 0000000..9a76611 --- /dev/null +++ b/lib/screens/login_screen.dart @@ -0,0 +1,331 @@ +import 'dart:async'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:nfc_manager/nfc_manager.dart'; +import '../app_version.dart'; +import '../models/kiosk_session.dart'; +import '../services/api_client.dart'; +import '../services/kiosk_service.dart'; +import 'enrollment_screen.dart'; +import 'stamp_screen.dart'; + +enum _LoginMode { pin, nfc, list } + +class LoginScreen extends StatefulWidget { + const LoginScreen({super.key}); + + @override + State createState() => _LoginScreenState(); +} + +class _LoginScreenState extends State { + final _service = KioskService(); + final _personnelCtrl = TextEditingController(); + final _pinCtrl = TextEditingController(); + final _nfcPinCtrl = TextEditingController(); + + _LoginMode _mode = _LoginMode.pin; + bool _loading = false; + bool _nfcScanning = false; + String? _error; + String? _scannedUid; + List? _employees; + + @override + void dispose() { + if (_nfcScanning) NfcManager.instance.stopSession(); + super.dispose(); + } + + String _dioMessage(DioException e) { + final detail = e.response?.data is Map ? e.response?.data['detail'] : null; + if (detail is String) return detail; + return 'Verbindung fehlgeschlagen.'; + } + + void _goToStamp(KioskSession session) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => StampScreen(session: session)), + ); + } + + Future _submitPin() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final session = await _service.loginPin(_personnelCtrl.text.trim(), _pinCtrl.text.trim()); + if (!mounted) return; + _goToStamp(session); + } on DioException catch (e) { + setState(() => _error = _dioMessage(e)); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _startNfcScan() async { + final available = await NfcManager.instance.isAvailable(); + if (!available) { + setState(() => _error = 'NFC ist auf diesem Gerät nicht verfügbar.'); + return; + } + setState(() { + _nfcScanning = true; + _scannedUid = null; + _error = null; + }); + try { + await NfcManager.instance.startSession( + onDiscovered: (NfcTag tag) async { + final uid = _extractUid(tag); + await NfcManager.instance.stopSession(); + if (!mounted) return; + setState(() { + _nfcScanning = false; + _scannedUid = uid; + if (uid == null) _error = 'Karte konnte nicht gelesen werden.'; + }); + }, + ); + } catch (e) { + if (mounted) { + setState(() { + _nfcScanning = false; + _error = 'NFC-Scan fehlgeschlagen: $e'; + }); + } + } + } + + String? _extractUid(NfcTag tag) { + for (final key in ['nfca', 'nfcb', 'nfcf', 'nfcv', 'mifareclassic', 'mifareultralight', 'isodep']) { + final techData = tag.data[key]; + if (techData is Map && techData['identifier'] != null) { + final bytes = (techData['identifier'] as List).cast(); + return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase(); + } + } + return null; + } + + Future _submitNfc() async { + if (_scannedUid == null) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final session = await _service.loginNfc(_scannedUid!, _nfcPinCtrl.text.trim()); + if (!mounted) return; + _goToStamp(session); + } on DioException catch (e) { + setState(() => _error = _dioMessage(e)); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _loadEmployees() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final list = await _service.listEmployees(); + if (!mounted) return; + setState(() => _employees = list); + } on DioException catch (e) { + setState(() => _error = _dioMessage(e)); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _submitListLogin(String userId) async { + setState(() { + _loading = true; + _error = null; + }); + try { + final session = await _service.loginList(userId); + if (!mounted) return; + _goToStamp(session); + } on DioException catch (e) { + setState(() => _error = _dioMessage(e)); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + void _resetEnrollment() { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Gerät zurücksetzen?'), + content: const Text('Bearer-Token wird gelöscht, Ersteinrichtung muss erneut erfolgen.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Abbrechen')), + TextButton( + onPressed: () async { + await NfcManager.instance.stopSession().catchError((_) {}); + await ApiClient.instance.resetEnrollment(); + if (ctx.mounted) Navigator.pop(ctx); + if (mounted) { + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const EnrollmentScreen()), + ); + } + }, + child: const Text('Zurücksetzen'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('TimeMaster Kiosk · v$kAppVersion'), + actions: [ + IconButton(icon: const Icon(Icons.settings), onPressed: _resetEnrollment), + ], + ), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SegmentedButton<_LoginMode>( + segments: const [ + 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)), + ], + selected: {_mode}, + onSelectionChanged: (s) { + setState(() { + _mode = s.first; + _error = null; + _scannedUid = null; + }); + if (_mode == _LoginMode.list && _employees == null) _loadEmployees(); + }, + ), + const SizedBox(height: 24), + if (_mode == _LoginMode.pin) ..._pinForm(), + if (_mode == _LoginMode.nfc) ..._nfcForm(), + if (_mode == _LoginMode.list) ..._listForm(), + if (_error != null) ...[ + const SizedBox(height: 16), + Text(_error!, style: const TextStyle(color: Colors.red), textAlign: TextAlign.center), + ], + ], + ), + ), + ), + ), + ); + } + + List _pinForm() { + return [ + TextField( + controller: _personnelCtrl, + decoration: const InputDecoration(labelText: 'Personalnummer'), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 12), + TextField( + controller: _pinCtrl, + decoration: const InputDecoration(labelText: 'PIN'), + obscureText: true, + keyboardType: TextInputType.number, + onSubmitted: (_) => _submitPin(), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 56, + child: FilledButton( + onPressed: _loading ? null : _submitPin, + child: _loading ? const CircularProgressIndicator() : const Text('Anmelden'), + ), + ), + ]; + } + + List _nfcForm() { + if (_scannedUid == null) { + return [ + Icon(_nfcScanning ? Icons.nfc : Icons.contactless, size: 80, color: Colors.indigo), + const SizedBox(height: 16), + Text(_nfcScanning ? 'Karte an Gerät halten…' : 'Karte scannen'), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 56, + child: FilledButton.icon( + onPressed: _nfcScanning ? null : _startNfcScan, + icon: const Icon(Icons.nfc), + label: const Text('Scan starten'), + ), + ), + ]; + } + return [ + const Icon(Icons.check_circle, size: 64, color: Colors.green), + const SizedBox(height: 8), + const Text('Karte erkannt – PIN erforderlich (Pflicht-Zweitfaktor)'), + const SizedBox(height: 16), + TextField( + controller: _nfcPinCtrl, + decoration: const InputDecoration(labelText: 'PIN'), + obscureText: true, + keyboardType: TextInputType.number, + onSubmitted: (_) => _submitNfc(), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 56, + child: FilledButton( + onPressed: _loading ? null : _submitNfc, + child: _loading ? const CircularProgressIndicator() : const Text('Anmelden'), + ), + ), + ]; + } + + List _listForm() { + if (_loading && _employees == null) { + return [const CircularProgressIndicator()]; + } + final employees = _employees ?? []; + return [ + SizedBox( + height: 400, + child: ListView.builder( + shrinkWrap: true, + itemCount: employees.length, + itemBuilder: (context, i) { + final e = employees[i]; + return Card( + child: ListTile( + title: Text(e.displayName), + trailing: const Icon(Icons.chevron_right), + onTap: _loading ? null : () => _submitListLogin(e.id), + ), + ); + }, + ), + ), + ]; + } +} diff --git a/lib/screens/stamp_screen.dart b/lib/screens/stamp_screen.dart new file mode 100644 index 0000000..cc9b0a5 --- /dev/null +++ b/lib/screens/stamp_screen.dart @@ -0,0 +1,218 @@ +import 'dart:async'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import '../app_version.dart'; +import '../models/kiosk_session.dart'; +import '../services/kiosk_service.dart'; +import 'login_screen.dart'; + +/// Zeigt Status + Stempel-Buttons für den eingeloggten Mitarbeiter. Session +/// lebt serverseitig nur 900s (siehe kiosk_session_service.py) – nach jeder +/// Stempel-Aktion automatisch zurück zum Login (kein Grund, eingeloggt zu +/// bleiben, DSGVO: keine sichtbaren Mitarbeiterdaten länger als nötig). +class StampScreen extends StatefulWidget { + final KioskSession session; + const StampScreen({super.key, required this.session}); + + @override + State createState() => _StampScreenState(); +} + +class _StampScreenState extends State { + final _service = KioskService(); + Timer? _heartbeatTimer; + StampStatus? _status; + bool _loading = true; + bool _actionInProgress = false; + String? _error; + + @override + void initState() { + super.initState(); + _refresh(); + _heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (_) { + _service.heartbeat(clientVersion: kAppVersion).catchError((_) {}); + }); + _service.heartbeat(clientVersion: kAppVersion).catchError((_) {}); + } + + @override + void dispose() { + _heartbeatTimer?.cancel(); + super.dispose(); + } + + String _dioMessage(DioException e) { + final detail = e.response?.data is Map ? e.response?.data['detail'] : null; + if (detail is String) return detail; + return 'Verbindung fehlgeschlagen.'; + } + + Future _refresh() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final status = await _service.status(widget.session.sessionToken); + if (!mounted) return; + setState(() { + _status = status; + _loading = false; + }); + } on DioException catch (e) { + setState(() { + _error = _dioMessage(e); + _loading = false; + }); + } + } + + Future _runAction(Future Function() action) async { + setState(() { + _actionInProgress = true; + _error = null; + }); + try { + final result = await action(); + if (result.warnings.isNotEmpty && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(result.warnings.join(' · ')), backgroundColor: Colors.orange[800]), + ); + } + await _logoutAndReturn(); + } on DioException catch (e) { + setState(() { + _error = _dioMessage(e); + _actionInProgress = false; + }); + } + } + + Future _logoutAndReturn() async { + await _service.logout(widget.session.sessionToken).catchError((_) {}); + if (!mounted) return; + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const LoginScreen()), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.session.userName), + actions: [ + IconButton(icon: const Icon(Icons.logout), onPressed: _logoutAndReturn), + ], + ), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 480), + child: Padding( + padding: const EdgeInsets.all(24), + child: _loading + ? const CircularProgressIndicator() + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_error != null) ...[ + Text(_error!, style: const TextStyle(color: Colors.red)), + const SizedBox(height: 16), + ], + _statusCard(), + const SizedBox(height: 32), + ..._actionButtons(), + ], + ), + ), + ), + ), + ); + } + + Widget _statusCard() { + final status = _status; + String text; + Color color; + if (status == null || !status.stampedIn) { + text = 'Ausgestempelt'; + color = Colors.grey; + } else if (status.onBreak) { + text = 'Pause aktiv'; + color = Colors.orange; + } else { + text = 'Eingestempelt seit ${status.startTime?.substring(0, 5) ?? '?'}'; + color = Colors.green; + } + return Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Icon(Icons.circle, color: color, size: 16), + const SizedBox(width: 12), + Expanded(child: Text(text, style: Theme.of(context).textTheme.titleLarge)), + ], + ), + ), + ); + } + + List _actionButtons() { + final status = _status; + final disabled = _actionInProgress; + final token = widget.session.sessionToken; + + if (status == null || !status.stampedIn) { + return [ + SizedBox( + width: double.infinity, + height: 72, + child: FilledButton.icon( + onPressed: disabled ? null : () => _runAction(() => _service.stampIn(token)), + icon: const Icon(Icons.login, size: 32), + label: const Text('Einstempeln', style: TextStyle(fontSize: 20)), + ), + ), + ]; + } + + if (status.onBreak) { + return [ + SizedBox( + width: double.infinity, + height: 72, + child: FilledButton.icon( + onPressed: disabled ? null : () => _runAction(() => _service.breakEnd(token)), + icon: const Icon(Icons.play_arrow, size: 32), + label: const Text('Pause beenden', style: TextStyle(fontSize: 20)), + ), + ), + ]; + } + + return [ + SizedBox( + width: double.infinity, + height: 64, + child: OutlinedButton.icon( + onPressed: disabled ? null : () => _runAction(() => _service.breakStart(token)), + icon: const Icon(Icons.free_breakfast, size: 28), + label: const Text('Pause beginnen', style: TextStyle(fontSize: 18)), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + height: 72, + child: FilledButton.icon( + style: FilledButton.styleFrom(backgroundColor: Colors.red[700]), + onPressed: disabled ? null : () => _runAction(() => _service.stampOut(token)), + icon: const Icon(Icons.logout, size: 32), + label: const Text('Ausstempeln', style: TextStyle(fontSize: 20)), + ), + ), + ]; + } +} diff --git a/lib/services/api_client.dart b/lib/services/api_client.dart new file mode 100644 index 0000000..cc42706 --- /dev/null +++ b/lib/services/api_client.dart @@ -0,0 +1,58 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Kiosk-Geräte-Auth: einfaches Bearer-Token (key_algorithm="bearer_vpn" im +/// Backend, siehe backend/app/core/kiosk_security.py). Kein Request-Signing +/// wie beim Browser-PC-Terminal (Ed25519) – Sicherheit kommt vom WireGuard- +/// VPN + IP-Whitelist, nicht vom Client. Token wird per +/// `timemaster kiosk add --auth-mode bearer` einmalig generiert und hier +/// bei der Ersteinrichtung eingetragen. +class ApiClient { + ApiClient._internal(); + static final ApiClient instance = ApiClient._internal(); + + static const _storage = FlutterSecureStorage(); + static const _keyBaseUrl = 'kiosk_base_url'; + static const _keyDeviceToken = 'kiosk_device_token'; + + late final Dio dio; + String _baseUrl = 'https://timemaster.example.com/api/v1'; + String? _deviceToken; + + Future init() async { + final savedUrl = await _storage.read(key: _keyBaseUrl); + if (savedUrl != null && savedUrl.isNotEmpty) _baseUrl = savedUrl; + _deviceToken = await _storage.read(key: _keyDeviceToken); + + dio = Dio(BaseOptions( + baseUrl: _baseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 15), + )); + + dio.interceptors.add(InterceptorsWrapper( + onRequest: (options, handler) { + if (_deviceToken != null) { + options.headers['Authorization'] = 'Bearer $_deviceToken'; + } + handler.next(options); + }, + )); + } + + String get baseUrl => _baseUrl; + bool get isEnrolled => _deviceToken != null && _deviceToken!.isNotEmpty; + + Future enroll({required String baseUrl, required String deviceToken}) async { + _baseUrl = baseUrl; + _deviceToken = deviceToken; + dio.options.baseUrl = baseUrl; + await _storage.write(key: _keyBaseUrl, value: baseUrl); + await _storage.write(key: _keyDeviceToken, value: deviceToken); + } + + Future resetEnrollment() async { + _deviceToken = null; + await _storage.delete(key: _keyDeviceToken); + } +} diff --git a/lib/services/kiosk_service.dart b/lib/services/kiosk_service.dart new file mode 100644 index 0000000..aa71942 --- /dev/null +++ b/lib/services/kiosk_service.dart @@ -0,0 +1,62 @@ +import '../models/kiosk_session.dart'; +import 'api_client.dart'; + +/// Bündelt alle /kiosk/*-Aufrufe. Device-Auth (Bearer-Token) hängt der +/// Dio-Interceptor in ApiClient automatisch an; session_token (Mitarbeiter- +/// Login) wird pro Aufruf im Body mitgegeben, siehe +/// backend/app/schemas/kiosk_auth.py. +class KioskService { + final _dio = ApiClient.instance.dio; + + Future loginPin(String personnelNumber, String pin) async { + final r = await _dio.post('/kiosk/auth/pin', data: { + 'personnel_number': personnelNumber, + 'pin': pin, + }); + return KioskSession.fromJson(r.data as Map); + } + + Future loginNfc(String nfcUid, String pin) async { + final r = await _dio.post('/kiosk/auth/nfc', data: { + 'nfc_uid': nfcUid, + 'pin': pin, + }); + return KioskSession.fromJson(r.data as Map); + } + + Future loginList(String userId) async { + final r = await _dio.post('/kiosk/auth/list', data: {'user_id': userId}); + return KioskSession.fromJson(r.data as Map); + } + + Future> listEmployees() async { + final r = await _dio.get('/kiosk/auth/users'); + return KioskEmployee.listFromResponse(r.data as Map); + } + + Future logout(String sessionToken) async { + await _dio.post('/kiosk/auth/logout', data: {'session_token': sessionToken}); + } + + Future status(String sessionToken) async { + final r = await _dio.post('/kiosk/stamp/status', data: {'session_token': sessionToken}); + return StampStatus.fromJson(r.data as Map); + } + + Future stampIn(String sessionToken) => _stamp('in', sessionToken); + Future stampOut(String sessionToken) => _stamp('out', sessionToken); + Future breakStart(String sessionToken) => _stamp('break-start', sessionToken); + Future breakEnd(String sessionToken) => _stamp('break-end', sessionToken); + + Future _stamp(String action, String sessionToken) async { + final r = await _dio.post('/kiosk/stamp/$action', data: {'session_token': sessionToken}); + return StampResult.fromJson(r.data as Map); + } + + Future heartbeat({int queuedOfflineEntries = 0, required String clientVersion}) async { + await _dio.post('/kiosk/heartbeat', data: { + 'queued_offline_entries': queuedOfflineEntries, + 'client_version': clientVersion, + }); + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..349313b --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,23 @@ +name: timemaster_kiosk +description: TimeMaster Kiosk – Stempeluhr für Android-Tablets hinter VPN +publish_to: 'none' +version: 0.1.0+1 + +environment: + sdk: '>=3.3.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.8 + dio: ^5.7.0 + flutter_secure_storage: ^9.2.2 + nfc_manager: ^3.5.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 + +flutter: + uses-material-design: true