- 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
241 lines
7.2 KiB
Dart
241 lines
7.2 KiB
Dart
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)),
|
||
),
|
||
),
|
||
];
|
||
}
|
||
}
|