Lieferstationen-Dialog (Backend/Vaadin): - Aufgaben per Drag & Drop neu anordnen, inkl. Drag-Handle, komprimierter Kachelansicht während des Drags und horizontaler Einfügelinie als Drop-Target - Drop-Indikator wird unterdrückt, wenn der Drop keine Positionsänderung bewirken würde, und nach dem Abschluss clientseitig zuverlässig aufgeräumt - Drag-Handle, Aufgabentyp-Label und Close-Button auf einheitlicher Position ausgerichtet; Abstände in der Kachel komprimiert Station-Abschluss-Flow (Flutter-App + Backend): - Neuer Button "Station abschließen" unter den Aufgaben; deaktiviert, solange Pflichtaufgaben offen sind, ansonsten aktiv (auch wenn nur optionale Aufgaben existieren) - Hinweisdialog nach Erledigung der letzten Pflichtaufgabe sowie Warnung bei offenen optionalen Aufgaben vor dem Senden - Neue station_completed-Nachricht (jobId, jobNumber, stationOrder, completedAt, hasIncompleteOptionalTasks) wird an den Server gesendet - Backend: Auftrag wird nicht mehr automatisch beim Erledigen der letzten Pflichtaufgabe abgeschlossen, sondern erst beim Empfang der station_completed-Nachricht (neuer Handler in MessageController und MessagingConfig) Aufgabenliste in der App: - Farbcodierung optionaler Aufgaben entfernt; stattdessen vertikal zentrierter "Optional"-Chip am rechten Kartenrand Weitere UI-Überarbeitungen über Login, Jobs, Chats, Settings, Aufgaben-Capture- Screens, Offline-Banner und zugehörige Widgets. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
174 lines
4.8 KiB
Dart
174 lines
4.8 KiB
Dart
import 'dart:async';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:votianlt_app/services/developer.dart' as developer;
|
||
import 'package:votianlt_app/services/websocket_service.dart';
|
||
import 'package:votianlt_app/services/dart_mq.dart';
|
||
import '../app_theme.dart';
|
||
|
||
class OfflineBanner extends StatefulWidget {
|
||
const OfflineBanner({super.key});
|
||
|
||
@override
|
||
State<OfflineBanner> createState() => _OfflineBannerState();
|
||
}
|
||
|
||
class _OfflineBannerState extends State<OfflineBanner> {
|
||
final StompService _stompService = StompService();
|
||
DartMQSubscription? _connSub;
|
||
Timer? _countdownTimer;
|
||
int _secondsToRetry = 15;
|
||
bool _hadConnection = false; // Track if we ever had a successful connection
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
// Check if we're already connected (e.g., coming back to this screen)
|
||
_hadConnection = _stompService.isConnected && _stompService.isAuthenticated;
|
||
// Initialize countdown based on current connection state
|
||
_onConnectionChange(
|
||
_stompService.isConnected && _stompService.isAuthenticated,
|
||
);
|
||
_connSub = DartMQ().subscribe<bool>(
|
||
MQTopics.connectionStatus,
|
||
_onConnectionChange,
|
||
);
|
||
}
|
||
|
||
void _onConnectionChange(bool isConnected) {
|
||
if (!mounted) return;
|
||
if (isConnected) {
|
||
_hadConnection = true; // Mark that we had a successful connection
|
||
_stopCountdown();
|
||
setState(() {});
|
||
} else {
|
||
_startCountdown();
|
||
}
|
||
}
|
||
|
||
void _startCountdown() {
|
||
_stopCountdown();
|
||
setState(() {
|
||
_secondsToRetry = 15;
|
||
});
|
||
_countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) async {
|
||
if (!mounted) return;
|
||
if (_stompService.isConnected) {
|
||
_stopCountdown();
|
||
return;
|
||
}
|
||
|
||
// Decrement until 0, then attempt reconnect
|
||
if (_secondsToRetry > 1) {
|
||
setState(() {
|
||
_secondsToRetry = _secondsToRetry - 1;
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Show 0 for one tick and try to reconnect now
|
||
setState(() {
|
||
_secondsToRetry = 0;
|
||
});
|
||
|
||
try {
|
||
// Only auto-reconnect if we already know the target; discovery remains user-initiated
|
||
await _stompService.connect();
|
||
} catch (e, stackTrace) {
|
||
developer.log(
|
||
'Auto-reconnect attempt failed: $e',
|
||
name: 'OfflineBanner',
|
||
);
|
||
developer.log('Stack trace: $stackTrace', name: 'OfflineBanner');
|
||
}
|
||
|
||
if (!mounted) return;
|
||
if (!_stompService.isConnected) {
|
||
// Still offline -> reset countdown for next attempt
|
||
setState(() {
|
||
_secondsToRetry = 15;
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
void _stopCountdown() {
|
||
_countdownTimer?.cancel();
|
||
_countdownTimer = null;
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_stopCountdown();
|
||
_connSub?.cancel();
|
||
_connSub = null;
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final isOnline = _stompService.isConnected && _stompService.isAuthenticated;
|
||
if (isOnline) return const SizedBox.shrink();
|
||
|
||
// Different messages for initial connection vs connection lost
|
||
final String title;
|
||
final String subtitle;
|
||
final IconData icon;
|
||
final Color? bgColor;
|
||
final Color? iconColor;
|
||
final Color? titleColor;
|
||
final Color? subtitleColor;
|
||
|
||
if (_hadConnection) {
|
||
// Connection was lost
|
||
title = 'Offline – Verbindung verloren';
|
||
subtitle = 'Verbindung wird wiederhergestellt.';
|
||
icon = Icons.wifi_off;
|
||
bgColor = AppColors.dangerSoft;
|
||
iconColor = AppColors.danger;
|
||
titleColor = AppColors.danger;
|
||
subtitleColor = AppColors.danger.withValues(alpha: 0.85);
|
||
} else {
|
||
// Initial connection attempt
|
||
title = 'Verbinde mit Server...';
|
||
subtitle = 'Bitte warten.';
|
||
icon = Icons.sync;
|
||
bgColor = AppColors.warningSoft;
|
||
iconColor = AppColors.warning;
|
||
titleColor = AppColors.warning;
|
||
subtitleColor = AppColors.warning.withValues(alpha: 0.85);
|
||
}
|
||
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 12),
|
||
color: bgColor,
|
||
child: Row(
|
||
children: [
|
||
Icon(icon, color: iconColor),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
title,
|
||
style: TextStyle(
|
||
color: titleColor,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
subtitle,
|
||
style: TextStyle(color: subtitleColor, fontSize: 12),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|