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/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 _ArticleDeliveryEntry { final Delivery delivery; final Article article; final String? carPlate; const _ArticleDeliveryEntry({ required this.delivery, required this.article, this.carPlate, }); } class _ArticleGroup { final String articleNumber; final String name; final int totalAmount; final int totalScanned; final int totalRemoved; final List<_ArticleDeliveryEntry> entries; bool get isComplete => totalScanned + totalRemoved >= totalAmount; int get scannedOrRemoved => totalScanned + totalRemoved; const _ArticleGroup({ required this.articleNumber, required this.name, required this.totalAmount, required this.totalScanned, required this.totalRemoved, required this.entries, }); } // --------------------------------------------------------------------------- // 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) { 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; final needingDeliveries = tourState.tour.deliveries .where((d) => d.state != DeliveryState.finished) .where((d) => d.articles.any((a) => a.articleNumber == articleNumber && 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, ) { 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); 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<_ArticleGroup> _buildArticleGroups(Tour tour) { final Map> grouped = {}; 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, ), ); } } 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)); } // ------------------------------------------------------------------------- // 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<_ArticleGroup> 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 Artikel", 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 _buildArticleTile(_ArticleGroup group, {int? carIdFilter}) { 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 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: 32, key: const ValueKey('progress'), child: Center( child: Text( '${group.scannedOrRemoved}/${group.totalAmount}', style: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, color: leadingColor, ), ), ), ), ), title: Text( group.name, style: TextStyle( fontWeight: FontWeight.w600, color: titleColor, ), ), subtitle: Text( "Artikelnr. ${group.articleNumber}", style: TextStyle( fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant, ), ), children: [ const Divider(height: 1, indent: 16, endIndent: 16), ...entries.map(_buildDeliveryEntry), 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; return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 2), leading: Icon( entryDone ? Icons.check_circle_outline : Icons.person_outline, color: entryDone ? Colors.green : Theme.of(context).colorScheme.onSurfaceVariant, size: 20, ), title: Text( customer.name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), ), subtitle: Text( customer.address.toString(), 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}×', style: TextStyle( fontWeight: FontWeight.bold, fontSize: 13, color: entryDone ? Colors.green : Theme.of(context).colorScheme.onSurfaceVariant, ), ), ], ), ); } // ------------------------------------------------------------------------- // Tab views // ------------------------------------------------------------------------- Widget _buildOpenTab( TourLoaded state, List<_ArticleGroup> openGroups, List<_ArticleGroup> allGroups, bool useHardwareScanner, ) { return Column( children: [ if (_isScanning) const LinearProgressIndicator(), if (!useHardwareScanner && openGroups.isNotEmpty) BarcodeScannerWidget(onBarcodeDetected: _handleBarcodeScanned), _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 Artikel geladen!", style: TextStyle(fontSize: 16), ), ], ), ) : ListView.builder( padding: const EdgeInsets.only(top: 8, bottom: 96), itemCount: openGroups.length, itemBuilder: (context, index) => _buildArticleTile(openGroups[index]), ), ), ], ); } Widget _buildLoadedTab(List<_ArticleGroup> 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 Artikel 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) => _buildArticleTile( loadedGroups[index], carIdFilter: _selectedCarId, ), ); } @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) { 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 = _buildArticleGroups(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(); // 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(); 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, ), ), ); } }