Final commit.
This commit is contained in:
@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user