diff --git a/lib/app_version.dart b/lib/app_version.dart index 66564b8..2782b37 100644 --- a/lib/app_version.dart +++ b/lib/app_version.dart @@ -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.1+2'; +const String kAppVersion = '0.1.2+3'; diff --git a/lib/main.dart b/lib/main.dart index 6596f89..cfd2357 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -33,7 +33,17 @@ Future main() async { runZonedGuarded(() async { WidgetsFlutterBinding.ensureInitialized(); - await ApiClient.instance.init(); + try { + await ApiClient.instance.init(); + } catch (e, st) { + // Scheitert z.B. flutter_secure_storage/Keystore beim Kaltstart, blieb + // die App sonst komplett schwarz stehen (runApp() nie erreicht, + // ErrorWidget.builder greift nur für Fehler im Widget-Tree, nicht + // hier) – auf einem unbeaufsichtigten Kiosk ohne angeschlossene + // Konsole nicht von einem simplen Hänger zu unterscheiden. Lieber + // sichtbar machen und mit leerem Enrollment-Status weiterstarten. + debugPrint('ApiClient.init() fehlgeschlagen (Kiosk $kAppVersion): $e\n$st'); + } runApp(const TimeMasterKioskApp()); }, (error, stack) { debugPrint('Unbehandelter Fehler (Kiosk $kAppVersion): $error\n$stack'); diff --git a/lib/screens/login_screen.dart b/lib/screens/login_screen.dart index 6cbb63a..0357edc 100644 --- a/lib/screens/login_screen.dart +++ b/lib/screens/login_screen.dart @@ -61,6 +61,11 @@ class _LoginScreenState extends State { _goToStamp(session); } on DioException catch (e) { setState(() => _error = _dioMessage(e)); + } catch (_) { + // Unerwartete Antwortform (z.B. fehlendes Pflichtfeld in fromJson) darf + // nicht stillschweigend verschluckt werden – sonst wirkt der Kiosk nur + // "eingefroren" (Spinner stoppt, nichts passiert, kein Hinweis). + if (mounted) setState(() => _error = 'Unerwartete Antwort vom Server.'); } finally { if (mounted) setState(() => _loading = false); } @@ -81,13 +86,24 @@ class _LoginScreenState extends State { await NfcManager.instance.startSession( pollingOptions: {NfcPollingOption.iso14443, NfcPollingOption.iso15693, NfcPollingOption.iso18092}, onDiscovered: (NfcTag tag) async { - final uid = _extractUid(tag); - await NfcManager.instance.stopSession(); + // try/finally: stopSession() muss auch bei einer Exception in + // _extractUid laufen, sonst bleibt die native NFC-Session offen + // und jeder weitere Scan-Versuch schlägt dauerhaft fehl (nur durch + // App-Neustart behebbar). + String? uid; + String? err; + try { + uid = _extractUid(tag); + } catch (_) { + err = 'Karte konnte nicht gelesen werden.'; + } finally { + await NfcManager.instance.stopSession().catchError((_) {}); + } if (!mounted) return; setState(() { _nfcScanning = false; _scannedUid = uid; - if (uid == null) _error = 'Karte konnte nicht gelesen werden.'; + _error = uid == null ? (err ?? 'Karte konnte nicht gelesen werden.') : null; }); }, ); @@ -121,6 +137,8 @@ class _LoginScreenState extends State { _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); } @@ -137,6 +155,8 @@ class _LoginScreenState extends State { setState(() => _employees = list); } on DioException catch (e) { setState(() => _error = _dioMessage(e)); + } catch (_) { + if (mounted) setState(() => _error = 'Unerwartete Antwort vom Server.'); } finally { if (mounted) setState(() => _loading = false); } @@ -153,6 +173,8 @@ class _LoginScreenState extends State { _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); } @@ -209,10 +231,18 @@ class _LoginScreenState extends State { ], selected: {_mode}, onSelectionChanged: (s) { + // Modus-Wechsel mitten im NFC-Scan: laufende native + // Session beenden, sonst bleibt sie verwaist offen und + // der nächste Scan-Versuch (auch für andere Mitarbeiter) + // schlägt dauerhaft fehl. + if (_nfcScanning) { + NfcManager.instance.stopSession().catchError((_) {}); + } setState(() { _mode = s.first; _error = null; _scannedUid = null; + _nfcScanning = false; }); if (_mode == _LoginMode.list && _employees == null) _loadEmployees(); }, diff --git a/lib/screens/stamp_screen.dart b/lib/screens/stamp_screen.dart index cc9b0a5..9a99c45 100644 --- a/lib/screens/stamp_screen.dart +++ b/lib/screens/stamp_screen.dart @@ -65,10 +65,21 @@ class _StampScreenState extends State { _error = _dioMessage(e); _loading = false; }); + } catch (_) { + if (mounted) { + setState(() { + _error = 'Unerwartete Antwort vom Server.'; + _loading = false; + }); + } } } Future _runAction(Future Function() action) async { + // Defensiver Re-Entrancy-Guard: die Buttons sind zwar per `disabled` + // gesperrt, aber das hängt an einem setState-Rebuild. Dieser Check kostet + // nichts und macht Doppel-Taps unabhängig vom Rendering sicher. + if (_actionInProgress) return; setState(() { _actionInProgress = true; _error = null; @@ -86,6 +97,17 @@ class _StampScreenState extends State { _error = _dioMessage(e); _actionInProgress = false; }); + // Status kann sich serverseitig trotz Fehler geändert haben (z.B. + // Konflikt durch parallelen Stempel-Vorgang) – neu laden, damit die + // Buttons nicht einen veralteten, falschen Zustand anzeigen. + unawaited(_refresh()); + } catch (_) { + if (mounted) { + setState(() { + _error = 'Unerwartete Antwort vom Server.'; + _actionInProgress = false; + }); + } } } diff --git a/lib/services/api_client.dart b/lib/services/api_client.dart index cc42706..8498a48 100644 --- a/lib/services/api_client.dart +++ b/lib/services/api_client.dart @@ -20,9 +20,17 @@ class ApiClient { 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); + // Storage-Reads bewusst einzeln try/catch – wenn z.B. der Android- + // Keystore beim Kaltstart noch nicht bereit ist, soll `dio` trotzdem + // konstruiert werden (sonst LateInitializationError beim ersten Zugriff, + // z.B. in enroll() oder KioskService). + try { + final savedUrl = await _storage.read(key: _keyBaseUrl); + if (savedUrl != null && savedUrl.isNotEmpty) _baseUrl = savedUrl; + } catch (_) {} + try { + _deviceToken = await _storage.read(key: _keyDeviceToken); + } catch (_) {} dio = Dio(BaseOptions( baseUrl: _baseUrl, diff --git a/pubspec.yaml b/pubspec.yaml index ede7c91..bc27965 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: timemaster_kiosk description: TimeMaster Kiosk – Stempeluhr für Android-Tablets hinter VPN publish_to: 'none' -version: 0.1.1+2 +version: 0.1.2+3 environment: sdk: '>=3.3.0 <4.0.0'