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

@@ -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,22 +729,24 @@ 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(),
),
),
],
);
}
}
}