Phasenbasierte Lieferübersicht + Beladen-Flow, plus Migrationsplan für Rust-Backend
UI-Restructuring: - TabBar in scan_page durch dedizierte Phasen ersetzt: Sortieren / Beladen / Ausliefern - PhaseBloc + PhaseService leiten Phase aus Tour-/Item-States ab - DeliverySelectionPage (ab 2 Autos) und DeliverySortPage als eigene Flows - LoadingOverviewPage / LoadingCustomerPage für die Beladephase - PhaseStepper-Widget im Home für Phasen-Anzeige - Lager-Differenzierung (Standardlager 0 vs. Außenlager) via WarehouseBadge Process-Stubs: - ProcessRepository für Hold/Cancel/Sort/Assign-Flows (stub, bereit für Backend-Anbindung) Doku: - docs/BACKEND_MIGRATION.md: Phasenplan für Umstellung auf das neue Rust-Backend (OpenAPI-Generator, Keycloak OIDC, Clean-Arch-Layering)
This commit is contained in:
@ -1,927 +0,0 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user