Files
timemaster-mobile/lib/screens/home_screen.dart
T
patrickandClaude Sonnet 5 ffd972392c feat: QR-Code für Kiosk-Login anzeigen
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
2026-09-10 14:53:56 +02:00

422 lines
13 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 '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'),
),
),
];
}
}