Neuer KioskQrScreen (erreichbar über QR-Icon im HomeScreen-AppBar) fordert über POST /kiosk/my-qr-token einen 5min gültigen, einmalig einlösbaren Login-Token an und zeigt ihn als QR-Code (qr_flutter). Mitarbeiter scannt ihn am Kiosk-Tablet statt PIN einzutippen. Live-Countdown, Neu-Erzeugen bei Ablauf. v0.1.11+12. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dzdm3EWeNdYFUuXFRJryA1
422 lines
13 KiB
Dart
422 lines
13 KiB
Dart
import 'dart:async';
|
||
import 'dart:ui' show FontFeature;
|
||
import 'package:flutter/material.dart';
|
||
import 'package:dio/dio.dart';
|
||
import '../app_version.dart';
|
||
import '../models/time_entry.dart';
|
||
import '../services/auth_service.dart';
|
||
import '../services/time_service.dart';
|
||
import '../services/update_check_service.dart';
|
||
import 'kiosk_qr_screen.dart';
|
||
import 'login_screen.dart';
|
||
|
||
class HomeScreen extends StatefulWidget {
|
||
const HomeScreen({super.key});
|
||
|
||
@override
|
||
State<HomeScreen> createState() => _HomeScreenState();
|
||
}
|
||
|
||
class _HomeScreenState extends State<HomeScreen> {
|
||
final _timeService = TimeService();
|
||
final _auth = AuthService();
|
||
|
||
bool _loading = true;
|
||
bool _actionInProgress = false;
|
||
TimeEntry? _openEntry;
|
||
String? _error;
|
||
Timer? _ticker;
|
||
String? _newerVersion;
|
||
final _updateService = UpdateCheckService();
|
||
bool _updating = false;
|
||
double? _updateProgress;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_refresh();
|
||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
||
if (_openEntry != null) setState(() {});
|
||
});
|
||
_updateService.checkForNewerVersion().then((v) {
|
||
if (mounted && v != null) setState(() => _newerVersion = v);
|
||
});
|
||
}
|
||
|
||
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')),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
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;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_ticker?.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
/// Parst "HH:MM:SS" oder "HH:MM:SS.ffffff" (Server sendet UTC, siehe
|
||
/// time_service.py: datetime.now(timezone.utc).time()) und liefert die
|
||
/// verstrichene Dauer bis jetzt. Negative/kaputte Werte -> Duration.zero.
|
||
Duration _elapsedSince(String hms) {
|
||
final parts = hms.split(':');
|
||
final h = int.parse(parts[0]);
|
||
final m = int.parse(parts[1]);
|
||
final s = parts.length > 2 ? int.parse(parts[2].split('.').first) : 0;
|
||
final nowUtc = DateTime.now().toUtc();
|
||
final startUtc = DateTime.utc(nowUtc.year, nowUtc.month, nowUtc.day, h, m, s);
|
||
final diff = nowUtc.difference(startUtc);
|
||
return diff.isNegative ? Duration.zero : diff;
|
||
}
|
||
|
||
/// "HH:MM:SS" oder "HH:MM:SS.ffffff" (UTC, siehe _elapsedSince) ->
|
||
/// "HH:MM" in der lokalen Zeitzone des Geräts für die Anzeige.
|
||
String _shortTime(String hms) {
|
||
final parts = hms.split(':');
|
||
if (parts.length < 2) return hms;
|
||
final h = int.tryParse(parts[0]);
|
||
final m = int.tryParse(parts[1]);
|
||
if (h == null || m == null) return hms;
|
||
final nowUtc = DateTime.now().toUtc();
|
||
final utc = DateTime.utc(nowUtc.year, nowUtc.month, nowUtc.day, h, m);
|
||
final local = utc.toLocal();
|
||
return '${local.hour.toString().padLeft(2, '0')}:${local.minute.toString().padLeft(2, '0')}';
|
||
}
|
||
|
||
String _formatDuration(Duration d) {
|
||
final h = d.inHours.toString().padLeft(2, '0');
|
||
final m = (d.inMinutes % 60).toString().padLeft(2, '0');
|
||
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
|
||
return '$h:$m:$s';
|
||
}
|
||
|
||
Future<void> _refresh() async {
|
||
setState(() {
|
||
_loading = true;
|
||
_error = null;
|
||
});
|
||
try {
|
||
final entries = await _timeService.getToday();
|
||
TimeEntry? open;
|
||
for (final e in entries) {
|
||
if (e.isOpen) open = e;
|
||
}
|
||
setState(() {
|
||
_openEntry = open;
|
||
_loading = false;
|
||
});
|
||
} on DioException catch (e) {
|
||
setState(() {
|
||
_error = _dioMessage(e);
|
||
_loading = false;
|
||
});
|
||
} catch (e) {
|
||
setState(() {
|
||
_error = 'Unerwarteter Fehler ($kAppVersion): $e';
|
||
_loading = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
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.';
|
||
}
|
||
|
||
Future<void> _runAction(Future<void> Function() action) async {
|
||
setState(() {
|
||
_actionInProgress = true;
|
||
_error = null;
|
||
});
|
||
try {
|
||
await action();
|
||
await _refresh();
|
||
} on DioException catch (e) {
|
||
setState(() {
|
||
_error = _dioMessage(e);
|
||
_actionInProgress = false;
|
||
});
|
||
} catch (e) {
|
||
setState(() {
|
||
_error = 'Unerwarteter Fehler ($kAppVersion): $e';
|
||
_actionInProgress = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
Future<void> _stampIn() => _runAction(() async {
|
||
final result = await _timeService.stampIn();
|
||
if (result.warnings.isNotEmpty) _showWarnings(result.warnings);
|
||
});
|
||
|
||
Future<void> _stampOut() => _runAction(() async {
|
||
final result = await _timeService.stampOut();
|
||
if (result.warnings.isNotEmpty) _showWarnings(result.warnings);
|
||
});
|
||
|
||
Future<void> _breakStart() => _runAction(() => _timeService.breakStart());
|
||
|
||
Future<void> _breakEnd() => _runAction(() => _timeService.breakEnd());
|
||
|
||
void _showWarnings(List<String> warnings) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text(warnings.join(' · ')), backgroundColor: Colors.orange[800]),
|
||
);
|
||
}
|
||
|
||
Future<void> _logout() async {
|
||
await _auth.logout();
|
||
if (!mounted) return;
|
||
Navigator.of(context).pushReplacement(
|
||
MaterialPageRoute(builder: (_) => const LoginScreen()),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text('Zeiterfassung · v$kAppVersion'),
|
||
actions: [
|
||
IconButton(
|
||
icon: const Icon(Icons.qr_code),
|
||
tooltip: 'Am Kiosk anmelden',
|
||
onPressed: () => Navigator.of(context).push(
|
||
MaterialPageRoute(builder: (_) => const KioskQrScreen()),
|
||
),
|
||
),
|
||
IconButton(icon: const Icon(Icons.info_outline), onPressed: _showVersionInfo, tooltip: 'Version'),
|
||
IconButton(icon: const Icon(Icons.logout), onPressed: _logout),
|
||
],
|
||
),
|
||
body: RefreshIndicator(
|
||
onRefresh: _refresh,
|
||
child: _loading
|
||
? const Center(child: CircularProgressIndicator())
|
||
: ListView(
|
||
padding: const EdgeInsets.all(24),
|
||
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'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
if (_error != null)
|
||
Card(
|
||
color: Colors.red[50],
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(12),
|
||
child: Text(_error!, style: const TextStyle(color: Colors.red)),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
_statusCard(),
|
||
const SizedBox(height: 24),
|
||
..._actionButtons(),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _statusCard() {
|
||
final entry = _openEntry;
|
||
String status;
|
||
Color color;
|
||
String duration;
|
||
if (entry == null) {
|
||
status = 'Ausgestempelt';
|
||
duration = '';
|
||
color = Colors.grey;
|
||
} else if (entry.isOnBreak) {
|
||
status = 'Pause seit ${_shortTime(entry.breakStart!)}';
|
||
duration = _formatDuration(_elapsedSince(entry.breakStart!));
|
||
color = Colors.orange;
|
||
} else {
|
||
status = 'Eingestempelt seit ${_shortTime(entry.startTime)}';
|
||
duration = _formatDuration(_elapsedSince(entry.startTime));
|
||
color = Colors.green;
|
||
}
|
||
return Card(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(20),
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.circle, color: color, size: 16),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(status, style: Theme.of(context).textTheme.titleMedium),
|
||
if (duration.isNotEmpty)
|
||
Text(
|
||
duration,
|
||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||
fontFeatures: const [FontFeature.tabularFigures()],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
List<Widget> _actionButtons() {
|
||
final entry = _openEntry;
|
||
final disabled = _actionInProgress;
|
||
|
||
if (entry == null) {
|
||
return [
|
||
SizedBox(
|
||
width: double.infinity,
|
||
height: 56,
|
||
child: FilledButton.icon(
|
||
onPressed: disabled ? null : _stampIn,
|
||
icon: const Icon(Icons.login),
|
||
label: const Text('Einstempeln'),
|
||
),
|
||
),
|
||
];
|
||
}
|
||
|
||
if (entry.isOnBreak) {
|
||
return [
|
||
SizedBox(
|
||
width: double.infinity,
|
||
height: 56,
|
||
child: FilledButton.icon(
|
||
onPressed: disabled ? null : _breakEnd,
|
||
icon: const Icon(Icons.play_arrow),
|
||
label: const Text('Pause beenden'),
|
||
),
|
||
),
|
||
];
|
||
}
|
||
|
||
return [
|
||
SizedBox(
|
||
width: double.infinity,
|
||
height: 56,
|
||
child: OutlinedButton.icon(
|
||
onPressed: disabled ? null : _breakStart,
|
||
icon: const Icon(Icons.free_breakfast),
|
||
label: const Text('Pause beginnen'),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
height: 56,
|
||
child: FilledButton.icon(
|
||
style: FilledButton.styleFrom(backgroundColor: Colors.red[700]),
|
||
onPressed: disabled ? null : _stampOut,
|
||
icon: const Icon(Icons.logout),
|
||
label: const Text('Ausstempeln'),
|
||
),
|
||
),
|
||
];
|
||
}
|
||
}
|