Files
Holzleitner-Lieferservice-App/lib/feature/delivery/overview/presentation/delivery_selection_page.dart
Dennis Nemec a206636ed0 feat(tour): Tour-Neuladen ueberall + Drawer in Leer-/Ladezustaenden
- PhaseStepper: Reload-Button (RefreshTour, Spinner waehrend Refresh)
- Beladen-Empty-State: 'Neu laden'-Button (LoadTour) + Hinweis 'keine Tour verfuegbar'
- Drawer + AppBar in TourEmpty/Lade-Branches (Beladen-Uebersicht, Lieferungen auswaehlen, Sortieren) -> kein Festsitzen ohne Logout

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 13:08:18 +02:00

456 lines
15 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';
/// 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 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(
customer?.name ?? '⟨Unbekannter Kunde⟩',
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(
delivery.deliveryAddressSnapshot.oneLine,
style: 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);
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(
customer?.name ?? '⟨Unbekannter Kunde⟩',
style: TextStyle(
fontWeight: FontWeight.w600,
color: isOwn ? theme.colorScheme.primary : null,
),
),
subtitle: Text(
delivery.deliveryAddressSnapshot.oneLine,
style: 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) {
return 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(),
),
);
},
);
}
}