Files
votianlt/app/lib/tasks/photo_capture_screen.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

753 lines
25 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
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';
class PhotoCaptureScreen extends StatefulWidget {
final PhotoTask task;
final Function(List<Uint8List>) onPhotosCompleted;
const PhotoCaptureScreen({
super.key,
required this.task,
required this.onPhotosCompleted,
});
@override
State<PhotoCaptureScreen> createState() => _PhotoCaptureScreenState();
}
class _PhotoCaptureScreenState extends State<PhotoCaptureScreen> {
CameraController? _cameraController; // Android/iOS/Web
List<CameraDescription>? _cameras;
final List<Uint8List> _capturedPhotos = [];
final PageController _pageController = PageController();
int _currentPhotoIndex = 0;
bool _isCameraInitialized = false;
bool _isCameraSupportedOnThisPlatform = false;
bool _useFilePickerMode = false; // desktop fallback
@override
void initState() {
super.initState();
_detectPlatformSupportAndInit();
}
@override
void dispose() {
_cameraController?.dispose();
_pageController.dispose();
super.dispose();
}
void _detectPlatformSupportAndInit() {
// Requirement: Desktop (macOS/Windows/Linux) uses file picker; Android/iOS uses camera.
if (kIsWeb) {
// Keep web behavior using camera if available
_isCameraSupportedOnThisPlatform = true;
_initializeCamera();
return;
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
_isCameraSupportedOnThisPlatform = true; // enable capture button
_useFilePickerMode = false;
_initializeCamera();
return;
case TargetPlatform.macOS:
case TargetPlatform.windows:
case TargetPlatform.linux:
// Desktop → use file picker instead of camera
_isCameraSupportedOnThisPlatform = true; // enable button
_useFilePickerMode = true;
return;
default:
_isCameraSupportedOnThisPlatform = false;
}
}
Future<void> _initializeCamera() async {
try {
// Android/iOS/Web camera initialization
_cameras = await availableCameras();
if (_cameras != null && _cameras!.isNotEmpty) {
_cameraController = CameraController(
_cameras![0],
ResolutionPreset.medium,
);
await _cameraController!.initialize();
if (mounted) {
setState(() {
_isCameraInitialized = true;
});
}
}
} catch (e, stackTrace) {
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'),
),
);
}
}
}
Future<void> _capturePhoto() async {
try {
// Camera plugin path (Android/iOS/Web)
if (_cameraController != null && _isCameraInitialized) {
final XFile photo = await _cameraController!.takePicture();
final Uint8List photoBytes = await photo.readAsBytes();
setState(() {
_capturedPhotos.add(photoBytes);
});
// Navigate to the newly added photo (even if it's the first one). If the
// PageView is not attached yet, _showPhotoAt will schedule a post-frame jump.
_showPhotoAt(_capturedPhotos.length - 1);
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).cameraNotReady),
),
);
}
}
} catch (e, stackTrace) {
developer.log('Error capturing photo: $e', name: 'PhotoCaptureScreen');
developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${AppLocalizations.of(context).photoError}: $e'),
),
);
}
}
}
Future<void> _pickPhotoFromFile() async {
try {
// Use file_selector for desktop and web for robust platform support
final bool useFileSelector =
kIsWeb ||
defaultTargetPlatform == TargetPlatform.macOS ||
defaultTargetPlatform == TargetPlatform.windows ||
defaultTargetPlatform == TargetPlatform.linux;
if (useFileSelector) {
final typeGroup = fsel.XTypeGroup(
label: 'images',
extensions: ['jpg', 'jpeg', 'png', 'heic', 'bmp', 'gif', 'webp'],
);
final fsel.XFile? picked = await fsel.openFile(
acceptedTypeGroups: [typeGroup],
);
if (picked != null) {
final data = await picked.readAsBytes();
setState(() {
_capturedPhotos.add(data);
});
_showPhotoAt(_capturedPhotos.length - 1);
}
} else {
// On Android/iOS, use file_picker which integrates with platform pickers
final result = await FilePicker.platform.pickFiles(
allowMultiple: false,
type: FileType.image,
withData: true,
);
if (result != null && result.files.isNotEmpty) {
final file = result.files.first;
final bytes = file.bytes;
if (bytes != null) {
setState(() {
_capturedPhotos.add(bytes);
});
_showPhotoAt(_capturedPhotos.length - 1);
} else {
// On some platforms, bytes may be null if withData was false; try path
final path = file.path;
if (path != null) {
final data = await XFile(path).readAsBytes();
setState(() {
_capturedPhotos.add(data);
});
if (_capturedPhotos.length > 1) {
_showPhotoAt(_capturedPhotos.length - 1);
}
}
}
}
}
} catch (e, stackTrace) {
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'),
),
);
}
}
}
void _deletePhoto(int index) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(AppLocalizations.of(context).deletePhoto),
content: Text(AppLocalizations.of(context).deletePhotoConfirm),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(AppLocalizations.of(context).cancel),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
// Remove and navigate to a valid photo if any remain
int targetIndex = _currentPhotoIndex;
setState(() {
_capturedPhotos.removeAt(index);
if (_capturedPhotos.isEmpty) {
_currentPhotoIndex = 0;
} else {
if (targetIndex >= _capturedPhotos.length) {
targetIndex = _capturedPhotos.length - 1;
}
_currentPhotoIndex = targetIndex;
}
});
if (_capturedPhotos.isNotEmpty) {
_showPhotoAt(_currentPhotoIndex);
}
},
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: Text(
AppLocalizations.of(context).delete,
style: const TextStyle(color: Colors.white),
),
),
],
);
},
);
}
bool get _canComplete {
return _capturedPhotos.length >= widget.task.minPhotoCount &&
_capturedPhotos.length <= widget.task.maxPhotoCount;
}
bool get _canTakeMore {
return _capturedPhotos.length < widget.task.maxPhotoCount;
}
bool get _isDesktopPlatform {
if (kIsWeb) return false;
switch (defaultTargetPlatform) {
case TargetPlatform.macOS:
case TargetPlatform.windows:
case TargetPlatform.linux:
return true;
default:
return false;
}
}
void _showPhotoAt(int index) {
if (_capturedPhotos.isEmpty) return;
final int clamped = index.clamp(0, _capturedPhotos.length - 1);
if (!mounted) return;
// Always schedule navigation after the current frame so that
// PageView has rebuilt with the latest itemCount before we jump/animate.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (_pageController.hasClients) {
try {
_pageController.animateToPage(
clamped,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
);
} catch (e, stackTrace) {
developer.log(
'Error animating to page: $e',
name: 'PhotoCaptureScreen',
);
developer.log('Stack trace: $stackTrace', name: 'PhotoCaptureScreen');
_pageController.jumpToPage(clamped);
}
setState(() {
_currentPhotoIndex = clamped;
});
}
});
}
void _goToPreviousPhoto() {
if (_currentPhotoIndex > 0) {
_showPhotoAt(_currentPhotoIndex - 1);
}
}
void _goToNextPhoto() {
if (_currentPhotoIndex < _capturedPhotos.length - 1) {
_showPhotoAt(_currentPhotoIndex + 1);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(context).photoCapture),
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
actions: [
if (_canComplete)
TextButton(
onPressed: () {
widget.onPhotosCompleted(_capturedPhotos);
Navigator.of(context).pop();
},
child: Text(
AppLocalizations.of(context).finish,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
],
),
body: Column(
children: [
OfflineBanner(),
// Task info header
Container(
width: double.infinity,
padding: EdgeInsets.all(16),
color: AppColors.surfaceMuted,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${AppLocalizations.of(context).requiredPhotos}: ${widget.task.minPhotoCount}-${widget.task.maxPhotoCount}',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
Text(
'${AppLocalizations.of(context).photosTaken}: ${_capturedPhotos.length}',
style: const TextStyle(
fontSize: 14,
color: AppColors.textMuted,
),
),
],
),
),
// Camera preview, photo gallery or empty state
Expanded(
child:
_capturedPhotos.isEmpty
? _buildCameraOrEmptyState()
: _buildPhotoGallery(),
),
// Bottom controls
Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.surface,
boxShadow: [
BoxShadow(
color: AppColors.textStrong.withValues(alpha: 0.12),
spreadRadius: 1,
blurRadius: 5,
offset: Offset(0, -3),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
// 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,
),
label: Text(
!_isCameraSupportedOnThisPlatform
? AppLocalizations.of(
context,
).cameraNotSupportedOnPlatform
: (!_canTakeMore
? 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)))),
),
style: ElevatedButton.styleFrom(
backgroundColor:
_canTakeMore &&
(_useFilePickerMode ||
_isCameraInitialized)
? AppColors.primary
: AppColors.borderStrong,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(vertical: 12),
),
),
),
if (_capturedPhotos.isNotEmpty) ...[
SizedBox(width: 16),
// Delete current photo button
ElevatedButton.icon(
onPressed: () => _deletePhoto(_currentPhotoIndex),
icon: Icon(Icons.delete),
label: Text(AppLocalizations.of(context).delete),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: EdgeInsets.symmetric(
vertical: 12,
horizontal: 16,
),
),
),
],
],
),
SizedBox(height: 12),
// Bottom 'Fertig' button placed under the row
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed:
_canComplete
? () {
widget.onPhotosCompleted(_capturedPhotos);
Navigator.of(context).pop();
}
: null,
style: ElevatedButton.styleFrom(
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),
),
),
),
],
),
),
],
),
);
}
Widget _buildCameraOrEmptyState() {
// If platform not supported, show informative message
if (!_isCameraSupportedOnThisPlatform) {
return Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.desktop_windows, size: 80, color: Colors.grey[400]),
SizedBox(height: 16),
Text(
AppLocalizations.of(context).cameraNotAvailable,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.grey[700],
),
),
SizedBox(height: 8),
Text(
AppLocalizations.of(context).cameraNotSupportedMessage,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
],
),
),
);
}
// macOS fallback: explain file selection
if (_useFilePickerMode) {
return Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.photo_library, size: 80, color: Colors.grey[400]),
SizedBox(height: 16),
Text(
AppLocalizations.of(context).addPhotos,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.grey[700],
),
),
SizedBox(height: 8),
Text(
AppLocalizations.of(context).addPhotosInstruction,
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
],
),
),
);
}
// Show camera preview if available on any platform
if (_isCameraInitialized && _cameraController != null) {
return Container(
margin: EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.3),
spreadRadius: 2,
blurRadius: 8,
offset: Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CameraPreview(_cameraController!),
),
);
}
// When camera is not available, show empty state
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.camera_alt, size: 80, color: Colors.grey[400]),
SizedBox(height: 16),
Text(
AppLocalizations.of(context).cameraInitializing,
style: TextStyle(
fontSize: 18,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 8),
Text(
AppLocalizations.of(context).cameraLoadingMessage,
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
textAlign: TextAlign.center,
),
],
),
);
}
Widget _buildPhotoGallery() {
return Column(
children: [
// Photo counter (if more than one photo)
if (_capturedPhotos.length > 1)
Container(
padding: EdgeInsets.symmetric(vertical: 8),
child: Text(
'${_currentPhotoIndex + 1} ${AppLocalizations.of(context).photoOf} ${_capturedPhotos.length}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.grey[700],
),
),
),
// Photo viewer with swipe gestures and navigation arrows
Expanded(
child: Stack(
children: [
PageView.builder(
controller: _pageController,
onPageChanged: (index) {
setState(() {
_currentPhotoIndex = index;
});
},
itemCount: _capturedPhotos.length,
itemBuilder: (context, index) {
return Container(
margin: EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.3),
spreadRadius: 2,
blurRadius: 8,
offset: Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(
_capturedPhotos[index],
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Container(
color: Colors.grey[300],
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error,
size: 50,
color: Colors.grey[600],
),
SizedBox(height: 8),
Text(AppLocalizations.of(context).photoError),
],
),
),
);
},
),
),
);
},
),
if (_capturedPhotos.length > 1 && _isDesktopPlatform)
Positioned(
left: 8,
top: 0,
bottom: 0,
child: Center(
child: IconButton(
onPressed:
_currentPhotoIndex > 0 ? _goToPreviousPhoto : null,
icon: Icon(Icons.chevron_left, size: 36),
style: IconButton.styleFrom(
backgroundColor: Colors.white.withValues(alpha: 0.7),
),
),
),
),
if (_capturedPhotos.length > 1 && _isDesktopPlatform)
Positioned(
right: 8,
top: 0,
bottom: 0,
child: Center(
child: IconButton(
onPressed:
_currentPhotoIndex < _capturedPhotos.length - 1
? _goToNextPhoto
: null,
icon: Icon(Icons.chevron_right, size: 36),
style: IconButton.styleFrom(
backgroundColor: Colors.white.withValues(alpha: 0.7),
),
),
),
),
],
),
),
// Photo indicators (if more than one photo)
if (_capturedPhotos.length > 1)
Container(
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
? AppColors.primary
: Colors.grey[400],
),
);
}).toList(),
),
),
],
);
}
}