Phasenbasierte Lieferübersicht + Beladen-Flow, plus Migrationsplan für Rust-Backend
UI-Restructuring: - TabBar in scan_page durch dedizierte Phasen ersetzt: Sortieren / Beladen / Ausliefern - PhaseBloc + PhaseService leiten Phase aus Tour-/Item-States ab - DeliverySelectionPage (ab 2 Autos) und DeliverySortPage als eigene Flows - LoadingOverviewPage / LoadingCustomerPage für die Beladephase - PhaseStepper-Widget im Home für Phasen-Anzeige - Lager-Differenzierung (Standardlager 0 vs. Außenlager) via WarehouseBadge Process-Stubs: - ProcessRepository für Hold/Cancel/Sort/Assign-Flows (stub, bereit für Backend-Anbindung) Doku: - docs/BACKEND_MIGRATION.md: Phasenplan für Umstellung auf das neue Rust-Backend (OpenAPI-Generator, Keycloak OIDC, Clean-Arch-Layering)
This commit is contained in:
431
lib/feature/loading/presentation/loading_overview_page.dart
Normal file
431
lib/feature/loading/presentation/loading_overview_page.dart
Normal file
@ -0,0 +1,431 @@
|
||||
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(int? 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user