Files
votianlt/app/lib/cargo_items_view.dart
T
Sven 5e6044e4b1 fix: Stationsabschluss absichern und Kundenformulare anpassen
Sperrt nachträgliche Aufgabenabschlüsse in App und Backend und zeigt erledigte Aufträge trotz offener optionaler Aufgaben grün an. Ergänzt Regressionstests, die Anredeauswahl und optionale Steuerangaben für Kunden. Enthält App-Version 0.9.25, Backend-Version 0.9.26 und das aktualisierte Frontend-Bundle.
2026-10-06 15:09:51 +02:00

517 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 'models/job_progress.dart';
import 'services/database_service.dart';
import 'task_view.dart';
import 'widgets/offline_banner.dart';
@visibleForTesting
Color? deliveryStationCardBackgroundColor(
DeliveryStation station,
Map<String, bool> taskStatuses, {
bool isClosed = false,
}) {
if (isClosed) return AppColors.successSoft;
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 {};
Set<int> _completedStations = const {};
@override
void initState() {
super.initState();
_loadLocalTaskStatuses();
}
Future<void> _loadLocalTaskStatuses() async {
final map = await _databaseService.loadAllTaskStatuses();
final completedStations = await _databaseService.loadCompletedStationOrders(
widget.job,
);
if (!mounted) return;
setState(() {
_taskStatuses = map;
_completedStations = completedStations;
});
}
@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,
isClosed: JobProgress(
widget.job,
taskStatuses: _taskStatuses,
localCompletedStations: _completedStations,
).isStationClosed(station.stationOrder),
);
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],
),
),
],
),
);
}
}