Implemented settings, new scan, enhanced UI/UX

This commit is contained in:
Dennis Nemec
2025-11-04 16:52:39 +01:00
parent b19a6e1cd4
commit 7ea9108f62
79 changed files with 3306 additions and 566 deletions

View File

@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import '../model/article.dart';
class ArticleOverview extends StatefulWidget {
const ArticleOverview({super.key, required this.articleGroups});
final Map<String, ArticleGroup> articleGroups;
@override
State<StatefulWidget> createState() => _ArticleOverviewState();
}
class _ArticleOverviewState extends State<ArticleOverview> {
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
final sortedArticles =
widget.articleGroups.values.toList()
..sort((a, b) => a.articleName.compareTo(b.articleName));
return sortedArticles.isEmpty
? Center(
child: Text(
'Keine Artikel zum Scannen vorhanden',
style: TextStyle(fontSize: 18),
),
)
: ListView.separated(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
itemCount: sortedArticles.length,
separatorBuilder: (context, index) => Divider(height: 0, color: Theme.of(context).colorScheme.surfaceContainerHighest),
itemBuilder: (context, index) {
final group = sortedArticles[index];
return ListTile(
leading:
group.isComplete
? Icon(Icons.check_circle, color: Colors.green, size: 32)
: Container(
width: 32,
alignment: Alignment.center,
child: Text(
'${group.scannedCount}/${group.totalCount}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color:
group.scannedCount > 0
? Colors.blue
: Colors.grey,
),
),
),
title: Text(
"${group.articleName} (Artikelnr. ${group.articleNumber})",
style: TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 10, bottom: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children:
group.deliveryIds
.map(
(delivery) => Row(
children: [
Icon(Icons.person),
Padding(
padding: const EdgeInsets.only(left: 5, bottom: 10),
child: Text(
"${delivery.customer.name.toString()}\n${delivery.customer.address.toString()}",
),
),
],
),
)
.toList(),
),
),
tileColor:
group.isComplete
? Colors.green.withValues(alpha: 0.1)
: Theme.of(context).colorScheme.onSecondary,
);
},
);
}
}

View File

