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.
504 lines
17 KiB
Dart
504 lines
17 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:votianlt_app/models/cargo_item.dart';
|
|
|
|
import 'app_theme.dart';
|
|
import 'l10n/app_localizations.dart';
|
|
import 'l10n/localization_helpers.dart';
|
|
import 'models/delivery_station.dart';
|
|
import 'models/job.dart';
|
|
import 'services/database_service.dart';
|
|
import 'task_view.dart';
|
|
import 'widgets/offline_banner.dart';
|
|
|
|
@visibleForTesting
|
|
Color? deliveryStationCardBackgroundColor(
|
|
DeliveryStation station,
|
|
Map<String, bool> taskStatuses,
|
|
) {
|
|
if (station.tasks.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
final isCompleted = station.tasks.every(
|
|
(task) => taskStatuses[task.id] ?? task.completed,
|
|
);
|
|
return isCompleted ? AppColors.successSoft : null;
|
|
}
|
|
|
|
class CargoItemsView extends StatefulWidget {
|
|
final Job job;
|
|
|
|
const CargoItemsView({super.key, required this.job});
|
|
|
|
@override
|
|
State<CargoItemsView> createState() => _CargoItemsViewState();
|
|
}
|
|
|
|
class _CargoItemsViewState extends State<CargoItemsView> {
|
|
final DatabaseService _databaseService = DatabaseService();
|
|
Map<String, bool> _taskStatuses = const {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadLocalTaskStatuses();
|
|
}
|
|
|
|
Future<void> _loadLocalTaskStatuses() async {
|
|
final map = await _databaseService.loadAllTaskStatuses();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_taskStatuses = map;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(widget.job.jobNumber),
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.arrow_back),
|
|
onPressed: () {
|
|
Navigator.of(context).pop();
|
|
},
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.chat),
|
|
onPressed: () {
|
|
Navigator.of(context).pushNamed('/chats');
|
|
},
|
|
tooltip: AppLocalizations.of(context).openChat,
|
|
),
|
|
],
|
|
),
|
|
body: Column(
|
|
children: [
|
|
OfflineBanner(),
|
|
// Main content area
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: ListView(
|
|
children: [
|
|
// Job summary card
|
|
Card(
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
elevation: 2,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
widget.job.jobNumber.isNotEmpty
|
|
? widget.job.jobNumber
|
|
: localizeKnownText(context, widget.job.title),
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
if (widget.job.customerSelection.isNotEmpty) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
widget.job.customerSelection,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: Colors.grey[700],
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.arrow_upward,
|
|
size: 16,
|
|
color: Colors.green[600],
|
|
),
|
|
const SizedBox(width: 4),
|
|
Flexible(
|
|
child: Text(
|
|
widget.job.pickupCity,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[700],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Icon(
|
|
Icons.arrow_forward,
|
|
size: 16,
|
|
color: Colors.grey[600],
|
|
),
|
|
const SizedBox(width: 8),
|
|
Icon(
|
|
Icons.arrow_downward,
|
|
size: 16,
|
|
color: AppColors.primary,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Flexible(
|
|
child: Text(
|
|
widget.job.deliveryCitiesDisplay.isNotEmpty
|
|
? widget.job.deliveryCitiesDisplay
|
|
: widget.job.deliveryCity,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[700],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
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: [
|
|
Icon(
|
|
Icons.local_shipping_outlined,
|
|
size: 24,
|
|
color: AppColors.primary,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
l10n.deliveryStationsCount(_deliveryStations.length),
|
|
style: const TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
_buildDeliveryStationsList(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
return [
|
|
DeliveryStation(
|
|
stationOrder: 0,
|
|
company: widget.job.deliveryCompany,
|
|
salutation: widget.job.deliverySalutation,
|
|
firstName: widget.job.deliveryFirstName,
|
|
lastName: widget.job.deliveryLastName,
|
|
phone: widget.job.deliveryPhone,
|
|
street: widget.job.deliveryStreet,
|
|
houseNumber: widget.job.deliveryHouseNumber,
|
|
addressAddition: widget.job.deliveryAddressAddition,
|
|
zip: widget.job.deliveryZip,
|
|
city: widget.job.deliveryCity,
|
|
deliveryDate: widget.job.deliveryDate,
|
|
deliveryTime: widget.job.deliveryTime,
|
|
tasks: widget.job.tasks,
|
|
),
|
|
];
|
|
}
|
|
|
|
Widget _buildDeliveryStationsList() {
|
|
if (_deliveryStations.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.local_shipping_outlined,
|
|
size: 64,
|
|
color: Colors.grey[400],
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
AppLocalizations.of(context).noDeliveryStations,
|
|
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
AppLocalizations.of(context).noDeliveryStationsMessage,
|
|
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: _deliveryStations.map(_buildDeliveryStationCard).toList(),
|
|
);
|
|
}
|
|
|
|
Widget _buildDeliveryStationCard(DeliveryStation station) {
|
|
final backgroundColor = deliveryStationCardBackgroundColor(
|
|
station,
|
|
_taskStatuses,
|
|
);
|
|
final title =
|
|
station.displayName.isNotEmpty ? station.displayName : station.company;
|
|
final subtitle =
|
|
station.company.isNotEmpty && station.company != title
|
|
? station.company
|
|
: null;
|
|
final l10n = AppLocalizations.of(context);
|
|
final addressLines =
|
|
<String>[
|
|
[
|
|
station.street,
|
|
station.houseNumber,
|
|
].where((part) => part.trim().isNotEmpty).join(' '),
|
|
if (station.addressAddition.trim().isNotEmpty)
|
|
station.addressAddition,
|
|
[
|
|
station.zip,
|
|
station.city,
|
|
].where((part) => part.trim().isNotEmpty).join(' '),
|
|
].where((line) => line.trim().isNotEmpty).toList();
|
|
|
|
return Card(
|
|
color: backgroundColor,
|
|
margin: const EdgeInsets.symmetric(horizontal: 0, vertical: 8),
|
|
elevation: 2,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
side: BorderSide(color: Colors.grey[300]!, width: 1),
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: InkWell(
|
|
onTap: () async {
|
|
await Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder:
|
|
(context) => TaskView(
|
|
job: widget.job,
|
|
stationOrder: station.stationOrder,
|
|
stationTitle:
|
|
station.displayName.isNotEmpty
|
|
? station.displayName
|
|
: l10n.stationNumber(station.stationOrder + 1),
|
|
),
|
|
),
|
|
);
|
|
await _loadLocalTaskStatuses();
|
|
},
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.primarySoft,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(
|
|
l10n.stationNumber(station.stationOrder + 1),
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.primaryStrong,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title.isNotEmpty
|
|
? localizeKnownText(context, title)
|
|
: l10n.unnamedStation,
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
if (subtitle != null) ...[
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
subtitle,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: Colors.grey[700],
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
_buildDetailItem(
|
|
Icons.location_on_outlined,
|
|
AppLocalizations.of(context).location,
|
|
addressLines.join('\n'),
|
|
AppColors.primary,
|
|
),
|
|
if (station.phone.trim().isNotEmpty) ...[
|
|
const SizedBox(height: 12),
|
|
_buildDetailItem(
|
|
Icons.phone_outlined,
|
|
l10n.phone,
|
|
station.phone,
|
|
AppColors.success,
|
|
),
|
|
],
|
|
if (station.deliveryDate.trim().isNotEmpty ||
|
|
station.deliveryTime.trim().isNotEmpty) ...[
|
|
const SizedBox(height: 12),
|
|
_buildDetailItem(
|
|
Icons.schedule,
|
|
AppLocalizations.of(context).delivery,
|
|
[
|
|
station.deliveryDate,
|
|
station.deliveryTime,
|
|
].where((part) => part.trim().isNotEmpty).join(' '),
|
|
AppColors.warning,
|
|
),
|
|
],
|
|
const SizedBox(height: 12),
|
|
_buildDetailItem(
|
|
Icons.task_alt,
|
|
AppLocalizations.of(context).tasks,
|
|
'${station.tasks.length}',
|
|
AppColors.primaryStrong,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDetailItem(
|
|
IconData icon,
|
|
String label,
|
|
String value,
|
|
Color color,
|
|
) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: color.withValues(alpha: 0.3)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(icon, size: 16, color: color.withValues(alpha: 0.8)),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[700],
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
value,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.grey[800],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|