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