@ -0,0 +1,279 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:hl_lieferservice/feature/delivery/overview/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/overview/bloc/tour_state.dart';
import 'package:hl_lieferservice/feature/scan/presentation/scan_screen.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';
enum TourHomeSteps { planning, delivery, off }
class ScanPage extends StatefulWidget {
const ScanPage({super.key});
@override
State<StatefulWidget> createState() => _ScanPageState();
}
class _ScanPageState extends State<ScanPage> {
int _currentStepIndex = 0;
@override
void initState() {
super.initState();
_tryFinish(context
.read<TourBloc>()
.state);
}
void _onStartScan() {
Navigator.of(
context,
).push(MaterialPageRoute(builder: (context) => ArticleScanningScreen()));
}
Widget _tourSteps(Tour tour) {
var allArticlesScanned = tour.deliveries.every(
(delivery) => delivery.allArticlesScanned(),
);
return Stepper(
currentStep: _currentStepIndex,
controlsBuilder: (context, details) {
if (details.stepIndex == TourHomeSteps.planning.index) {
return Container(
alignment: Alignment.center,
child: Padding(
padding: const EdgeInsets.only(top: 15),
child: FilledButton.icon(
label: const Text("Scannen"),
onPressed: _onStartScan,
icon: const Icon(Icons.qr_code),
),
),
);
} else {
return Container(
alignment: Alignment.center,
child: Padding(
padding: const EdgeInsets.only(top: 15),
child: FilledButton.icon(
label: const Text("Auslieferung starten"),
onPressed:
allArticlesScanned
? () =>
context.read<NavigationBloc>().add(
NavigateToIndex(index: 1))
: null,
icon: const Icon(Icons.local_shipping),
),
),
);
}
},
onStepContinue:
_currentStepIndex >= 1
? null
: () =>
setState(() {
if (_currentStepIndex < 2) {
_currentStepIndex += 1;
}
}),
onStepCancel:
_currentStepIndex == 0
? null
: () =>
setState(() {
if (_currentStepIndex > 0) {
_currentStepIndex -= 1;
}
}),
onStepTapped:
(value) =>
setState(() {
if (_currentStepIndex == 1 && allArticlesScanned) {
return;
}
_currentStepIndex = value;
}),
steps: [
Step(
title: Row(
children: [
Text(
"Fahrzeuge beladen",
style: TextStyle(
color: allArticlesScanned ? Colors.grey : null,
),
),
Padding(
padding: const EdgeInsets.only(left: 5),
child:
!allArticlesScanned
? const Icon(
Icons.access_time_filled,
color: Colors.orangeAccent,
)
: const Icon(
Icons.check_circle,
color: Colors.lightGreen,
),
),
],
),
content: const Column(
children: [
Padding(
padding: EdgeInsets.only(bottom: 10),
child: Icon(Icons.barcode_reader, color: Colors.black),
),
Text(
"Scannen Sie die Ware, die Sie für die Auslieferungen benötigen.",
),
],
),
),
Step(
title: const Text("Ausliefern"),
content: Container(
alignment: Alignment.centerLeft,
child:
!allArticlesScanned
? const Text(
"Scannen Sie erst die benötigte Ware, um die Auslieferungen zu beginnen.",
)
: null,
),
),
],
);
}
Widget _info(Tour tour) {
int amountArticles = tour.deliveries.fold(
0,
(acc, delivery) =>
acc +
delivery.articles
.where((article) => article.scannable)
.fold(
0,
(amountArticles, article) => amountArticles + article.amount,
),
);
int amountCars = tour.driver.cars.length;
int amountDeliveries = tour.deliveries.length;
return Padding(
padding: const EdgeInsets.all(10),
child: SizedBox(
width: double.infinity,
child: Card(
color: Theme
.of(context)
.colorScheme
.onSecondary,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Padding(
padding: const EdgeInsets.only(top: 0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.archive),
Padding(
padding: const EdgeInsets.only(left: 5),
child: Text("Anzahl Artikel"),
),
],
),
Text(amountArticles.toString()),
],
),
),
Padding(
padding: const EdgeInsets.only(top: 15),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.local_shipping_outlined),
Padding(
padding: const EdgeInsets.only(left: 5),
child: Text("Anzahl Fahrzeuge"),
),
],
),
Text(amountCars.toString()),
],
),
),
Padding(
padding: const EdgeInsets.only(top: 15),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.person),
Padding(
padding: const EdgeInsets.only(left: 5),
child: Text("Anzahl Lieferungen"),
),
],
),
Text(amountDeliveries.toString()),
],
),
),
],
),
),
),
),
);
}
void _tryFinish(TourState state) {
if (state is TourLoaded) {
if (state.tour.deliveries.every(
(delivery) => delivery.allArticlesScanned(),
)) {
setState(() {
_currentStepIndex = 1;
});
}
}
}
@override
Widget build(BuildContext context) {
return BlocConsumer<TourBloc, TourState>(
listener: (context, state) {
_tryFinish(state);
},
builder: (context, state) {
if (state is TourLoaded) {
return Column(children: [_info(state.tour), _tourSteps(state.tour)]);
}
return Center(child: CircularProgressIndicator());
},
);
}
}

View File

