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

@ -0,0 +1,457 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
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';
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_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
import 'package:hl_lieferservice/widget/scanner/article_scanner_stripe.dart';
import 'package:hl_lieferservice/widget/scanner/item_matcher.dart';
import 'package:hl_lieferservice/widget/scanner/manual_entry_dialog.dart';
import 'package:hl_lieferservice/widget/scanner/scan_code_parser.dart';
/// Scan-Screen für die Filial-Abholung in der Auslieferungs-Phase.
///
/// Der Fahrer hat in der Beladen-Phase das Standardlager beladen. Artikel
/// aus einer Filiale waren noch offen — bevor er ausliefert, muss er zur
/// Filiale, die Ware holen und hier abscannen. Diese Page wird beim Tap auf
/// eine Lieferung mit offenen Filial-Artikeln geöffnet (siehe
/// `DeliveryOverview`).
///
/// Fokus auf **eine** Lieferung + **nur** deren Filial-Items. Gleiche
/// QR-Validierung (`Artikelnr;Kundennr;Belegnr`) und derselbe `ScanItem`-
/// Pfad wie die Beladen-Phase — über die geteilten Scanner-Module.
///
/// Nach vollständigem Scan: Erfolgs-Zustand + Button „zurück zur Übersicht".
/// Der Fahrer fährt dann zum Kunden; dort öffnet ein erneuter Tap die
/// eigentliche Auslieferung (`DeliveryDetail`).
class FilialePickupScanPage extends StatefulWidget {
const FilialePickupScanPage({super.key, required this.deliveryId});
final String deliveryId;
@override
State<FilialePickupScanPage> createState() => _FilialePickupScanPageState();
}
class _FilialePickupScanPageState extends State<FilialePickupScanPage> {
static const String _notIntendedMessage =
'Dieser Artikel gehört nicht zu dieser Filial-Abholung';
String _carId(BuildContext context) {
final state = context.read<CarSelectBloc>().state;
if (state is CarSelectComplete) return state.selectedCar.id;
return '00000000-0000-0000-0000-000000000000';
}
void _showSnackbar(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
);
}
/// Nur scanbare, nicht-entfernte Filial-Items dieser Lieferung,
/// aufsteigend nach Belegzeile.
List<DeliveryItem> _externalItems(Delivery delivery, TourDetails details) {
final items = delivery.items.where((it) {
if (it.isRemoved) return false;
if (!details.isArticleScannable(it.articleId)) return false;
final w = details.warehouseOf(it.warehouseId);
return w != null && !w.isStandard;
}).toList()
// Oberartikel vor seinen Komponenten (für eingerückte Darstellung).
..sort((a, b) {
final byLine = a.belegzeilenNr.compareTo(b.belegzeilenNr);
if (byLine != 0) return byLine;
final byParent =
(a.isComponent ? 1 : 0).compareTo(b.isComponent ? 1 : 0);
if (byParent != 0) return byParent;
return (a.komponentenArtikelNr ?? '')
.compareTo(b.komponentenArtikelNr ?? '');
});
return items;
}
void _onBarcode({
required String code,
required Delivery delivery,
required TourDetails details,
}) {
final customer = details.customerOf(delivery);
final parsed = parseScanCode(code);
if (parsed == null ||
customer?.erpCustomerId != parsed.customerErpId ||
delivery.erpBelegnummer != parsed.beleg) {
_showSnackbar(_notIntendedMessage);
return;
}
final match = matchItem(
delivery: delivery,
details: details,
articleNumber: parsed.articleNumber,
// Nur Filial-Items zählen — ein Standardlager-Artikel hat hier nichts
// zu suchen (der ist längst auf dem LKW).
itemFilter: (it) {
final w = details.warehouseOf(it.warehouseId);
return w != null && !w.isStandard;
},
);
switch (match) {
case ItemMatchOk(:final item):
context
.read<TourBloc>()
.add(ScanItem(deliveryItemId: item.id, actorCarId: _carId(context)));
case ItemMatchNotInDelivery():
_showSnackbar(_notIntendedMessage);
case ItemMatchNotScannable():
_showSnackbar('Diese Position ist nicht zum Scannen vorgesehen.');
case ItemMatchAllDone():
_showSnackbar('Dieser Artikel ist bereits geladen.');
case ItemMatchAllRemoved():
_showSnackbar('Diese Position wurde aus der Lieferung entfernt.');
case ItemMatchNotOpen():
_showSnackbar('Diese Position ist nicht (mehr) offen.');
}
}
Future<void> _onManualEntry({
required Delivery delivery,
required TourDetails details,
}) async {
final code = await showManualEntryDialog(context);
if (code == null || code.isEmpty || !mounted) return;
_onBarcode(code: code, delivery: delivery, details: details);
}
/// Fallback ohne Barcode: die ganze Restmenge der Filial-Position manuell
/// als geholt bestätigen. Bewusste Aussage → Bestätigungs-Dialog; das
/// Backend protokolliert den Scan als `manual`.
Future<void> _onManualConfirm(DeliveryItem item) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Manuell bestätigen'),
content: const Text(
'Diese Position ohne Scan als aus der Filiale geholt markieren? '
'Das wird als manuelle Bestätigung protokolliert.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Abbrechen'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Als geholt bestätigen'),
),
],
),
);
if (confirmed != true || !mounted) return;
context.read<TourBloc>().add(ScanItem(
deliveryItemId: item.id,
actorCarId: _carId(context),
manual: true,
));
}
@override
Widget build(BuildContext context) {
return BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
if (state is! TourLoaded) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
final details = state.details;
final delivery = _findDelivery(details);
if (delivery == null) {
return Scaffold(
appBar: AppBar(title: const Text('Filial-Abholung')),
body: const Center(
child: Text('Lieferung nicht in der Tour gefunden.'),
),
);
}
final customer = details.customerOf(delivery);
final externalItems = _externalItems(delivery, details);
final doneCount = externalItems.where((it) => it.isDone).length;
final allDone = externalItems.isNotEmpty && doneCount == externalItems.length;
final warehouseNames = details.externalWarehouseLabels(delivery);
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
title: Text(
warehouseNames.isEmpty
? 'Filial-Abholung'
: 'Abholung: ${warehouseNames.join(", ")}',
),
),
body: Column(
children: [
ArticleScannerStripe(
onBarcode: (code) =>
_onBarcode(code: code, delivery: delivery, details: details),
onManualEntry: () =>
_onManualEntry(delivery: delivery, details: details),
),
Expanded(
child: ListView(
// Wenn alle Artikel gescannt sind, deckt die _DoneBar den
// Inset ab; vorher endet die Liste unten frei — daher hier
// die System-Navigationsleiste freihalten.
padding: EdgeInsets.fromLTRB(
16,
12,
16,
24 + (allDone ? 0 : MediaQuery.viewPaddingOf(context).bottom),
),
children: [
_Header(
customerName: customer?.name ?? '⟨Unbekannter Kunde⟩',
belegnummer: delivery.erpBelegnummer,
doneCount: doneCount,
total: externalItems.length,
),
const SizedBox(height: 12),
for (final item in externalItems)
_ExternalItemRow(
item: item,
details: details,
onManualConfirm: () => _onManualConfirm(item),
),
],
),
),
if (allDone) _DoneBar(onConfirm: () => Navigator.of(context).pop()),
],
),
);
},
);
}
Delivery? _findDelivery(TourDetails details) {
for (final d in details.deliveries) {
if (d.id == widget.deliveryId) return d;
}
return null;
}
}
class _Header extends StatelessWidget {
const _Header({
required this.customerName,
required this.belegnummer,
required this.doneCount,
required this.total,
});
final String customerName;
final String belegnummer;
final int doneCount;
final int total;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.warehouse_outlined, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
'Artikel aus der Filiale holen & scannen',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
Text(
'$doneCount / $total',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: doneCount == total
? Colors.green.shade700
: theme.colorScheme.primary,
),
),
],
),
const SizedBox(height: 8),
Text(
'$customerName · Beleg-Nr. $belegnummer',
style: TextStyle(
fontSize: 13,
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: total == 0 ? 0 : doneCount / total,
minHeight: 6,
backgroundColor: theme.colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
doneCount == total
? Colors.green.shade600
: theme.colorScheme.primary,
),
),
),
],
),
),
);
}
}
class _ExternalItemRow extends StatelessWidget {
const _ExternalItemRow({
required this.item,
required this.details,
required this.onManualConfirm,
});
final DeliveryItem item;
final TourDetails details;
final VoidCallback onManualConfirm;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final article = details.articleOf(item.articleId);
final warehouse = details.warehouseOf(item.warehouseId);
final done = item.isDone;
// Manueller Fallback nur für offene Positionen. Liste ist bereits
// scanbar + extern + nicht-entfernt gefiltert; hier nur done/held raus.
final canManualConfirm = !done && !item.isHeld;
return Card(
// Komponenten eingerückt → gehören zum Oberartikel darüber.
margin: EdgeInsets.only(top: 8, left: item.isComponent ? 24 : 0),
elevation: 0,
color: done
? Colors.green.withValues(alpha: 0.08)
: theme.colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: done
? Colors.green.withValues(alpha: 0.4)
: Colors.transparent,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(
done ? Icons.check_circle : Icons.radio_button_unchecked,
color: done
? Colors.green.shade600
: theme.colorScheme.onSurfaceVariant,
),
title: Text(
'${item.isComponent ? '' : ''}${article?.name ?? '⟨Unbekannter Artikel⟩'}',
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(
[
article?.articleNumber ?? item.articleId,
if (warehouse != null) warehouse.name,
].join(' · '),
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurfaceVariant,
),
),
trailing: Text(
'${item.scanProgress.scannedQuantity} / ${item.requiredQuantity}',
style: TextStyle(
fontWeight: FontWeight.bold,
color: done ? Colors.green.shade700 : theme.colorScheme.onSurface,
),
),
),
if (canManualConfirm)
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: onManualConfirm,
icon: const Icon(Icons.check_circle_outline, size: 18),
label: const Text('Manuell als geholt bestätigen'),
),
),
),
],
),
);
}
}
class _DoneBar extends StatelessWidget {
const _DoneBar({required this.onConfirm});
final VoidCallback onConfirm;
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
child: Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
color: Colors.green.withValues(alpha: 0.12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(Icons.check_circle, color: Colors.green.shade700),
const SizedBox(width: 8),
Expanded(
child: Text(
'Alle Filial-Artikel geladen',
style: TextStyle(
fontWeight: FontWeight.w700,
color: Colors.green.shade800,
),
),
),
],
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: onConfirm,
icon: const Icon(Icons.arrow_back),
label: const Text('Fertig — zurück zur Übersicht'),
),
),
],
),
),
);
}
}