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
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-10 02:23:59 +02:00
commit e42cae74f3
11 files changed
+981

No files matched your search

+13
View File
@@ -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/
+49
View File
@@ -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).
+3
View File
@@ -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';
+54
View File
@@ -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<void> 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(),
);
}
}
+78
View File
@@ -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<String, dynamic> 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<String, dynamic> json) {
return KioskEmployee(
id: json['id'] as String,
displayName: json['display_name'] as String,
);
}
/// Backend gibt {"users": [...]} zurück (KioskUserListResponse).
static List<KioskEmployee> listFromResponse(Map<String, dynamic> json) {
return (json['users'] as List<dynamic>)
.map((e) => KioskEmployee.fromJson(e as Map<String, dynamic>))
.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<String, dynamic> 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<String> warnings;
StampResult({required this.status, required this.warnings});
factory StampResult.fromJson(Map<String, dynamic> json) {
return StampResult(
status: json['status'] as String? ?? '',
warnings: (json['warnings'] as List<dynamic>? ?? []).cast<String>(),
);
}
}
+92
View File
@@ -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<EnrollmentScreen> createState() => _EnrollmentScreenState();
}
class _EnrollmentScreenState extends State<EnrollmentScreen> {
final _urlCtrl = TextEditingController(text: ApiClient.instance.baseUrl);
final _tokenCtrl = TextEditingController();
bool _saving = false;
String? _error;
Future<void> _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'),
),
),
],
),
),
),
),
);
}
}
+331
View File
@@ -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<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
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<KioskEmployee>? _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<void> _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<void> _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<dynamic>).cast<int>();
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase();
}
}
return null;
}
Future<void> _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<void> _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<void> _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<Widget> _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<Widget> _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<Widget> _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),
),
);
},
),
),
];
}
}
+218
View File
@@ -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<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)),
),
),
];
}
}
+58
View File
@@ -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<void> 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<void> 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<void> resetEnrollment() async {
_deviceToken = null;
await _storage.delete(key: _keyDeviceToken);
}
}
+62
View File
@@ -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<KioskSession> 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<String, dynamic>);
}
Future<KioskSession> 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<String, dynamic>);
}
Future<KioskSession> loginList(String userId) async {
final r = await _dio.post('/kiosk/auth/list', data: {'user_id': userId});
return KioskSession.fromJson(r.data as Map<String, dynamic>);
}
Future<List<KioskEmployee>> listEmployees() async {
final r = await _dio.get('/kiosk/auth/users');
return KioskEmployee.listFromResponse(r.data as Map<String, dynamic>);
}
Future<void> logout(String sessionToken) async {
await _dio.post('/kiosk/auth/logout', data: {'session_token': sessionToken});
}
Future<StampStatus> status(String sessionToken) async {
final r = await _dio.post('/kiosk/stamp/status', data: {'session_token': sessionToken});
return StampStatus.fromJson(r.data as Map<String, dynamic>);
}
Future<StampResult> stampIn(String sessionToken) => _stamp('in', sessionToken);
Future<StampResult> stampOut(String sessionToken) => _stamp('out', sessionToken);
Future<StampResult> breakStart(String sessionToken) => _stamp('break-start', sessionToken);
Future<StampResult> breakEnd(String sessionToken) => _stamp('break-end', sessionToken);
Future<StampResult> _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<String, dynamic>);
}
Future<void> heartbeat({int queuedOfflineEntries = 0, required String clientVersion}) async {
await _dio.post('/kiosk/heartbeat', data: {
'queued_offline_entries': queuedOfflineEntries,
'client_version': clientVersion,
});
}
}
+23
View File
@@ -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