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