Files
timemaster-kiosk/lib/screens/login_screen.dart
T
patrickandClaude Sonnet 5 7b0f0fe1fc feat: Info-Menüpunkt für Version + manuellen Update-Check
Neuer Info-Button im LoginScreen-AppBar öffnet Dialog: installierte
Version, "Jetzt prüfen"-Button ruft checkForNewerVersion() manuell auf
(nicht nur beim App-Start automatisch) und zeigt Ergebnis inline. Findet
er eine neuere Version, erscheint danach auch das normale Update-Banner.
v0.1.7+8.

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

483 lines
16 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 '../services/update_check_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;
final _updateService = UpdateCheckService();
String? _newerVersion;
bool _updating = false;
double? _updateProgress;
@override
void initState() {
super.initState();
_updateService.checkForNewerVersion().then((v) {
if (mounted && v != null) setState(() => _newerVersion = v);
});
}
@override
void dispose() {
if (_nfcScanning) NfcManager.instance.stopSession();
super.dispose();
}
Future<void> _installUpdate() async {
setState(() {
_updating = true;
_updateProgress = null;
_error = null;
});
try {
await _updateService.downloadAndInstall(
onProgress: (p) {
if (mounted) setState(() => _updateProgress = p);
},
);
} catch (e) {
if (mounted) setState(() => _error = 'Update fehlgeschlagen: $e');
} finally {
if (mounted) {
setState(() {
_updating = false;
_updateProgress = null;
});
}
}
}
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);
}
}
Future<void> _showVersionInfo() async {
bool checking = false;
String? checkResult;
await showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => AlertDialog(
title: const Text('Version'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Installiert: v$kAppVersion'),
const SizedBox(height: 12),
if (checking)
const Center(child: Padding(padding: EdgeInsets.all(8), child: CircularProgressIndicator()))
else if (checkResult != null)
Text(checkResult!, style: TextStyle(color: checkResult!.startsWith('Neue') ? Colors.blue : Colors.green[700])),
const SizedBox(height: 8),
TextButton.icon(
onPressed: checking
? null
: () async {
setDialogState(() => checking = true);
final newer = await _updateService.checkForNewerVersion();
setDialogState(() {
checking = false;
checkResult = newer != null ? 'Neue Version verfügbar: v$newer' : 'Aktuell – kein Update verfügbar.';
});
if (newer != null && mounted) setState(() => _newerVersion = newer);
},
icon: const Icon(Icons.refresh),
label: const Text('Jetzt prüfen'),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Schließen')),
],
),
),
);
}
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.info_outline), onPressed: _showVersionInfo, tooltip: 'Version'),
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: [
if (_newerVersion != null) ...[
Card(
color: Colors.blue[50],
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.system_update, color: Colors.blue),
const SizedBox(width: 12),
Expanded(
child: Text(
'Neue Version $_newerVersion verfügbar (installiert: $kAppVersion).',
style: const TextStyle(color: Colors.blue),
),
),
],
),
const SizedBox(height: 8),
if (_updating)
LinearProgressIndicator(value: _updateProgress)
else
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _installUpdate,
icon: const Icon(Icons.download),
label: const Text('Jetzt aktualisieren'),
),
),
],
),
),
),
const SizedBox(height: 16),
],
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),
),
);
},
),
),
];
}
}