Files
timemaster-kiosk/lib/screens/login_screen.dart
T
patrickandClaude Sonnet 5 cea8ba8f08 fix: Fehlerbehandlung + NFC-Session-Leak (flutter-expert-Review)
- NFC onDiscovered: try/finally um stopSession(), sonst bleibt die native
  NFC-Session bei einer Exception in _extractUid offen und jeder weitere
  Scan schlägt dauerhaft fehl (nur App-Neustart behebt es).
- Alle Login-/Stamp-Handler fangen jetzt auch generische Exceptions (nicht
  nur DioException) – sonst wirkt der Kiosk bei unerwarteter Serverantwort
  nur "eingefroren" ohne Hinweis.
- _runAction: expliziter Re-Entrancy-Guard gegen Doppel-Tap, Status-Refresh
  nach fehlgeschlagener Stempel-Aktion (kein veralteter Button-Zustand).
- ApiClient.init(): Secure-Storage-Reads einzeln try/catch, dio wird immer
  konstruiert auch wenn Keystore beim Kaltstart noch nicht bereit ist.
- main(): ApiClient.init() in try/catch – Fehler dort ließ die App vorher
  komplett schwarz bleiben (runApp() nie erreicht).

v0.1.2+3. Review von flutter-expert-Subagent, Diffs manuell gegengeprüft.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dzdm3EWeNdYFUuXFRJryA1
2026-09-10 11:55:02 +02:00

361 lines
12 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 'package:nfc_manager/nfc_manager_android.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));
} catch (_) {
// Unerwartete Antwortform (z.B. fehlendes Pflichtfeld in fromJson) darf
// nicht stillschweigend verschluckt werden – sonst wirkt der Kiosk nur
// "eingefroren" (Spinner stoppt, nichts passiert, kein Hinweis).
if (mounted) setState(() => _error = 'Unerwartete Antwort vom Server.');
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _startNfcScan() async {
final availability = await NfcManager.instance.checkAvailability();
if (availability != NfcAvailability.enabled) {
setState(() => _error = 'NFC ist nicht verfügbar oder deaktiviert (Status: ${availability.name}).');
return;
}
setState(() {
_nfcScanning = true;
_scannedUid = null;
_error = null;
});
try {
await NfcManager.instance.startSession(
pollingOptions: {NfcPollingOption.iso14443, NfcPollingOption.iso15693, NfcPollingOption.iso18092},
onDiscovered: (NfcTag tag) async {
// try/finally: stopSession() muss auch bei einer Exception in
// _extractUid laufen, sonst bleibt die native NFC-Session offen
// und jeder weitere Scan-Versuch schlägt dauerhaft fehl (nur durch
// App-Neustart behebbar).
String? uid;
String? err;
try {
uid = _extractUid(tag);
} catch (_) {
err = 'Karte konnte nicht gelesen werden.';
} finally {
await NfcManager.instance.stopSession().catchError((_) {});
}
if (!mounted) return;
setState(() {
_nfcScanning = false;
_scannedUid = uid;
_error = uid == null ? (err ?? 'Karte konnte nicht gelesen werden.') : null;
});
},
);
} catch (e) {
if (mounted) {
setState(() {
_nfcScanning = false;
_error = 'NFC-Scan fehlgeschlagen: $e';
});
}
}
}
/// Android-spezifisch (siehe nfc_manager 4.x: NfcTagAndroid.from(tag).id).
/// Nur Android relevant – Kiosk-Tablets, kein iOS-Support geplant.
String? _extractUid(NfcTag tag) {
final androidTag = NfcTagAndroid.from(tag);
if (androidTag == null) return null;
return androidTag.id.map((b) => b.toRadixString(16).padLeft(2, '0')).join().toUpperCase();
}
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));
} catch (_) {
if (mounted) setState(() => _error = 'Unerwartete Antwort vom Server.');
} 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));
} catch (_) {
if (mounted) setState(() => _error = 'Unerwartete Antwort vom Server.');
} 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));
} catch (_) {
if (mounted) setState(() => _error = 'Unerwartete Antwort vom Server.');
} 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) {
// Modus-Wechsel mitten im NFC-Scan: laufende native
// Session beenden, sonst bleibt sie verwaist offen und
// der nächste Scan-Versuch (auch für andere Mitarbeiter)
// schlägt dauerhaft fehl.
if (_nfcScanning) {
NfcManager.instance.stopSession().catchError((_) {});
}
setState(() {
_mode = s.first;
_error = null;
_scannedUid = null;
_nfcScanning = false;
});
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),
),
);
},
),
),
];
}
}