Files
Holzleitner-Lieferservice-App/lib/feature/loading/presentation/loading_overview_page.dart
Dennis Nemec 3ecbc82885 Phase C+D-1: Cars-Domain auf Rust-Backend umgestellt
Clean-Arch-Schichten für Cars:
- lib/domain/entity/car.dart: UUID-id, accountId (Personalnummer),
  plate, active. Pendant zum Backend-Schema.
- lib/domain/repository/cars_repository.dart: Port — listMine,
  create, update. Keine teamId/personalnummer-Parameter, der
  Account fließt serverseitig aus dem JWT.
- lib/data/mapper/car_mapper.dart: API-DTO (built_value) → Domain.
- lib/data/repository/cars_repository_impl.dart: konkrete Impl via
  generierter CarsApi (dio), mit DioException → CarsRepositoryException-
  Übersetzung.

Feature-Cars-Refactoring:
- CarsBloc nimmt jetzt die Domain-Repository-Schnittstelle. Events:
  CarLoad/CarAdd/CarEdit/CarDeactivate (statt CarDelete). Keine
  teamId-Parameter mehr. Kein authBloc-Bezug, Session-Expiry läuft
  über den globalen Provider-Stream.
- CarsState sealed mit CarsInitial/Loading/LoadingFailed/Loaded.
- Pages: car_management_page, car_management, car_card, car_fail_page,
  car_selection_page komplett auf die neue Entity und Event-Signaturen.
- Alte lib/feature/cars/service/cars_service.dart und
  lib/feature/cars/repository/cars_repository.dart gelöscht.

CarSelectBloc + Storage:
- CarSelection.selectedCarId von int? auf String? umgestellt.
- CarSelectionRepository persistiert die UUID jetzt als String;
  defensive Migration für noch vorhandene int-Werte (alte
  Pre-Migration-Installations) verwirft den Wert leise und
  erzwingt Neuauswahl.

Konsequenz-Cleanup im Tour-Code (Phase-D-Vorbereitung):
- Delivery.carId String? statt int?.
- Tour.hasUndeliveredLoadedArticles / getFinishedDeliveries auf
  String carId.
- _selectedCarId / int? carId / int selectedCarId in DeliveryOverview,
  LoadingCustomerPage/OverviewPage, Home, DeliverySelection/SortPage,
  DeliveryInfo/List, CustomSortDialog, SortableDeliveryList auf
  String umgestellt.
- TourRepository ersetzt int.parse(carId)/int.tryParse-Zuweisungen
  direkt durch String.
- lib/model/car.dart wird zum Re-Export der neuen Domain-Entity,
  damit Legacy-Imports während Phase-D-Übergang weiter compilieren.

DI:
- app.dart: CarsBloc bekommt CarsRepositoryImpl(locator<HolzleitnerApi>())
  statt der alten CarsRepository(service: CarService()).

Build (flutter build apk --debug) durch, flutter analyze ohne
errors.
2026-05-15 11:55:24 +02:00

432 lines
16 KiB
Dart

