Files
votianlt/app/lib/widgets/chat_photo_dialog.dart
Sven Carstensen 6e8bedd9b4 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>
2026-04-13 11:26:30 +02:00

356 lines
9.2 KiB
Dart

import 'package:camera/camera.dart';
import 'package:file_picker/file_picker.dart';
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 {
const ChatPhotoDialog({super.key});
@override
State<ChatPhotoDialog> createState() => _ChatPhotoDialogState();
}
class _ChatPhotoDialogState extends State<ChatPhotoDialog> {
CameraController? _cameraController;
bool _isCameraInitialized = false;
bool _useCamera = false;
bool _useFilePicker = false;
bool _isBusy = false;
Uint8List? _previewBytes;
String? _errorMessage;
@override
void initState() {
super.initState();
_detectAndInit();
}
@override
void dispose() {
_cameraController?.dispose();
super.dispose();
}
Future<void> _detectAndInit() async {
if (kIsWeb) {
setState(() {
_useCamera = true;
_useFilePicker = true;
});
await _initializeCamera();
return;
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
setState(() {
_useCamera = true;
_useFilePicker = true;
});
await _initializeCamera();
return;
case TargetPlatform.macOS:
case TargetPlatform.windows:
case TargetPlatform.linux:
setState(() {
_useFilePicker = true;
});
return;
default:
setState(() {
_errorMessage = 'Dieses Gerät unterstützt keine Fotoaufnahme.';
});
}
}
Future<void> _initializeCamera() async {
try {
final cameras = await availableCameras();
if (cameras.isEmpty) {
setState(() {
_errorMessage = 'Keine Kamera gefunden.';
});
return;
}
final controller = CameraController(
cameras.first,
ResolutionPreset.medium,
enableAudio: false,
);
await controller.initialize();
if (!mounted) {
await controller.dispose();
return;
}
setState(() {
_cameraController = controller;
_isCameraInitialized = true;
});
} catch (e, stackTrace) {
developer.log(
'Fehler beim Initialisieren der Kamera: $e',
name: 'ChatPhotoDialog',
);
developer.log('StackTrace: $stackTrace', name: 'ChatPhotoDialog');
if (!mounted) {
return;
}
setState(() {
_errorMessage = 'Kamera konnte nicht gestartet werden.';
});
}
}
Future<void> _capturePhoto() async {
if (_cameraController == null || !_cameraController!.value.isInitialized) {
setState(() {
_errorMessage = 'Kamera ist nicht bereit.';
});
return;
}
try {
setState(() {
_isBusy = true;
_errorMessage = null;
});
final XFile photo = await _cameraController!.takePicture();
final Uint8List bytes = await photo.readAsBytes();
if (!mounted) {
return;
}
setState(() {
_previewBytes = bytes;
});
} catch (e, stackTrace) {
developer.log(
'Fehler beim Aufnehmen des Fotos: $e',
name: 'ChatPhotoDialog',
);
developer.log('StackTrace: $stackTrace', name: 'ChatPhotoDialog');
if (!mounted) {
return;
}
setState(() {
_errorMessage = 'Foto konnte nicht aufgenommen werden.';
});
} finally {
if (mounted) {
setState(() {
_isBusy = false;
});
}
}
}
Future<void> _pickPhotoFromFile() async {
try {
setState(() {
_isBusy = true;
_errorMessage = null;
});
if (kIsWeb ||
defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.linux) {
final group = file_selector.XTypeGroup(
label: 'images',
extensions: ['jpg', 'jpeg', 'png', 'heic', 'bmp', 'gif', 'webp'],
);
final file = await file_selector.openFile(acceptedTypeGroups: [group]);
if (file == null) {
return;
}
final bytes = await file.readAsBytes();
if (!mounted) {
return;
}
setState(() {
_previewBytes = bytes;
});
return;
}
final result = await FilePicker.platform.pickFiles(
allowMultiple: false,
type: FileType.image,
withData: true,
);
if (result == null || result.files.isEmpty) {
return;
}
final picked = result.files.first;
final bytes = picked.bytes;
if (bytes == null) {
setState(() {
_errorMessage = 'Die ausgewählte Datei konnte nicht gelesen werden.';
});
return;
}
if (!mounted) {
return;
}
setState(() {
_previewBytes = bytes;
});
} catch (e, stackTrace) {
developer.log(
'Fehler beim Auswählen eines Fotos: $e',
name: 'ChatPhotoDialog',
);
developer.log('StackTrace: $stackTrace', name: 'ChatPhotoDialog');
if (!mounted) {
return;
}
setState(() {
_errorMessage = 'Foto konnte nicht geladen werden.';
});
} finally {
if (mounted) {
setState(() {
_isBusy = false;
});
}
}
}
void _resetPreview() {
setState(() {
_previewBytes = null;
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(AppLocalizations.of(context).takePhoto),
content: SizedBox(width: 320, child: _buildDialogBody()),
actions: [
TextButton(
onPressed: _isBusy ? null : () => Navigator.of(context).pop(),
child: Text(AppLocalizations.of(context).cancel),
),
TextButton(
onPressed:
_previewBytes != null && !_isBusy
? () => Navigator.of(context).pop(_previewBytes)
: null,
child: Text(AppLocalizations.of(context).send),
),
],
);
}
Widget _buildDialogBody() {
if (_previewBytes != null) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
AspectRatio(
aspectRatio: 4 / 3,
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(_previewBytes!, fit: BoxFit.cover),
),
),
const SizedBox(height: 12),
TextButton.icon(
onPressed: _isBusy ? null : _resetPreview,
icon: const Icon(Icons.refresh),
label: Text(AppLocalizations.of(context).retakePhoto),
),
],
);
}
if (_errorMessage != null) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.warning, color: AppColors.warning, size: 40),
const SizedBox(height: 12),
Text(_errorMessage!, textAlign: TextAlign.center),
],
);
}
if (_useCamera) {
if (!_isCameraInitialized) {
return const SizedBox(
height: 200,
child: Center(child: CircularProgressIndicator()),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
AspectRatio(
aspectRatio: _cameraController?.value.aspectRatio ?? (3 / 4),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CameraPreview(_cameraController!),
),
),
const SizedBox(height: 12),
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: [
ElevatedButton.icon(
onPressed: _isBusy ? null : _capturePhoto,
icon: const Icon(Icons.camera_alt),
label: Text(AppLocalizations.of(context).takePhoto),
),
if (_useFilePicker)
OutlinedButton.icon(
onPressed: _isBusy ? null : _pickPhotoFromFile,
icon: const Icon(Icons.photo_library),
label: Text(AppLocalizations.of(context).selectFromLibrary),
),
],
),
],
);
}
if (_useFilePicker) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
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.',
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
ElevatedButton.icon(
onPressed: _isBusy ? null : _pickPhotoFromFile,
icon: const Icon(Icons.photo_library),
label: Text(AppLocalizations.of(context).selectPhoto),
),
],
);
}
return const SizedBox(
height: 160,
child: Center(child: CircularProgressIndicator()),
);
}
}