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:
1 parent
d7c0173493
commit
7325c77739
11 files changed
+277
-65
No files matched your search
@@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1216,6 +1216,7 @@ class WebSocketService {
|
||||
name: 'WebSocketService',
|
||||
);
|
||||
developer.log('Stack: $st', name: 'WebSocketService');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+46
-20
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user