@ -0,0 +1,405 @@
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/delivery/overview/bloc/tour_event.dart';
import 'package:hl_lieferservice/feature/delivery/overview/bloc/tour_state.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/car.dart';
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:hl_lieferservice/model/tour.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
import '../../delivery/overview/bloc/tour_bloc.dart';
class ArticleScanningScreen extends StatefulWidget {
const ArticleScanningScreen({super.key});
@override
State<ArticleScanningScreen> createState() => _ArticleScanningScreenState();
}
class _ArticleScanningScreenState extends State<ArticleScanningScreen> {
final FocusNode _focusNode = FocusNode();
String _buffer = '';
Timer? _bufferTimer;
int _selectedDelivery = 0;
int? _selectedCarId;
@override
void initState() {
super.initState();
// Focus anfordern, um Keyboard-Events zu empfangen
WidgetsBinding.instance.addPostFrameCallback((_) {
_focusNode.requestFocus();
});
final state = context.read<TourBloc>().state;
if (state is TourLoaded) {
setState(() {
_selectedCarId = state.tour.deliveries[_selectedDelivery].carId;
});
}
}
@override
void dispose() {
_focusNode.dispose();
_bufferTimer?.cancel();
super.dispose();
}
void _handleKey(KeyEvent event) {
if (event is KeyDownEvent) {
if (event.logicalKey == LogicalKeyboardKey.enter) {
// Enter = Scan abgeschlossen
_bufferTimer?.cancel();
if (_buffer.isNotEmpty) {
_handleBarcodeScanned(_buffer);
_buffer = '';
}
} else {
// Zeichen zum Buffer hinzufügen
final character = event.character;
if (character != null && character.isNotEmpty) {
_buffer += character;
// Timer zurücksetzen
_bufferTimer?.cancel();
_bufferTimer = Timer(Duration(milliseconds: 1000), () {
// Nach 1 Sekunde ohne neue Eingabe: Buffer verarbeiten
if (_buffer.isNotEmpty) {
_handleBarcodeScanned(_buffer);
_buffer = '';
}
});
}
}
}
}
void _handleBarcodeScanned(String barcode) {
if (_selectedCarId == null) {
context.read<OperationBloc>().add(
FailOperation(message: "Wählen Sie zu erst ein Fahrzeug aus"),
);
return;
}
final state = context.read<TourBloc>().state as TourLoaded;
context.read<TourBloc>().add(
ScanArticleEvent(
articleNumber: barcode,
carId: _selectedCarId!.toString(),
deliveryId: state.tour.deliveries[_selectedDelivery].id,
),
);
}
Widget _carSelection(List<Car> cars, List<Delivery> deliveries) {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Fahrzeug auswählen",
style: Theme.of(context).textTheme.headlineSmall,
),
Padding(
padding: const EdgeInsets.only(top: 10),
child: SizedBox(
width: double.infinity,
height: 50,
child: ListView(
scrollDirection: Axis.horizontal,
children:
cars.map((car) {
Color? backgroundColor;
Color? iconColor = Theme.of(context).primaryColor;
Color? textColor;
if (_selectedCarId == car.id) {
backgroundColor = Theme.of(context).primaryColor;
textColor = Theme.of(context).colorScheme.onSecondary;
iconColor = Theme.of(context).colorScheme.onSecondary;
}
return Padding(
padding: const EdgeInsets.only(right: 8),
child: GestureDetector(
onTap: () {
context.read<TourBloc>().add(
AssignCarEvent(
deliveryId: deliveries[_selectedDelivery].id,
carId: car.id.toString(),
),
);
setState(() {
_selectedCarId = car.id;
});
},
child: Chip(
backgroundColor: backgroundColor,
label: Row(
children: [
Icon(
Icons.local_shipping,
color: iconColor,
size: 20,
),
Padding(
padding: const EdgeInsets.only(left: 5),
child: Text(
car.plate,
style: TextStyle(
color: textColor,
fontSize: 12,
),
),
),
],
),
),
),
);
}).toList(),
),
),
),
],
),
);
}
Widget _articles(List<Article> articles) {
List<Article> scannableArticles =
articles.where((article) => article.scannable).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 20, bottom: 20),
child: Text(
"Artikel",
style: Theme.of(context).textTheme.headlineSmall,
),
),
scannableArticles.isEmpty
? Center(
child: Text(
'Keine Artikel zum Scannen vorhanden',
style: TextStyle(fontSize: 18),
),
)
: ListView.separated(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
itemCount: scannableArticles.length,
separatorBuilder:
(context, index) => Divider(
height: 0,
color:
Theme.of(context).colorScheme.surfaceContainerHighest,
),
itemBuilder: (context, index) {
final article = scannableArticles[index];
return ListTile(
leading:
article.scannedAmount == article.amount
? Icon(
Icons.check_circle,
color: Colors.green,
size: 32,
)
: Container(
width: 32,
alignment: Alignment.center,
child: Text(
'${article.scannedAmount}/${article.amount}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color:
article.scannedAmount > 0
? Colors.blue
: Colors.grey,
),
),
),
title: Text(
article.name,
style: TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text("Artikelnr. ${article.articleNumber}"),
tileColor:
article.scannedAmount == article.amount
? Colors.green.withValues(alpha: 0.1)
: Theme.of(context).colorScheme.onSecondary,
);
},
),
],
);
}
void _selectDelivery(int? index) {
setState(() {
_selectedDelivery = index!;
});
}
Widget _navigation(List<Delivery> deliveries) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
OutlinedButton(
onPressed:
_selectedDelivery > 0
? () => {
if (_selectedDelivery > 0)
{
setState(() {
_selectedDelivery -= 1;
_selectedCarId = deliveries[_selectedDelivery].carId;
}),
},
}
: null,
child: Text("zurück"),
),
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 20, right: 20),
child: DropdownButton(
menuWidth: MediaQuery.of(context).size.width,
isExpanded: true,
items:
deliveries
.mapIndexed(
(index, delivery) => DropdownMenuItem(
value: index,
child: Text(
delivery.customer.name,
overflow: TextOverflow.ellipsis,
),
),
)
.toList(),
onChanged: _selectDelivery,
value: _selectedDelivery,
),
),
),
OutlinedButton(
onPressed:
_selectedDelivery < deliveries.length - 1
? () => {
if (_selectedDelivery + 1 < deliveries.length)
{
setState(() {
_selectedDelivery += 1;
_selectedCarId = deliveries[_selectedDelivery].carId;
}),
},
}
: null,
child: Text("weiter"),
),
],
);
}
Widget _deliveryStepper(Tour tour) {
final settingsState = context.read<SettingsBloc>().state;
Widget scannerWidget = BarcodeScannerWidget(
onBarcodeDetected: _handleBarcodeScanned,
);
if (settingsState is AppSettingsFailed) {
context.read<OperationBloc>().add(
FailOperation(
message:
"Einstellungen konnten nicht geladen werden. Nutze Kamera-Scanner.",
),
);
}
if (settingsState is AppSettingsLoaded) {
if (settingsState.settings.useHardwareScanner) {
scannerWidget = Container();
}
}
return Padding(
padding: const EdgeInsets.all(0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
scannerWidget,
_carSelection(tour.driver.cars, tour.deliveries),
_articles(tour.deliveries[_selectedDelivery].articles),
],
),
);
}
@override
Widget build(BuildContext context) {
return BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
if (state is TourLoaded) {
Delivery delivery = state.tour.deliveries[_selectedDelivery];
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
delivery.customer.name,
style: TextStyle(
color: Theme.of(context).colorScheme.onSecondary,
fontWeight: FontWeight.w500,
),
),
Text(
delivery.customer.address.toString(),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
color: Theme.of(context).colorScheme.onSecondary,
),
),
],
),
backgroundColor: Theme.of(context).primaryColor,
),
bottomNavigationBar: Padding(
padding: const EdgeInsets.all(25),
child: _navigation(state.tour.deliveries),
),
body: KeyboardListener(
focusNode: _focusNode,
onKeyEvent: _handleKey,
child: _deliveryStepper(state.tour),
),
);
}
return Container();
},
);
}
}

