928 lines
31 KiB
Dart
928 lines
31 KiB
Dart
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<Article> 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<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) {
|
||
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<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;
|
||
|
||
// ── 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();
|
||
|
||
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, {
|
||
bool isComponent = false,
|
||
}) {
|
||
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);
|
||
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<Color>(
|
||
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<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 && 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<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 = _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<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,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|