feat: Frachtanzeige und Stationsabschluss für Version 0.9.23

Fracht mit Maßen und Gewicht anzeigen und Stationsabschlüsse gegen Wiederholung absichern. Backend-Abschluss atomar ausführen und mit Regressionstests abdecken. Optionale Aufgaben beim Kopieren erhalten und Kunden mit Adressen anzeigen. App und Backend auf Version 0.9.23 aktualisieren.
This commit is contained in:
Sven committed 2026-09-28 11:30:36 +02:00
1 parent d7c0173493
commit 7325c77739
11 files changed
+277 -65

No files matched your search

+86 -26
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:votianlt_app/models/cargo_item.dart';
import 'app_theme.dart';
import 'l10n/app_localizations.dart';
@@ -81,8 +82,7 @@ class _CargoItemsViewState extends State<CargoItemsView> {
Expanded(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
child: ListView(
children: [
// Job summary card
Card(
@@ -122,11 +122,13 @@ class _CargoItemsViewState extends State<CargoItemsView> {
color: Colors.green[600],
),
const SizedBox(width: 4),
Text(
widget.job.pickupCity,
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
Flexible(
child: Text(
widget.job.pickupCity,
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
),
),
),
const SizedBox(width: 8),
@@ -142,13 +144,15 @@ class _CargoItemsViewState extends State<CargoItemsView> {
color: AppColors.primary,
),
const SizedBox(width: 4),
Text(
widget.job.deliveryCitiesDisplay.isNotEmpty
? widget.job.deliveryCitiesDisplay
: widget.job.deliveryCity,
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
Flexible(
child: Text(
widget.job.deliveryCitiesDisplay.isNotEmpty
? widget.job.deliveryCitiesDisplay
: widget.job.deliveryCity,
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
),
),
),
],
@@ -157,6 +161,30 @@ class _CargoItemsViewState extends State<CargoItemsView> {
),
),
),
if (widget.job.cargoItems.isNotEmpty) ...[
Row(
children: [
Icon(
Icons.inventory_2_outlined,
size: 24,
color: AppColors.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.cargo,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 8),
...widget.job.cargoItems.map(_buildCargoItemCard),
const SizedBox(height: 16),
],
// Delivery stations section header
Row(
children: [
@@ -166,17 +194,19 @@ class _CargoItemsViewState extends State<CargoItemsView> {
color: AppColors.primary,
),
const SizedBox(width: 8),
Text(
l10n.deliveryStationsCount(_deliveryStations.length),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
Expanded(
child: Text(
l10n.deliveryStationsCount(_deliveryStations.length),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 16),
Expanded(child: _buildDeliveryStationsList()),
_buildDeliveryStationsList(),
],
),
),
@@ -186,6 +216,39 @@ class _CargoItemsViewState extends State<CargoItemsView> {
);
}
Widget _buildCargoItemCard(CargoItem item) {
final l10n = AppLocalizations.of(context);
return Card(
margin: const EdgeInsets.symmetric(vertical: 8),
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.description.trim().isNotEmpty
? item.description
: l10n.cargo,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text('${l10n.quantity}: ${item.quantity}'),
if (item.hasDimensions) ...[
const SizedBox(height: 4),
Text('${l10n.dimensions}: ${item.formattedDimensions}'),
],
if (item.weightKg.isFinite && item.weightKg > 0) ...[
const SizedBox(height: 4),
Text('${l10n.weight}: ${item.formattedWeight}'),
],
],
),
),
);
}
List<DeliveryStation> get _deliveryStations {
if (widget.job.deliveryStations.isNotEmpty) {
return widget.job.deliveryStations;
@@ -238,12 +301,9 @@ class _CargoItemsViewState extends State<CargoItemsView> {
);
}
return ListView.builder(
itemCount: _deliveryStations.length,
itemBuilder: (context, index) {
final station = _deliveryStations[index];
return _buildDeliveryStationCard(station);
},
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: _deliveryStations.map(_buildDeliveryStationCard).toList(),
);
}
+10 -1
View File
@@ -87,9 +87,18 @@ class CargoItem {
@override
int get hashCode => id.hashCode;
bool get hasDimensions =>
[lengthCm, widthCm, heightCm].any((value) => value.isFinite && value > 0);
/// Get formatted dimensions string for display
String get formattedDimensions {
return '${lengthCm.toInt()} × ${widthCm.toInt()} × ${heightCm.toInt()} cm';
final dimensions = [lengthCm, widthCm, heightCm].map((value) {
if (!value.isFinite || value <= 0) return '–';
return value == value.truncateToDouble()
? value.toStringAsFixed(0)
: value.toString();
});
return '${dimensions.join(' × ')} cm';
}
/// Get formatted weight string for display
+1
View File
@@ -1216,6 +1216,7 @@ class WebSocketService {
name: 'WebSocketService',
);
developer.log('Stack: $st', name: 'WebSocketService');
rethrow;
}
}
+46 -20
View File
@@ -42,6 +42,9 @@ class TaskView extends StatefulWidget {
class _TaskViewState extends State<TaskView> {
final Set<String> _completedTasks = {};
final DatabaseService _databaseService = DatabaseService();
bool _statusesLoaded = false;
bool _stationCompleted = false;
bool _isCompletingStation = false;
// Store SVG representations of signatures per task for later use
final Map<String, String> _signatureSvgByTask = {};
@@ -61,10 +64,19 @@ class _TaskViewState extends State<TaskView> {
.toList();
}
String get _stationCompletionKey =>
'station_completed:${widget.job.id}:${widget.stationOrder ?? 0}';
/// Load task completion and skipped statuses from database and merge with JSON task states
Future<void> _loadTaskStatuses() async {
final statuses = await _databaseService.loadAllTaskStatuses();
final stationCompleted =
await _databaseService.loadKeyValue(_stationCompletionKey) == '1';
if (!mounted) return;
setState(() {
_stationCompleted =
stationCompleted || widget.job.status.toUpperCase() == 'COMPLETED';
_statusesLoaded = true;
_completedTasks.clear();
// 1) Add all completed from DB
for (final entry in statuses.entries) {
@@ -148,9 +160,11 @@ class _TaskViewState extends State<TaskView> {
}
bool get _canCompleteStation {
if (!_statusesLoaded || _stationCompleted || _isCompletingStation) {
return false;
}
// Station kann abgeschlossen werden, wenn alle nicht-optionalen Aufgaben
// erledigt sind. Sind nur optionale Aufgaben vorhanden, ist der Button
// immer aktiv.
// erledigt sind und der Stationsabschluss noch nicht gesendet wurde.
for (final t in _visibleTasks) {
if (!t.optional && !_completedTasks.contains(t.id)) {
return false;
@@ -184,6 +198,7 @@ class _TaskViewState extends State<TaskView> {
}
void _onCompleteStationPressed() {
if (!_canCompleteStation) return;
if (_hasIncompleteOptionalTasks) {
showDialog(
context: context,
@@ -214,21 +229,30 @@ class _TaskViewState extends State<TaskView> {
}
}
void _sendStationCompleted() {
Future<void> _sendStationCompleted() async {
if (!_canCompleteStation) return;
setState(() => _isCompletingStation = true);
final stationOrder = widget.stationOrder ?? 0;
try {
StompService().sendStationCompleted(
await StompService().sendStationCompleted(
jobId: widget.job.id,
jobNumber: widget.job.jobNumber,
stationOrder: stationOrder,
hasIncompleteOptionalTasks: _hasIncompleteOptionalTasks,
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Station abgeschlossen')),
);
_stationCompleted = true;
await _databaseService.saveKeyValue(_stationCompletionKey, '1');
if (!mounted) return;
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');
} finally {
if (mounted) {
setState(() => _isCompletingStation = false);
}
}
}
@@ -372,18 +396,18 @@ class _TaskViewState extends State<TaskView> {
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,
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(
@@ -756,7 +780,7 @@ class _TaskViewState extends State<TaskView> {
// den Benutzer fragen, ob er die Station jetzt abschließen möchte.
if (hadOpenMandatoryBefore && !_hasOpenMandatoryTasks) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
if (mounted && _canCompleteStation) {
_showLastMandatoryCompletedDialog();
}
});
@@ -899,7 +923,9 @@ class _TaskViewState extends State<TaskView> {
? localizeKnownText(context, task.description!)
: null;
final String? signatureNote =
(task is SignatureTask && task.note != null && task.note!.trim().isNotEmpty)
(task is SignatureTask &&
task.note != null &&
task.note!.trim().isNotEmpty)
? task.note!.trim()
: null;
+1 -1
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.9.22+922
version: 0.9.23+923
environment:
sdk: ^3.7.0