From 6e8bedd9b423cf55120f45bb020bedf19f6f410b Mon Sep 17 00:00:00 2001 From: Sven Carstensen Date: Mon, 13 Apr 2026 11:26:30 +0200 Subject: [PATCH] feat: Drag-and-Drop-Reihenfolge, Station-Abschluss-Flow und UI-Verbesserungen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/lib/app_theme.dart | 175 +++++++++ app/lib/cargo_items_view.dart | 20 +- app/lib/chat_details_view.dart | 33 +- app/lib/chats_view.dart | 31 +- app/lib/jobs_view.dart | 52 +-- app/lib/login_view.dart | 365 +++++++++--------- app/lib/main.dart | 11 +- app/lib/services/websocket_service.dart | 30 ++ app/lib/settings_view.dart | 58 ++- app/lib/task_view.dart | 330 +++++++++++----- app/lib/tasks/barcode_capture_screen.dart | 132 ++++++- app/lib/tasks/photo_capture_screen.dart | 215 +++++++---- app/lib/tasks/signature_capture_screen.dart | 124 +++--- app/lib/widgets/chat_photo_dialog.dart | 9 +- app/lib/widgets/offline_banner.dart | 37 +- .../frontend/themes/votian-modern/styles.css | 108 +++++- .../controller/MessageController.java | 45 ++- .../votianlt/messaging/MessagingConfig.java | 8 + .../ui/component/DeliveryStationDialog.java | 223 ++++++++++- 19 files changed, 1458 insertions(+), 548 deletions(-) create mode 100644 app/lib/app_theme.dart diff --git a/app/lib/app_theme.dart b/app/lib/app_theme.dart new file mode 100644 index 0000000..41c4408 --- /dev/null +++ b/app/lib/app_theme.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; + +class AppColors { + static const Color primary = Color(0xFF2563EB); + static const Color primaryStrong = Color(0xFF1D4ED8); + static const Color primarySoft = Color(0xFFE8F0FF); + static const Color secondary = Color(0xFF0F4C5C); + static const Color secondarySoft = Color(0xFFDDEEF2); + static const Color success = Color(0xFF059669); + static const Color successSoft = Color(0xFFE7F6F1); + static const Color warning = Color(0xFFD97706); + static const Color warningSoft = Color(0xFFFFF4E5); + static const Color danger = Color(0xFFDC2626); + static const Color dangerSoft = Color(0xFFFDECEC); + static const Color surface = Color(0xFFFFFFFF); + static const Color surfaceMuted = Color(0xFFF7FAFF); + static const Color scaffold = Color(0xFFF5F7FB); + static const Color scaffoldAccent = Color(0xFFEEF4FF); + static const Color border = Color(0xFFD6DDE7); + static const Color borderStrong = Color(0xFFC6D0DD); + static const Color text = Color(0xFF1E293B); + static const Color textStrong = Color(0xFF0F172A); + static const Color textMuted = Color(0xFF64748B); +} + +class AppGradients { + static const LinearGradient shellBackground = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.scaffoldAccent, + AppColors.surfaceMuted, + AppColors.scaffold, + ], + stops: [0, 0.45, 1], + ); +} + +ThemeData buildAppTheme() { + final colorScheme = ColorScheme.fromSeed( + seedColor: AppColors.primary, + brightness: Brightness.light, + ).copyWith( + primary: AppColors.primary, + onPrimary: Colors.white, + primaryContainer: AppColors.primarySoft, + onPrimaryContainer: AppColors.primaryStrong, + secondary: AppColors.secondary, + onSecondary: Colors.white, + secondaryContainer: AppColors.secondarySoft, + onSecondaryContainer: AppColors.secondary, + tertiary: AppColors.success, + onTertiary: Colors.white, + tertiaryContainer: AppColors.successSoft, + onTertiaryContainer: AppColors.success, + surface: AppColors.surface, + onSurface: AppColors.textStrong, + onSurfaceVariant: AppColors.textMuted, + outline: AppColors.border, + surfaceTint: Colors.transparent, + error: AppColors.danger, + onError: Colors.white, + ); + final baseTheme = ThemeData(useMaterial3: true, colorScheme: colorScheme); + const radius = Radius.circular(14); + final border = OutlineInputBorder( + borderRadius: const BorderRadius.all(radius), + borderSide: const BorderSide(color: AppColors.border), + ); + + return baseTheme.copyWith( + scaffoldBackgroundColor: AppColors.scaffold, + canvasColor: AppColors.scaffold, + textTheme: baseTheme.textTheme + .apply(bodyColor: AppColors.text, displayColor: AppColors.textStrong) + .copyWith( + headlineMedium: baseTheme.textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w700, + color: AppColors.textStrong, + ), + titleLarge: baseTheme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + color: AppColors.textStrong, + ), + bodyLarge: baseTheme.textTheme.bodyLarge?.copyWith( + color: AppColors.text, + ), + bodyMedium: baseTheme.textTheme.bodyMedium?.copyWith( + color: AppColors.text, + ), + ), + appBarTheme: const AppBarTheme( + backgroundColor: AppColors.primarySoft, + foregroundColor: AppColors.primaryStrong, + surfaceTintColor: Colors.transparent, + elevation: 0, + ), + cardTheme: const CardThemeData( + color: AppColors.surface, + surfaceTintColor: Colors.transparent, + elevation: 0, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(18)), + side: BorderSide(color: AppColors.border), + ), + ), + dividerTheme: const DividerThemeData( + color: AppColors.border, + space: 1, + thickness: 1, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: AppColors.surface, + labelStyle: const TextStyle(color: AppColors.textMuted), + hintStyle: const TextStyle(color: AppColors.textMuted), + prefixIconColor: AppColors.textMuted, + suffixIconColor: AppColors.textMuted, + border: border, + enabledBorder: border, + focusedBorder: border.copyWith( + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + errorBorder: border.copyWith( + borderSide: const BorderSide(color: AppColors.danger), + ), + focusedErrorBorder: border.copyWith( + borderSide: const BorderSide(color: AppColors.danger, width: 1.5), + ), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.border, + disabledForegroundColor: AppColors.textMuted, + elevation: 0, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + textStyle: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primaryStrong, + side: const BorderSide(color: AppColors.border), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: AppColors.primaryStrong, + textStyle: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + snackBarTheme: SnackBarThemeData( + behavior: SnackBarBehavior.floating, + backgroundColor: AppColors.textStrong, + contentTextStyle: baseTheme.textTheme.bodyMedium?.copyWith( + color: Colors.white, + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + ), + badgeTheme: const BadgeThemeData( + backgroundColor: AppColors.primary, + textColor: Colors.white, + ), + progressIndicatorTheme: const ProgressIndicatorThemeData( + color: AppColors.primary, + ), + listTileTheme: const ListTileThemeData(iconColor: AppColors.textMuted), + ); +} diff --git a/app/lib/cargo_items_view.dart b/app/lib/cargo_items_view.dart index 25af807..690e586 100644 --- a/app/lib/cargo_items_view.dart +++ b/app/lib/cargo_items_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'app_theme.dart'; import 'l10n/app_localizations.dart'; import 'l10n/localization_helpers.dart'; import 'models/delivery_station.dart'; @@ -20,7 +21,7 @@ Color? deliveryStationCardBackgroundColor( final isCompleted = station.tasks.every( (task) => taskStatuses[task.id] ?? task.completed, ); - return isCompleted ? Colors.green[50] : null; + return isCompleted ? AppColors.successSoft : null; } class CargoItemsView extends StatefulWidget { @@ -57,7 +58,6 @@ class _CargoItemsViewState extends State { return Scaffold( appBar: AppBar( title: Text(widget.job.jobNumber), - backgroundColor: Colors.deepPurple[100], leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () { @@ -139,7 +139,7 @@ class _CargoItemsViewState extends State { Icon( Icons.arrow_downward, size: 16, - color: Colors.blue[600], + color: AppColors.primary, ), const SizedBox(width: 4), Text( @@ -163,7 +163,7 @@ class _CargoItemsViewState extends State { Icon( Icons.local_shipping_outlined, size: 24, - color: Colors.deepPurple[600], + color: AppColors.primary, ), const SizedBox(width: 8), Text( @@ -313,7 +313,7 @@ class _CargoItemsViewState extends State { vertical: 4, ), decoration: BoxDecoration( - color: Colors.deepPurple[100], + color: AppColors.primarySoft, borderRadius: BorderRadius.circular(12), ), child: Text( @@ -321,7 +321,7 @@ class _CargoItemsViewState extends State { style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, - color: Colors.deepPurple[700], + color: AppColors.primaryStrong, ), ), ), @@ -359,7 +359,7 @@ class _CargoItemsViewState extends State { Icons.location_on_outlined, AppLocalizations.of(context).location, addressLines.join('\n'), - Colors.blue, + AppColors.primary, ), if (station.phone.trim().isNotEmpty) ...[ const SizedBox(height: 12), @@ -367,7 +367,7 @@ class _CargoItemsViewState extends State { Icons.phone_outlined, l10n.phone, station.phone, - Colors.green, + AppColors.success, ), ], if (station.deliveryDate.trim().isNotEmpty || @@ -380,7 +380,7 @@ class _CargoItemsViewState extends State { station.deliveryDate, station.deliveryTime, ].where((part) => part.trim().isNotEmpty).join(' '), - Colors.orange, + AppColors.warning, ), ], const SizedBox(height: 12), @@ -388,7 +388,7 @@ class _CargoItemsViewState extends State { Icons.task_alt, AppLocalizations.of(context).tasks, '${station.tasks.length}', - Colors.deepPurple, + AppColors.primaryStrong, ), ], ), diff --git a/app/lib/chat_details_view.dart b/app/lib/chat_details_view.dart index 40f34d8..8d39b25 100644 --- a/app/lib/chat_details_view.dart +++ b/app/lib/chat_details_view.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:image/image.dart' as img; +import 'app_theme.dart'; import 'l10n/app_localizations.dart'; import 'l10n/localization_helpers.dart'; import 'app_state.dart'; @@ -257,13 +258,12 @@ class _ChatDetailsViewState extends State { 'Job-Nr: ${_activeChat.jobNumber}', style: TextStyle( fontSize: 12, - color: Colors.grey[600], + color: AppColors.textMuted, fontWeight: FontWeight.normal, ), ), ], ), - backgroundColor: Colors.deepPurple[100], actions: [ IconButton( icon: Icon(isJobChat ? Icons.work : Icons.support_agent), @@ -281,7 +281,7 @@ class _ChatDetailsViewState extends State { // Messages list Expanded( child: Container( - decoration: BoxDecoration(color: Colors.grey[50]), + decoration: const BoxDecoration(color: AppColors.surfaceMuted), child: ListView.builder( controller: _scrollController, padding: const EdgeInsets.fromLTRB(8, 8, 8, 96), @@ -325,7 +325,7 @@ class _ChatDetailsViewState extends State { vertical: isImage ? 6 : 8, ), decoration: BoxDecoration( - color: isOwn ? Colors.deepPurple[100] : Colors.white, + color: isOwn ? AppColors.primarySoft : AppColors.surface, borderRadius: BorderRadius.only( topLeft: const Radius.circular(12), topRight: const Radius.circular(12), @@ -351,7 +351,10 @@ class _ChatDetailsViewState extends State { children: [ Text( _formatMessageTime(message.createdAt), - style: TextStyle(fontSize: 11, color: Colors.grey[600]), + style: const TextStyle( + fontSize: 11, + color: AppColors.textMuted, + ), ), if (isOwn) ...[ const SizedBox(width: 4), @@ -362,10 +365,10 @@ class _ChatDetailsViewState extends State { size: 14, color: message.pendingSync - ? Colors.orange[700] + ? AppColors.warning : (message.read - ? Colors.deepPurple[400] - : Colors.grey[600]), + ? AppColors.primary + : AppColors.textMuted), ), ], ], @@ -384,7 +387,7 @@ class _ChatDetailsViewState extends State { if (!isImage) { return Text( message.content, - style: TextStyle(fontSize: 15, color: Colors.grey[800]), + style: const TextStyle(fontSize: 15, color: AppColors.textStrong), ); } @@ -455,8 +458,8 @@ class _ChatDetailsViewState extends State { return Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: Colors.white, - border: Border(top: BorderSide(color: Colors.grey[300]!)), + color: AppColors.surface, + border: const Border(top: BorderSide(color: AppColors.border)), ), child: SafeArea( child: Row( @@ -466,12 +469,12 @@ class _ChatDetailsViewState extends State { child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: Colors.grey[200], + color: AppColors.surfaceMuted, borderRadius: BorderRadius.circular(20), ), child: const Icon( Icons.attach_file, - color: Colors.black87, + color: AppColors.text, size: 20, ), ), @@ -480,7 +483,7 @@ class _ChatDetailsViewState extends State { Expanded( child: Container( decoration: BoxDecoration( - color: Colors.grey[100], + color: AppColors.surfaceMuted, borderRadius: BorderRadius.circular(20), ), child: TextField( @@ -508,7 +511,7 @@ class _ChatDetailsViewState extends State { child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: Colors.deepPurple, + color: AppColors.primary, borderRadius: BorderRadius.circular(20), ), child: const Icon(Icons.send, color: Colors.white, size: 20), diff --git a/app/lib/chats_view.dart b/app/lib/chats_view.dart index a821e3f..f0b71f2 100644 --- a/app/lib/chats_view.dart +++ b/app/lib/chats_view.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'app_theme.dart'; import 'l10n/app_localizations.dart'; import 'l10n/localization_helpers.dart'; import 'models/chat.dart'; @@ -52,10 +53,7 @@ class _ChatsViewState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: Text(AppLocalizations.of(context).chats), - backgroundColor: Colors.deepPurple[100], - ), + appBar: AppBar(title: Text(AppLocalizations.of(context).chats)), body: Column( children: [const OfflineBanner(), Expanded(child: _buildBody())], ), @@ -72,11 +70,15 @@ class _ChatsViewState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.chat_outlined, size: 64, color: Colors.grey), + const Icon( + Icons.chat_outlined, + size: 64, + color: AppColors.textMuted, + ), const SizedBox(height: 16), Text( AppLocalizations.of(context).noChatsAvailable, - style: const TextStyle(fontSize: 16, color: Colors.grey), + style: const TextStyle(fontSize: 16, color: AppColors.textMuted), ), ], ), @@ -108,10 +110,11 @@ class _ChatsViewState extends State { margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: ListTile( leading: CircleAvatar( - backgroundColor: isJobChat ? Colors.blue[100] : Colors.green[100], + backgroundColor: + isJobChat ? AppColors.primarySoft : AppColors.secondarySoft, child: Icon( isJobChat ? Icons.work : Icons.support_agent, - color: isJobChat ? Colors.blue[700] : Colors.green[700], + color: isJobChat ? AppColors.primaryStrong : AppColors.secondary, ), ), title: Text(() { @@ -129,7 +132,7 @@ class _ChatsViewState extends State { previewText, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 14, color: Colors.grey[700]), + style: const TextStyle(fontSize: 14, color: AppColors.textMuted), ), trailing: Column( crossAxisAlignment: CrossAxisAlignment.end, @@ -137,16 +140,17 @@ class _ChatsViewState extends State { children: [ Text( timeLabel, - style: TextStyle(fontSize: 12, color: Colors.grey[500]), + style: const TextStyle(fontSize: 12, color: AppColors.textMuted), ), const SizedBox(height: 4), Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: isJobChat ? Colors.blue[50] : Colors.green[50], + color: + isJobChat ? AppColors.primarySoft : AppColors.secondarySoft, borderRadius: BorderRadius.circular(10), border: Border.all( - color: isJobChat ? Colors.blue[200]! : Colors.green[200]!, + color: isJobChat ? AppColors.primary : AppColors.secondary, ), ), child: Text( @@ -154,7 +158,8 @@ class _ChatsViewState extends State { style: TextStyle( fontSize: 10, fontWeight: FontWeight.w600, - color: isJobChat ? Colors.blue[700] : Colors.green[700], + color: + isJobChat ? AppColors.primaryStrong : AppColors.secondary, ), ), ), diff --git a/app/lib/jobs_view.dart b/app/lib/jobs_view.dart index 7663277..018a697 100644 --- a/app/lib/jobs_view.dart +++ b/app/lib/jobs_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'app_state.dart'; +import 'app_theme.dart'; import 'l10n/app_localizations.dart'; import 'l10n/localization_helpers.dart'; import 'services/websocket_service.dart'; @@ -98,7 +99,7 @@ class _JobsViewState extends State with RouteAware { if (isConnected && !_wasConnected) { _showSnack( AppLocalizations.of(context).connectionRestored, - backgroundColor: Colors.green, + backgroundColor: AppColors.success, ); if (_appState.isLoggedIn) { _loadJobs(); @@ -115,7 +116,7 @@ class _JobsViewState extends State with RouteAware { if (_appState.isLoggedIn && !_isLoggingOut) { _showSnack( AppLocalizations.of(context).connectionLost, - backgroundColor: Colors.red, + backgroundColor: AppColors.danger, ); } } @@ -146,7 +147,7 @@ class _JobsViewState extends State with RouteAware { jobNumber != null ? 'Job $jobNumber ${AppLocalizations.of(context).jobRemoved}' : AppLocalizations.of(context).jobRemoved; - _showSnack(message, backgroundColor: Colors.orange); + _showSnack(message, backgroundColor: AppColors.warning); } }); @@ -177,7 +178,7 @@ class _JobsViewState extends State with RouteAware { jobNumber.isNotEmpty ? '${AppLocalizations.of(context).newJobReceived}: $jobNumber' : AppLocalizations.of(context).newJobReceived; - _showSnack(message, backgroundColor: Colors.green); + _showSnack(message, backgroundColor: AppColors.success); } } catch (e) { developer.log('Error handling job_created event: $e', name: 'JobsView'); @@ -204,7 +205,7 @@ class _JobsViewState extends State with RouteAware { }); _showSnack( AppLocalizations.of(context).jobsUpdated, - backgroundColor: Colors.green, + backgroundColor: AppColors.success, ); } } finally { @@ -560,7 +561,6 @@ class _JobsViewState extends State with RouteAware { appBar: AppBar( automaticallyImplyLeading: false, title: Text(AppLocalizations.of(context).availableJobs), - backgroundColor: Colors.deepPurple[100], leading: IconButton( icon: const Icon(Icons.logout), onPressed: () { @@ -694,7 +694,7 @@ class _JobsViewState extends State with RouteAware { } }, style: ElevatedButton.styleFrom( - backgroundColor: Colors.red, + backgroundColor: AppColors.danger, foregroundColor: Colors.white, ), child: Text(AppLocalizations.of(context).logout), @@ -766,8 +766,8 @@ class _JobsViewState extends State with RouteAware { icon: const Icon(Icons.refresh), label: Text(AppLocalizations.of(context).refresh), style: ElevatedButton.styleFrom( - backgroundColor: Colors.deepPurple[100], - foregroundColor: Colors.deepPurple[700], + backgroundColor: AppColors.primarySoft, + foregroundColor: AppColors.primaryStrong, ), ), ], @@ -792,7 +792,7 @@ class _JobsViewState extends State with RouteAware { } else { _showSnack( AppLocalizations.of(context).offline, - backgroundColor: Colors.red, + backgroundColor: AppColors.danger, ); } } @@ -908,7 +908,7 @@ class _JobsViewState extends State with RouteAware { if (mounted) { _showSnack( AppLocalizations.of(context).jobDeleted, - backgroundColor: Colors.red, + backgroundColor: AppColors.danger, ); } } catch (e, st) { @@ -917,7 +917,7 @@ class _JobsViewState extends State with RouteAware { if (mounted) { _showSnack( AppLocalizations.of(context).jobDeleteError, - backgroundColor: Colors.red, + backgroundColor: AppColors.danger, ); } } finally { @@ -935,19 +935,19 @@ class _JobsViewState extends State with RouteAware { Color statusColor; switch (job.statusColor) { case 'green': - statusColor = Colors.green; + statusColor = AppColors.success; break; case 'blue': - statusColor = Colors.blue; + statusColor = AppColors.primary; break; case 'orange': - statusColor = Colors.orange; + statusColor = AppColors.warning; break; case 'red': - statusColor = Colors.red; + statusColor = AppColors.danger; break; default: - statusColor = Colors.grey; + statusColor = AppColors.textMuted; } // Determine card background color based on task completion @@ -965,9 +965,9 @@ class _JobsViewState extends State with RouteAware { if (totalTasks == 0 || completedTasks == 0) { cardBg = null; // unchanged (default) } else if (completedTasks > 0 && completedTasks < totalTasks) { - cardBg = Colors.yellow[50]; + cardBg = AppColors.warningSoft; } else if (completedTasks == totalTasks) { - cardBg = Colors.green[50]; + cardBg = AppColors.successSoft; } // Build robust display strings with fallbacks final pickupName = _joinNonEmpty([job.pickupFirstName, job.pickupLastName]); @@ -1033,7 +1033,7 @@ class _JobsViewState extends State with RouteAware { iconSize: 28, padding: const EdgeInsets.all(10), splashRadius: 24, - icon: const Icon(Icons.delete, color: Colors.red), + icon: const Icon(Icons.delete, color: AppColors.danger), tooltip: AppLocalizations.of(context).deleteJob, onPressed: () { if (isDeleting) { @@ -1233,13 +1233,13 @@ class _JobsViewState extends State with RouteAware { ? 0 : completedTasks / totalTasks, minHeight: 8, - backgroundColor: Colors.grey[200], + backgroundColor: AppColors.border, valueColor: AlwaysStoppedAnimation( completedTasks >= totalTasks - ? Colors.green + ? AppColors.success : (completedTasks > 0 - ? Colors.amber - : Colors.deepPurpleAccent), + ? AppColors.warning + : AppColors.primary), ), ), ), @@ -1336,7 +1336,7 @@ class _JobsViewState extends State with RouteAware { Icon( Icons.arrow_downward, size: 16, - color: Colors.blue[600], + color: AppColors.primary, ), const SizedBox(width: 4), Text( @@ -1375,7 +1375,7 @@ class _JobsViewState extends State with RouteAware { tooltip: 'Route planen', icon: const Icon( Icons.route, - color: Colors.blueAccent, + color: AppColors.primary, ), onPressed: () { if (_routeActionInProgress) return; diff --git a/app/lib/login_view.dart b/app/lib/login_view.dart index 5984e4f..6563c12 100644 --- a/app/lib/login_view.dart +++ b/app/lib/login_view.dart @@ -7,6 +7,7 @@ import 'services/websocket_service.dart'; import 'services/dart_mq.dart'; import 'services/database_service.dart'; import 'app_state.dart'; +import 'app_theme.dart'; import 'l10n/app_localizations.dart'; class LoginView extends StatefulWidget { @@ -57,7 +58,7 @@ class _LoginViewState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context).loginSuccess), - backgroundColor: Colors.green, + backgroundColor: AppColors.success, duration: const Duration(seconds: 1), ), ); @@ -228,7 +229,7 @@ class _LoginViewState extends State { content: Text( '${AppLocalizations.of(context).loginFailed}: $errorMessage', ), - backgroundColor: Colors.red, + backgroundColor: AppColors.danger, duration: const Duration(seconds: 1), ), ); @@ -292,7 +293,7 @@ class _LoginViewState extends State { scaffoldMessenger.showSnackBar( SnackBar( content: Text(localizations.connecting), - backgroundColor: Colors.blue, + backgroundColor: AppColors.primary, duration: const Duration(seconds: 1), ), ); @@ -345,7 +346,7 @@ class _LoginViewState extends State { scaffoldMessenger.showSnackBar( SnackBar( content: Text(localizations.connectionTimeout), - backgroundColor: Colors.red, + backgroundColor: AppColors.danger, duration: const Duration(seconds: 2), ), ); @@ -364,7 +365,7 @@ class _LoginViewState extends State { scaffoldMessenger.showSnackBar( SnackBar( content: Text('${localizations.connectionError}: $e'), - backgroundColor: Colors.red, + backgroundColor: AppColors.danger, duration: const Duration(seconds: 1), ), ); @@ -420,7 +421,7 @@ class _LoginViewState extends State { scaffoldMessenger.showSnackBar( SnackBar( content: Text('${localizations.loginError}: $e'), - backgroundColor: Colors.red, + backgroundColor: AppColors.danger, duration: const Duration(seconds: 1), ), ); @@ -440,203 +441,207 @@ class _LoginViewState extends State { final l10n = AppLocalizations.of(context); return Scaffold( - backgroundColor: Colors.grey[50], - body: Column( - children: [ - Expanded( - child: SafeArea( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: Form( - key: _formKey, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Logo oder App-Name - Icon( - Icons.account_circle, - size: 100, - color: Colors.deepPurple, - ), - const SizedBox(height: 32), - - Text( - l10n.welcomeBack, - style: Theme.of( - context, - ).textTheme.headlineMedium?.copyWith( - fontWeight: FontWeight.bold, - color: Colors.grey[800], + body: DecoratedBox( + decoration: const BoxDecoration(gradient: AppGradients.shellBackground), + child: Column( + children: [ + Expanded( + child: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Icon( + Icons.account_circle, + size: 100, + color: AppColors.primary, ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), + const SizedBox(height: 32), - Text( - l10n.loginSubtitle, - style: Theme.of(context).textTheme.bodyLarge - ?.copyWith(color: Colors.grey[600]), - textAlign: TextAlign.center, - ), - const SizedBox(height: 32), - // E-Mail-Feld - TextFormField( - controller: _emailController, - keyboardType: TextInputType.emailAddress, - decoration: InputDecoration( - labelText: l10n.emailAddress, - hintText: l10n.emailAddressHint, - prefixIcon: const Icon(Icons.email_outlined), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + Text( + l10n.welcomeBack, + style: Theme.of( + context, + ).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + color: AppColors.textStrong, ), - filled: true, - fillColor: Colors.white, + textAlign: TextAlign.center, ), - validator: (value) { - if (value == null || value.isEmpty) { - return l10n.emailAddressRequired; - } - if (!RegExp( - r'^[A-Za-z0-9_.+-]+@[A-Za-z0-9-]+\.[A-Za-z0-9.-]+$', - ).hasMatch(value)) { - return l10n.emailAddressInvalid; - } - return null; - }, - ), - const SizedBox(height: 16), + const SizedBox(height: 8), - // Passwort-Feld - TextFormField( - controller: _passwordController, - obscureText: !_isPasswordVisible, - decoration: InputDecoration( - labelText: l10n.password, - hintText: l10n.passwordHint, - prefixIcon: const Icon(Icons.lock_outlined), - suffixIcon: IconButton( - icon: Icon( - _isPasswordVisible - ? Icons.visibility - : Icons.visibility_off, + Text( + l10n.loginSubtitle, + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(color: AppColors.textMuted), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + // E-Mail-Feld + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + decoration: InputDecoration( + labelText: l10n.emailAddress, + hintText: l10n.emailAddressHint, + prefixIcon: const Icon(Icons.email_outlined), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), ), - onPressed: () { - setState(() { - _isPasswordVisible = !_isPasswordVisible; - }); - }, + filled: true, + fillColor: AppColors.surface, ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - filled: true, - fillColor: Colors.white, - ), - validator: (value) { - if (value == null || value.isEmpty) { - return l10n.passwordRequired; - } - if (value.length < 6) { - return l10n.passwordMinLength; - } - return null; - }, - ), - const SizedBox(height: 24), - - // Passwort vergessen Link - Align( - alignment: Alignment.centerRight, - child: TextButton( - onPressed: () { - // Hier würde die "Passwort vergessen" Funktionalität implementiert werden - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(l10n.forgotPasswordMessage), - duration: const Duration(seconds: 1), - ), - ); + validator: (value) { + if (value == null || value.isEmpty) { + return l10n.emailAddressRequired; + } + if (!RegExp( + r'^[A-Za-z0-9_.+-]+@[A-Za-z0-9-]+\.[A-Za-z0-9.-]+$', + ).hasMatch(value)) { + return l10n.emailAddressInvalid; + } + return null; }, - child: Text( - l10n.forgotPassword, - style: const TextStyle( - color: Colors.deepPurple, - fontWeight: FontWeight.w500, + ), + const SizedBox(height: 16), + + // Passwort-Feld + TextFormField( + controller: _passwordController, + obscureText: !_isPasswordVisible, + decoration: InputDecoration( + labelText: l10n.password, + hintText: l10n.passwordHint, + prefixIcon: const Icon(Icons.lock_outlined), + suffixIcon: IconButton( + icon: Icon( + _isPasswordVisible + ? Icons.visibility + : Icons.visibility_off, + ), + onPressed: () { + setState(() { + _isPasswordVisible = !_isPasswordVisible; + }); + }, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + filled: true, + fillColor: AppColors.surface, + ), + validator: (value) { + if (value == null || value.isEmpty) { + return l10n.passwordRequired; + } + if (value.length < 6) { + return l10n.passwordMinLength; + } + return null; + }, + ), + const SizedBox(height: 24), + + // Passwort vergessen Link + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () { + // Hier würde die "Passwort vergessen" Funktionalität implementiert werden + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(l10n.forgotPasswordMessage), + duration: const Duration(seconds: 1), + ), + ); + }, + child: Text( + l10n.forgotPassword, + style: const TextStyle( + color: AppColors.primaryStrong, + fontWeight: FontWeight.w500, + ), ), ), ), - ), - const SizedBox(height: 24), + const SizedBox(height: 24), - // Verbindungsstatus - // Anmelden Button - ElevatedButton( - onPressed: _isLoggingIn ? null : _handleLogin, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.deepPurple, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + // Verbindungsstatus + // Anmelden Button + ElevatedButton( + onPressed: _isLoggingIn ? null : _handleLogin, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, ), - elevation: 2, - ), - child: - _isLoggingIn - ? Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2.5, - valueColor: - AlwaysStoppedAnimation( - Colors.white, - ), + child: + _isLoggingIn + ? Row( + mainAxisAlignment: + MainAxisAlignment.center, + children: [ + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2.5, + valueColor: + AlwaysStoppedAnimation( + Colors.white, + ), + ), ), - ), - const SizedBox(width: 12), - Text( - l10n.loggingIn, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, + const SizedBox(width: 12), + Text( + l10n.loggingIn, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), ), + ], + ) + : Text( + l10n.login, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, ), - ], - ) - : Text( - l10n.login, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, ), - ), - ), - const SizedBox(height: 24), - ], + ), + const SizedBox(height: 24), + ], + ), ), ), ), ), ), - ), - // Version number at the bottom - if (_appVersion.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: Text( - 'Version $_appVersion', - style: TextStyle(fontSize: 12, color: Colors.grey[500]), - textAlign: TextAlign.center, + if (_appVersion.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Text( + 'Version $_appVersion', + style: const TextStyle( + fontSize: 12, + color: AppColors.textMuted, + ), + textAlign: TextAlign.center, + ), ), - ), - ], + ], + ), ), ); } diff --git a/app/lib/main.dart b/app/lib/main.dart index 581876f..84aff0f 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; +import 'app_theme.dart'; import 'login_view.dart'; import 'jobs_view.dart'; import 'cargo_items_view.dart'; @@ -104,10 +105,7 @@ class _MyAppState extends State with WidgetsBindingObserver { return MaterialApp( title: 'VotianLT App', debugShowCheckedModeBanner: false, - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), - useMaterial3: true, - ), + theme: buildAppTheme(), // Localization configuration locale: locale, localizationsDelegates: const [ @@ -178,10 +176,7 @@ class _MyHomePageState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - title: Text(widget.title), - ), + appBar: AppBar(title: Text(widget.title)), body: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/app/lib/services/websocket_service.dart b/app/lib/services/websocket_service.dart index 5ba380c..434bc6f 100644 --- a/app/lib/services/websocket_service.dart +++ b/app/lib/services/websocket_service.dart @@ -1189,6 +1189,36 @@ class WebSocketService { } } + /// Send station completion event to server. + /// Messages are buffered if offline and sent automatically when reconnected. + Future sendStationCompleted({ + required String jobId, + required String jobNumber, + required int stationOrder, + bool hasIncompleteOptionalTasks = false, + }) async { + const String destination = '/server/station_completed'; + + final payload = { + 'jobId': jobId, + 'jobNumber': jobNumber, + 'stationOrder': stationOrder, + 'completedAt': DateTime.now().toUtc().toIso8601String(), + 'hasIncompleteOptionalTasks': hasIncompleteOptionalTasks, + }; + + try { + final jsonPayload = jsonEncode(payload); + sendMessage(destination, jsonPayload); + } catch (e, st) { + developer.log( + 'Error sending station completion: $e', + name: 'WebSocketService', + ); + developer.log('Stack: $st', name: 'WebSocketService'); + } + } + /// Dispose resources void dispose() { _stopReconnectTimer(); diff --git a/app/lib/settings_view.dart b/app/lib/settings_view.dart index d81147e..49721da 100644 --- a/app/lib/settings_view.dart +++ b/app/lib/settings_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'l10n/app_localizations.dart'; import 'app_state.dart'; +import 'app_theme.dart'; /// Supported languages with their display names and flag emojis class LanguageOption { @@ -36,17 +37,17 @@ class _SettingsViewState extends State { setState(() { _selectedLanguageCode = languageCode; }); - + // Save language preference await _appState.setLanguage(languageCode); - + // Show confirmation snackbar _showLanguageChangedSnackBar(languageCode); } void _showLanguageChangedSnackBar(String languageCode) { final l10n = AppLocalizations.of(context); - + // Get the language name from the corresponding localization String languageName; String flagEmoji; @@ -98,11 +99,9 @@ class _SettingsViewState extends State { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - '${l10n.languageChanged}: $flagEmoji $languageName', - ), + content: Text('${l10n.languageChanged}: $flagEmoji $languageName'), duration: const Duration(seconds: 2), - backgroundColor: Colors.green, + backgroundColor: AppColors.success, ), ); } @@ -129,10 +128,7 @@ class _SettingsViewState extends State { final languageOptions = _getLanguageOptions(); return Scaffold( - appBar: AppBar( - title: Text(l10n.settings), - backgroundColor: Colors.deepPurple[100], - ), + appBar: AppBar(title: Text(l10n.settings)), body: ListView( children: [ // Language Selection Section @@ -143,7 +139,7 @@ class _SettingsViewState extends State { style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, - color: Colors.grey, + color: AppColors.textMuted, letterSpacing: 1.2, ), ), @@ -160,7 +156,7 @@ class _SettingsViewState extends State { width: 40, height: 40, decoration: BoxDecoration( - color: Colors.grey[100], + color: AppColors.surfaceMuted, borderRadius: BorderRadius.circular(20), ), child: Center( @@ -173,22 +169,27 @@ class _SettingsViewState extends State { title: Text( language.name, style: TextStyle( - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - color: isSelected ? Colors.deepPurple : Colors.black87, + fontWeight: + isSelected ? FontWeight.w600 : FontWeight.normal, + color: + isSelected + ? AppColors.primaryStrong + : AppColors.textStrong, ), ), - trailing: isSelected - ? const Icon( - Icons.check_circle, - color: Colors.deepPurple, - ) - : const Icon( - Icons.circle_outlined, - color: Colors.grey, - ), + trailing: + isSelected + ? const Icon( + Icons.check_circle, + color: AppColors.primary, + ) + : const Icon( + Icons.circle_outlined, + color: AppColors.textMuted, + ), onTap: () => _onLanguageSelected(language.code), selected: isSelected, - selectedTileColor: Colors.deepPurple.withValues(alpha: 0.05), + selectedTileColor: AppColors.primarySoft, ), const Divider(height: 1, indent: 72), ], @@ -203,17 +204,14 @@ class _SettingsViewState extends State { style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w600, - color: Colors.grey, + color: AppColors.textMuted, letterSpacing: 1.2, ), ), ), const Divider(height: 1), ListTile( - leading: Icon( - Icons.info_outline, - color: Colors.grey[600], - ), + leading: Icon(Icons.info_outline, color: AppColors.textMuted), title: Text(l10n.version), subtitle: const Text('0.9.2'), ), diff --git a/app/lib/task_view.dart b/app/lib/task_view.dart index eda821b..03adbd3 100644 --- a/app/lib/task_view.dart +++ b/app/lib/task_view.dart @@ -5,6 +5,7 @@ import 'package:image/image.dart' as img; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; +import 'app_theme.dart'; import 'l10n/app_localizations.dart'; import 'l10n/localization_helpers.dart'; import 'models/job.dart'; @@ -89,7 +90,6 @@ class _TaskViewState extends State { ? '${AppLocalizations.of(context).tasks} - ${widget.stationTitle}' : '${AppLocalizations.of(context).tasks} - ${widget.job.jobNumber}', ), - backgroundColor: Colors.deepPurple[100], leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () { @@ -116,14 +116,14 @@ class _TaskViewState extends State { constraints: const BoxConstraints(maxHeight: 150), padding: const EdgeInsets.all(12.0), decoration: BoxDecoration( - color: const Color(0xFFF8F9FA), - border: Border.all(color: Colors.grey[300]!, width: 1), + color: AppColors.surfaceMuted, + border: Border.all(color: AppColors.border, width: 1), borderRadius: BorderRadius.circular(8), ), child: SingleChildScrollView( child: Text( _getRemark(), - style: TextStyle(fontSize: 14, color: Colors.grey[700]), + style: const TextStyle(fontSize: 14, color: AppColors.text), ), ), ), @@ -132,7 +132,13 @@ class _TaskViewState extends State { padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [Expanded(child: _buildTasksStepper())], + children: [ + Expanded(child: _buildTasksStepper()), + if (_visibleTasks.isNotEmpty) ...[ + const SizedBox(height: 12), + _buildCompleteStationButton(), + ], + ], ), ), ), @@ -141,22 +147,111 @@ class _TaskViewState extends State { ); } + bool get _canCompleteStation { + // Station kann abgeschlossen werden, wenn alle nicht-optionalen Aufgaben + // erledigt sind. Sind nur optionale Aufgaben vorhanden, ist der Button + // immer aktiv. + for (final t in _visibleTasks) { + if (!t.optional && !_completedTasks.contains(t.id)) { + return false; + } + } + return true; + } + + bool get _hasIncompleteOptionalTasks { + for (final t in _visibleTasks) { + if (t.optional && !_completedTasks.contains(t.id)) { + return true; + } + } + return false; + } + + Widget _buildCompleteStationButton() { + final bool enabled = _canCompleteStation; + return SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: enabled ? _onCompleteStationPressed : null, + icon: const Icon(Icons.flag), + label: const Text('Station abschließen'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + ), + ), + ); + } + + void _onCompleteStationPressed() { + if (_hasIncompleteOptionalTasks) { + showDialog( + context: context, + builder: (BuildContext ctx) { + return AlertDialog( + title: const Text('Offene optionale Aufgaben'), + content: const Text( + 'Es gibt nicht erledigte optionale Aufgaben. Möchten Sie die Station trotzdem abschließen?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text(AppLocalizations.of(context).cancel), + ), + ElevatedButton( + onPressed: () { + Navigator.of(ctx).pop(); + _sendStationCompleted(); + }, + child: const Text('Trotzdem abschließen'), + ), + ], + ); + }, + ); + } else { + _sendStationCompleted(); + } + } + + void _sendStationCompleted() { + final stationOrder = widget.stationOrder ?? 0; + try { + StompService().sendStationCompleted( + jobId: widget.job.id, + jobNumber: widget.job.jobNumber, + stationOrder: stationOrder, + hasIncompleteOptionalTasks: _hasIncompleteOptionalTasks, + ); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Station abgeschlossen')), + ); + Navigator.of(context).pop(); + } catch (e) { + developer.log('Error sending station completion: $e', name: 'TaskView'); + } + } + Widget _buildTasksStepper() { if (_visibleTasks.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.task_outlined, size: 64, color: Colors.grey[400]), + const Icon( + Icons.task_outlined, + size: 64, + color: AppColors.textMuted, + ), const SizedBox(height: 16), Text( AppLocalizations.of(context).noTasks, - style: TextStyle(fontSize: 16, color: Colors.grey[600]), + style: const TextStyle(fontSize: 16, color: AppColors.textMuted), ), const SizedBox(height: 8), Text( AppLocalizations.of(context).noTasksMessage, - style: TextStyle(fontSize: 14, color: Colors.grey[500]), + style: const TextStyle(fontSize: 14, color: AppColors.textMuted), textAlign: TextAlign.center, ), ], @@ -174,24 +269,25 @@ class _TaskViewState extends State { // Hintergrundfarbe je nach Status: // abgeschlossen → hellgrün, bearbeitbar → weiß, gesperrt → hellgrau + // (Optionale Aufgaben werden durch einen Chip markiert, nicht per Farbe.) final Color cardColor = isCompleted - ? const Color(0xFFE8F5E9) // hellgrün + ? AppColors.successSoft : canBeCompletedNow - ? Colors.white - : const Color(0xFFF5F5F5); // hellgrau + ? AppColors.surface + : AppColors.surfaceMuted; final Color borderColor = isCompleted - ? Colors.green[300]! + ? AppColors.success.withValues(alpha: 0.35) : canBeCompletedNow - ? Colors.grey[300]! - : Colors.grey[200]!; + ? AppColors.border + : AppColors.border.withValues(alpha: 0.7); final Color circleColor = isCompleted - ? Colors.green[600]! + ? AppColors.success : canBeCompletedNow - ? Colors.deepPurple[400]! - : Colors.grey[400]!; + ? AppColors.primary + : AppColors.textMuted; return Card( margin: const EdgeInsets.only(bottom: 12), @@ -212,79 +308,93 @@ class _TaskViewState extends State { borderRadius: BorderRadius.circular(12), color: cardColor, ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, + child: Stack( children: [ - // Task number circle - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: circleColor, - ), - child: Center( - child: Text( - '${index + 1}', - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 16, + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Task number circle + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: circleColor, + ), + child: Center( + child: Text( + '${index + 1}', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + ), + ), + const SizedBox(width: 16), + // Task content + Expanded( + child: Padding( + padding: EdgeInsets.only( + right: task.optional ? 72 : 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTaskDisplayText(task, isCompleted, index), + if (_getTaskStationLabel(task) != null) ...[ + const SizedBox(height: 4), + Text( + _getTaskStationLabel(task)!, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + fontWeight: FontWeight.w500, + ), + ), + ], + ], + ), + ), + ), + if (isCompleted) ...[ + const SizedBox(width: 8), + const Icon( + Icons.check_circle, + color: AppColors.success, + ), + ], + ], + ), + if (task.optional) + Positioned.fill( + child: Align( + alignment: Alignment.centerRight, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: AppColors.warningSoft, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppColors.warning.withValues(alpha: 0.5), + width: 1, + ), + ), + child: const Text( + 'Optional', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColors.warning, + ), + ), ), ), ), - ), - const SizedBox(width: 16), - // Task content - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildTaskDisplayText( - task, - isCompleted, - index, - ), - if (_getTaskStationLabel(task) != null) ...[ - const SizedBox(height: 4), - Text( - _getTaskStationLabel(task)!, - style: TextStyle( - fontSize: 12, - color: Colors.grey[600], - fontWeight: FontWeight.w500, - ), - ), - ], - if (task.optional) ...[ - const SizedBox(height: 4), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: BoxDecoration( - color: Colors.amber[50], - border: Border.all(color: Colors.amber[300]!), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - AppLocalizations.of(context).optional, - style: TextStyle( - fontSize: 11, - color: Colors.amber[800], - fontWeight: FontWeight.w600, - ), - ), - ), - ], - ], - ), - ), - if (isCompleted) ...[ - const SizedBox(width: 8), - Icon(Icons.check_circle, color: Colors.green[600]), - ], ], ), ), @@ -622,6 +732,7 @@ class _TaskViewState extends State { String? taskType, Map? extraData, }) { + final bool hadOpenMandatoryBefore = _hasOpenMandatoryTasks; setState(() { _completedTasks.add(taskId); }); @@ -638,6 +749,53 @@ class _TaskViewState extends State { } catch (e) { developer.log('Error sending task completion: $e', name: 'TaskView'); } + + // Wenn die letzte nicht-optionale Aufgabe gerade erledigt wurde, + // den Benutzer fragen, ob er die Station jetzt abschließen möchte. + if (hadOpenMandatoryBefore && !_hasOpenMandatoryTasks) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _showLastMandatoryCompletedDialog(); + } + }); + } + } + + bool get _hasOpenMandatoryTasks { + for (final t in _visibleTasks) { + if (!t.optional && !_completedTasks.contains(t.id)) { + return true; + } + } + return false; + } + + void _showLastMandatoryCompletedDialog() { + showDialog( + context: context, + builder: (BuildContext ctx) { + return AlertDialog( + title: const Text('Alle Pflichtaufgaben erledigt'), + content: const Text( + 'Alle nicht optionalen Aufgaben dieser Station sind erledigt. ' + 'Möchten Sie die Station jetzt abschließen?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Später'), + ), + ElevatedButton( + onPressed: () { + Navigator.of(ctx).pop(); + _onCompleteStationPressed(); + }, + child: const Text('Station abschließen'), + ), + ], + ); + }, + ); } bool _arePreviousTasksCompleted(int index) { diff --git a/app/lib/tasks/barcode_capture_screen.dart b/app/lib/tasks/barcode_capture_screen.dart index adcd469..996560e 100644 --- a/app/lib/tasks/barcode_capture_screen.dart +++ b/app/lib/tasks/barcode_capture_screen.dart @@ -9,7 +9,11 @@ class BarcodeCaptureScreen extends StatefulWidget { final BarcodeTask task; final Function(List) onBarcodesCompleted; - const BarcodeCaptureScreen({super.key, required this.task, required this.onBarcodesCompleted}); + const BarcodeCaptureScreen({ + super.key, + required this.task, + required this.onBarcodesCompleted, + }); @override State createState() => _BarcodeCaptureScreenState(); @@ -70,7 +74,11 @@ class _BarcodeCaptureScreenState extends State { }); } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('${AppLocalizations.of(context).cameraError}: $e'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${AppLocalizations.of(context).cameraError}: $e'), + ), + ); } } } @@ -142,7 +150,28 @@ class _BarcodeCaptureScreenState extends State { @override Widget build(BuildContext context) { - return Scaffold(appBar: AppBar(title: Text(AppLocalizations.of(context).barcodeScan), backgroundColor: Colors.deepPurple[100], leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).pop())), body: Column(children: [OfflineBanner(), Expanded(child: _isScannerInitialized ? (_isMobilePlatform ? _buildMobileView() : _buildDesktopView()) : const Center(child: CircularProgressIndicator()))])); + return Scaffold( + appBar: AppBar( + title: Text(AppLocalizations.of(context).barcodeScan), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).pop(), + ), + ), + body: Column( + children: [ + const OfflineBanner(), + Expanded( + child: + _isScannerInitialized + ? (_isMobilePlatform + ? _buildMobileView() + : _buildDesktopView()) + : const Center(child: CircularProgressIndicator()), + ), + ], + ), + ); } Widget _buildMobileView() { @@ -153,9 +182,33 @@ class _BarcodeCaptureScreenState extends State { flex: 3, child: Stack( children: [ - MobileScanner(controller: _scannerController, onDetect: _onBarcodeDetected), + MobileScanner( + controller: _scannerController, + onDetect: _onBarcodeDetected, + ), // Overlay with scanning frame - Container(decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5)), child: Center(child: Container(width: 250, height: 250, decoration: BoxDecoration(border: Border.all(color: Colors.white, width: 2), borderRadius: BorderRadius.circular(12)), child: Container(margin: const EdgeInsets.all(20), decoration: BoxDecoration(border: Border.all(color: Colors.green, width: 2), borderRadius: BorderRadius.circular(8)))))), + Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + ), + child: Center( + child: Container( + width: 250, + height: 250, + decoration: BoxDecoration( + border: Border.all(color: Colors.white, width: 2), + borderRadius: BorderRadius.circular(12), + ), + child: Container( + margin: const EdgeInsets.all(20), + decoration: BoxDecoration( + border: Border.all(color: Colors.green, width: 2), + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ), ], ), ), @@ -167,20 +220,47 @@ class _BarcodeCaptureScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('${AppLocalizations.of(context).scannedBarcodes} (${_scannedBarcodes.length}/${widget.task.maxBarcodeCount})', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + Text( + '${AppLocalizations.of(context).scannedBarcodes} (${_scannedBarcodes.length}/${widget.task.maxBarcodeCount})', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), const SizedBox(height: 8), - Text('${AppLocalizations.of(context).minBarcodes} ${widget.task.minBarcodeCount} ${AppLocalizations.of(context).barcodesRequired}', style: TextStyle(fontSize: 14, color: Colors.grey[600])), + Text( + '${AppLocalizations.of(context).minBarcodes} ${widget.task.minBarcodeCount} ${AppLocalizations.of(context).barcodesRequired}', + style: TextStyle(fontSize: 14, color: Colors.grey[600]), + ), const SizedBox(height: 16), Expanded( child: ListView.builder( itemCount: _scannedBarcodes.length, itemBuilder: (context, index) { - return Card(child: ListTile(leading: const Icon(Icons.qr_code), title: Text(_scannedBarcodes[index]), trailing: IconButton(icon: const Icon(Icons.delete, color: Colors.red), onPressed: () => _removeBarcode(index)))); + return Card( + child: ListTile( + leading: const Icon(Icons.qr_code), + title: Text(_scannedBarcodes[index]), + trailing: IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + onPressed: () => _removeBarcode(index), + ), + ), + ); }, ), ), const SizedBox(height: 16), - SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _canFinish() ? _finishTask : null, style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), child: Text(AppLocalizations.of(context).finish))), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _canFinish() ? _finishTask : null, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: Text(AppLocalizations.of(context).finish), + ), + ), ], ), ), @@ -195,9 +275,15 @@ class _BarcodeCaptureScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(AppLocalizations.of(context).enterBarcode, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), + Text( + AppLocalizations.of(context).enterBarcode, + style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), const SizedBox(height: 8), - Text('${AppLocalizations.of(context).barcodeEnterDescription} (${widget.task.minBarcodeCount}-${widget.task.maxBarcodeCount})', style: TextStyle(fontSize: 16, color: Colors.grey[600])), + Text( + '${AppLocalizations.of(context).barcodeEnterDescription} (${widget.task.minBarcodeCount}-${widget.task.maxBarcodeCount})', + style: TextStyle(fontSize: 16, color: Colors.grey[600]), + ), const SizedBox(height: 24), Expanded( child: ListView.builder( @@ -207,7 +293,18 @@ class _BarcodeCaptureScreenState extends State { padding: const EdgeInsets.only(bottom: 12), child: TextField( controller: _textControllers[index], - decoration: InputDecoration(labelText: index < widget.task.minBarcodeCount ? AppLocalizations.of(context).barcodeNumberRequired(index + 1) : AppLocalizations.of(context).barcodeNumberOptional(index + 1), border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.qr_code)), + decoration: InputDecoration( + labelText: + index < widget.task.minBarcodeCount + ? AppLocalizations.of( + context, + ).barcodeNumberRequired(index + 1) + : AppLocalizations.of( + context, + ).barcodeNumberOptional(index + 1), + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.qr_code), + ), onChanged: (value) { setState(() { // Trigger rebuild to update button state @@ -219,7 +316,16 @@ class _BarcodeCaptureScreenState extends State { ), ), const SizedBox(height: 16), - SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _canFinish() ? _finishTask : null, style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)), child: Text(AppLocalizations.of(context).finish))), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _canFinish() ? _finishTask : null, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: Text(AppLocalizations.of(context).finish), + ), + ), ], ), ); diff --git a/app/lib/tasks/photo_capture_screen.dart b/app/lib/tasks/photo_capture_screen.dart index 93e0316..3030007 100644 --- a/app/lib/tasks/photo_capture_screen.dart +++ b/app/lib/tasks/photo_capture_screen.dart @@ -6,6 +6,7 @@ import 'package:camera/camera.dart'; import 'package:file_picker/file_picker.dart'; import 'package:file_selector/file_selector.dart' as fsel; import 'package:votianlt_app/services/developer.dart' as developer; +import '../app_theme.dart'; import '../l10n/app_localizations.dart'; import '../models/tasks/photo_task.dart'; import '../widgets/offline_banner.dart'; @@ -91,11 +92,16 @@ class _PhotoCaptureScreenState extends State { } } } catch (e, stackTrace) { - developer.log('Error initializing camera: $e', name: 'PhotoCaptureScreen'); + developer.log( + 'Error initializing camera: $e', + name: 'PhotoCaptureScreen', + ); developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${AppLocalizations.of(context).cameraError}: $e')), + SnackBar( + content: Text('${AppLocalizations.of(context).cameraError}: $e'), + ), ); } } @@ -118,7 +124,9 @@ class _PhotoCaptureScreenState extends State { } else { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(AppLocalizations.of(context).cameraNotReady)), + SnackBar( + content: Text(AppLocalizations.of(context).cameraNotReady), + ), ); } } @@ -127,7 +135,9 @@ class _PhotoCaptureScreenState extends State { developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${AppLocalizations.of(context).photoError}: $e')), + SnackBar( + content: Text('${AppLocalizations.of(context).photoError}: $e'), + ), ); } } @@ -136,7 +146,8 @@ class _PhotoCaptureScreenState extends State { Future _pickPhotoFromFile() async { try { // Use file_selector for desktop and web for robust platform support - final bool useFileSelector = kIsWeb || + final bool useFileSelector = + kIsWeb || defaultTargetPlatform == TargetPlatform.macOS || defaultTargetPlatform == TargetPlatform.windows || defaultTargetPlatform == TargetPlatform.linux; @@ -146,7 +157,9 @@ class _PhotoCaptureScreenState extends State { label: 'images', extensions: ['jpg', 'jpeg', 'png', 'heic', 'bmp', 'gif', 'webp'], ); - final fsel.XFile? picked = await fsel.openFile(acceptedTypeGroups: [typeGroup]); + final fsel.XFile? picked = await fsel.openFile( + acceptedTypeGroups: [typeGroup], + ); if (picked != null) { final data = await picked.readAsBytes(); setState(() { @@ -187,11 +200,16 @@ class _PhotoCaptureScreenState extends State { } } } catch (e, stackTrace) { - developer.log('Error picking photo from file: $e', name: 'PhotoCaptureScreen'); + developer.log( + 'Error picking photo from file: $e', + name: 'PhotoCaptureScreen', + ); developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${AppLocalizations.of(context).photoError}: $e')), + SnackBar( + content: Text('${AppLocalizations.of(context).photoError}: $e'), + ), ); } } @@ -230,7 +248,10 @@ class _PhotoCaptureScreenState extends State { } }, style: ElevatedButton.styleFrom(backgroundColor: Colors.red), - child: Text(AppLocalizations.of(context).delete, style: const TextStyle(color: Colors.white)), + child: Text( + AppLocalizations.of(context).delete, + style: const TextStyle(color: Colors.white), + ), ), ], ); @@ -240,7 +261,7 @@ class _PhotoCaptureScreenState extends State { bool get _canComplete { return _capturedPhotos.length >= widget.task.minPhotoCount && - _capturedPhotos.length <= widget.task.maxPhotoCount; + _capturedPhotos.length <= widget.task.maxPhotoCount; } bool get _canTakeMore { @@ -276,7 +297,10 @@ class _PhotoCaptureScreenState extends State { curve: Curves.easeInOut, ); } catch (e, stackTrace) { - developer.log('Error animating to page: $e', name: 'PhotoCaptureScreen'); + developer.log( + 'Error animating to page: $e', + name: 'PhotoCaptureScreen', + ); developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen'); _pageController.jumpToPage(clamped); } @@ -304,7 +328,7 @@ class _PhotoCaptureScreenState extends State { return Scaffold( appBar: AppBar( title: Text(AppLocalizations.of(context).photoCapture), - backgroundColor: Colors.blue, + backgroundColor: AppColors.primary, foregroundColor: Colors.white, actions: [ if (_canComplete) @@ -315,7 +339,10 @@ class _PhotoCaptureScreenState extends State { }, child: Text( AppLocalizations.of(context).finish, - style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), ), ), ], @@ -327,7 +354,7 @@ class _PhotoCaptureScreenState extends State { Container( width: double.infinity, padding: EdgeInsets.all(16), - color: Colors.grey[100], + color: AppColors.surfaceMuted, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -337,7 +364,10 @@ class _PhotoCaptureScreenState extends State { ), Text( '${AppLocalizations.of(context).photosTaken}: ${_capturedPhotos.length}', - style: TextStyle(fontSize: 14, color: Colors.grey[600]), + style: const TextStyle( + fontSize: 14, + color: AppColors.textMuted, + ), ), ], ), @@ -345,19 +375,20 @@ class _PhotoCaptureScreenState extends State { // Camera preview, photo gallery or empty state Expanded( - child: _capturedPhotos.isEmpty - ? _buildCameraOrEmptyState() - : _buildPhotoGallery(), + child: + _capturedPhotos.isEmpty + ? _buildCameraOrEmptyState() + : _buildPhotoGallery(), ), // Bottom controls Container( padding: EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.white, + color: AppColors.surface, boxShadow: [ BoxShadow( - color: Colors.grey.withValues(alpha: 0.3), + color: AppColors.textStrong.withValues(alpha: 0.12), spreadRadius: 1, blurRadius: 5, offset: Offset(0, -3), @@ -372,23 +403,50 @@ class _PhotoCaptureScreenState extends State { // Camera or file select button Expanded( child: ElevatedButton.icon( - onPressed: _canTakeMore && _isCameraSupportedOnThisPlatform - ? (_useFilePickerMode - ? _pickPhotoFromFile - : (_isCameraInitialized ? _capturePhoto : null)) - : null, - icon: Icon(_useFilePickerMode ? Icons.photo_library : Icons.camera_alt), + onPressed: + _canTakeMore && _isCameraSupportedOnThisPlatform + ? (_useFilePickerMode + ? _pickPhotoFromFile + : (_isCameraInitialized + ? _capturePhoto + : null)) + : null, + icon: Icon( + _useFilePickerMode + ? Icons.photo_library + : Icons.camera_alt, + ), label: Text( !_isCameraSupportedOnThisPlatform - ? AppLocalizations.of(context).cameraNotSupportedOnPlatform + ? AppLocalizations.of( + context, + ).cameraNotSupportedOnPlatform : (!_canTakeMore - ? AppLocalizations.of(context).maxPhotosReached + ? AppLocalizations.of( + context, + ).maxPhotosReached : (_useFilePickerMode ? AppLocalizations.of(context).selectPhoto - : (_isCameraInitialized ? AppLocalizations.of(context).takePhoto : (defaultTargetPlatform == TargetPlatform.macOS ? AppLocalizations.of(context).cameraReadyNoPreview : AppLocalizations.of(context).cameraLoading)))), + : (_isCameraInitialized + ? AppLocalizations.of( + context, + ).takePhoto + : (defaultTargetPlatform == + TargetPlatform.macOS + ? AppLocalizations.of( + context, + ).cameraReadyNoPreview + : AppLocalizations.of( + context, + ).cameraLoading)))), ), style: ElevatedButton.styleFrom( - backgroundColor: _canTakeMore && (_useFilePickerMode || _isCameraInitialized) ? Colors.blue : Colors.grey, + backgroundColor: + _canTakeMore && + (_useFilePickerMode || + _isCameraInitialized) + ? AppColors.primary + : AppColors.borderStrong, foregroundColor: Colors.white, padding: EdgeInsets.symmetric(vertical: 12), ), @@ -405,7 +463,10 @@ class _PhotoCaptureScreenState extends State { style: ElevatedButton.styleFrom( backgroundColor: Colors.red, foregroundColor: Colors.white, - padding: EdgeInsets.symmetric(vertical: 12, horizontal: 16), + padding: EdgeInsets.symmetric( + vertical: 12, + horizontal: 16, + ), ), ), ], @@ -416,18 +477,23 @@ class _PhotoCaptureScreenState extends State { SizedBox( width: double.infinity, child: ElevatedButton( - onPressed: _canComplete - ? () { - widget.onPhotosCompleted(_capturedPhotos); - Navigator.of(context).pop(); - } - : null, + onPressed: + _canComplete + ? () { + widget.onPhotosCompleted(_capturedPhotos); + Navigator.of(context).pop(); + } + : null, style: ElevatedButton.styleFrom( - backgroundColor: _canComplete ? Colors.green : Colors.grey, + backgroundColor: + _canComplete ? Colors.green : Colors.grey, foregroundColor: Colors.white, padding: EdgeInsets.symmetric(vertical: 14), ), - child: Text(AppLocalizations.of(context).finish, style: const TextStyle(fontWeight: FontWeight.bold)), + child: Text( + AppLocalizations.of(context).finish, + style: const TextStyle(fontWeight: FontWeight.bold), + ), ), ), ], @@ -451,7 +517,11 @@ class _PhotoCaptureScreenState extends State { SizedBox(height: 16), Text( AppLocalizations.of(context).cameraNotAvailable, - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.grey[700]), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.grey[700], + ), ), SizedBox(height: 8), Text( @@ -477,7 +547,11 @@ class _PhotoCaptureScreenState extends State { SizedBox(height: 16), Text( AppLocalizations.of(context).addPhotos, - style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.grey[700]), + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.grey[700], + ), ), SizedBox(height: 8), Text( @@ -518,11 +592,7 @@ class _PhotoCaptureScreenState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.camera_alt, - size: 80, - color: Colors.grey[400], - ), + Icon(Icons.camera_alt, size: 80, color: Colors.grey[400]), SizedBox(height: 16), Text( AppLocalizations.of(context).cameraInitializing, @@ -535,10 +605,7 @@ class _PhotoCaptureScreenState extends State { SizedBox(height: 8), Text( AppLocalizations.of(context).cameraLoadingMessage, - style: TextStyle( - fontSize: 14, - color: Colors.grey[500], - ), + style: TextStyle(fontSize: 14, color: Colors.grey[500]), textAlign: TextAlign.center, ), ], @@ -601,7 +668,11 @@ class _PhotoCaptureScreenState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.error, size: 50, color: Colors.grey[600]), + Icon( + Icons.error, + size: 50, + color: Colors.grey[600], + ), SizedBox(height: 8), Text(AppLocalizations.of(context).photoError), ], @@ -621,9 +692,8 @@ class _PhotoCaptureScreenState extends State { bottom: 0, child: Center( child: IconButton( - onPressed: _currentPhotoIndex > 0 - ? _goToPreviousPhoto - : null, + onPressed: + _currentPhotoIndex > 0 ? _goToPreviousPhoto : null, icon: Icon(Icons.chevron_left, size: 36), style: IconButton.styleFrom( backgroundColor: Colors.white.withValues(alpha: 0.7), @@ -638,9 +708,10 @@ class _PhotoCaptureScreenState extends State { bottom: 0, child: Center( child: IconButton( - onPressed: _currentPhotoIndex < _capturedPhotos.length - 1 - ? _goToNextPhoto - : null, + onPressed: + _currentPhotoIndex < _capturedPhotos.length - 1 + ? _goToNextPhoto + : null, icon: Icon(Icons.chevron_right, size: 36), style: IconButton.styleFrom( backgroundColor: Colors.white.withValues(alpha: 0.7), @@ -658,22 +729,24 @@ class _PhotoCaptureScreenState extends State { padding: EdgeInsets.symmetric(vertical: 16), child: Row( mainAxisAlignment: MainAxisAlignment.center, - children: _capturedPhotos.asMap().entries.map((entry) { - return Container( - width: 8, - height: 8, - margin: EdgeInsets.symmetric(horizontal: 4), - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _currentPhotoIndex == entry.key - ? Colors.blue - : Colors.grey[400], - ), - ); - }).toList(), + children: + _capturedPhotos.asMap().entries.map((entry) { + return Container( + width: 8, + height: 8, + margin: EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: + _currentPhotoIndex == entry.key + ? AppColors.primary + : Colors.grey[400], + ), + ); + }).toList(), ), ), ], ); } -} \ No newline at end of file +} diff --git a/app/lib/tasks/signature_capture_screen.dart b/app/lib/tasks/signature_capture_screen.dart index 8c5413f..fea9bba 100644 --- a/app/lib/tasks/signature_capture_screen.dart +++ b/app/lib/tasks/signature_capture_screen.dart @@ -1,8 +1,8 @@ - import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:signature/signature.dart'; +import '../app_theme.dart'; import '../l10n/app_localizations.dart'; import '../models/tasks/signature_task.dart'; import '../widgets/offline_banner.dart'; @@ -88,7 +88,11 @@ class _SignatureCaptureScreenState extends State { super.dispose(); } - String _buildSvgFromPoints(List points, {double strokeWidth = 3.0, String strokeColor = '#000000'}) { + String _buildSvgFromPoints( + List points, { + double strokeWidth = 3.0, + String strokeColor = '#000000', + }) { // Convert collected signature points (with null separators for stroke breaks) into an SVG string // Determine bounds double? minX, minY, maxX, maxY; @@ -130,7 +134,8 @@ class _SignatureCaptureScreenState extends State { } } - final String svg = ''; + final String svg = + ''; return svg; } @@ -141,7 +146,9 @@ class _SignatureCaptureScreenState extends State { if (!hasAnyPoint) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(AppLocalizations.of(context).signatureRequired)), + SnackBar( + content: Text(AppLocalizations.of(context).signatureRequired), + ), ); return; } @@ -159,7 +166,9 @@ class _SignatureCaptureScreenState extends State { } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${AppLocalizations.of(context).signatureError}: $e')), + SnackBar( + content: Text('${AppLocalizations.of(context).signatureError}: $e'), + ), ); } } @@ -169,7 +178,6 @@ class _SignatureCaptureScreenState extends State { return Scaffold( appBar: AppBar( title: Text(AppLocalizations.of(context).signatureCapture), - backgroundColor: Colors.deepPurple[100], leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () { @@ -197,61 +205,61 @@ class _SignatureCaptureScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - AppLocalizations.of(context).signatureInstruction, - style: TextStyle(color: Colors.grey[700]), - ), - const SizedBox(height: 12), - Expanded( - child: Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey[400]!), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 6, - offset: const Offset(0, 2), - ), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Signature( - controller: _controller, - backgroundColor: Colors.white, + Text( + AppLocalizations.of(context).signatureInstruction, + style: const TextStyle(color: AppColors.textMuted), ), - ), + const SizedBox(height: 12), + Expanded( + child: Container( + decoration: BoxDecoration( + color: AppColors.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.borderStrong), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Signature( + controller: _controller, + backgroundColor: AppColors.surface, + ), + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + OutlinedButton.icon( + onPressed: () { + _controller.clear(); + // The listener will automatically update _hasSignature when points are cleared + }, + icon: const Icon(Icons.refresh), + label: Text(AppLocalizations.of(context).clear), + ), + const Spacer(), + SizedBox( + width: 160, + child: ElevatedButton( + onPressed: _hasSignature ? _finish : null, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + ), + child: Text(AppLocalizations.of(context).finish), + ), + ), + ], + ), + ], ), ), - const SizedBox(height: 16), - Row( - children: [ - OutlinedButton.icon( - onPressed: () { - _controller.clear(); - // The listener will automatically update _hasSignature when points are cleared - }, - icon: const Icon(Icons.refresh), - label: Text(AppLocalizations.of(context).clear), - ), - const Spacer(), - SizedBox( - width: 160, - child: ElevatedButton( - onPressed: _hasSignature ? _finish : null, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14), - ), - child: Text(AppLocalizations.of(context).finish), - ), - ), - ], - ), - ], - ), - ), ), ], ), diff --git a/app/lib/widgets/chat_photo_dialog.dart b/app/lib/widgets/chat_photo_dialog.dart index 3043a8b..52dd234 100644 --- a/app/lib/widgets/chat_photo_dialog.dart +++ b/app/lib/widgets/chat_photo_dialog.dart @@ -4,6 +4,7 @@ import 'package:file_selector/file_selector.dart' as file_selector; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:votianlt_app/services/developer.dart' as developer; +import '../app_theme.dart'; import '../l10n/app_localizations.dart'; class ChatPhotoDialog extends StatefulWidget { @@ -278,7 +279,7 @@ class _ChatPhotoDialogState extends State { return Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.warning, color: Colors.orange[700], size: 40), + const Icon(Icons.warning, color: AppColors.warning, size: 40), const SizedBox(height: 12), Text(_errorMessage!, textAlign: TextAlign.center), ], @@ -330,11 +331,7 @@ class _ChatPhotoDialogState extends State { return Column( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.photo_camera_back, - color: Colors.deepPurple[400], - size: 48, - ), + Icon(Icons.photo_camera_back, color: AppColors.primary, size: 48), const SizedBox(height: 12), const Text( 'Wähle ein Foto von deinem Gerät aus.', diff --git a/app/lib/widgets/offline_banner.dart b/app/lib/widgets/offline_banner.dart index 6f412b5..3357ba5 100644 --- a/app/lib/widgets/offline_banner.dart +++ b/app/lib/widgets/offline_banner.dart @@ -3,6 +3,7 @@ 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}); @@ -24,8 +25,13 @@ class _OfflineBannerState extends State { // 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(MQTopics.connectionStatus, _onConnectionChange); + _onConnectionChange( + _stompService.isConnected && _stompService.isAuthenticated, + ); + _connSub = DartMQ().subscribe( + MQTopics.connectionStatus, + _onConnectionChange, + ); } void _onConnectionChange(bool isConnected) { @@ -68,7 +74,10 @@ class _OfflineBannerState extends State { // 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( + 'Auto-reconnect attempt failed: $e', + name: 'OfflineBanner', + ); developer.log('Stack trace: $stackTrace', name: 'OfflineBanner'); } @@ -114,19 +123,19 @@ class _OfflineBannerState extends State { title = 'Offline – Verbindung verloren'; subtitle = 'Verbindung wird wiederhergestellt.'; icon = Icons.wifi_off; - bgColor = Colors.red[50]; - iconColor = Colors.red[700]; - titleColor = Colors.red[900]; - subtitleColor = Colors.red[800]; + 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 = Colors.orange[50]; - iconColor = Colors.orange[700]; - titleColor = Colors.orange[900]; - subtitleColor = Colors.orange[800]; + bgColor = AppColors.warningSoft; + iconColor = AppColors.warning; + titleColor = AppColors.warning; + subtitleColor = AppColors.warning.withValues(alpha: 0.85); } return Container( @@ -152,10 +161,7 @@ class _OfflineBannerState extends State { const SizedBox(height: 2), Text( subtitle, - style: TextStyle( - color: subtitleColor, - fontSize: 12, - ), + style: TextStyle(color: subtitleColor, fontSize: 12), ), ], ), @@ -165,4 +171,3 @@ class _OfflineBannerState extends State { ); } } - diff --git a/backend/src/main/frontend/themes/votian-modern/styles.css b/backend/src/main/frontend/themes/votian-modern/styles.css index 912891a..09ba84c 100644 --- a/backend/src/main/frontend/themes/votian-modern/styles.css +++ b/backend/src/main/frontend/themes/votian-modern/styles.css @@ -1143,6 +1143,8 @@ vaadin-grid-tree-toggle[expanded] .nav-expand-icon { .dialog-task-card { position: relative; + padding-top: calc(var(--lumo-space-m) + 5px) !important; + gap: calc(var(--lumo-space-m) / 16) !important; transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease; } @@ -1152,14 +1154,112 @@ vaadin-grid-tree-toggle[expanded] .nav-expand-icon { border-color: rgba(37, 99, 235, 0.24); } -.dialog-floating-delete { +.dialog-task-card.drag-over-top::before, +.dialog-task-card.drag-over-bottom::after { + content: ""; position: absolute; - top: 0.65rem; - right: 0.65rem; - z-index: 10; + left: 0; + right: 0; + height: 3px; + background: var(--lumo-primary-color); + border-radius: 2px; + z-index: 20; + pointer-events: none; + box-shadow: 0 0 6px rgba(37, 99, 235, 0.45); +} + +.dialog-task-card.drag-over-top::before { + top: calc(-0.5 * var(--lumo-space-m) - 1.5px); +} + +.dialog-task-card.drag-over-bottom::after { + bottom: calc(-0.5 * var(--lumo-space-m) - 1.5px); +} + +.dialog-task-card.dragging { + opacity: 0.35; + transform: scale(0.96); + box-shadow: none; +} + +/* Compressed cards during reorder drag */ +.tasks-reordering .dialog-task-card { + padding: 0.4rem 0.8rem !important; + transition: padding 0.2s ease, max-height 0.2s ease; +} + +.tasks-reordering .dialog-task-card:hover { + transform: none; +} + +.tasks-reordering .dialog-task-config, +.tasks-reordering .dialog-floating-delete, +.tasks-reordering .dialog-task-drag-handle { + display: none; +} + +.tasks-reordering .dialog-task-card vaadin-combo-box { + display: none; +} + +.dialog-task-summary { + display: none; + align-items: center; + gap: 0.5rem; + font-size: var(--lumo-font-size-s); + color: var(--lumo-body-text-color); + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dialog-task-summary .task-type-label { + color: var(--lumo-primary-color); + font-weight: 600; +} + +.dialog-task-summary .task-desc-label { + color: var(--lumo-secondary-text-color); + font-weight: 400; + overflow: hidden; + text-overflow: ellipsis; +} + +.tasks-reordering .dialog-task-summary { + display: flex; +} + +.dialog-task-card[draggable="true"] { + cursor: grab; +} + +.dialog-task-drag-handle { + cursor: grab; + color: var(--lumo-secondary-text-color); padding: 0.2rem; min-width: 1.7rem; min-height: 1.7rem; + flex-shrink: 0; + position: absolute; + top: -5px; + left: 15px; + z-index: 10; +} + +.dialog-task-drag-handle:hover { + color: var(--lumo-primary-color); +} + +.dialog-floating-delete { + padding: 0.2rem; + min-width: 1.7rem; + min-height: 1.7rem; + flex-shrink: 0; + position: absolute; + top: -5px; + right: 15px; + z-index: 10; } .inline-caption, diff --git a/backend/src/main/java/de/assecutor/votianlt/controller/MessageController.java b/backend/src/main/java/de/assecutor/votianlt/controller/MessageController.java index ba30bf1..dc673fa 100644 --- a/backend/src/main/java/de/assecutor/votianlt/controller/MessageController.java +++ b/backend/src/main/java/de/assecutor/votianlt/controller/MessageController.java @@ -375,7 +375,9 @@ public class MessageController { String taskType = task.getTaskType() != null ? task.getTaskType().toString() : "Unknown"; String completedBy = task.getCompletedBy() != null ? task.getCompletedBy() : "Unknown"; emailService.sendTaskCompletionNotification(jobId, taskType, taskIdStr, completedBy); - checkAndHandleJobCompletion(jobId, completedBy); + // Job completion is no longer auto-triggered by task completion. + // It is now driven by explicit station_completed messages from the app + // (see handleStationCompleted). } catch (Exception e) { // Ignore email notification errors } @@ -430,6 +432,47 @@ public class MessageController { } } + /** + * Handle station completion message from app. Client sends to + * /server/station_completed with payload: + * { + * "jobId": "jobnum:ABC123", + * "jobNumber": "ABC123", + * "stationOrder": 0, + * "completedAt": "2026-04-13T12:34:56.789Z", + * "hasIncompleteOptionalTasks": false + * } + * + * The job is marked as completed once this message is received and all + * mandatory tasks across all stations are completed. + */ + public void handleStationCompleted(String appUserId, Map payload) { + try { + String jobNumber = payload.get("jobNumber") != null ? payload.get("jobNumber").toString() : null; + if (jobNumber == null || jobNumber.isBlank()) { + log.warn("[STATION] station_completed without jobNumber"); + return; + } + + Optional jobOpt = jobRepository.findByJobNumber(jobNumber); + if (jobOpt.isEmpty()) { + log.warn("[STATION] Job with jobNumber {} not found", jobNumber); + return; + } + + ObjectId jobId = jobOpt.get().getId(); + String completedBy = appUserId != null ? appUserId : "Unknown"; + + log.info("[STATION] station_completed received for jobNumber={}, stationOrder={}", jobNumber, + payload.get("stationOrder")); + + checkAndHandleJobCompletion(jobId, completedBy); + jobUpdateBroadcaster.broadcast(jobId); + } catch (Exception e) { + log.error("[STATION] Error handling station_completed: {}", e.getMessage()); + } + } + /** * Handle incoming message from a client via WebSocket. Client sends to * /server/message with payload: { "content": "message payload", "contentType": diff --git a/backend/src/main/java/de/assecutor/votianlt/messaging/MessagingConfig.java b/backend/src/main/java/de/assecutor/votianlt/messaging/MessagingConfig.java index c4d19b6..0d3b581 100644 --- a/backend/src/main/java/de/assecutor/votianlt/messaging/MessagingConfig.java +++ b/backend/src/main/java/de/assecutor/votianlt/messaging/MessagingConfig.java @@ -70,6 +70,14 @@ public class MessagingConfig { }); }); + // Station completion handler — marks a job as completed once all mandatory + // tasks have been finished and the app confirms the station is done. + webSocketService.registerMessageHandler("station_completed", (appUserId, payload) -> { + handlePayload(payload, payloadMap -> { + messageController.handleStationCompleted(appUserId, payloadMap); + }); + }); + // Chat message handler webSocketService.registerMessageHandler("message", (appUserId, payload) -> { handlePayload(payload, payloadMap -> { diff --git a/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationDialog.java b/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationDialog.java index 270b241..61c4c1c 100644 --- a/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationDialog.java +++ b/backend/src/main/java/de/assecutor/votianlt/pages/base/ui/component/DeliveryStationDialog.java @@ -5,6 +5,10 @@ import com.vaadin.flow.component.button.ButtonVariant; import com.vaadin.flow.component.checkbox.Checkbox; import com.vaadin.flow.component.combobox.ComboBox; import com.vaadin.flow.component.dialog.Dialog; +import com.vaadin.flow.component.dnd.DragSource; +import com.vaadin.flow.component.dnd.DropEffect; +import com.vaadin.flow.component.dnd.DropTarget; +import com.vaadin.flow.component.dnd.EffectAllowed; import com.vaadin.flow.component.html.Div; import com.vaadin.flow.component.html.H3; import com.vaadin.flow.component.html.Span; @@ -203,6 +207,7 @@ public class DeliveryStationDialog extends Dialog { private final List tasksState = new ArrayList<>(); private VerticalLayout tasksList; + private VerticalLayout draggedTaskContainer; private Span addressTabError; private Span tasksTabError; @@ -866,6 +871,15 @@ public class DeliveryStationDialog extends Dialog { taskContainer.setSpacing(true); taskContainer.addClassName("dialog-task-card"); + // Drag handle + Button dragHandle = new Button(new Icon(VaadinIcon.GRID_SMALL)); + dragHandle.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + dragHandle.addClassName("dialog-task-drag-handle"); + + // Compact summary shown during drag + HorizontalLayout summaryRow = createDragSummary("", ""); + summaryRow.addClassName("dialog-task-summary"); + // Task type selection ComboBox taskTypeCombo = new ComboBox<>(translationHelper.getTranslation("addjob.tasks.tasktype")); taskTypeCombo.setItems(TaskType.values()); @@ -877,6 +891,7 @@ public class DeliveryStationDialog extends Dialog { VerticalLayout configContainer = new VerticalLayout(); configContainer.setPadding(false); configContainer.setSpacing(true); + configContainer.addClassName("dialog-task-config"); // Red X button positioned in top-right corner Button deleteXButton = new Button(new Icon(VaadinIcon.CLOSE_SMALL)); @@ -884,8 +899,14 @@ public class DeliveryStationDialog extends Dialog { deleteXButton.addClassName("dialog-floating-delete"); deleteXButton.addClickListener(e -> removeTaskRow(taskContainer)); - taskContainer.add(taskTypeCombo, configContainer); - taskContainer.add(deleteXButton); + HorizontalLayout headerRow = new HorizontalLayout(dragHandle, summaryRow, taskTypeCombo, deleteXButton); + headerRow.setAlignItems(FlexComponent.Alignment.START); + headerRow.setWidthFull(); + headerRow.setFlexGrow(1, taskTypeCombo); + + taskContainer.add(headerRow, configContainer); + + setupDragAndDrop(taskContainer); // Create Task and add to state with correct order BaseTask task = new ConfirmationTask(""); @@ -896,6 +917,7 @@ public class DeliveryStationDialog extends Dialog { taskTypeCombo.setValue(TaskType.CONFIRMATION); updateTaskConfiguration(configContainer, currentTask[0]); + updateDragSummary(summaryRow, TaskType.CONFIRMATION, task); taskTypeCombo.addValueChangeListener(ev -> { TaskType selectedType = ev.getValue(); @@ -940,6 +962,7 @@ public class DeliveryStationDialog extends Dialog { } updateTaskConfiguration(configContainer, newTask); + updateDragSummary(summaryRow, selectedType, newTask); } }); @@ -953,6 +976,18 @@ public class DeliveryStationDialog extends Dialog { taskContainer.setSpacing(true); taskContainer.addClassName("dialog-task-card"); + // Drag handle + Button dragHandle = new Button(new Icon(VaadinIcon.GRID_SMALL)); + dragHandle.addThemeVariants(ButtonVariant.LUMO_TERTIARY); + dragHandle.addClassName("dialog-task-drag-handle"); + + // Compact summary shown during drag + TaskType initialTaskType = getTaskTypeFromTask(task); + HorizontalLayout summaryRow = createDragSummary( + initialTaskType != null ? initialTaskType.getDisplayName() : "", + task.getDescription() != null ? task.getDescription() : ""); + summaryRow.addClassName("dialog-task-summary"); + ComboBox taskTypeCombo = new ComboBox<>(translationHelper.getTranslation("addjob.tasks.tasktype")); taskTypeCombo.setItems(TaskType.values()); taskTypeCombo.setItemLabelGenerator(TaskType::getDisplayName); @@ -962,21 +997,27 @@ public class DeliveryStationDialog extends Dialog { VerticalLayout configContainer = new VerticalLayout(); configContainer.setPadding(false); configContainer.setSpacing(true); + configContainer.addClassName("dialog-task-config"); Button deleteXButton = new Button(new Icon(VaadinIcon.CLOSE_SMALL)); deleteXButton.addThemeVariants(ButtonVariant.LUMO_ERROR, ButtonVariant.LUMO_TERTIARY); deleteXButton.addClassName("dialog-floating-delete"); deleteXButton.addClickListener(e -> removeTaskRow(taskContainer)); - taskContainer.add(taskTypeCombo, configContainer); - taskContainer.add(deleteXButton); + HorizontalLayout headerRow = new HorizontalLayout(dragHandle, summaryRow, taskTypeCombo, deleteXButton); + headerRow.setAlignItems(FlexComponent.Alignment.START); + headerRow.setWidthFull(); + headerRow.setFlexGrow(1, taskTypeCombo); + + taskContainer.add(headerRow, configContainer); + + setupDragAndDrop(taskContainer); final BaseTask[] currentTask = { task }; // Set the combo value BEFORE registering the listener - TaskType taskType = getTaskTypeFromTask(task); - if (taskType != null) { - taskTypeCombo.setValue(taskType); + if (initialTaskType != null) { + taskTypeCombo.setValue(initialTaskType); } // Register the listener for user-initiated type changes only @@ -1021,11 +1062,13 @@ public class DeliveryStationDialog extends Dialog { } updateTaskConfiguration(configContainer, newTask); + updateDragSummary(summaryRow, selectedType, newTask); } }); // Render the UI with the loaded task updateTaskConfiguration(configContainer, task); + updateDragSummary(summaryRow, initialTaskType, task); tasksList.add(taskContainer); updateTaskDeleteAvailability(); @@ -1042,6 +1085,160 @@ public class DeliveryStationDialog extends Dialog { }; } + private HorizontalLayout createDragSummary(String typeName, String description) { + Span typeLabel = new Span(typeName); + typeLabel.addClassName("task-type-label"); + Span descLabel = new Span(description != null && !description.isBlank() ? " — " + description : ""); + descLabel.addClassName("task-desc-label"); + HorizontalLayout layout = new HorizontalLayout(typeLabel, descLabel); + layout.setSpacing(false); + layout.setPadding(false); + layout.setAlignItems(FlexComponent.Alignment.CENTER); + return layout; + } + + private void updateDragSummary(HorizontalLayout summaryRow, TaskType taskType, BaseTask task) { + summaryRow.getChildren() + .filter(Span.class::isInstance) + .map(Span.class::cast) + .forEach(span -> { + if (span.getClassNames().contains("task-type-label")) { + span.setText(taskType != null ? taskType.getDisplayName() : ""); + } else if (span.getClassNames().contains("task-desc-label")) { + String desc = task.getDescription(); + span.setText(desc != null && !desc.isBlank() ? " — " + desc : ""); + } + }); + } + + private void clearAllDropIndicators() { + if (tasksList == null) { + return; + } + tasksList.removeClassName("tasks-reordering"); + tasksList.getChildren() + .filter(VerticalLayout.class::isInstance) + .map(VerticalLayout.class::cast) + .forEach(c -> { + c.removeClassName("drag-over-top"); + c.removeClassName("drag-over-bottom"); + c.removeClassName("dragging"); + }); + } + + private void setupDragAndDrop(VerticalLayout taskContainer) { + DragSource dragSource = DragSource.create(taskContainer); + dragSource.setEffectAllowed(EffectAllowed.MOVE); + dragSource.addDragStartListener(e -> { + draggedTaskContainer = taskContainer; + taskContainer.addClassName("dragging"); + if (tasksList != null) { + // Update all summaries with latest description values before compressing + List rows = tasksList.getChildren().toList(); + for (int i = 0; i < rows.size() && i < tasksState.size(); i++) { + if (rows.get(i) instanceof VerticalLayout row) { + row.getChildren() + .filter(HorizontalLayout.class::isInstance) + .map(HorizontalLayout.class::cast) + .findFirst() + .ifPresent(headerRow -> headerRow.getChildren() + .filter(HorizontalLayout.class::isInstance) + .map(HorizontalLayout.class::cast) + .filter(c -> c.getClassNames().contains("dialog-task-summary")) + .findFirst() + .ifPresent(summary -> { + int idx = rows.indexOf(row); + if (idx >= 0 && idx < tasksState.size()) { + BaseTask t = tasksState.get(idx); + TaskType tt = getTaskTypeFromTask(t); + updateDragSummary(summary, tt, t); + } + })); + } + } + tasksList.addClassName("tasks-reordering"); + } + }); + dragSource.addDragEndListener(e -> { + draggedTaskContainer = null; + clearAllDropIndicators(); + }); + + DropTarget dropTarget = DropTarget.create(taskContainer); + dropTarget.setDropEffect(DropEffect.MOVE); + dropTarget.addDropListener(e -> { + if (draggedTaskContainer != null && draggedTaskContainer != taskContainer) { + moveTaskRow(draggedTaskContainer, taskContainer); + } + clearAllDropIndicators(); + }); + + // Client-side drag events with position detection and no-op suppression + taskContainer.getElement().executeJs( + "const el = this;" + + "function clearIndicators() {" + + " var p = el.parentElement;" + + " if (p) p.querySelectorAll('.drag-over-top, .drag-over-bottom').forEach(" + + " function(c) { c.classList.remove('drag-over-top', 'drag-over-bottom'); });" + + "}" + + "function getSiblings() {" + + " return Array.from(el.parentElement.children).filter(" + + " function(c) { return c.classList.contains('dialog-task-card'); });" + + "}" + + "el.addEventListener('dragstart', function() {" + + " el.parentElement.__draggedEl = el;" + + "});" + + "el.addEventListener('dragend', function() {" + + " el.parentElement.__draggedEl = null;" + + "});" + + "el.addEventListener('dragover', function(e) {" + + " e.preventDefault();" + + " var dragged = el.parentElement.__draggedEl;" + + " if (!dragged || dragged === el) { clearIndicators(); return; }" + + " var cards = getSiblings();" + + " var dragIdx = cards.indexOf(dragged);" + + " var myIdx = cards.indexOf(el);" + + " clearIndicators();" + + " var rect = el.getBoundingClientRect();" + + " var midY = rect.top + rect.height / 2;" + + " if (e.clientY < midY) {" + + " if (myIdx !== dragIdx + 1) el.classList.add('drag-over-top');" + + " } else {" + + " if (myIdx !== dragIdx - 1) el.classList.add('drag-over-bottom');" + + " }" + + "});" + + "el.addEventListener('dragleave', function() {" + + " el.classList.remove('drag-over-top', 'drag-over-bottom');" + + "});" + + "el.addEventListener('drop', function() {" + + " clearIndicators();" + + "});"); + } + + private void moveTaskRow(VerticalLayout source, VerticalLayout target) { + List rows = tasksList.getChildren().toList(); + int fromIndex = rows.indexOf(source); + int toIndex = rows.indexOf(target); + if (fromIndex < 0 || toIndex < 0 || fromIndex == toIndex) { + return; + } + + // Reorder tasksState + BaseTask movedTask = tasksState.remove(fromIndex); + tasksState.add(toIndex, movedTask); + + // Reorder UI: remove all, re-add in new order + List rowList = new ArrayList<>(rows); + com.vaadin.flow.component.Component movedRow = rowList.remove(fromIndex); + rowList.add(toIndex, movedRow); + + tasksList.removeAll(); + rowList.forEach(tasksList::add); + + // Update taskOrder values + reorderTasksAfterDeletion(); + } + private void reorderTasksAfterDeletion() { for (int i = 0; i < tasksState.size(); i++) { BaseTask task = tasksState.get(i); @@ -1094,11 +1291,15 @@ public class DeliveryStationDialog extends Dialog { .filter(VerticalLayout.class::isInstance) .map(VerticalLayout.class::cast) .forEach(taskContainer -> taskContainer.getChildren() - .filter(Button.class::isInstance) - .map(Button.class::cast) - .filter(button -> button.getClassNames().contains("dialog-floating-delete")) + .filter(HorizontalLayout.class::isInstance) + .map(HorizontalLayout.class::cast) .findFirst() - .ifPresent(button -> button.setEnabled(deletable))); + .ifPresent(headerRow -> headerRow.getChildren() + .filter(Button.class::isInstance) + .map(Button.class::cast) + .filter(button -> button.getClassNames().contains("dialog-floating-delete")) + .findFirst() + .ifPresent(button -> button.setEnabled(deletable)))); } private void updateTaskConfiguration(VerticalLayout configContainer, BaseTask task) {