import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.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/feature/loading/model/loading_group.dart';
import 'package:hl_lieferservice/feature/loading/presentation/loading_customer_page.dart';
import 'package:hl_lieferservice/feature/loading/util/loading_order.dart';
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:hl_lieferservice/model/tour.dart';
import 'package:hl_lieferservice/widget/home/presentation/home_drawer.dart';
import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart';
/// Übersichts-Ansicht für die Beladen-Phase: alle Kunden in Beladereihen-
/// folge mit Fortschritts-Status. KEIN Scanner — der Scanner-Fokus bleibt
/// auf der [LoadingCustomerPage]. Tap auf einen Kunden öffnet seine
/// Vollbild-Ansicht mit dem entsprechenden Index.
class LoadingOverviewPage extends StatelessWidget {
const LoadingOverviewPage({super.key});
String? _lookupCarPlate(String? carId, Tour tour) {
if (carId == null) return null;
return tour.driver.cars.firstWhereOrNull((c) => c.id == carId)?.plate;
}
List<LoadingGroup> _buildGroups(TourLoaded state, String carIdStr) {
final orderedIds = LoadingOrder.computeForCar(
state: state,
carIdStr: carIdStr,
);
final byId = {for (final d in state.tour.deliveries) d.id: d};
final groups = <LoadingGroup>[];
for (final id in orderedIds) {
final delivery = byId[id];
if (delivery == null) continue;
if (delivery.state == DeliveryState.finished) continue;
final scannable =
delivery.articles.where((a) => a.scannable).toList(growable: false);
if (scannable.isEmpty && delivery.state != DeliveryState.canceled) {
continue;
}
groups.add(LoadingGroup(
delivery: delivery,
articles: scannable,
carPlate: _lookupCarPlate(delivery.carId, state.tour),
));
}
return groups;
}
@override
Widget build(BuildContext context) {
return BlocBuilder<CarSelectBloc, CarSelectState>(
builder: (context, carState) {
final carIdStr =
carState is CarSelectComplete ? carState.selectedCar.id.toString() : "";
return BlocBuilder<TourBloc, TourState>(
builder: (context, tourState) {
if (tourState is TourLoadingFailed) {
return const DeliveryLoadingFailedPage();
}
if (tourState is! TourLoaded) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final groups = _buildGroups(tourState, carIdStr);
return Scaffold(
drawer: const HomeAppDrawer(),
appBar: PreferredSize(
preferredSize: const Size.fromHeight(140),
child: PhaseStepper(
currentPhase: DeliveryPhase.beladen,
carId: carIdStr,
),
),
body: SafeArea(
top: false,
child: groups.isEmpty
? const _EmptyOverview()
: _OverviewList(groups: groups),
),
);
},
);
},
);
}
}
class _OverviewList extends StatelessWidget {
const _OverviewList({required this.groups});
final List<LoadingGroup> groups;
@override
Widget build(BuildContext context) {
final totalActive = groups
.where((g) => g.delivery.state != DeliveryState.canceled)
.length;
final doneActive = groups
.where((g) =>
g.delivery.state != DeliveryState.canceled && g.isComplete)
.length;
return Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Beladereihenfolge",
style: Theme.of(context).textTheme.titleMedium,
),
Text(
"$doneActive / $totalActive Kunden",
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: totalActive == 0 ? 0.0 : doneActive / totalActive,
minHeight: 6,
backgroundColor: Theme.of(context)
.colorScheme
.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
doneActive == totalActive && totalActive > 0
? Colors.green
: Theme.of(context).primaryColor,
),
),
),
],
),
),
const Divider(height: 1),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.only(top: 8, bottom: 16),
itemCount: groups.length,
itemBuilder: (context, index) {
final g = groups[index];
return _OverviewTile(
position: index + 1,
group: g,
onTap: () {
// Push (kein pushReplacement): die Übersicht ist seit dem
// Routing-Umbau in home.dart die Wurzel der Beladen-Phase.
// Vom Vollbild kehrt der Fahrer per pop zurück auf diese
// Übersicht — der Stack bleibt damit flach.
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => LoadingCustomerPage(initialIndex: index),
),
);
},
);
},
),
),
],
);
}
}
class _OverviewTile extends StatelessWidget {
const _OverviewTile({
required this.position,
required this.group,
required this.onTap,
});
final int position;
final LoadingGroup group;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final canceled = group.delivery.state == DeliveryState.canceled;
final isComplete = group.isComplete;
final isPartial = group.isPartial;
final hasExternalWarehouse = group.hasExternalWarehouseArticles;
// cardColor und borderColor sind nicht final, weil das Außenlager-
// Highlight sie weiter unten ggf. überschreibt.
Color cardColor;
Color borderColor;
final Color titleColor;
final String statusText;
final IconData statusIcon;
if (canceled) {
cardColor = Colors.grey.withValues(alpha: 0.08);
borderColor = Colors.grey.withValues(alpha: 0.35);
titleColor = Colors.grey.shade700;
statusText = "Abgebrochen";
statusIcon = Icons.cancel_outlined;
} else if (isComplete && hasExternalWarehouse) {
// Standardlager ist fertig, aber es liegen noch Artikel in einem
// anderen Lager — die Lieferung ist also NICHT komplett beladen.
// Wir machen das im Status-Text explizit, damit der Fahrer nicht
// fälschlich davon ausgeht, dass nichts mehr offen ist.
cardColor = Colors.deepOrange.withValues(alpha: 0.10);
borderColor = Colors.deepOrange.withValues(alpha: 0.45);
titleColor = Colors.deepOrange.shade800;
statusText = "Standardlager fertig — Außenlager offen";
statusIcon = Icons.warehouse_outlined;
} else if (isComplete) {
cardColor = Colors.green.withValues(alpha: 0.07);
borderColor = Colors.green.withValues(alpha: 0.35);
titleColor = Colors.green.shade700;
statusText = "Fertig beladen";
statusIcon = Icons.check_circle_outline;
} else if (isPartial) {
cardColor = Colors.orange.withValues(alpha: 0.07);
borderColor = Colors.orange.withValues(alpha: 0.35);
titleColor = Colors.orange.shade800;
statusText = "Beladung läuft";
statusIcon = Icons.pending_outlined;
} else {
cardColor = theme.colorScheme.surfaceContainerLow;
borderColor = Colors.transparent;
titleColor = theme.colorScheme.onSurface;
statusText = "Offen";
statusIcon = Icons.radio_button_unchecked;
}
// Außenlager-Hervorhebung: lebt unabhängig vom Scan-Status. Eine
// abgebrochene Lieferung bleibt grau, ansonsten überschreibt das
// Außenlager-Highlight die Standard-Farben durch ein klar erkennbares
// Orange — der Fahrer muss früh genug wissen, dass er ein anderes
// Lager anfahren wird. Der Sonderzweig "isComplete && hasExternal-
// Warehouse" oben hat das Highlight schon gesetzt, hier greift es
// für die noch nicht fertigen Fälle.
if (!canceled && hasExternalWarehouse && !isComplete) {
cardColor = Colors.deepOrange.withValues(alpha: 0.10);
borderColor = Colors.deepOrange.withValues(alpha: 0.65);
}
final progressLabel = canceled
? ""
: "${group.completeArticles}/${group.totalArticles} Artikel";
return Opacity(
opacity: canceled ? 0.65 : 1.0,
child: Card(
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
elevation: 0,
color: cardColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: borderColor),
),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
CircleAvatar(
backgroundColor:
canceled ? Colors.grey : theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
radius: 18,
child: Text(
"$position",
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
group.delivery.customer.name,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: titleColor,
decoration: canceled
? TextDecoration.lineThrough
: TextDecoration.none,
),
),
const SizedBox(height: 2),
Text(
group.delivery.customer.address.toString(),
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(statusIcon,
size: 14, color: titleColor),
),
const SizedBox(width: 4),
// Expanded, damit lange Status-Texte wie
// "Standardlager fertig — Außenlager offen"
// umbrechen statt zu überlaufen.
Expanded(
child: Text(
statusText,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: titleColor,
),
softWrap: true,
),
),
const SizedBox(width: 10),
Text(
progressLabel,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
if (!canceled && hasExternalWarehouse) ...[
const SizedBox(height: 6),
_ExternalWarehouseBadge(
labels: group.externalWarehouseLabels,
),
],
],
),
),
const Icon(Icons.chevron_right),
],
),
),
),
),
);
}
}
/// Hinweis-Badge unter dem Status-Row einer Lieferung mit Artikeln aus
/// einem oder mehreren Außenlagern. Listet die betroffenen Lager-Namen
/// auf, damit der Fahrer beim Beladen weiß, wohin er zusätzlich muss.
class _ExternalWarehouseBadge extends StatelessWidget {
const _ExternalWarehouseBadge({required this.labels});
final List<String> labels;
@override
Widget build(BuildContext context) {
final text = labels.isEmpty
? "Außenlager"
: "Außenlager: ${labels.join(", ")}";
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.deepOrange.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.deepOrange.withValues(alpha: 0.6)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.warehouse_outlined,
size: 14, color: Colors.deepOrange.shade700),
const SizedBox(width: 4),
Flexible(
child: Text(
text,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.deepOrange.shade800,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
class _EmptyOverview extends StatelessWidget {
const _EmptyOverview();
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.inbox_outlined, size: 64, color: scheme.onSurfaceVariant),
const SizedBox(height: 12),
Text(
"Keine Lieferungen zum Beladen",
style: Theme.of(context).textTheme.titleMedium,
),
],
),
);
}
}