Files
Holzleitner-Lieferservice-App/lib/feature/delivery/overview/presentation/delivery_selection_page.dart
Dennis Nemec 857c92031a feat: abweichende Lieferadresse und Empfänger vor Ort anzeigen
Belege können im ERP eine eigene Lieferadresse (LieferAdressId) mit
anderer Person tragen als der Besteller. Die App zeigt jetzt überall den
Empfänger vor Ort:

- TourDetails.recipientOf liefert Empfänger (Name aus der Lieferadresse,
  sonst Besteller), Lieferanschrift und differsFromOrderer.
- Sortieren, Auswählen, Beladen und Liefer-Übersicht: Empfängername +
  Lieferadresse, bei Abweichung „Bestellt von: <Kunde>".
- Lieferdetails: hervorgehobene Karte „Abweichende Lieferadresse" mit
  Empfänger, Anschrift und Kontakt; Kunden-Block als „Besteller (Kunde)"
  mit Adresse, Kundennummer und Kontaktdaten.
- Maps navigiert zur Lieferadresse statt zur Kundenadresse; AppBar,
  Unterschrift und Beladen-Avatar nutzen den Empfänger.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:51:31 +02:00

468 lines
16 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_fail_page.dart';
import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart';
import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart';
import 'package:hl_lieferservice/widget/orderer_hint.dart';
/// Page für die erste Phase bei Mehr-Auto-Teams: Auswählen der eigenen
/// Lieferungen aus dem gemeinsamen Tour-Pool.
///
/// Aufbau:
/// * Tab "Verfügbar" — alle Lieferungen, die noch keinem Fahrzeug
/// zugeordnet sind. Multiselect, Bulk-Bestätigung per BottomBar-Button.
/// * Tab "Vergeben" — Lieferungen, die bereits zugeordnet sind. Tap auf
/// eigene → Freigabe-Dialog; Tap auf fremde → Umlade-Dialog.
/// * Persistente BottomBar: "Weiter zum Sortieren" — wechselt die Phase.
class DeliverySelectionPage extends StatefulWidget {
const DeliverySelectionPage({super.key, required this.selectedCarId});
final String selectedCarId;
@override
State<StatefulWidget> createState() => _DeliverySelectionPageState();
}
class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
final Set<String> _selectedIds = <String>{};
/// Sucht das Kennzeichen eines Fahrzeugs in der aktuell geladenen
/// CarsBloc-Liste. Liefert "?" als Fallback, wenn das Auto nicht (mehr)
/// im Account ist — z. B. nach Personalwechsel zwischen Tour-Syncs.
String _plateFor(String? carId) {
if (carId == null) return '?';
final carsState = context.read<CarsBloc>().state;
if (carsState is! CarsLoaded) return '?';
for (final c in carsState.cars) {
if (c.id == carId) return c.plate;
}
return '?';
}
void _confirmSelection() {
if (_selectedIds.isEmpty) return;
final ids = List<String>.from(_selectedIds);
// EIN Bulk-Event statt N parallel laufender Single-Events: vermeidet
// die Race-Condition, bei der `flutter_bloc`s default-concurrent
// Event-Processing parallele Handler den Initial-State lesen lässt
// und sich am Ende beim `emit` gegenseitig überschreiben.
context.read<TourBloc>().add(
AssignCarToDeliveries(
deliveryIds: ids,
carId: widget.selectedCarId,
),
);
setState(_selectedIds.clear);
}
void _goToSorting() {
context.read<PhaseBloc>().add(
PhaseSet(carId: widget.selectedCarId, phase: DeliveryPhase.sortieren),
);
}
Future<void> _showReleaseDialog(Delivery delivery, TourDetails details) async {
final customer = details.customerOf(delivery);
final result = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Lieferung freigeben'),
content: Text(
'${customer?.name ?? 'Diese Lieferung'} wurde Ihrem Fahrzeug '
'zugeordnet. Möchten Sie sie wieder freigeben?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Freigeben'),
),
],
),
);
if (result != true || !mounted) return;
context.read<TourBloc>().add(
AssignCarToDelivery(deliveryId: delivery.id, carId: null),
);
}
Future<void> _showTakeoverDialog(
Delivery delivery,
TourDetails details,
) async {
final customer = details.customerOf(delivery);
final foreignPlate = _plateFor(delivery.assignedCarId);
final ownPlate = _plateFor(widget.selectedCarId);
final theme = Theme.of(context);
final result = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Lieferung umladen'),
content: RichText(
text: TextSpan(
style: theme.textTheme.bodyMedium,
children: [
TextSpan(
text: '${customer?.name ?? 'Diese Lieferung'} ist aktuell ',
),
TextSpan(
text: foreignPlate,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const TextSpan(
text: ' zugeordnet. Möchten Sie diese Lieferung auf ',
),
TextSpan(
text: ownPlate,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const TextSpan(text: ' umladen?'),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Abbrechen'),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: theme.colorScheme.error,
foregroundColor: theme.colorScheme.onError,
),
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Übernehmen'),
),
],
),
);
if (result != true || !mounted) return;
context.read<TourBloc>().add(
AssignCarToDelivery(
deliveryId: delivery.id,
carId: widget.selectedCarId,
),
);
}
// ─── Widgets ─────────────────────────────────────────────────────────
Widget _plateBadge(BuildContext context, String plate, {bool own = false}) {
final theme = Theme.of(context);
final bg = own
? theme.colorScheme.primary
: theme.colorScheme.surfaceContainerHighest;
final fg = own
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurfaceVariant;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.local_shipping_outlined, size: 12, color: fg),
const SizedBox(width: 4),
Text(
plate,
style: TextStyle(fontSize: 11, fontWeight: FontWeight.bold, color: fg),
),
],
),
);
}
Widget _emptyState({
required IconData icon,
required String title,
String? subtitle,
}) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 64, color: theme.colorScheme.outline),
const SizedBox(height: 12),
Text(
title,
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
if (subtitle != null) ...[
const SizedBox(height: 6),
Text(
subtitle,
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
],
),
),
);
}
Widget _availableTab(List<Delivery> available, TourDetails details) {
if (available.isEmpty) {
return _emptyState(
icon: Icons.inbox_outlined,
title: 'Alle Lieferungen sind verteilt.',
subtitle: 'Im Tab "Vergeben" können Sie eigene Lieferungen '
'freigeben oder fremde übernehmen.',
);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: available.length,
itemBuilder: (context, index) {
final delivery = available[index];
final customer = details.customerOf(delivery);
final recipient = details.recipientOf(delivery);
final isSelected = _selectedIds.contains(delivery.id);
return CheckboxListTile(
key: ValueKey('available-${delivery.id}'),
value: isSelected,
onChanged: (checked) {
// Während ein Bulk-Zuweisung läuft, blockiert der
// OperationViewEnforcer die Eingabe global; ein separates
// `_isAssigning`-Lock ist hier nicht mehr nötig.
setState(() {
if (checked == true) {
_selectedIds.add(delivery.id);
} else {
_selectedIds.remove(delivery.id);
}
});
},
title: Text(
recipient.name,
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: RecipientSubtitle(
recipient: recipient,
ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
addressStyle: const TextStyle(fontSize: 12),
),
controlAffinity: ListTileControlAffinity.leading,
);
},
);
}
Widget _assignedTab(List<Delivery> assigned, TourDetails details) {
if (assigned.isEmpty) {
return _emptyState(
icon: Icons.local_shipping_outlined,
title: 'Noch keine Lieferungen verteilt.',
);
}
final theme = Theme.of(context);
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: assigned.length,
itemBuilder: (context, index) {
final delivery = assigned[index];
final isOwn = delivery.assignedCarId == widget.selectedCarId;
final plate = _plateFor(delivery.assignedCarId);
final customer = details.customerOf(delivery);
final recipient = details.recipientOf(delivery);
return Material(
color: isOwn
? theme.colorScheme.primaryContainer.withValues(alpha: 0.35)
: null,
child: ListTile(
key: ValueKey('assigned-${delivery.id}'),
onTap: () {
if (isOwn) {
_showReleaseDialog(delivery, details);
} else {
_showTakeoverDialog(delivery, details);
}
},
leading: Icon(
isOwn ? Icons.check_circle : Icons.person_outline,
color: isOwn
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
title: Text(
recipient.name,
style: TextStyle(
fontWeight: FontWeight.w600,
color: isOwn ? theme.colorScheme.primary : null,
),
),
subtitle: RecipientSubtitle(
recipient: recipient,
ordererName: customer?.name ?? '⟨Unbekannter Kunde⟩',
addressStyle: const TextStyle(fontSize: 12),
),
trailing: _plateBadge(context, plate, own: isOwn),
),
);
},
);
}
Widget _buildBottomBar() {
final hasSelection = _selectedIds.isNotEmpty;
// Disabled-State und Spinner während des Bulk-Zuweisens übernimmt
// global der `OperationViewEnforcer` (StartOperation/FinishOperation
// aus dem TourBloc) — daher hier keine lokale Lock-Variable mehr.
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (hasSelection)
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _confirmSelection,
icon: const Icon(Icons.check),
label: Text(
'Auswahl bestätigen (${_selectedIds.length})',
),
),
),
if (hasSelection) const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _goToSorting,
icon: const Icon(Icons.arrow_forward),
label: const Text('Weiter zum Sortieren'),
),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
if (state is TourLoadFailed) {
// Mit Scaffold + Drawer: die Fehlerseite (inkl. „Erneut versuchen")
// stand bisher nackt ohne Material-Rahmen da, und der Fahrer kam
// nicht an Einstellungen/Logout.
return Scaffold(
drawer: const HomeAppDrawer(),
appBar: AppBar(title: const Text('Lieferungen auswählen')),
body: const DeliveryLoadingFailedPage(),
);
}
if (state is TourEmpty) {
return Scaffold(
drawer: const HomeAppDrawer(),
appBar: AppBar(title: const Text('Lieferungen auswählen')),
body: const Center(
child: Padding(
padding: EdgeInsets.all(24),
child: Text(
'Für heute ist keine Tour zugewiesen.',
style: TextStyle(fontSize: 16),
textAlign: TextAlign.center,
),
),
),
);
}
if (state is! TourLoaded) {
// Drawer auch hier — Fahrer soll im Lade-Hang ausloggen können.
return Scaffold(
drawer: const HomeAppDrawer(),
appBar: AppBar(title: const Text('Lieferungen auswählen')),
body: const Center(child: CircularProgressIndicator()),
);
}
final details = state.details;
final available = details.deliveries
.where((d) => d.assignedCarId == null)
.toList();
final assigned = details.deliveries
.where((d) => d.assignedCarId != null)
.toList();
_selectedIds.removeWhere(
(id) => !available.any((d) => d.id == id),
);
return DefaultTabController(
length: 2,
child: Scaffold(
drawer: const HomeAppDrawer(),
appBar: PreferredSize(
preferredSize: const Size.fromHeight(140 + kTextTabBarHeight),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
PhaseStepper(
currentPhase: DeliveryPhase.auswaehlen,
carId: widget.selectedCarId,
),
Material(
color: Theme.of(context).primaryColor,
child: TabBar(
labelColor: Theme.of(context).colorScheme.onPrimary,
unselectedLabelColor: Theme.of(context)
.colorScheme
.onPrimary
.withValues(alpha: 0.6),
indicatorColor: Theme.of(context).colorScheme.onPrimary,
tabs: [
Tab(text: 'Verfügbar (${available.length})'),
Tab(text: 'Vergeben (${assigned.length})'),
],
),
),
],
),
),
body: TabBarView(
children: [
_availableTab(available, details),
_assignedTab(assigned, details),
],
),
bottomNavigationBar: _buildBottomBar(),
),
);
},
);
}
}