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:
commit
e42cae74f3
11 files changed
+981
No files matched your search
+13
@@ -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/
|
||||
@@ -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).
|
||||
@@ -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';
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user