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:
2026-04-13 11:26:30 +02:00
parent 1ac755bcbd
commit 6e8bedd9b4
19 changed files with 1458 additions and 548 deletions

View File

@@ -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,
),
),
),
],
],
),
),
);
}