import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.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/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/feature/delivery/overview/presentation/delivery_fail_page.dart'; 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'; import 'package:hl_lieferservice/widget/home/bloc/navigation_event.dart'; import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart'; // --------------------------------------------------------------------------- // Data helpers // --------------------------------------------------------------------------- class _DeliveryGroup { final Delivery delivery; final String? carPlate; final List
articles; const _DeliveryGroup({ required this.delivery, required this.articles, this.carPlate, }); int get totalArticles => articles.length; int get completeArticles => articles .where((a) => a.isFullyScanned) .length; 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; } // --------------------------------------------------------------------------- // ScanPage // --------------------------------------------------------------------------- class ScanPage extends StatefulWidget { const ScanPage({super.key}); @override State createState() => _ScanPageState(); } class _ScanPageState extends State with SingleTickerProviderStateMixin { late final TabController _tabController; final FocusNode _focusNode = FocusNode(); String _buffer = ''; Timer? _bufferTimer; int? _selectedCarId; bool _isScanning = false; @override void initState() { super.initState(); _tabController = TabController(length: 2, vsync: this); WidgetsBinding.instance.addPostFrameCallback((_) => _focusNode.requestFocus()); final carState = context.read().state; if (carState is CarSelectComplete) { _selectedCarId = carState.selectedCar.id; } } @override void dispose() { _tabController.dispose(); _focusNode.dispose(); _bufferTimer?.cancel(); super.dispose(); } // ------------------------------------------------------------------------- // Scanner input // ------------------------------------------------------------------------- void _handleKey(KeyEvent event) { if (event is! KeyDownEvent) return; if (event.logicalKey == LogicalKeyboardKey.enter) { _bufferTimer?.cancel(); if (_buffer.isNotEmpty) { _handleBarcodeScanned(_buffer); _buffer = ''; } } else { final character = event.character; if (character != null && character.isNotEmpty) { _buffer += character; _bufferTimer?.cancel(); _bufferTimer = Timer(const Duration(milliseconds: 1000), () { if (_buffer.isNotEmpty) { _handleBarcodeScanned(_buffer); _buffer = ''; } }); } } } /// Extrahiert die Artikelnummer aus einem Barcode der Form /// `;;`. /// 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(); if (articleNumber.isEmpty) return null; return articleNumber; } void _handleBarcodeScanned(String barcode) { if (!mounted) return; if (_selectedCarId == null) { context.read().add( FailOperation(message: "Kein Fahrzeug ausgewählt"), ); return; } final articleNumber = _extractArticleNumber(barcode); if (articleNumber == null) { context.read().add( FailOperation(message: "Ungültiger Barcode: $barcode"), ); return; } final tourState = context.read().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().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(); if (needingDeliveries.isEmpty) { setState(() => _isScanning = true); context.read().add(ScanArticleEvent( articleNumber: articleNumber, carId: _selectedCarId!.toString(), deliveryId: tourState.tour.deliveries.first.id, )); return; } if (needingDeliveries.length == 1) { setState(() => _isScanning = true); context.read().add(ScanArticleEvent( articleNumber: articleNumber, carId: _selectedCarId!.toString(), deliveryId: needingDeliveries.first.id, )); return; } _showCustomerSelectionSheet(articleNumber, needingDeliveries, tourState.tour); } void _showCustomerSelectionSheet( String articleNumber, List deliveries, Tour tour, { bool isComponent = false, }) { final tourBloc = context.read(); final carId = _selectedCarId!; showModalBottomSheet( context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (ctx) { return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 8), Container( width: 40, height: 4, decoration: BoxDecoration( color: Colors.grey.shade300, borderRadius: BorderRadius.circular(2), ), ), const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ const Icon(Icons.help_outline, size: 20), const SizedBox(width: 8), Text( "Für welchen Kunden?", style: Theme.of(ctx).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), ], ), ), const SizedBox(height: 8), const Divider(height: 1), ...deliveries.map((delivery) { final carPlate = _lookupCarPlate(delivery.carId, tour); return ListTile( leading: const Icon(Icons.person_outline), title: Text(delivery.customer.name), subtitle: Text( delivery.customer.address.toString(), style: const TextStyle(fontSize: 12), ), trailing: carPlate != null ? _carBadge(ctx, carPlate) : null, onTap: () { Navigator.pop(ctx); setState(() => _isScanning = true); 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, )); } }, ); }), const SizedBox(height: 8), ], ), ); }, ); } // ------------------------------------------------------------------------- // Data // ------------------------------------------------------------------------- String? _lookupCarPlate(int? carId, Tour tour) { if (carId == null) return null; return tour.driver.cars.firstWhereOrNull((c) => c.id == carId)?.plate; } List<_DeliveryGroup> _buildDeliveryGroups(Tour tour) { final List<_DeliveryGroup> groups = []; for (final delivery in tour.deliveries) { if (delivery.state == DeliveryState.finished) continue; 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 groups; } // ------------------------------------------------------------------------- // Widgets // ------------------------------------------------------------------------- Widget _carBadge(BuildContext context, String plate) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primaryContainer, borderRadius: BorderRadius.circular(6), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.local_shipping_outlined, size: 12, color: Theme.of(context).colorScheme.onPrimaryContainer, ), const SizedBox(width: 4), Text( plate, style: TextStyle( fontSize: 11, fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.onPrimaryContainer, ), ), ], ), ); } 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; return Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 10), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( "Beladungsfortschritt", style: Theme.of(context).textTheme.titleSmall?.copyWith( color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), Text( "$done / $total Kunden", style: Theme.of(context).textTheme.titleSmall?.copyWith( fontWeight: FontWeight.bold, ), ), ], ), const SizedBox(height: 6), ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( value: progress, minHeight: 6, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest, valueColor: AlwaysStoppedAnimation( done == total && total > 0 ? Colors.green : Theme.of(context).primaryColor, ), ), ), ], ), ); } Widget _buildDeliveryTile(_DeliveryGroup group) { final isComplete = group.isComplete; final isPartial = group.isPartial; final Color cardColor; final Color borderColor; final Color titleColor; final Color leadingColor; if (isComplete) { cardColor = Colors.green.withValues(alpha: 0.07); borderColor = Colors.green.withValues(alpha: 0.35); titleColor = Colors.green.shade700; leadingColor = Colors.green; } else if (isPartial) { cardColor = Colors.orange.withValues(alpha: 0.07); borderColor = Colors.orange.withValues(alpha: 0.35); titleColor = Colors.orange.shade800; leadingColor = Colors.orange.shade700; } else { cardColor = Theme.of(context).colorScheme.surfaceContainerLow; borderColor = Colors.transparent; titleColor = Theme.of(context).colorScheme.onSurface; leadingColor = Theme.of(context).colorScheme.onSurfaceVariant; } return Card( margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), elevation: 0, color: cardColor, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: BorderSide(color: borderColor), ), child: ExpansionTile( shape: const Border(), collapsedShape: const Border(), leading: AnimatedSwitcher( duration: const Duration(milliseconds: 300), child: isComplete ? Icon( Icons.check_circle_rounded, color: leadingColor, size: 32, key: const ValueKey('done'), ) : SizedBox( width: 36, key: const ValueKey('progress'), child: Center( child: Text( '${group.completeArticles}/${group.totalArticles}', style: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, color: leadingColor, ), ), ), ), ), title: Text( group.delivery.customer.name, style: TextStyle( fontWeight: FontWeight.w600, color: titleColor, ), ), subtitle: Text( 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), ...group.articles.map(_buildArticleEntry), const SizedBox(height: 4), ], ), ); } 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.inventory_2_outlined, color: entryDone ? Colors.green : Theme.of(context).colorScheme.onSurfaceVariant, size: 20, ), title: Text( article.name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), ), subtitle: Text( "Artikelnr. ${article.articleNumber}", style: TextStyle( fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), 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( 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, ), ), ), ); } // ------------------------------------------------------------------------- // Tab views // ------------------------------------------------------------------------- Widget _buildOpenTab( TourLoaded state, List<_DeliveryGroup> openGroups, List<_DeliveryGroup> allGroups, bool useHardwareScanner, ) { return Column( children: [ if (_isScanning) const LinearProgressIndicator(), if (!useHardwareScanner && openGroups.isNotEmpty) 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( child: openGroups.isEmpty ? Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.check_circle_rounded, size: 64, color: Colors.green.shade400, ), const SizedBox(height: 12), const Text( "Alle Kunden vollständig beladen!", style: TextStyle(fontSize: 16), ), ], ), ) : ListView.builder( padding: const EdgeInsets.only(top: 8, bottom: 96), itemCount: openGroups.length, itemBuilder: (context, index) => _buildDeliveryTile(openGroups[index]), ), ), ], ); } Widget _buildLoadedTab(List<_DeliveryGroup> loadedGroups) { if (_selectedCarId == null) { return const Center(child: Text("Kein Fahrzeug ausgewählt")); } if (loadedGroups.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.inventory_2_outlined, size: 64, color: Theme.of(context).colorScheme.onSurfaceVariant, ), const SizedBox(height: 12), Text( "Noch keine Kunden im Auto", style: TextStyle( fontSize: 16, color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], ), ); } return ListView.builder( padding: const EdgeInsets.only(top: 8, bottom: 96), itemCount: loadedGroups.length, itemBuilder: (context, index) => _buildDeliveryTile(loadedGroups[index]), ); } @override Widget build(BuildContext context) { return BlocConsumer( listener: (context, carState) { if (carState is CarSelectComplete) { setState(() => _selectedCarId = carState.selectedCar.id); } }, builder: (context, carState) { return BlocConsumer( listener: (context, tourState) { if (tourState is TourLoaded && tourState.pendingScanRequests == 0) { setState(() => _isScanning = false); } }, builder: (context, tourState) { if (tourState is TourLoadingFailed) { return const DeliveryLoadingFailedPage(); } if (tourState is! TourLoaded) { return const Center(child: CircularProgressIndicator()); } final settingsState = context.read().state; final useHardwareScanner = settingsState is AppSettingsLoaded && settingsState.settings.useHardwareScanner; if (settingsState is AppSettingsFailed) { context.read().add(FailOperation( message: "Einstellungen konnten nicht geladen werden. Nutze Kamera-Scanner.", )); } final allGroups = _buildDeliveryGroups(tourState.tour); // 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: 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; return Scaffold( appBar: AppBar( title: const Text("Beladung"), backgroundColor: Theme.of(context).primaryColor, foregroundColor: Theme.of(context).colorScheme.onSecondary, centerTitle: false, actions: [ if (carState is CarSelectComplete) Padding( padding: const EdgeInsets.only(right: 16), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.local_shipping, color: Theme.of(context).colorScheme.onSecondary, size: 20, ), const SizedBox(width: 6), Text( carState.selectedCar.plate, style: TextStyle( color: Theme.of(context).colorScheme.onSecondary, fontWeight: FontWeight.bold, ), ), ], ), ), ], bottom: TabBar( controller: _tabController, labelColor: Theme.of(context).colorScheme.onSecondary, unselectedLabelColor: Theme.of(context) .colorScheme .onSecondary .withValues(alpha: 0.6), indicatorColor: Theme.of(context).colorScheme.onSecondary, tabs: [ Tab( child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.pending_outlined, size: 18), const SizedBox(width: 6), const Text("Offen"), if (openGroups.isNotEmpty) ...[ const SizedBox(width: 6), _tabBadge( context, openGroups.length.toString(), ), ], ], ), ), Tab( child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.local_shipping_outlined, size: 18), const SizedBox(width: 6), const Text("Im Auto"), if (loadedGroups.isNotEmpty) ...[ const SizedBox(width: 6), _tabBadge( context, loadedGroups.length.toString(), color: Colors.green, ), ], ], ), ), ], ), ), floatingActionButton: allDone ? FloatingActionButton.extended( onPressed: () { context .read() .add(NavigateToIndex(index: 1)); }, icon: const Icon(Icons.local_shipping_outlined), label: const Text("Tour starten"), backgroundColor: Colors.green, foregroundColor: Colors.white, ) : null, body: KeyboardListener( focusNode: _focusNode, onKeyEvent: _handleKey, child: TabBarView( controller: _tabController, children: [ _buildOpenTab( tourState, openGroups, allGroups, useHardwareScanner, ), _buildLoadedTab(loadedGroups), ], ), ), ); }, ); }, ); } Widget _tabBadge(BuildContext context, String label, {Color? color}) { return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: (color ?? Theme.of(context).colorScheme.onSecondary) .withValues(alpha: 0.2), borderRadius: BorderRadius.circular(10), ), child: Text( label, style: TextStyle( fontSize: 11, fontWeight: FontWeight.bold, color: color ?? Theme.of(context).colorScheme.onSecondary, ), ), ); } }