Files
timemaster-kiosk/lib/screens/login_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

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