Files
timemaster-kiosk/lib/screens/stamp_screen.dart
T
patrickandClaude Sonnet 5 cea8ba8f08 fix: Fehlerbehandlung + NFC-Session-Leak (flutter-expert-Review)
- NFC onDiscovered: try/finally um stopSession(), sonst bleibt die native
  NFC-Session bei einer Exception in _extractUid offen und jeder weitere
  Scan schlägt dauerhaft fehl (nur App-Neustart behebt es).
- Alle Login-/Stamp-Handler fangen jetzt auch generische Exceptions (nicht
  nur DioException) – sonst wirkt der Kiosk bei unerwarteter Serverantwort
  nur "eingefroren" ohne Hinweis.
- _runAction: expliziter Re-Entrancy-Guard gegen Doppel-Tap, Status-Refresh
  nach fehlgeschlagener Stempel-Aktion (kein veralteter Button-Zustand).
- ApiClient.init(): Secure-Storage-Reads einzeln try/catch, dio wird immer
  konstruiert auch wenn Keystore beim Kaltstart noch nicht bereit ist.
- main(): ApiClient.init() in try/catch – Fehler dort ließ die App vorher
  komplett schwarz bleiben (runApp() nie erreicht).

v0.1.2+3. Review von flutter-expert-Subagent, Diffs manuell gegengeprüft.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dzdm3EWeNdYFUuXFRJryA1
2026-09-10 11:55:02 +02:00

241 lines
7.2 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<StampScreen> createState() => _StampScreenState();
}
class _StampScreenState extends State<StampScreen> {
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<void> _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;
});
} catch (_) {
if (mounted) {
setState(() {
_error = 'Unerwartete Antwort vom Server.';
_loading = false;
});
}
}
}
Future<void> _runAction(Future<StampResult> 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;
});
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;
});
// 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;
});
}
}
}
Future<void> _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<Widget> _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)),
),
),
];
}
}