- 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>
456 lines
15 KiB
Dart
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(),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|