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.
432 lines
16 KiB
Dart
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,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|