Files
timemaster-kiosk/lib/screens/stamp_screen.dart
T
patrickandClaude Sonnet 5 e42cae74f3 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dzdm3EWeNdYFUuXFRJryA1
2026-09-10 02:23:59 +02:00

219 lines
6.3 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;
});
}
}
Future<void> _runAction(Future<StampResult> 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<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)),
),
),
];
}
}