Final commit.

This commit is contained in:
Dennis Nemec
2026-06-01 17:12:28 +02:00
parent 3ecbc82885
commit a9bf8ecdd1
385 changed files with 29081 additions and 12089 deletions

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/bloc/app_bloc.dart';
import 'package:hl_lieferservice/data/cache/attachment_cache.dart';
import 'package:hl_lieferservice/data/network/keycloak_oidc_token_provider.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart';
@ -11,18 +12,20 @@ import 'package:hl_lieferservice/feature/car_selection/presentation/car_selectio
import 'package:hl_lieferservice/feature/car_selection/repository/car_selection_repository.dart';
import 'package:hl_lieferservice/data/repository/cars_repository_impl.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/cars/presentation/car_management_page.dart';
import 'package:hl_lieferservice/data/repository/payment_methods_repository_impl.dart';
import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart';
import 'package:holzleitner_api/holzleitner_api.dart' show HolzleitnerApi;
import 'package:hl_lieferservice/data/repository/tour_repository_impl.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.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/repository/tour_repository.dart';
import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
import 'package:hl_lieferservice/widget/operations/presentation/operation_view_enforcer.dart';
import 'package:hl_lieferservice/bloc/app_states.dart';
import '../feature/delivery/service/tour_service.dart';
import 'home/presentation/home.dart';
class DeliveryApp extends StatefulWidget {
@ -55,14 +58,15 @@ class _DeliveryAppState extends State<DeliveryApp> {
..add(const RestoreSessionRequested()),
),
BlocProvider(
create:
(context) => TourBloc(
opBloc: context.read<OperationBloc>(),
authBloc: context.read<AuthBloc>(),
tourRepository: TourRepository(
service: TourService(),
),
),
// Phase-C+D-2-Migration: produktive TourRepository-Impl
// gegen das generierte Rust-Backend-API. Account-Filter
// serverseitig aus dem JWT, deshalb braucht der Bloc
// keinen AuthBloc-Bezug mehr.
create: (context) => TourBloc(
tourRepository: TourRepositoryImpl(locator<HolzleitnerApi>()),
opBloc: context.read<OperationBloc>(),
attachmentCache: locator<AttachmentCache>(),
),
),
BlocProvider(
create: (context) =>
@ -79,19 +83,41 @@ class _DeliveryAppState extends State<DeliveryApp> {
),
),
BlocProvider(
// PhaseBloc darf erst NACH dem TourBloc gebaut werden,
// da er die Anzahl der Team-Fahrzeuge daraus liest, um
// beim ersten Load eines Fahrzeugs die korrekte
// Eintrittsphase (Auswählen vs. Sortieren) zu bestimmen.
// PhaseBloc liest die Team-Fahrzeug-Anzahl jetzt direkt
// aus dem CarsBloc — der ist die alleinige Quelle der
// Fahrzeug-Stammdaten. Beim ersten Load eines Fahrzeugs
// bestimmt das die Eintrittsphase (Auswählen vs. Sortieren).
create: (context) => PhaseBloc(
carCountResolver: () {
final carsState = context.read<CarsBloc>().state;
return carsState is CarsLoaded
? carsState.cars.length
: null;
},
// Bindet die persistierten Phasen-Häkchen an die aktuelle
// Tour-Version (Tour.syncedAt). Ein erneuter Sync/Seed
// schreibt eine neue syncedAt → neuer Token → frische
// Phasen, ohne dass alte lokale Häkchen hängen bleiben.
tourTokenResolver: () {
final tourState = context.read<TourBloc>().state;
return tourState is TourLoaded
? tourState.tour.driver.cars.length
? tourState.details.tour.syncedAt
.millisecondsSinceEpoch
.toString()
: null;
},
),
),
BlocProvider(
// Zahlungsmethoden sind firmenweite Stammdaten — wir laden
// sie einmal beim App-Start und cachen sie im Cubit. Der
// Detail-Screen einer Lieferung greift darauf zu, um den
// `paymentMethodId`-FK auf einen lesbaren Namen aufzulösen.
create: (context) => PaymentMethodsCubit(
repository:
PaymentMethodsRepositoryImpl(locator<HolzleitnerApi>()),
)..load(),
),
],
child: MaterialApp(
// Wrap the Navigator (not just the home route) so the loading

View File

@ -0,0 +1,184 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:holzleitner_api/holzleitner_api.dart' show HolzleitnerApi;
import 'package:hl_lieferservice/data/cache/attachment_cache.dart';
import 'package:hl_lieferservice/main.dart' show locator;
/// Lädt und zeigt ein Attachment-Vorschaubild über
/// `GET /attachments/{id}` an. Nutzt die geteilte, authentifizierte
/// Dio-Instanz (`HolzleitnerApi.dio`) — der Auth-Interceptor hängt den
/// Bearer-Token an, ein generierter Client-Methodenaufruf ist für die
/// Binär-Antwort nicht zuverlässig.
///
/// Reines Lese-Widget (kein Bloc): Bild-Anzeige ist kein App-State.
class AttachmentImage extends StatefulWidget {
const AttachmentImage({
super.key,
required this.attachmentId,
this.width = 1024,
this.height = 1024,
this.quality = 80,
this.fit = BoxFit.cover,
this.deleted = false,
});
/// Unsere Attachment-UUID (steht in `DeliveryNote.imageAttachment`).
final String attachmentId;
/// Wenn `true`: die lokale Bilddatei wurde nach dem Report-Upload gelöscht
/// (das Bild steckt im Lieferbericht in DOCUframe). Statt eines Downloads
/// wird ein Hinweis gezeigt.
final bool deleted;
/// Angefragte Vorschau-Abmessungen (Backend rendert per DOCUframe).
final int width;
final int height;
final int quality;
final BoxFit fit;
@override
State<AttachmentImage> createState() => _AttachmentImageState();
}
class _AttachmentImageState extends State<AttachmentImage> {
late Future<Uint8List> _future;
@override
void initState() {
super.initState();
// Gelöschte Anhänge nicht laden — es gibt keine lokale Datei mehr.
_future = widget.deleted ? Future.value(Uint8List(0)) : _load();
}
@override
void didUpdateWidget(AttachmentImage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.attachmentId != widget.attachmentId ||
oldWidget.width != widget.width ||
oldWidget.height != widget.height ||
oldWidget.quality != widget.quality) {
_future = _load();
}
}
static const _ext = 'jpeg';
Future<Uint8List> _load() async {
final cache = locator<AttachmentCache>();
// 1. Disk-Cache zuerst — Attachments sind unveränderlich, ein Treffer
// ist also immer gültig (auch offline).
final cached = await cache.read(
attachmentId: widget.attachmentId,
w: widget.width,
h: widget.height,
q: widget.quality,
ext: _ext,
);
if (cached != null) return cached;
// 2. Miss → über die authentifizierte Dio-Instanz aus DOCUframe holen.
final api = locator<HolzleitnerApi>();
final response = await api.dio.get<List<int>>(
'/attachments/${widget.attachmentId}',
queryParameters: {
'w': widget.width,
'h': widget.height,
'q': widget.quality,
'ext': _ext,
},
options: Options(responseType: ResponseType.bytes),
);
final bytes = Uint8List.fromList(response.data ?? const []);
// 3. Erfolgreichen Download persistieren (best-effort, blockiert die
// Anzeige nicht — write schluckt Fehler).
if (bytes.isNotEmpty) {
await cache.write(
attachmentId: widget.attachmentId,
w: widget.width,
h: widget.height,
q: widget.quality,
ext: _ext,
bytes: bytes,
);
}
return bytes;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (widget.deleted) {
return _DeletedHint(fit: widget.fit);
}
return FutureBuilder<Uint8List>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
if (snapshot.hasError ||
snapshot.data == null ||
snapshot.data!.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Icon(
Icons.broken_image_outlined,
color: theme.colorScheme.onSurfaceVariant,
),
),
);
}
return Image.memory(snapshot.data!, fit: widget.fit);
},
);
}
}
/// Hinweis für gelöschte Bild-Anhänge: das Bild liegt im Lieferbericht.
class _DeletedHint extends StatelessWidget {
const _DeletedHint({required this.fit});
final BoxFit fit;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
color: theme.colorScheme.surfaceContainerHighest,
alignment: Alignment.center,
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.picture_as_pdf_outlined,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(height: 8),
Text(
'Bild im Lieferbericht enthalten',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}

View File

@ -1,11 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.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/car_selection/presentation/selected_car_bar.dart';
import 'package:hl_lieferservice/feature/cars/presentation/car_management_page.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_event.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/phase_state.dart';
@ -17,10 +16,6 @@ import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_selection_page.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_sort_page.dart';
import 'package:hl_lieferservice/feature/loading/presentation/loading_overview_page.dart';
import 'package:hl_lieferservice/feature/settings/presentation/settings_page.dart';
import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart';
import 'package:hl_lieferservice/widget/home/bloc/navigation_state.dart';
import 'package:hl_lieferservice/widget/navigation_bar/presentation/navigation_bar.dart';
/// Wurzel-Widget des authentifizierten Bereichs. Routet anhand der aktuellen
/// Phase des ausgewählten Fahrzeugs:
@ -40,28 +35,75 @@ class Home extends StatefulWidget {
class _HomeState extends State<Home> {
String? _initializedCarId;
/// Merkt, für welches Auto die automatische Lieferungs-Zuordnung bereits
/// erledigt ist (Ein-Auto-Teams, s. [_ensureSingleCarAssignment]).
String? _autoAssignedForCar;
@override
void initState() {
super.initState();
// Tour beim ersten Aufbau laden.
final authState = context.read<AuthBloc>().state as Authenticated;
context.read<TourBloc>().add(LoadTour(teamId: authState.user.number));
// Tour beim ersten Aufbau laden. Account-Filter sitzt jetzt
// serverseitig im JWT — kein Personalnummer-Argument mehr nötig.
context.read<TourBloc>().add(const LoadTour());
// CarsBloc auch hier triggern: wenn der CarSelectBloc beim App-Start
// eine valide Tages-Auswahl aus den SharedPreferences fand, wurde die
// CarSelectionPage übersprungen — und damit auch ihr `CarLoad`-Trigger.
// Ohne diesen Aufruf hängen drei Stellen am leeren CarsBloc-State:
// `PhaseStepper.carCount`, `app.carCountResolver` (Eintrittsphase) und
// `delivery_selection_page._plateFor` (Vergeben-Tab zeigt sonst "?").
// Der Bloc-interne `if (state is CarsLoaded && !event.force) return;`
// macht den Aufruf idempotent.
context.read<CarsBloc>().add(const CarLoad());
}
/// Stellt sicher, dass für das aktuell gewählte Auto eine Phase im
/// [PhaseBloc] existiert. Wird im build() reaktiv aufgerufen, daher mit
/// `_initializedCarId` gegen mehrfache Loads gesichert.
///
/// Wichtig: Wir feuern den Load erst, sobald die Tour geladen ist —
/// sonst kennt der PhaseBloc die Anzahl der Team-Fahrzeuge nicht und
/// würde fälschlich mit `sortieren` einsteigen, statt mit `auswaehlen`.
/// Wichtig: Wir feuern den Load erst, sobald sowohl Tour als auch Cars
/// einen geladen-Zustand haben — sonst fragt der `PhaseBloc` den
/// `carCountResolver` ab, bekommt `null` und entscheidet für Mehr-Auto-
/// Teams fälschlich auf `sortieren` statt `auswaehlen`.
void _ensurePhaseLoaded(String carId) {
if (_initializedCarId == carId) return;
final carsState = context.read<CarsBloc>().state;
if (carsState is! CarsLoaded) return;
_initializedCarId = carId;
context.read<PhaseBloc>().add(PhaseLoadForCar(carId: carId));
}
/// Ein-Auto-Teams überspringen die „Auswählen"-Phase (s. [PhaseBloc]),
/// in der Lieferungen normalerweise einem Fahrzeug zugeordnet werden.
/// Ohne Zuordnung (`assignedCarId`) blieben sie in „Ausliefern" unsichtbar.
/// Daher hier einmalig: ist genau EIN Fahrzeug im Team, werden alle noch
/// nicht zugeordneten Lieferungen automatisch diesem Fahrzeug zugewiesen.
///
/// Reaktiv (im build) aufgerufen, aber per [_autoAssignedForCar] gegen
/// Mehrfachausführung gesichert. Greift erst, wenn Tour UND Cars geladen
/// sind (sonst Abbruch ohne Flag → erneuter Versuch beim nächsten Build).
void _ensureSingleCarAssignment(String carId) {
if (_autoAssignedForCar == carId) return;
final carsState = context.read<CarsBloc>().state;
final tourState = context.read<TourBloc>().state;
if (carsState is! CarsLoaded || tourState is! TourLoaded) return;
_autoAssignedForCar = carId;
// Nur Ein-Auto-Teams: bei ≥2 Fahrzeugen entscheidet der Fahrer aktiv in
// der „Auswählen"-Phase, hier wird bewusst nichts automatisch zugewiesen.
if (carsState.cars.length != 1) return;
final tourBloc = context.read<TourBloc>();
for (final delivery in tourState.details.deliveries) {
if (delivery.assignedCarId != carId) {
tourBloc.add(
AssignCarToDelivery(deliveryId: delivery.id, carId: carId),
);
}
}
}
@override
Widget build(BuildContext context) {
return BlocBuilder<CarSelectBloc, CarSelectState>(
@ -74,32 +116,49 @@ class _HomeState extends State<Home> {
final carId = carState.selectedCar.id.toString();
return BlocBuilder<TourBloc, TourState>(
// Tour-Status mitnehmen, weil die Eintrittsphase davon abhängt.
// Nur bei TourLoaded triggern wir den Phasen-Load.
builder: (context, tourState) {
if (tourState is TourLoaded) {
_ensurePhaseLoaded(carId);
}
return BlocBuilder<PhaseBloc, PhaseState>(
builder: (context, phaseState) {
final phase = phaseState is PhaseReady
? phaseState.phaseFor(carId)
: null;
// Solange weder Tour noch Phase geladen sind, kurzen Spinner
// zeigen — das dauert in der Praxis maximal einen Frame.
if (phase == null) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return _buildForPhase(context, phase, carState.selectedCar.id);
},
);
// Nachzieh-Trigger: wenn Cars erst NACH dem Tour-Build fertig
// werden, würde `_ensurePhaseLoaded` beim Tour-Build noch
// skippen. Dieser Listener feuert sobald `CarsLoaded` da ist.
return BlocListener<CarsBloc, CarsState>(
listenWhen: (prev, curr) =>
prev is! CarsLoaded && curr is CarsLoaded,
listener: (context, _) {
_ensurePhaseLoaded(carId);
_ensureSingleCarAssignment(carId);
},
child: BlocBuilder<TourBloc, TourState>(
builder: (context, tourState) {
if (tourState is TourLoaded || tourState is TourEmpty) {
_ensurePhaseLoaded(carId);
}
if (tourState is TourLoaded) {
_ensureSingleCarAssignment(carId);
}
return BlocBuilder<PhaseBloc, PhaseState>(
builder: (context, phaseState) {
final phase = phaseState is PhaseReady
? phaseState.phaseFor(carId)
: null;
// Solange Tour, Cars oder Phase noch laden, kurzen
// Spinner zeigen — das dauert in der Praxis maximal
// ein paar Frames.
if (phase == null) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return _buildForPhase(
context,
phase,
carState.selectedCar.id,
);
},
);
},
),
);
},
);
@ -129,46 +188,15 @@ class _HomeState extends State<Home> {
}
}
/// Klassisches Home für die Auslieferungs-Phase: BottomNavigationBar mit
/// drei Tabs (Auslieferung / Fahrzeuge / Einstellungen). Die Beladung als
/// Tab entfällt bewusst — wer in dieser Phase zurück zur Beladung möchte,
/// nutzt den Phasen-Stepper auf den jeweiligen Pages oder den Drawer.
/// Home für die Auslieferungs-Phase. Reine Hülle um die Übersicht — es gibt
/// keine BottomNavigationBar mehr: Fahrzeug-Verwaltung und Einstellungen
/// sind über den Drawer erreichbar, der Fahrzeug-Wechsel direkt aus dem
/// PhaseStepper (Icon neben dem Plate).
class _DeliveryHome extends StatelessWidget {
const _DeliveryHome();
Widget _buildPage(int index) {
switch (index) {
case 0:
return const DeliveryOverviewPage();
case 1:
return const CarManagementPage();
case 2:
return const SettingsPage();
default:
return const SizedBox.shrink();
}
}
@override
Widget build(BuildContext context) {
return BlocBuilder<NavigationBloc, NavigationState>(
builder: (context, state) {
final navIndex = state is NavigationInfo ? state.navigationIndex : 0;
// Bei einem Tab-Index, der außerhalb des neuen Bereichs liegt
// (z. B. vom alten 4-Tab-Layout: 0..3), normieren wir defensiv auf 0.
final safeIndex = (navIndex >= 0 && navIndex <= 2) ? navIndex : 0;
return Scaffold(
body: _buildPage(safeIndex),
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: const [
SelectedCarBar(),
AppNavigationBar(),
],
),
);
},
);
return const DeliveryOverviewPage();
}
}

View File

@ -15,7 +15,7 @@ class OperationBloc extends Bloc<OperationEvent, OperationState> {
/// Minimum time the overlay stays visible, even if the underlying request
/// completes faster. Prevents a "did anything happen?" UX where a sub-100 ms
/// roundtrip flashes the overlay for one frame.
static const Duration _minimumDisplayDuration = Duration(milliseconds: 350);
static const Duration _minimumDisplayDuration = Duration(milliseconds: 500);
OperationBloc() : super(OperationIdle()) {
on<StartOperation>(_startOperation);
@ -40,6 +40,13 @@ class OperationBloc extends Bloc<OperationEvent, OperationState> {
) async {
_inFlightCount = (_inFlightCount - 1).clamp(0, 1 << 30);
// Spinner-Mindestdauer einhalten, BEVOR wir ihn schließen — auch wenn eine
// Erfolgsmeldung folgt. Sonst blitzt der Spinner bei schnellen Requests nur
// einen Frame lang auf (genau der Grund für den vorherigen „kein Spinner").
if (_inFlightCount == 0) {
await _awaitMinimumOverlayDuration();
}
if (event.message != null) {
emit(OperationFinished(message: event.message));
await Future.delayed(const Duration(seconds: 5));
@ -48,7 +55,6 @@ class OperationBloc extends Bloc<OperationEvent, OperationState> {
if (_inFlightCount > 0) {
emit(OperationInProgress());
} else {
await _awaitMinimumOverlayDuration();
_overlayStartedAt = null;
emit(OperationIdle());
}
@ -59,6 +65,13 @@ class OperationBloc extends Bloc<OperationEvent, OperationState> {
Emitter<OperationState> emit,
) async {
_inFlightCount = (_inFlightCount - 1).clamp(0, 1 << 30);
// Auch im Fehlerfall den Spinner mindestens kurz zeigen, bevor die
// Fehler-SnackBar erscheint.
if (_inFlightCount == 0) {
await _awaitMinimumOverlayDuration();
}
emit(OperationFailed(message: event.message));
await Future.delayed(const Duration(seconds: 5));

View File

@ -48,18 +48,30 @@ class OperationViewEnforcer extends StatelessWidget {
color: Colors.black54,
),
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
if (progressMessage != null) ...[
const SizedBox(height: 16),
Text(
progressMessage,
style: const TextStyle(color: Colors.white),
),
// Material liefert einen DefaultTextStyle — sonst rendert
// der Text hier (über dem Navigator, ohne Scaffold) mit
// der gelb-unterstrichenen Fallback-Darstellung.
child: Material(
type: MaterialType.transparency,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
if (progressMessage != null) ...[
const SizedBox(height: 16),
Text(
progressMessage,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
decoration: TextDecoration.none,
),
),
],
],
],
),
),
),
],

View File

@ -1,12 +1,13 @@
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/events.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/state.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/phase_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';
/// Horizontaler Phasen-Stepper für die drei bzw. vier Schritte
@ -19,9 +20,9 @@ import 'package:hl_lieferservice/feature/delivery/model/delivery_phase.dart';
/// * Ein-Auto-Teams: Sortieren, Beladen, Ausliefern (3 Schritte).
/// * Mehr-Auto-Teams: Auswählen, Sortieren, Beladen, Ausliefern (4 Schritte).
///
/// Die Sichtbarkeitsliste wird intern aus dem [TourBloc] abgeleitet
/// (Anzahl `tour.driver.cars`). So müssen Aufrufer den Stepper nicht mit
/// Routing-Wissen versorgen — er bleibt eine reine Anzeige-Komponente,
/// Die Sichtbarkeitsliste wird intern aus dem [CarsBloc] abgeleitet
/// (Anzahl Fahrzeuge im Account). So müssen Aufrufer den Stepper nicht
/// mit Routing-Wissen versorgen — er bleibt eine reine Anzeige-Komponente,
/// die auf den globalen State reagiert.
///
/// Verhalten:
@ -47,8 +48,8 @@ class PhaseStepper extends StatelessWidget {
/// Auto-ID, an der die Phase im [PhaseBloc] gespeichert wird.
final String carId;
/// Optionaler Override für Tests / Sonderfälle. Default: aus TourBloc
/// abgeleitet (Anzahl cars im Team).
/// Optionaler Override für Tests / Sonderfälle. Default: aus CarsBloc
/// abgeleitet (Anzahl cars im Account).
final List<DeliveryPhase>? visiblePhases;
IconData _iconFor(DeliveryPhase phase) {
@ -107,18 +108,16 @@ class PhaseStepper extends StatelessWidget {
final theme = Theme.of(context);
final onPrimary = theme.colorScheme.onPrimary;
return BlocBuilder<TourBloc, TourState>(
return BlocBuilder<CarsBloc, CarsState>(
// Stepper reagiert auf cars.length-Änderungen — sonst praktisch statisch.
buildWhen: (prev, curr) {
if (visiblePhases != null) return prev != curr; // override aktiv
final prevCars = prev is TourLoaded ? prev.tour.driver.cars.length : 0;
final currCars = curr is TourLoaded ? curr.tour.driver.cars.length : 0;
final prevCars = prev is CarsLoaded ? prev.cars.length : 0;
final currCars = curr is CarsLoaded ? curr.cars.length : 0;
return prevCars != currCars || prev.runtimeType != curr.runtimeType;
},
builder: (context, tourState) {
final carCount = tourState is TourLoaded
? tourState.tour.driver.cars.length
: 0;
builder: (context, carsState) {
final carCount = carsState is CarsLoaded ? carsState.cars.length : 0;
final phases = visiblePhases ?? _effectivePhases(carCount);
// Höchste erreichte Phase aus dem PhaseBloc — bestimmt, welche
@ -151,27 +150,9 @@ class PhaseStepper extends StatelessWidget {
if (state is! CarSelectComplete) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(right: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.local_shipping,
color: onPrimary,
size: 18,
),
const SizedBox(width: 6),
Text(
state.selectedCar.plate,
style: TextStyle(
color: onPrimary,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
return _SelectedCarPill(
plate: state.selectedCar.plate,
onPrimary: onPrimary,
);
},
),
@ -337,6 +318,65 @@ class _StepperItem extends StatelessWidget {
}
}
/// Dezente Pille rechts oben im Header: Lkw-Icon, Plate, kleiner Swap-Button.
///
/// Visuell zurückhaltend gehalten: leicht durchscheinender Hintergrund auf
/// dem Primary-Header, kompakter IconButton — der Plate-Text bleibt
/// dominantes Element, der Wechseln-Knopf ist eine sekundäre Geste, die
/// erst auf Anfrage benutzt wird.
class _SelectedCarPill extends StatelessWidget {
const _SelectedCarPill({required this.plate, required this.onPrimary});
final String plate;
final Color onPrimary;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.only(left: 10, right: 2),
decoration: BoxDecoration(
color: onPrimary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.local_shipping, color: onPrimary, size: 16),
const SizedBox(width: 6),
Text(
plate,
style: TextStyle(
color: onPrimary,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(width: 2),
IconButton(
tooltip: 'Fahrzeug wechseln',
icon: Icon(
Icons.swap_horiz_rounded,
// Etwas heller als das Plate, damit der Button als sekundäre
// Aktion gelesen wird und das Plate nicht überstrahlt.
color: onPrimary.withValues(alpha: 0.85),
size: 18,
),
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(4),
constraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
splashRadius: 18,
onPressed: () =>
context.read<CarSelectBloc>().add(CarSelectChange()),
),
],
),
);
}
}
class _Connector extends StatelessWidget {
const _Connector({required this.isPassed});

View File

@ -0,0 +1,201 @@
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
/// Schmaler Kamera-Stripe mit Torch- und Manual-Entry-Button sowie
/// Zoom-Steuerung. Geteilt zwischen Beladen-Phase und Filial-Abholung.
///
/// Wichtig: sollte **einmal** und außerhalb eines `PageView` gemountet
/// werden, damit der Kamera-Stream beim Wischen nicht abreißt (eine einzige
/// Kamera-Instanz pro Page-Lebenszyklus).
class ArticleScannerStripe extends StatefulWidget {
const ArticleScannerStripe({
super.key,
required this.onBarcode,
required this.onManualEntry,
});
/// Roh-Wert eines erkannten Barcodes (bereits getrimmt, dedupliziert).
final void Function(String code) onBarcode;
/// Tap auf das Tastatur-Icon → Aufrufer öffnet den Manual-Entry-Dialog.
final VoidCallback onManualEntry;
@override
State<ArticleScannerStripe> createState() => _ArticleScannerStripeState();
}
class _ArticleScannerStripeState extends State<ArticleScannerStripe> {
late final MobileScannerController _scanner = MobileScannerController(
detectionSpeed: DetectionSpeed.normal,
formats: const [
BarcodeFormat.ean13,
BarcodeFormat.ean8,
BarcodeFormat.code128,
BarcodeFormat.code39,
BarcodeFormat.qrCode,
],
);
/// Dedupliziert Detections derselben Tastendruck-Geste: der Scanner liest
/// dasselbe Code-Bild viele Frames lang. Nur wenn der Code für
/// `_minRepeatGap` lang nicht gesehen wurde, lassen wir ihn erneut zu.
String? _lastCode;
DateTime _lastEmit = DateTime.fromMillisecondsSinceEpoch(0);
static const Duration _minRepeatGap = Duration(milliseconds: 1200);
/// Schrittweite der `-` / `+`-Buttons im normalisierten Zoom-Bereich
/// [0.0, 1.0]. 5% pro Tastendruck — fühlt sich auf einem 6"-Display
/// natürlich an, ohne dass der Fahrer zehnmal tappen muss.
static const double _zoomStep = 0.05;
@override
void dispose() {
_scanner.dispose();
super.dispose();
}
void _onDetect(BarcodeCapture capture) {
if (capture.barcodes.isEmpty) return;
final raw = capture.barcodes.first.rawValue?.trim();
if (raw == null || raw.isEmpty) return;
final now = DateTime.now();
if (raw == _lastCode && now.difference(_lastEmit) < _minRepeatGap) {
return;
}
_lastCode = raw;
_lastEmit = now;
widget.onBarcode(raw);
}
Future<void> _setZoom(double normalized) async {
final clamped = normalized.clamp(0.0, 1.0);
await _scanner.setZoomScale(clamped);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
SizedBox(
height: 140,
child: Stack(
children: [
Positioned.fill(
child: MobileScanner(
controller: _scanner,
onDetect: _onDetect,
errorBuilder: (context, error) {
return Container(
color: Colors.black,
child: Center(
child: Padding(
padding: const EdgeInsets.all(12),
child: Text(
'Kamera nicht verfügbar: ${error.errorCode.name}',
style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
),
),
);
},
),
),
Positioned(
right: 8,
top: 8,
child: Material(
color: Colors.black54,
borderRadius: BorderRadius.circular(20),
child: IconButton(
tooltip: 'Manuelle Eingabe',
icon: const Icon(Icons.keyboard, color: Colors.white),
onPressed: widget.onManualEntry,
),
),
),
Positioned(
left: 8,
top: 8,
child: Material(
color: Colors.black54,
borderRadius: BorderRadius.circular(20),
child: IconButton(
tooltip: 'Taschenlampe',
icon: const Icon(Icons.flash_on, color: Colors.white),
onPressed: () => _scanner.toggleTorch(),
),
),
),
],
),
),
_ZoomBar(controller: _scanner, onChange: _setZoom, step: _zoomStep),
],
);
}
}
/// Zoom-Steuerung: `-` Button links, Slider in der Mitte, `+` rechts.
/// Bindet an `MobileScannerController.value.zoomScale` via
/// ValueListenableBuilder — damit reagiert der Slider sofort, wenn das
/// Plugin den Zoom selbst clampt (z. B. bei Geräten, die nicht den vollen
/// Bereich unterstützen).
class _ZoomBar extends StatelessWidget {
const _ZoomBar({
required this.controller,
required this.onChange,
required this.step,
});
final MobileScannerController controller;
final Future<void> Function(double normalized) onChange;
final double step;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<MobileScannerState>(
valueListenable: controller,
builder: (context, state, _) {
final zoom = state.zoomScale;
return Material(
color: Colors.black87,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
children: [
IconButton(
tooltip: 'Auszoomen',
icon: const Icon(Icons.remove, color: Colors.white),
onPressed: zoom <= 0.0 ? null : () => onChange(zoom - step),
),
Expanded(
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
activeTrackColor: Colors.white,
inactiveTrackColor: Colors.white24,
thumbColor: Colors.white,
overlayColor: Colors.white24,
trackHeight: 3,
),
child: Slider(
min: 0.0,
max: 1.0,
value: zoom.clamp(0.0, 1.0),
onChanged: (v) => onChange(v),
),
),
),
IconButton(
tooltip: 'Reinzoomen',
icon: const Icon(Icons.add, color: Colors.white),
onPressed: zoom >= 1.0 ? null : () => onChange(zoom + step),
),
],
),
),
);
},
);
}
}

View File

@ -0,0 +1,99 @@
import 'package:hl_lieferservice/domain/entity/delivery.dart';
import 'package:hl_lieferservice/domain/entity/delivery_item.dart';
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
/// Ergebnis der Item-Auflösung beim Scan. Sealed, damit das UI per
/// `switch` exhaustiv pro Fall eine passende Meldung zeigt.
sealed class ItemMatch {
const ItemMatch();
const factory ItemMatch.ok(DeliveryItem item) = ItemMatchOk;
const factory ItemMatch.notInDelivery() = ItemMatchNotInDelivery;
const factory ItemMatch.notScannable() = ItemMatchNotScannable;
const factory ItemMatch.allDone() = ItemMatchAllDone;
const factory ItemMatch.allRemoved() = ItemMatchAllRemoved;
const factory ItemMatch.notOpen() = ItemMatchNotOpen;
}
class ItemMatchOk extends ItemMatch {
const ItemMatchOk(this.item);
final DeliveryItem item;
}
class ItemMatchNotInDelivery extends ItemMatch {
const ItemMatchNotInDelivery();
}
class ItemMatchNotScannable extends ItemMatch {
const ItemMatchNotScannable();
}
class ItemMatchAllDone extends ItemMatch {
const ItemMatchAllDone();
}
class ItemMatchAllRemoved extends ItemMatch {
const ItemMatchAllRemoved();
}
class ItemMatchNotOpen extends ItemMatch {
const ItemMatchNotOpen();
}
/// Findet in der Lieferung das erste **nicht fertig** gescannte Item mit der
/// gegebenen Article-Nummer. „Top-down"-Strategie: hat eine Lieferung zwei
/// Belegzeilen mit demselben Artikel (z. B. 20 + 10), wird zuerst die
/// niedrigere Belegzeile gefüllt. Erst wenn diese fertig ist, „rollt" der
/// nächste Scan auf die zweite Zeile weiter.
///
/// [itemFilter] schränkt die betrachteten Positionen ein — z. B. nur
/// Filial-Items in der Abhol-Phase. Ohne Filter werden alle Positionen
/// berücksichtigt (Beladen-Phase).
///
/// Klassifiziert den Misserfolgs-Grund (nicht scanbar / bereits fertig /
/// entfernt …), damit das UI dem Fahrer eine sinnvolle Meldung zeigt.
ItemMatch matchItem({
required Delivery delivery,
required TourDetails details,
required String articleNumber,
bool Function(DeliveryItem item)? itemFilter,
}) {
final normalized = articleNumber.trim();
final candidates = delivery.items
.where((it) => itemFilter?.call(it) ?? true)
.toList()
..sort((a, b) => a.belegzeilenNr.compareTo(b.belegzeilenNr));
final matchingArticle = candidates.where((it) {
final art = details.articleOf(it.articleId);
return art?.articleNumber == normalized;
}).toList();
if (matchingArticle.isEmpty) {
return const ItemMatch.notInDelivery();
}
// Erstes Item, das wir tatsächlich scannen können.
for (final item in matchingArticle) {
if (item.isDone || item.isRemoved) continue;
final article = details.articleOf(item.articleId);
if (article == null || !article.scannable) continue;
return ItemMatch.ok(item);
}
// Kein scannbares offenes Item — Grund anhand der Item-Verteilung
// klassifizieren, damit das UI eine sinnvolle Meldung zeigt.
final allNotScannable = matchingArticle.every((it) {
final art = details.articleOf(it.articleId);
return art == null || !art.scannable;
});
if (allNotScannable) return const ItemMatch.notScannable();
final allRemoved = matchingArticle.every((it) => it.isRemoved);
if (allRemoved) return const ItemMatch.allRemoved();
final allDone = matchingArticle.every((it) => it.isDone);
if (allDone) return const ItemMatch.allDone();
// Gemischte Konstellation (z. B. eine Zeile entfernt, eine fertig) —
// praktisch selten; konservativ als „nicht (mehr) offen" melden.
return const ItemMatch.notOpen();
}

View File

@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
/// Öffnet den manuellen Scan-Code-Eingabe-Dialog und liefert den
/// eingegebenen Code (oder `null` bei Abbruch / leerer Eingabe).
Future<String?> showManualEntryDialog(BuildContext context) {
return showDialog<String>(
context: context,
builder: (_) => const ManualEntryDialog(),
);
}
/// Fallback-Eingabe, wenn ein Sticker nicht scanbar ist (beschädigt,
/// schlechtes Licht). Erwartet das gleiche Format wie der QR-Code:
/// `Artikelnr;Kundennr;Belegnr`.
class ManualEntryDialog extends StatefulWidget {
const ManualEntryDialog({super.key});
@override
State<ManualEntryDialog> createState() => _ManualEntryDialogState();
}
class _ManualEntryDialogState extends State<ManualEntryDialog> {
final _controller = TextEditingController();
final _focusNode = FocusNode();
@override
void initState() {
super.initState();
WidgetsBinding.instance
.addPostFrameCallback((_) => _focusNode.requestFocus());
}
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
void _submit() {
final v = _controller.text.trim();
if (v.isEmpty) return;
Navigator.of(context).pop(v);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Scan-Code eingeben'),
content: TextField(
controller: _controller,
focusNode: _focusNode,
autocorrect: false,
textInputAction: TextInputAction.done,
decoration: const InputDecoration(
labelText: 'Artikelnr;Kundennr;Belegnr',
hintText: 'z. B. BRETT-200;4711;AB-2026-0001',
helperText: 'Format wie auf dem Sticker — drei Werte mit Semikolon',
),
onSubmitted: (_) => _submit(),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: _submit,
child: const Text('Übernehmen'),
),
],
);
}
}

View File

@ -0,0 +1,28 @@
/// Geparster Scan-Code im Format
/// `<Artikelnummer>;<Kundennummer>;<Belegnummer>`.
typedef ScanCode = ({String articleNumber, int customerErpId, String beleg});
/// Parst das QR-Code-Format `<Artikelnummer>;<Kundennummer>;<Belegnummer>`.
///
/// Trimmt jedes Feld, lehnt leere Felder und nicht-numerische
/// Kundennummern ab. Liefert `null`, wenn das Format nicht stimmt — der
/// Aufrufer übersetzt das in eine einheitliche „nicht vorgesehen"-Meldung,
/// damit der Fahrer kein Backstage-Tech-Feedback bekommt.
///
/// Geteilt zwischen Beladen-Phase (`LoadingCustomerPage`) und Filial-Abholung
/// (`FilialePickupScanPage`) — beide nutzen dasselbe Sticker-Format.
ScanCode? parseScanCode(String raw) {
final parts = raw.split(';');
if (parts.length != 3) return null;
final articleNumber = parts[0].trim();
final customerStr = parts[1].trim();
final beleg = parts[2].trim();
if (articleNumber.isEmpty || beleg.isEmpty) return null;
final customerErpId = int.tryParse(customerStr);
if (customerErpId == null) return null;
return (
articleNumber: articleNumber,
customerErpId: customerErpId,
beleg: beleg,
);
}

View File

@ -1,13 +1,13 @@
import 'package:flutter/material.dart';
/// Einheitliche Visualisierung für "Artikel aus Außenlager".
/// Einheitliche Visualisierung für "Artikel aus Filiale".
///
/// Wir nutzen bewusst nur eine Farbe (Amber) — selbst bei 510 möglichen
/// Lagern bleibt die Karte mit einem zusätzlichen Lagernamen als Text
/// lesbar. Mehrere Lager-Farben hätten zu Konfusion geführt und einzelne
/// Lager nicht eindeutig zugeordnet.
///
/// [warehouseNames] ist optional: ohne Namen erscheint nur "Außenlager".
/// [warehouseNames] ist optional: ohne Namen erscheint nur "Filiale".
class WarehouseBadge extends StatelessWidget {
const WarehouseBadge({
super.key,
@ -66,7 +66,7 @@ class WarehouseBadge extends StatelessWidget {
}
String _buildLabel() {
if (warehouseNames.isEmpty) return "Außenlager";
if (warehouseNames.isEmpty) return "Filiale";
if (warehouseNames.length == 1) return warehouseNames.first;
return warehouseNames.join(" + ");
}