View File

@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
/// StatefulWidget für den Barcode-Scanner mit grünem Border-Feedback
class BarcodeScannerWidget extends StatefulWidget {
final Function(String) onBarcodeDetected;
const BarcodeScannerWidget({
Key? key,
required this.onBarcodeDetected,
}) : super(key: key);
@override
State<BarcodeScannerWidget> createState() => _BarcodeScannerWidgetState();
}
class _BarcodeScannerWidgetState extends State<BarcodeScannerWidget> {
bool _isDetected = false;
DateTime? _lastScannedTime;
final Duration _scanTimeout = const Duration(milliseconds: 2000); // 2 Sekunden Cooldown
void _handleBarcodeDetected(String barcode) {
final now = DateTime.now();
// Prüfe ob genug Zeit seit dem letzten erfolgreichen Scan vergangen ist
if (_lastScannedTime != null &&
now.difference(_lastScannedTime!).inMilliseconds < _scanTimeout.inMilliseconds) {
// Timeout nicht abgelaufen - ignoriere diesen Scan
debugPrint('Scan ignoriert - Cooldown aktiv');
return;
}
// Update letzte Scan-Zeit
_lastScannedTime = now;
// Rand grün färben
setState(() {
_isDetected = true;
});
// Nach 500ms wieder zurücksetzen
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted) {
setState(() {
_isDetected = false;
});
}
});
// Callback aufrufen
widget.onBarcodeDetected(barcode);
}
@override
Widget build(BuildContext context) {
final screenHeight = MediaQuery.of(context).size.height;
final scannerHeight = screenHeight / 4;
return Container(
height: scannerHeight,
decoration: BoxDecoration(
border: Border.all(
color: _isDetected ? Colors.green : Colors.grey,
width: _isDetected ? 4 : 2,
),
),
child: MobileScanner(
onDetect: (capture) {
final List<Barcode> barcodes = capture.barcodes;
for (final barcode in barcodes) {
if (barcode.rawValue != null) {
_handleBarcodeDetected(barcode.rawValue!);
}
}
},
),
);
}
}