Added components to article

This commit is contained in:
Dennis Nemec
2026-05-11 17:12:05 +02:00
parent 2470299a10
commit ac6b03227d
37 changed files with 1189 additions and 513 deletions

View File

@ -13,6 +13,7 @@ import 'package:hl_lieferservice/feature/scan/presentation/scanner.dart';
import 'package:hl_lieferservice/feature/settings/bloc/settings_bloc.dart';
import 'package:hl_lieferservice/feature/settings/bloc/settings_state.dart';
import 'package:hl_lieferservice/model/article.dart';
import 'package:hl_lieferservice/model/component.dart';
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:hl_lieferservice/model/tour.dart';
import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart';
@ -24,37 +25,42 @@ import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
// Data helpers
// ---------------------------------------------------------------------------
class _ArticleDeliveryEntry {
class _DeliveryGroup {
final Delivery delivery;
final Article article;
final String? carPlate;
final List<Article> articles;
const _ArticleDeliveryEntry({
const _DeliveryGroup({
required this.delivery,
required this.article,
required this.articles,
this.carPlate,
});
}
class _ArticleGroup {
final String articleNumber;
final String name;
final int totalAmount;
final int totalScanned;
final int totalRemoved;
final List<_ArticleDeliveryEntry> entries;
int get totalArticles => articles.length;
bool get isComplete => totalScanned + totalRemoved >= totalAmount;
int get scannedOrRemoved => totalScanned + totalRemoved;
int get completeArticles => articles
.where((a) => a.isFullyScanned)
.length;
const _ArticleGroup({
required this.articleNumber,
required this.name,
required this.totalAmount,
required this.totalScanned,
required this.totalRemoved,
required this.entries,
});
int get totalUnits => articles.fold(0, (sum, a) {
if (a.isParent && a.components.isNotEmpty) {
return sum + a.components.fold(0, (s, c) => s + c.requiredAmount);
}
return sum + a.amount;
});
int get scannedUnits => articles.fold(0, (sum, a) {
if (a.isParent && a.components.isNotEmpty) {
return sum + a.components.fold(0, (s, c) => s + c.scannedAmount);
}
return sum + a.scannedAmount + a.scannedRemovedAmount;
});
bool get isComplete => totalArticles > 0 && completeArticles == totalArticles;
bool get hasAnyScanned => scannedUnits > 0;
bool get isPartial => hasAnyScanned && !isComplete;
}
// ---------------------------------------------------------------------------
@ -128,6 +134,8 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
/// `<artikelnummer>;<kundennummer>;<belegnummer>`.
/// Liefert `null`, wenn der Barcode dem erwarteten Format nicht entspricht.
String? _extractArticleNumber(String barcode) {
debugPrint("QR CODE: $barcode");
final parts = barcode.split(';');
if (parts.length != 3) return null;
final articleNumber = parts[0].trim();
@ -156,10 +164,43 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
final tourState = context.read<TourBloc>().state;
if (tourState is! TourLoaded) return;
// ── 1. Try component match first (Stückliste) ──
final componentDeliveries = tourState.tour.deliveries
.where((d) => d.state != DeliveryState.finished)
.where((d) {
final parent = d.findParentOfComponent(articleNumber);
if (parent == null) return false;
final comp = parent.findComponent(articleNumber);
return comp != null && !comp.isFullyScanned;
})
.toList();
if (componentDeliveries.isNotEmpty) {
if (componentDeliveries.length == 1) {
setState(() => _isScanning = true);
context.read<TourBloc>().add(ScanComponentEvent(
componentArticleNumber: articleNumber,
carId: _selectedCarId!.toString(),
deliveryId: componentDeliveries.first.id,
));
return;
}
_showCustomerSelectionSheet(
articleNumber,
componentDeliveries,
tourState.tour,
isComponent: true,
);
return;
}
// ── 2. Regular article scan ──
final needingDeliveries = tourState.tour.deliveries
.where((d) => d.state != DeliveryState.finished)
.where((d) => d.articles.any((a) =>
a.articleNumber == articleNumber &&
!a.isParent &&
a.scannedAmount + a.scannedRemovedAmount < a.amount))
.toList();
@ -189,8 +230,9 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
void _showCustomerSelectionSheet(
String articleNumber,
List<Delivery> deliveries,
Tour tour,
) {
Tour tour, {
bool isComponent = false,
}) {
final tourBloc = context.read<TourBloc>();
final carId = _selectedCarId!;
@ -244,11 +286,19 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
onTap: () {
Navigator.pop(ctx);
setState(() => _isScanning = true);
tourBloc.add(ScanArticleEvent(
articleNumber: articleNumber,
carId: carId.toString(),
deliveryId: delivery.id,
));
if (isComponent) {
tourBloc.add(ScanComponentEvent(
componentArticleNumber: articleNumber,
carId: carId.toString(),
deliveryId: delivery.id,
));
} else {
tourBloc.add(ScanArticleEvent(
articleNumber: articleNumber,
carId: carId.toString(),
deliveryId: delivery.id,
));
}
},
);
}),
@ -269,37 +319,23 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
return tour.driver.cars.firstWhereOrNull((c) => c.id == carId)?.plate;
}
List<_ArticleGroup> _buildArticleGroups(Tour tour) {
final Map<String, List<_ArticleDeliveryEntry>> grouped = {};
List<_DeliveryGroup> _buildDeliveryGroups(Tour tour) {
final List<_DeliveryGroup> groups = [];
for (final delivery in tour.deliveries) {
if (delivery.state == DeliveryState.finished) continue;
final carPlate = _lookupCarPlate(delivery.carId, tour);
for (final article in delivery.articles) {
if (!article.scannable) continue;
grouped.putIfAbsent(article.articleNumber, () => []);
grouped[article.articleNumber]!.add(
_ArticleDeliveryEntry(
delivery: delivery,
article: article,
carPlate: carPlate,
),
);
}
final scannableArticles =
delivery.articles.where((a) => a.scannable).toList();
if (scannableArticles.isEmpty) continue;
groups.add(_DeliveryGroup(
delivery: delivery,
articles: scannableArticles,
carPlate: _lookupCarPlate(delivery.carId, tour),
));
}
return grouped.entries.map((e) {
final entries = e.value;
return _ArticleGroup(
articleNumber: e.key,
name: entries.first.article.name,
totalAmount: entries.fold(0, (sum, e) => sum + e.article.amount),
totalScanned: entries.fold(0, (sum, e) => sum + e.article.scannedAmount),
totalRemoved: entries.fold(0, (sum, e) => sum + e.article.scannedRemovedAmount),
entries: entries,
);
}).toList()
..sort((a, b) => a.name.compareTo(b.name));
return groups;
}
// -------------------------------------------------------------------------
@ -335,7 +371,7 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
);
}
Widget _buildProgressHeader(List<_ArticleGroup> allGroups) {
Widget _buildProgressHeader(List<_DeliveryGroup> allGroups) {
final total = allGroups.length;
final done = allGroups.where((g) => g.isComplete).length;
final progress = total > 0 ? done / total : 0.0;
@ -355,7 +391,7 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
),
),
Text(
"$done / $total Artikel",
"$done / $total Kunden",
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
@ -382,14 +418,9 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
);
}
Widget _buildArticleTile(_ArticleGroup group, {int? carIdFilter}) {
Widget _buildDeliveryTile(_DeliveryGroup group) {
final isComplete = group.isComplete;
final isPartial = group.scannedOrRemoved > 0 && !isComplete;
final entries = carIdFilter != null
? group.entries
.where((e) => e.delivery.carId == carIdFilter)
.toList()
: group.entries;
final isPartial = group.isPartial;
final Color cardColor;
final Color borderColor;
@ -434,11 +465,11 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
key: const ValueKey('done'),
)
: SizedBox(
width: 32,
width: 36,
key: const ValueKey('progress'),
child: Center(
child: Text(
'${group.scannedOrRemoved}/${group.totalAmount}',
'${group.completeArticles}/${group.totalArticles}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
@ -449,73 +480,154 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
),
),
title: Text(
group.name,
group.delivery.customer.name,
style: TextStyle(
fontWeight: FontWeight.w600,
color: titleColor,
),
),
subtitle: Text(
"Artikelnr. ${group.articleNumber}",
group.delivery.customer.address.toString(),
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
trailing: group.carPlate != null
? _carBadge(context, group.carPlate!)
: null,
children: [
const Divider(height: 1, indent: 16, endIndent: 16),
...entries.map(_buildDeliveryEntry),
...group.articles.map(_buildArticleEntry),
const SizedBox(height: 4),
],
),
);
}
Widget _buildDeliveryEntry(_ArticleDeliveryEntry entry) {
final article = entry.article;
final customer = entry.delivery.customer;
final entryDone =
article.scannedAmount + article.scannedRemovedAmount >= article.amount;
Widget _buildArticleEntry(Article article) {
if (article.isParent && article.components.isNotEmpty) {
return _buildParentArticleEntry(article);
}
final entryDone = article.isFullyScanned;
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 2),
leading: Icon(
entryDone ? Icons.check_circle_outline : Icons.person_outline,
entryDone ? Icons.check_circle_outline : Icons.inventory_2_outlined,
color: entryDone
? Colors.green
: Theme.of(context).colorScheme.onSurfaceVariant,
size: 20,
),
title: Text(
customer.name,
article.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
subtitle: Text(
customer.address.toString(),
"Artikelnr. ${article.articleNumber}",
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
trailing: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (entry.carPlate != null) ...[
_carBadge(context, entry.carPlate!),
const SizedBox(height: 4),
],
Text(
'${article.scannedAmount + article.scannedRemovedAmount} / ${article.amount}×',
trailing: Text(
'${article.scannedAmount + article.scannedRemovedAmount} / ${article.amount}×',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: entryDone
? Colors.green
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
}
/// Renders a parent article (Stückliste) with its components listed below.
Widget _buildParentArticleEntry(Article article) {
final allDone = article.isFullyScanned;
final scannedCount =
article.components.where((c) => c.isFullyScanned).length;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 2),
leading: Icon(
allDone
? Icons.check_circle_outline
: Icons.account_tree_outlined,
color: allDone
? Colors.green
: Theme.of(context).colorScheme.primary,
size: 20,
),
title: Text(
article.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
subtitle: Text(
"Stückliste · $scannedCount/${article.components.length} Komponenten",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: entryDone
? Colors.green
: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
trailing: Icon(
allDone ? Icons.check_circle : Icons.pending_outlined,
color: allDone ? Colors.green : Colors.orange,
size: 18,
),
),
...article.components.map(_buildComponentEntry),
],
);
}
/// Single component row, indented below the parent article.
Widget _buildComponentEntry(Component component) {
final done = component.isFullyScanned;
return Padding(
padding: const EdgeInsets.only(left: 32),
child: ListTile(
dense: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 0),
leading: Icon(
done
? Icons.check_circle_outline
: Icons.radio_button_unchecked,
color: done
? Colors.green
: Theme.of(context).colorScheme.onSurfaceVariant,
size: 16,
),
title: Text(
component.name,
style: const TextStyle(fontSize: 13),
),
subtitle: Text(
"Artikelnr. ${component.articleNumber}",
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
trailing: Text(
'${component.scannedAmount} / ${component.requiredAmount}×',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: done
? Colors.green
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
);
}
@ -526,8 +638,8 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
Widget _buildOpenTab(
TourLoaded state,
List<_ArticleGroup> openGroups,
List<_ArticleGroup> allGroups,
List<_DeliveryGroup> openGroups,
List<_DeliveryGroup> allGroups,
bool useHardwareScanner,
) {
return Column(
@ -535,7 +647,31 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
if (_isScanning)
const LinearProgressIndicator(),
if (!useHardwareScanner && openGroups.isNotEmpty)
BarcodeScannerWidget(onBarcodeDetected: _handleBarcodeScanned),
Stack(
children: [
BarcodeScannerWidget(onBarcodeDetected: _handleBarcodeScanned),
if (state.pendingScanRequests > 0)
Positioned(
top: 8,
right: 8,
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(20),
),
child: const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(Colors.white),
),
),
),
),
],
),
_buildProgressHeader(allGroups),
const Divider(height: 1),
Expanded(
@ -551,7 +687,7 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
),
const SizedBox(height: 12),
const Text(
"Alle Artikel geladen!",
"Alle Kunden vollständig beladen!",
style: TextStyle(fontSize: 16),
),
],
@ -561,14 +697,14 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
padding: const EdgeInsets.only(top: 8, bottom: 96),
itemCount: openGroups.length,
itemBuilder: (context, index) =>
_buildArticleTile(openGroups[index]),
_buildDeliveryTile(openGroups[index]),
),
),
],
);
}
Widget _buildLoadedTab(List<_ArticleGroup> loadedGroups) {
Widget _buildLoadedTab(List<_DeliveryGroup> loadedGroups) {
if (_selectedCarId == null) {
return const Center(child: Text("Kein Fahrzeug ausgewählt"));
}
@ -585,7 +721,7 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
),
const SizedBox(height: 12),
Text(
"Noch keine Artikel im Auto",
"Noch keine Kunden im Auto",
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
@ -599,10 +735,8 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
return ListView.builder(
padding: const EdgeInsets.only(top: 8, bottom: 96),
itemCount: loadedGroups.length,
itemBuilder: (context, index) => _buildArticleTile(
loadedGroups[index],
carIdFilter: _selectedCarId,
),
itemBuilder: (context, index) =>
_buildDeliveryTile(loadedGroups[index]),
);
}
@ -617,7 +751,7 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
builder: (context, carState) {
return BlocConsumer<TourBloc, TourState>(
listener: (context, tourState) {
if (tourState is TourLoaded) {
if (tourState is TourLoaded && tourState.pendingScanRequests == 0) {
setState(() => _isScanning = false);
}
},
@ -641,20 +775,19 @@ class _ScanPageState extends State<ScanPage> with SingleTickerProviderStateMixin
));
}
final allGroups = _buildArticleGroups(tourState.tour);
final allGroups = _buildDeliveryGroups(tourState.tour);
// Offen: mindestens ein Kundeneintrag ist noch nicht vollständig gescannt
final openGroups = allGroups.where((g) => g.entries.any((e) =>
e.article.scannedAmount + e.article.scannedRemovedAmount <
e.article.amount,
)).toList();
// Offen: Lieferung hat noch mindestens einen nicht vollständig
// gescannten Artikel (über alle Autos hinweg).
final openGroups =
allGroups.where((g) => !g.isComplete).toList();
// Im Auto: mindestens ein Kundeneintrag für das aktuelle Auto ist vollständig
final loadedGroups = allGroups.where((g) => g.entries.any((e) =>
e.delivery.carId == _selectedCarId &&
e.article.scannedAmount + e.article.scannedRemovedAmount >=
e.article.amount,
)).toList();
// Im Auto: Lieferung des aktuellen Autos, bei der mindestens ein
// Stück gescannt wurde.
final loadedGroups = allGroups
.where((g) =>
g.delivery.carId == _selectedCarId && g.hasAnyScanned)
.toList();
final allDone = tourState.tour.deliveries.isNotEmpty &&
openGroups.isEmpty;