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:
Dennis Nemec
2026-05-14 22:27:56 +02:00
parent ac6b03227d
commit 456fb59668
29 changed files with 5425 additions and 1015 deletions

View File

@ -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,
),
),
);
}
}