Files
Holzleitner-Lieferservice-App/lib/feature/scan/presentation/scan_page.dart
Dennis Nemec 2470299a10 BIG FAT
2026-04-28 13:03:09 +02:00

795 lines
26 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<ScanPage> createState() => _ScanPageState();
}
class _ScanPageState extends State<ScanPage> 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<CarSelectBloc>().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
/// `<artikelnummer>;<kundennummer>;<belegnummer>`.
/// 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<OperationBloc>().add(
FailOperation(message: "Kein Fahrzeug ausgewählt"),
);
return;
}
final articleNumber = _extractArticleNumber(barcode);
if (articleNumber == null) {
context.read<OperationBloc>().add(
FailOperation(message: "Ungültiger Barcode: $barcode"),
);
return;
}
final tourState = context.read<TourBloc>().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<TourBloc>().add(ScanArticleEvent(
articleNumber: articleNumber,
carId: _selectedCarId!.toString(),
deliveryId: tourState.tour.deliveries.first.id,
));
return;
}
if (needingDeliveries.length == 1) {
setState(() => _isScanning = true);
context.read<TourBloc>().add(ScanArticleEvent(
articleNumber: articleNumber,
carId: _selectedCarId!.toString(),
deliveryId: needingDeliveries.first.id,
));
return;
}
_showCustomerSelectionSheet(articleNumber, needingDeliveries, tourState.tour);
}
void _showCustomerSelectionSheet(
String articleNumber,
List<Delivery> deliveries,
Tour tour,
) {
final tourBloc = context.read<TourBloc>();
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<String, List<_ArticleDeliveryEntry>> 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<Color>(
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<CarSelectBloc, CarSelectState>(
listener: (context, carState) {
if (carState is CarSelectComplete) {
setState(() => _selectedCarId = carState.selectedCar.id);
}
},
builder: (context, carState) {
return BlocConsumer<TourBloc, TourState>(
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<SettingsBloc>().state;
final useHardwareScanner = settingsState is AppSettingsLoaded &&
settingsState.settings.useHardwareScanner;
if (settingsState is AppSettingsFailed) {
context.read<OperationBloc>().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<NavigationBloc>()
.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,
),
),
);
}
}