Final commit.
This commit is contained in:
@ -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
|
||||
|
||||
184
lib/widget/attachment_image.dart
Normal file
184
lib/widget/attachment_image.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -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});
|
||||
|
||||
|
||||
201
lib/widget/scanner/article_scanner_stripe.dart
Normal file
201
lib/widget/scanner/article_scanner_stripe.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
99
lib/widget/scanner/item_matcher.dart
Normal file
99
lib/widget/scanner/item_matcher.dart
Normal 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();
|
||||
}
|
||||
74
lib/widget/scanner/manual_entry_dialog.dart
Normal file
74
lib/widget/scanner/manual_entry_dialog.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
28
lib/widget/scanner/scan_code_parser.dart
Normal file
28
lib/widget/scanner/scan_code_parser.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
@ -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 5–10 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(" + ");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user