feat: Drag-and-Drop-Reihenfolge, Station-Abschluss-Flow und UI-Verbesserungen
Lieferstationen-Dialog (Backend/Vaadin): - Aufgaben per Drag & Drop neu anordnen, inkl. Drag-Handle, komprimierter Kachelansicht während des Drags und horizontaler Einfügelinie als Drop-Target - Drop-Indikator wird unterdrückt, wenn der Drop keine Positionsänderung bewirken würde, und nach dem Abschluss clientseitig zuverlässig aufgeräumt - Drag-Handle, Aufgabentyp-Label und Close-Button auf einheitlicher Position ausgerichtet; Abstände in der Kachel komprimiert Station-Abschluss-Flow (Flutter-App + Backend): - Neuer Button "Station abschließen" unter den Aufgaben; deaktiviert, solange Pflichtaufgaben offen sind, ansonsten aktiv (auch wenn nur optionale Aufgaben existieren) - Hinweisdialog nach Erledigung der letzten Pflichtaufgabe sowie Warnung bei offenen optionalen Aufgaben vor dem Senden - Neue station_completed-Nachricht (jobId, jobNumber, stationOrder, completedAt, hasIncompleteOptionalTasks) wird an den Server gesendet - Backend: Auftrag wird nicht mehr automatisch beim Erledigen der letzten Pflichtaufgabe abgeschlossen, sondern erst beim Empfang der station_completed-Nachricht (neuer Handler in MessageController und MessagingConfig) Aufgabenliste in der App: - Farbcodierung optionaler Aufgaben entfernt; stattdessen vertikal zentrierter "Optional"-Chip am rechten Kartenrand Weitere UI-Überarbeitungen über Login, Jobs, Chats, Settings, Aufgaben-Capture- Screens, Offline-Banner und zugehörige Widgets. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
175
app/lib/app_theme.dart
Normal file
175
app/lib/app_theme.dart
Normal file
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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<CargoItemsView> {
|
||||
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<CargoItemsView> {
|
||||
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<CargoItemsView> {
|
||||
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<CargoItemsView> {
|
||||
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<CargoItemsView> {
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.deepPurple[700],
|
||||
color: AppColors.primaryStrong,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -359,7 +359,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
||||
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<CargoItemsView> {
|
||||
Icons.phone_outlined,
|
||||
l10n.phone,
|
||||
station.phone,
|
||||
Colors.green,
|
||||
AppColors.success,
|
||||
),
|
||||
],
|
||||
if (station.deliveryDate.trim().isNotEmpty ||
|
||||
@@ -380,7 +380,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
|
||||
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<CargoItemsView> {
|
||||
Icons.task_alt,
|
||||
AppLocalizations.of(context).tasks,
|
||||
'${station.tasks.length}',
|
||||
Colors.deepPurple,
|
||||
AppColors.primaryStrong,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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<ChatDetailsView> {
|
||||
'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<ChatDetailsView> {
|
||||
// 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<ChatDetailsView> {
|
||||
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<ChatDetailsView> {
|
||||
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<ChatDetailsView> {
|
||||
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<ChatDetailsView> {
|
||||
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<ChatDetailsView> {
|
||||
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<ChatDetailsView> {
|
||||
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<ChatDetailsView> {
|
||||
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<ChatDetailsView> {
|
||||
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),
|
||||
|
||||
@@ -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<ChatsView> {
|
||||
@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<ChatsView> {
|
||||
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<ChatsView> {
|
||||
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<ChatsView> {
|
||||
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<ChatsView> {
|
||||
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<ChatsView> {
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isJobChat ? Colors.blue[700] : Colors.green[700],
|
||||
color:
|
||||
isJobChat ? AppColors.primaryStrong : AppColors.secondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<JobsView> 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<JobsView> 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<JobsView> 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<JobsView> 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<JobsView> with RouteAware {
|
||||
});
|
||||
_showSnack(
|
||||
AppLocalizations.of(context).jobsUpdated,
|
||||
backgroundColor: Colors.green,
|
||||
backgroundColor: AppColors.success,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -560,7 +561,6 @@ class _JobsViewState extends State<JobsView> 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<JobsView> 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<JobsView> 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<JobsView> with RouteAware {
|
||||
} else {
|
||||
_showSnack(
|
||||
AppLocalizations.of(context).offline,
|
||||
backgroundColor: Colors.red,
|
||||
backgroundColor: AppColors.danger,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -908,7 +908,7 @@ class _JobsViewState extends State<JobsView> 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<JobsView> with RouteAware {
|
||||
if (mounted) {
|
||||
_showSnack(
|
||||
AppLocalizations.of(context).jobDeleteError,
|
||||
backgroundColor: Colors.red,
|
||||
backgroundColor: AppColors.danger,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -935,19 +935,19 @@ class _JobsViewState extends State<JobsView> 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<JobsView> 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<JobsView> 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<JobsView> with RouteAware {
|
||||
? 0
|
||||
: completedTasks / totalTasks,
|
||||
minHeight: 8,
|
||||
backgroundColor: Colors.grey[200],
|
||||
backgroundColor: AppColors.border,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
completedTasks >= totalTasks
|
||||
? Colors.green
|
||||
? AppColors.success
|
||||
: (completedTasks > 0
|
||||
? Colors.amber
|
||||
: Colors.deepPurpleAccent),
|
||||
? AppColors.warning
|
||||
: AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1336,7 +1336,7 @@ class _JobsViewState extends State<JobsView> 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<JobsView> with RouteAware {
|
||||
tooltip: 'Route planen',
|
||||
icon: const Icon(
|
||||
Icons.route,
|
||||
color: Colors.blueAccent,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
onPressed: () {
|
||||
if (_routeActionInProgress) return;
|
||||
|
||||
@@ -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<LoginView> {
|
||||
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<LoginView> {
|
||||
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<LoginView> {
|
||||
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<LoginView> {
|
||||
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<LoginView> {
|
||||
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<LoginView> {
|
||||
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<LoginView> {
|
||||
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<Color>(
|
||||
Colors.white,
|
||||
),
|
||||
child:
|
||||
_isLoggingIn
|
||||
? Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation<Color>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<MyApp> 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<MyHomePage> {
|
||||
@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,
|
||||
|
||||
@@ -1189,6 +1189,36 @@ class WebSocketService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Send station completion event to server.
|
||||
/// Messages are buffered if offline and sent automatically when reconnected.
|
||||
Future<void> sendStationCompleted({
|
||||
required String jobId,
|
||||
required String jobNumber,
|
||||
required int stationOrder,
|
||||
bool hasIncompleteOptionalTasks = false,
|
||||
}) async {
|
||||
const String destination = '/server/station_completed';
|
||||
|
||||
final payload = <String, dynamic>{
|
||||
'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();
|
||||
|
||||
@@ -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 {
|
||||
@@ -98,11 +99,9 @@ class _SettingsViewState extends State<SettingsView> {
|
||||
|
||||
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<SettingsView> {
|
||||
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<SettingsView> {
|
||||
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<SettingsView> {
|
||||
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<SettingsView> {
|
||||
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<SettingsView> {
|
||||
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'),
|
||||
),
|
||||
|
||||
@@ -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<TaskView> {
|
||||
? '${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<TaskView> {
|
||||
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<TaskView> {
|
||||
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<TaskView> {
|
||||
);
|
||||
}
|
||||
|
||||
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<TaskView> {
|
||||
|
||||
// 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<TaskView> {
|
||||
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<TaskView> {
|
||||
String? taskType,
|
||||
Map<String, dynamic>? extraData,
|
||||
}) {
|
||||
final bool hadOpenMandatoryBefore = _hasOpenMandatoryTasks;
|
||||
setState(() {
|
||||
_completedTasks.add(taskId);
|
||||
});
|
||||
@@ -638,6 +749,53 @@ class _TaskViewState extends State<TaskView> {
|
||||
} 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) {
|
||||
|
||||
@@ -9,7 +9,11 @@ class BarcodeCaptureScreen extends StatefulWidget {
|
||||
final BarcodeTask task;
|
||||
final Function(List<String>) onBarcodesCompleted;
|
||||
|
||||
const BarcodeCaptureScreen({super.key, required this.task, required this.onBarcodesCompleted});
|
||||
const BarcodeCaptureScreen({
|
||||
super.key,
|
||||
required this.task,
|
||||
required this.onBarcodesCompleted,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BarcodeCaptureScreen> createState() => _BarcodeCaptureScreenState();
|
||||
@@ -70,7 +74,11 @@ class _BarcodeCaptureScreenState extends State<BarcodeCaptureScreen> {
|
||||
});
|
||||
} 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<BarcodeCaptureScreen> {
|
||||
|
||||
@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<BarcodeCaptureScreen> {
|
||||
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<BarcodeCaptureScreen> {
|
||||
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<BarcodeCaptureScreen> {
|
||||
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<BarcodeCaptureScreen> {
|
||||
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<BarcodeCaptureScreen> {
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<PhotoCaptureScreen> {
|
||||
}
|
||||
}
|
||||
} 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<PhotoCaptureScreen> {
|
||||
} 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<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'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -136,7 +146,8 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
||||
Future<void> _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<PhotoCaptureScreen> {
|
||||
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<PhotoCaptureScreen> {
|
||||
}
|
||||
}
|
||||
} 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<PhotoCaptureScreen> {
|
||||
}
|
||||
},
|
||||
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<PhotoCaptureScreen> {
|
||||
|
||||
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<PhotoCaptureScreen> {
|
||||
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<PhotoCaptureScreen> {
|
||||
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<PhotoCaptureScreen> {
|
||||
},
|
||||
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<PhotoCaptureScreen> {
|
||||
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<PhotoCaptureScreen> {
|
||||
),
|
||||
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<PhotoCaptureScreen> {
|
||||
|
||||
// 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<PhotoCaptureScreen> {
|
||||
// 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<PhotoCaptureScreen> {
|
||||
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<PhotoCaptureScreen> {
|
||||
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<PhotoCaptureScreen> {
|
||||
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<PhotoCaptureScreen> {
|
||||
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<PhotoCaptureScreen> {
|
||||
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<PhotoCaptureScreen> {
|
||||
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<PhotoCaptureScreen> {
|
||||
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<PhotoCaptureScreen> {
|
||||
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<PhotoCaptureScreen> {
|
||||
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,19 +729,21 @@ class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
|
||||
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(),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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<SignatureCaptureScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _buildSvgFromPoints(List<Point?> points, {double strokeWidth = 3.0, String strokeColor = '#000000'}) {
|
||||
String _buildSvgFromPoints(
|
||||
List<Point?> 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<SignatureCaptureScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
final String svg = '<svg xmlns="http://www.w3.org/2000/svg" width="${width.toStringAsFixed(2)}" height="${height.toStringAsFixed(2)}" viewBox="0 0 ${width.toStringAsFixed(2)} ${height.toStringAsFixed(2)}"><path d="${d.toString().trim()}" fill="none" stroke="$strokeColor" stroke-width="$strokeWidth" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
final String svg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="${width.toStringAsFixed(2)}" height="${height.toStringAsFixed(2)}" viewBox="0 0 ${width.toStringAsFixed(2)} ${height.toStringAsFixed(2)}"><path d="${d.toString().trim()}" fill="none" stroke="$strokeColor" stroke-width="$strokeWidth" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||
return svg;
|
||||
}
|
||||
|
||||
@@ -141,7 +146,9 @@ class _SignatureCaptureScreenState extends State<SignatureCaptureScreen> {
|
||||
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<SignatureCaptureScreen> {
|
||||
} 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<SignatureCaptureScreen> {
|
||||
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<SignatureCaptureScreen> {
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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<ChatPhotoDialog> {
|
||||
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<ChatPhotoDialog> {
|
||||
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.',
|
||||
|
||||
@@ -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<OfflineBanner> {
|
||||
// Check if we're already connected (e.g., coming back to this screen)
|
||||
_hadConnection = _stompService.isConnected && _stompService.isAuthenticated;
|
||||
// Initialize countdown based on current connection state
|
||||
_onConnectionChange(_stompService.isConnected && _stompService.isAuthenticated);
|
||||
_connSub = DartMQ().subscribe<bool>(MQTopics.connectionStatus, _onConnectionChange);
|
||||
_onConnectionChange(
|
||||
_stompService.isConnected && _stompService.isAuthenticated,
|
||||
);
|
||||
_connSub = DartMQ().subscribe<bool>(
|
||||
MQTopics.connectionStatus,
|
||||
_onConnectionChange,
|
||||
);
|
||||
}
|
||||
|
||||
void _onConnectionChange(bool isConnected) {
|
||||
@@ -68,7 +74,10 @@ class _OfflineBannerState extends State<OfflineBanner> {
|
||||
// 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<OfflineBanner> {
|
||||
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<OfflineBanner> {
|
||||
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<OfflineBanner> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String, Object> 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<Job> 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":
|
||||
|
||||
@@ -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 -> {
|
||||
|
||||
@@ -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<BaseTask> 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<TaskType> 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<TaskType> 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<VerticalLayout> 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<com.vaadin.flow.component.Component> 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<VerticalLayout> 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<com.vaadin.flow.component.Component> 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<com.vaadin.flow.component.Component> 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) {
|
||||
|
||||
Reference in New Issue
Block a user