This commit is contained in:
Dennis Nemec
2026-04-28 13:03:09 +02:00
parent de8668c11a
commit 2470299a10
53 changed files with 2409 additions and 1433 deletions

View File

@ -5,10 +5,11 @@ part 'customer.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class CustomerDTO {
CustomerDTO({required this.name, required this.address});
CustomerDTO({required this.name, required this.address, this.eMail});
String name;
AddressDTO address;
String? eMail;
factory CustomerDTO.fromJson(Map<String, dynamic> json) => _$CustomerDTOFromJson(json);
Map<dynamic, dynamic> toJson() => _$CustomerDTOToJson(this);

View File

@ -9,7 +9,12 @@ part of 'customer.dart';
CustomerDTO _$CustomerDTOFromJson(Map<String, dynamic> json) => CustomerDTO(
name: json['name'] as String,
address: AddressDTO.fromJson(json['address'] as Map<String, dynamic>),
eMail: json['e_mail'] as String?,
);
Map<String, dynamic> _$CustomerDTOToJson(CustomerDTO instance) =>
<String, dynamic>{'name': instance.name, 'address': instance.address};
<String, dynamic>{
'name': instance.name,
'address': instance.address,
'e_mail': instance.eMail,
};

View File

@ -1,4 +1,5 @@
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:intl/intl.dart';
import 'package:json_annotation/json_annotation.dart';
part 'delivery_update.g.dart';
@ -74,7 +75,9 @@ class DeliveryUpdateDTO {
carId: delivery.carId?.toString() ,
selectedPaymentMethodId: delivery.payment.id,
options: delivery.options.map(DeliveryOptionUpdateDTO.fromEntity).toList(),
finishedDate: DateTime.now().millisecondsSinceEpoch.toString()
finishedDate: delivery.state == DeliveryState.finished
? DateFormat('yyyy-MM-dd HH:mm').format(DateTime.now())
: null,
);
}

View File

@ -15,15 +15,13 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
: super(Unauthenticated()) {
on<SetAuthenticatedEvent>(_auth);
on<Logout>(_logout);
on<SessionExpiredEvent>(_sessionExpired);
}
Future<void> _auth(
SetAuthenticatedEvent event,
Emitter<AuthState> emit,
) async {
operationBloc.add(LoadOperation());
await Future.delayed(Duration(seconds: 5));
try {
debugPrint("Retrieve user information");
@ -31,7 +29,6 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
var state = Authenticated(sessionId: event.sessionId, user: response);
locator.registerSingleton<Authenticated>(state);
emit(state);
operationBloc.add(FinishOperation());
} catch (err, st) {
debugPrint("Failed to retrieve user information");
debugPrint(err.toString());
@ -46,6 +43,19 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
}
Future<void> _logout(Logout event, Emitter<AuthState> emit) async {
if (locator.isRegistered<Authenticated>()) {
locator.unregister<Authenticated>();
}
emit(Unauthenticated());
}
Future<void> _sessionExpired(
SessionExpiredEvent event,
Emitter<AuthState> emit,
) async {
if (locator.isRegistered<Authenticated>()) {
locator.unregister<Authenticated>();
}
emit(Unauthenticated(sessionExpired: true));
}
}

View File

@ -10,4 +10,6 @@ class Logout extends AuthEvent {
String username;
Logout({required this.username});
}
}
class SessionExpiredEvent extends AuthEvent {}

View File

@ -2,7 +2,11 @@ import 'package:hl_lieferservice/feature/authentication/model/user.dart';
abstract class AuthState {}
class Unauthenticated extends AuthState {}
class Unauthenticated extends AuthState {
final bool sessionExpired;
Unauthenticated({this.sessionExpired = false});
}
class Authenticated extends AuthState {
User user;
String sessionId;

View File

@ -18,7 +18,8 @@ class LoginEnforcer extends StatelessWidget {
return child;
}
return LoginPage();
final expired = state is Unauthenticated && state.sessionExpired;
return LoginPage(sessionExpired: expired);
},
);
}

View File

@ -7,7 +7,9 @@ import 'package:url_launcher/url_launcher.dart';
import 'dart:async';
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
final bool sessionExpired;
const LoginPage({super.key, this.sessionExpired = false});
@override
State<StatefulWidget> createState() => _LoginPageState();
@ -60,7 +62,7 @@ class _LoginPageState extends State<LoginPage> {
debugPrint("🔵 Opening browser to: http://localhost:3000/login");
final loginUrl = Uri.parse('http://100.72.100.33:3000/login');
final loginUrl = Uri.parse('http://192.168.1.9:3000/login');
final launched = await launchUrl(
loginUrl,
mode: LaunchMode.externalApplication,
@ -127,8 +129,22 @@ class _LoginPageState extends State<LoginPage> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: Column(
body: Column(
children: [
if (widget.sessionExpired)
MaterialBanner(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
content: const Text(
"Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.",
style: TextStyle(color: Colors.white),
),
backgroundColor: Colors.orange.shade800,
leading: const Icon(Icons.warning_amber_rounded, color: Colors.white),
actions: [const SizedBox.shrink()],
),
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Padding(
@ -179,6 +195,9 @@ class _LoginPageState extends State<LoginPage> {
),
],
),
),
),
],
),
);
}

View File

@ -0,0 +1,88 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/repository/car_selection_repository.dart';
import 'package:hl_lieferservice/feature/cars/model/selection.dart';
import 'package:hl_lieferservice/model/car.dart';
import 'events.dart';
import 'state.dart';
class CarSelectBloc extends Bloc<CarSelectEvent, CarSelectState> {
final CarSelectionRepository repository;
CarSelectBloc({required this.repository}) : super(CarSelectInitial()) {
on<CarSelectLoad>(_load);
on<CarSelectConfirm>(_confirm);
on<CarSelectChange>(_change);
on<CarSelectCancel>(_cancel);
}
Future<void> _load(
CarSelectLoad event,
Emitter<CarSelectState> emit,
) async {
try {
emit(CarSelectLoading());
final CarSelection? stored = await repository.getSelection();
final today = DateTime.now();
final bool validForToday =
stored != null &&
stored.selectedCarId != null &&
stored.selectedCarPlate != null &&
stored.date.year == today.year &&
stored.date.month == today.month &&
stored.date.day == today.day;
if (validForToday) {
emit(
CarSelectComplete(
selectedCar: Car(
id: stored.selectedCarId!,
plate: stored.selectedCarPlate!,
),
),
);
} else {
emit(CarSelectRequired());
}
} catch (e, st) {
debugPrint('CarSelectBloc._load failed: $e');
debugPrint('Stacktrace: $st');
emit(CarSelectFailed());
}
}
void _change(CarSelectChange event, Emitter<CarSelectState> emit) {
final previousCar =
state is CarSelectComplete ? (state as CarSelectComplete).selectedCar : null;
emit(CarSelectRequired(previousCar: previousCar));
}
void _cancel(CarSelectCancel event, Emitter<CarSelectState> emit) {
// Restore without touching SharedPreferences — no tour reload needed.
emit(CarSelectComplete(selectedCar: event.car));
}
Future<void> _confirm(
CarSelectConfirm event,
Emitter<CarSelectState> emit,
) async {
try {
final today = DateTime.now();
await repository.saveSelection(
CarSelection(
date: today,
selectedCarId: event.car.id,
selectedCarPlate: event.car.plate,
),
);
emit(CarSelectComplete(selectedCar: event.car));
} catch (e, st) {
debugPrint('CarSelectBloc._confirm failed: $e');
debugPrint('Stacktrace: $st');
emit(CarSelectFailed());
}
}
}

View File

@ -0,0 +1,25 @@
import 'package:hl_lieferservice/model/car.dart';
abstract class CarSelectEvent {}
/// Fired at app startup to check if a car has already been selected for today.
class CarSelectLoad extends CarSelectEvent {}
/// Fired when the driver confirms their car choice for the day.
class CarSelectConfirm extends CarSelectEvent {
final Car car;
CarSelectConfirm({required this.car});
}
/// Fired when the driver wants to switch to a different car.
/// Resets the selection so the enforcer shows the picker again.
class CarSelectChange extends CarSelectEvent {}
/// Fired when the driver cancels the change and wants to keep the previous car.
/// Restores [CarSelectComplete] without writing to SharedPreferences.
class CarSelectCancel extends CarSelectEvent {
final Car car;
CarSelectCancel({required this.car});
}

View File

@ -0,0 +1,25 @@
import 'package:hl_lieferservice/model/car.dart';
abstract class CarSelectState {}
class CarSelectInitial extends CarSelectState {}
class CarSelectLoading extends CarSelectState {}
/// No valid car selection exists for today — the driver must choose.
/// [previousCar] is set when the driver triggered a manual change,
/// allowing the page to pre-highlight the current car and offer a cancel.
class CarSelectRequired extends CarSelectState {
final Car? previousCar;
CarSelectRequired({this.previousCar});
}
/// A car has been selected for today. The selection is persisted locally.
class CarSelectComplete extends CarSelectState {
final Car selectedCar;
CarSelectComplete({required this.selectedCar});
}
class CarSelectFailed extends CarSelectState {}

View File

@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:hl_lieferservice/model/car.dart';
class CarSelectionCard extends StatelessWidget {
final Car car;
final bool isSelected;
final VoidCallback onTap;
const CarSelectionCard({
super.key,
required this.car,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final color = Theme.of(context).primaryColor;
return Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: isSelected
? BorderSide(color: color, width: 2)
: BorderSide.none,
),
color: isSelected
? color.withValues(alpha: 0.08)
: null,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Icon(
Icons.local_shipping,
size: 32,
color: isSelected ? color : Colors.grey,
),
const SizedBox(width: 16),
Expanded(
child: Text(
car.plate,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
),
if (isSelected)
Icon(Icons.check_circle, color: color),
],
),
),
),
);
}
}

View File

@ -0,0 +1,82 @@
import 'package:flutter/material.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/events.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
import 'package:hl_lieferservice/feature/car_selection/presentation/car_selection_page.dart';
class CarSelectionEnforcer extends StatefulWidget {
final Widget child;
const CarSelectionEnforcer({super.key, required this.child});
@override
State<CarSelectionEnforcer> createState() => _CarSelectionEnforcerState();
}
class _CarSelectionEnforcerState extends State<CarSelectionEnforcer> {
@override
void initState() {
super.initState();
context.read<CarSelectBloc>().add(CarSelectLoad());
}
@override
Widget build(BuildContext context) {
return BlocBuilder<CarSelectBloc, CarSelectState>(
builder: (context, state) {
// Show a full-screen spinner only while the persisted selection is
// being read from SharedPreferences (at most one frame on cold start).
if (state is CarSelectInitial || state is CarSelectLoading) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
if (state is CarSelectFailed) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 72,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 24),
const Text(
"Fehler beim Laden der Fahrzeugauswahl.",
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton(
onPressed: () =>
context.read<CarSelectBloc>().add(CarSelectLoad()),
child: const Text("Erneut versuchen"),
),
],
),
),
),
);
}
// For both CarSelectRequired and CarSelectComplete, keep Home alive
// in the widget tree so initState is never re-triggered. The selection
// page is overlaid on top when a (re-)selection is required.
return Stack(
children: [
widget.child,
if (state is CarSelectRequired)
Positioned.fill(
child: CarSelectionPage(previousCar: state.previousCar),
),
],
);
},
);
}
}

View File

@ -0,0 +1,232 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/events.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
import 'package:hl_lieferservice/feature/car_selection/presentation/car_selection_card.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_event.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart';
import 'package:hl_lieferservice/feature/cars/presentation/car_dialog.dart';
import 'package:hl_lieferservice/model/car.dart';
class CarSelectionPage extends StatefulWidget {
/// When set, the page is in "change" mode: the car is pre-highlighted
/// and a cancel button is shown to revert without choosing a new car.
final Car? previousCar;
const CarSelectionPage({super.key, this.previousCar});
@override
State<CarSelectionPage> createState() => _CarSelectionPageState();
}
class _CarSelectionPageState extends State<CarSelectionPage> {
Car? _selectedCar;
bool get _isChanging => widget.previousCar != null;
@override
void initState() {
super.initState();
_selectedCar = widget.previousCar;
final authState = context.read<AuthBloc>().state as Authenticated;
context.read<CarsBloc>().add(CarLoad(teamId: authState.user.number));
}
void _onAddCar() {
final authState = context.read<AuthBloc>().state as Authenticated;
showDialog(
context: context,
builder: (_) => CarDialog(
onAction: (plate) {
context.read<CarsBloc>().add(
CarAdd(teamId: authState.user.number, plate: plate),
);
},
),
);
}
void _onConfirm() {
if (_selectedCar == null) return;
context.read<CarSelectBloc>().add(CarSelectConfirm(car: _selectedCar!));
}
Widget _buildCarList(List<Car> cars) {
if (cars.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.local_shipping_outlined, size: 72, color: Colors.grey),
const SizedBox(height: 24),
Text(
"Noch kein Fahrzeug vorhanden.",
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
"Füge zuerst ein Fahrzeug hinzu, bevor du fortfahren kannst.",
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _onAddCar,
icon: const Icon(Icons.add),
label: const Text("Fahrzeug hinzufügen"),
),
],
),
),
);
}
final authState = context.read<AuthBloc>().state as Authenticated;
return RefreshIndicator(
onRefresh: () async {
context.read<CarsBloc>().add(
CarLoad(teamId: authState.user.number, force: true),
);
},
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: cars.length,
itemBuilder: (context, index) {
final car = cars[index];
return CarSelectionCard(
car: car,
isSelected: _selectedCar?.id == car.id,
onTap: () => setState(() => _selectedCar = car),
);
},
),
);
}
@override
Widget build(BuildContext context) {
return BlocListener<CarSelectBloc, CarSelectState>(
listener: (context, state) {
if (state is CarSelectFailed) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("Fehler beim Speichern der Fahrzeugauswahl."),
),
);
}
},
child: Scaffold(
appBar: _isChanging
? AppBar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: () => context.read<CarSelectBloc>().add(
CarSelectCancel(car: widget.previousCar!),
),
),
title: const Text("Fahrzeug wechseln"),
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
)
: null,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!_isChanging) ...[
Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 4),
child: Text(
"Fahrzeug auswählen",
style: Theme.of(context).textTheme.headlineSmall,
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 4, 20, 16),
child: Text(
"Wähle das Fahrzeug aus, das du heute verwendest.",
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey,
),
),
),
],
Expanded(
child: BlocBuilder<CarsBloc, CarsState>(
builder: (context, state) {
if (state is CarsLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is CarsLoaded) {
return _buildCarList(state.cars);
}
if (state is CarsLoadingFailed) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline,
size: 72,
color: Theme.of(context).colorScheme.error,
),
const SizedBox(height: 24),
const Text(
"Fehler beim Laden der Fahrzeuge.",
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
FilledButton(
onPressed: () {
final authState =
context.read<AuthBloc>().state
as Authenticated;
context.read<CarsBloc>().add(
CarLoad(
teamId: authState.user.number,
),
);
},
child: const Text("Erneut versuchen"),
),
],
),
),
);
}
return const SizedBox.shrink();
},
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _selectedCar != null ? _onConfirm : null,
child: const Text("Auswählen"),
),
),
),
],
),
),
),
);
}
}

View File

@ -0,0 +1,56 @@
import 'package:flutter/material.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/events.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/state.dart';
class SelectedCarBar extends StatelessWidget {
const SelectedCarBar({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<CarSelectBloc, CarSelectState>(
builder: (context, state) {
if (state is! CarSelectComplete) return const SizedBox.shrink();
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Divider(height: 1, thickness: 1),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(
Icons.local_shipping,
size: 20,
color: Theme.of(context).primaryColor,
),
const SizedBox(width: 8),
Expanded(
child: Text(
state.selectedCar.plate,
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
),
),
),
OutlinedButton.icon(
onPressed: () =>
context.read<CarSelectBloc>().add(CarSelectChange()),
icon: const Icon(Icons.swap_horiz, size: 18),
label: const Text("Wechseln"),
style: OutlinedButton.styleFrom(
visualDensity: VisualDensity.compact,
),
),
],
),
),
],
);
},
);
}
}

View File

@ -0,0 +1,34 @@
import 'package:hl_lieferservice/feature/cars/model/selection.dart';
import 'package:shared_preferences/shared_preferences.dart';
class CarSelectionRepository {
static const _keyDate = 'car_selection_date';
static const _keyCarId = 'car_selection_car_id';
static const _keyCarPlate = 'car_selection_car_plate';
/// Returns the stored [CarSelection], or null if nothing has been saved yet.
Future<CarSelection?> getSelection() async {
final prefs = await SharedPreferences.getInstance();
final dateString = prefs.getString(_keyDate);
final carId = prefs.getInt(_keyCarId);
final plate = prefs.getString(_keyCarPlate);
if (dateString == null || carId == null || plate == null) return null;
return CarSelection(
date: DateTime.parse(dateString),
selectedCarId: carId,
selectedCarPlate: plate,
);
}
/// Persists the given [selection] locally on this device.
Future<void> saveSelection(CarSelection selection) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyDate, selection.date.toIso8601String());
await prefs.setInt(_keyCarId, selection.selectedCarId!);
await prefs.setString(_keyCarPlate, selection.selectedCarPlate!);
}
}

View File

@ -1,4 +1,7 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart';
import 'package:hl_lieferservice/feature/authentication/exceptions.dart';
import 'package:hl_lieferservice/feature/cars/repository/cars_repository.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
@ -10,8 +13,9 @@ import 'cars_state.dart';
class CarsBloc extends Bloc<CarEvents, CarsState> {
CarsRepository repository;
OperationBloc opBloc;
AuthBloc authBloc;
CarsBloc({required this.repository, required this.opBloc})
CarsBloc({required this.repository, required this.opBloc, required this.authBloc})
: super(CarsInitial()) {
on<CarAdd>(_carAdd);
on<CarEdit>(_carEdit);
@ -19,12 +23,27 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
on<CarLoad>(_carLoad);
}
void _handleError(Object e, String fallbackMessage) {
if (e is UserUnauthorized) {
authBloc.add(SessionExpiredEvent());
} else {
opBloc.add(FailOperation(message: fallbackMessage));
}
}
Future<void> _carLoad(CarLoad event, Emitter<CarsState> emit) async {
// Skip the API call if cars are already loaded and no force-refresh requested.
if (state is CarsLoaded && !event.force) return;
try {
emit(CarsLoading());
List<Car> cars = await repository.getAll(event.teamId);
emit(CarsLoaded(cars: cars, teamId: event.teamId));
} catch (e) {
if (e is UserUnauthorized) {
authBloc.add(SessionExpiredEvent());
return;
}
emit(CarsLoadingFailed());
}
}
@ -33,7 +52,6 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
final currentState = state;
try {
opBloc.add(LoadOperation());
Car newCar = await repository.add(event.teamId, event.plate);
if (currentState is CarsLoaded) {
@ -46,7 +64,7 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
opBloc.add(FinishOperation(message: "Auto erfolgreich hinzugefügt"));
} catch (e) {
opBloc.add(FailOperation(message: "Fehler beim Hinzufügen eines Autos"));
_handleError(e, "Fehler beim Hinzufügen eines Autos");
}
}
@ -54,7 +72,6 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
final currentState = state;
try {
opBloc.add(LoadOperation());
await repository.edit(event.teamId, event.newCar);
if (currentState is CarsLoaded) {
@ -74,7 +91,7 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
opBloc.add(FinishOperation(message: "Auto erfolgreich editiert"));
} catch (e) {
opBloc.add(FailOperation(message: "Fehler beim Editieren des Autos"));
_handleError(e, "Fehler beim Editieren des Autos");
}
}
@ -82,7 +99,6 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
final currentState = state;
try {
opBloc.add(LoadOperation());
await repository.delete(event.carId, event.teamId);
if (currentState is CarsLoaded) {
@ -100,7 +116,7 @@ class CarsBloc extends Bloc<CarEvents, CarsState> {
opBloc.add(FinishOperation(message: "Auto erfolgreich gelöscht"));
} catch (e) {
opBloc.add(FailOperation(message: "Fehler beim Löschen des Autos"));
_handleError(e, "Fehler beim Löschen des Autos");
}
}
}

View File

@ -5,7 +5,11 @@ abstract class CarEvents {}
class CarLoad extends CarEvents {
String teamId;
CarLoad({required this.teamId});
/// If [force] is true the API is always called, bypassing the cache.
/// Use this for pull-to-refresh. Defaults to false.
bool force;
CarLoad({required this.teamId, this.force = false});
}
class CarEdit extends CarEvents {

View File

@ -0,0 +1,14 @@
/*
Settings for the driver to select a car for the current workday.
*/
class CarSelection {
final DateTime date;
final int? selectedCarId;
final String? selectedCarPlate;
CarSelection({
required this.date,
this.selectedCarId,
this.selectedCarPlate,
});
}

View File

@ -5,6 +5,7 @@ import 'car_dialog.dart';
class CarCard extends StatelessWidget {
final Car car;
final bool isSelected;
final Function(Car car) onDelete;
final Function(Car car, String newName) onEdit;
@ -13,11 +14,20 @@ class CarCard extends StatelessWidget {
required this.car,
required this.onEdit,
required this.onDelete,
this.isSelected = false,
});
@override
Widget build(BuildContext context) {
final primary = Theme.of(context).primaryColor;
return Card(
color: isSelected ? primary.withValues(alpha: 0.08) : null,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: isSelected
? BorderSide(color: primary, width: 2)
: BorderSide.none,
),
child: Padding(
padding: const EdgeInsets.all(10),
child: Row(
@ -30,13 +40,30 @@ class CarCard extends StatelessWidget {
child: Icon(
Icons.local_shipping,
size: 32,
color: Theme.of(context).primaryColor,
color: primary,
),
),
Padding(
padding: const EdgeInsets.only(left: 10),
child: Text(car.plate),
child: Text(
car.plate,
style: TextStyle(
fontWeight: isSelected
? FontWeight.bold
: FontWeight.normal,
),
),
),
if (isSelected)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Icon(
Icons.check_circle,
size: 20,
color: primary,
semanticLabel: 'Aktuell ausgewählt',
),
),
],
),

View File

@ -6,9 +6,11 @@ import 'package:flutter/material.dart';
class CarManagementOverview extends StatefulWidget {
final List<Car> cars;
final int? selectedCarId;
final Function(String plate) onAdd;
final Function(String id) onDelete;
final Function(String id, String plate) onEdit;
final Future<void> Function() onRefresh;
const CarManagementOverview({
super.key,
@ -16,6 +18,8 @@ class CarManagementOverview extends StatefulWidget {
required this.onDelete,
required this.onEdit,
required this.onAdd,
required this.onRefresh,
this.selectedCarId,
});
@override
@ -40,30 +44,14 @@ class _CarManagementOverviewState extends State<CarManagementOverview> {
widget.onEdit(car.id.toString(), newName);
}
Widget _buildCarOverview() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(padding: const EdgeInsets.all(15), child: Text("Fahrzeuge", style: Theme.of(context).textTheme.headlineSmall),),
Expanded(child: Padding(
padding: const EdgeInsets.all(10),
child: widget.cars.isEmpty ? const Center(child: Text("keine Fahrzeuge vorhanden")) : ListView.builder(
itemBuilder:
(context, index) => CarCard(
car: widget.cars[index],
onEdit: _editCar,
onDelete: _removeCar,
),
itemCount: widget.cars.length,
),
))
],
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Fahrzeuge"),
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
),
floatingActionButton: FloatingActionButton(
onPressed: _addCar,
backgroundColor: Theme.of(context).primaryColor,
@ -72,7 +60,34 @@ class _CarManagementOverviewState extends State<CarManagementOverview> {
color: Theme.of(context).colorScheme.onSecondary,
),
),
body: _buildCarOverview(),
body: RefreshIndicator(
onRefresh: widget.onRefresh,
child: widget.cars.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(10),
children: const [
SizedBox(
height: 200,
child: Center(child: Text("keine Fahrzeuge vorhanden")),
),
],
)
: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(10),
itemCount: widget.cars.length,
itemBuilder: (context, index) {
final car = widget.cars[index];
return CarCard(
car: car,
isSelected: widget.selectedCarId == car.id,
onEdit: _editCar,
onDelete: _removeCar,
);
},
),
),
);
}
}

View File

@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_state.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/cars/bloc/cars_bloc.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_event.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_state.dart';
@ -37,7 +39,55 @@ class _CarManagementPageState extends State<CarManagementPage> {
);
}
Future<void> _refresh() async {
context.read<CarsBloc>().add(CarLoad(teamId: _authState.user.number, force: true));
}
void _remove(String id) {
final carId = int.parse(id);
final carSelectState = context.read<CarSelectBloc>().state;
if (carSelectState is CarSelectComplete &&
carSelectState.selectedCar.id == carId) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"Dieses Fahrzeug ist aktuell ausgewählt und kann nicht gelöscht werden. "
"Bitte wähle zuerst ein anderes Fahrzeug aus.",
),
duration: Duration(seconds: 4),
),
);
return;
}
final tourState = context.read<TourBloc>().state;
if (tourState is! TourLoaded) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"Die Tourdaten sind noch nicht verfügbar. "
"Bitte versuche es in Kürze erneut.",
),
duration: Duration(seconds: 4),
),
);
return;
}
if (tourState.tour.hasUndeliveredLoadedArticles(carId)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
"Dieses Fahrzeug hat noch geladene Artikel, die nicht ausgeliefert wurden. "
"Bitte schließe alle offenen Lieferungen ab, bevor du das Fahrzeug löschst.",
),
duration: Duration(seconds: 4),
),
);
return;
}
context.read<CarsBloc>().add(
CarDelete(carId: id, teamId: _authState.user.number),
);
@ -68,11 +118,20 @@ class _CarManagementPageState extends State<CarManagementPage> {
}
if (state is CarsLoaded) {
return CarManagementOverview(
cars: state.cars,
onEdit: _edit,
onAdd: _add,
onDelete: _remove,
return BlocBuilder<CarSelectBloc, CarSelectState>(
builder: (context, selectState) {
final int? selectedCarId = selectState is CarSelectComplete
? selectState.selectedCar.id
: null;
return CarManagementOverview(
cars: state.cars,
selectedCarId: selectedCarId,
onEdit: _edit,
onAdd: _add,
onDelete: _remove,
onRefresh: _refresh,
);
},
);
}

View File

@ -9,16 +9,20 @@ import 'package:hl_lieferservice/feature/delivery/overview/service/distance_serv
import 'package:hl_lieferservice/feature/delivery/overview/service/reorder_service.dart';
import 'package:hl_lieferservice/feature/delivery/util.dart';
import 'package:hl_lieferservice/model/tour.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart';
import 'package:hl_lieferservice/feature/authentication/exceptions.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
import 'package:rxdart/rxdart.dart';
class TourBloc extends Bloc<TourEvent, TourState> {
OperationBloc opBloc;
AuthBloc authBloc;
TourRepository tourRepository;
StreamSubscription? _combinedSubscription;
TourBloc({required this.opBloc, required this.tourRepository})
TourBloc({required this.opBloc, required this.authBloc, required this.tourRepository})
: super(TourInitial()) {
_combinedSubscription = CombineLatestStream.combine2(
tourRepository.tour,
@ -61,17 +65,23 @@ class TourBloc extends Bloc<TourEvent, TourState> {
@override
Future<void> close() {
_combinedSubscription?.cancel();
return super.close();
}
void _handleError(Object e, String fallbackMessage) {
if (e is UserUnauthorized) {
authBloc.add(SessionExpiredEvent());
} else {
opBloc.add(FailOperation(message: fallbackMessage));
}
}
void _setArticleAmount(
SetArticleAmountEvent event,
Emitter<TourState> emit,
) async {
final currentState = state;
if (currentState is TourLoaded) {
opBloc.add(LoadOperation());
try {
await tourRepository.setArticleAmount(
event.deliveryId,
@ -79,15 +89,9 @@ class TourBloc extends Bloc<TourEvent, TourState> {
event.amount,
event.reason,
);
opBloc.add(FinishOperation());
} catch (e, st) {
opBloc.add(
FailOperation(message: "Fehler beim Ändern der Menge des Artikels"),
);
debugPrint("$e");
debugPrint("$st");
debugPrint("$e $st");
_handleError(e, "Fehler beim Ändern der Menge des Artikels");
}
}
}
@ -126,7 +130,6 @@ class TourBloc extends Bloc<TourEvent, TourState> {
Emitter<TourState> emit,
) async {
Map<String, double> distances = {};
opBloc.add(LoadOperation());
emit(TourRequestingDistances(tour: event.tour, payments: event.payments));
@ -135,7 +138,7 @@ class TourBloc extends Bloc<TourEvent, TourState> {
distances[delivery.id] = await DistanceService.getDistanceByRoad(
delivery.customer.address.toString(),
);
} catch (e,st) {
} catch (e, st) {
debugPrint("Fehler beim Laden der Distanz: $e");
debugPrint("$st");
@ -145,7 +148,6 @@ class TourBloc extends Bloc<TourEvent, TourState> {
}
}
opBloc.add(FinishOperation());
// If an error occurred, then the distances will be empty
// If the distances are empty then they shouldn't be displayed
add(
@ -251,17 +253,11 @@ class TourBloc extends Bloc<TourEvent, TourState> {
) async {
final currentState = state;
if (currentState is TourLoaded) {
opBloc.add(LoadOperation());
try {
await tourRepository.reactivateDelivery(event.deliveryId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("$e");
debugPrint("$st");
opBloc.add(
FailOperation(message: "Fehler beim Zurückstellen der Lieferung"),
);
debugPrint("$e $st");
_handleError(e, "Fehler beim Zurückstellen der Lieferung");
}
}
}
@ -269,17 +265,11 @@ class TourBloc extends Bloc<TourEvent, TourState> {
void _holdDelivery(HoldDeliveryEvent event, Emitter<TourState> emit) async {
final currentState = state;
if (currentState is TourLoaded) {
opBloc.add(LoadOperation());
try {
await tourRepository.holdDelivery(event.deliveryId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("$e");
debugPrint("$st");
opBloc.add(
FailOperation(message: "Fehler beim Zurückstellen der Lieferung"),
);
debugPrint("$e $st");
_handleError(e, "Fehler beim Zurückstellen der Lieferung");
}
}
}
@ -290,24 +280,17 @@ class TourBloc extends Bloc<TourEvent, TourState> {
) async {
final currentState = state;
if (currentState is TourLoaded) {
opBloc.add(LoadOperation());
try {
await tourRepository.cancelDelivery(event.deliveryId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("$e");
debugPrint("$st");
opBloc.add(
FailOperation(message: "Fehler beim Zurückstellen der Lieferung"),
);
debugPrint("$e $st");
_handleError(e, "Fehler beim Stornieren der Lieferung");
}
}
}
void _scan(ScanArticleEvent event, Emitter<TourState> emit) async {
final currentState = state;
opBloc.add(LoadOperation());
if (currentState is TourLoaded) {
try {
@ -333,9 +316,8 @@ class TourBloc extends Bloc<TourEvent, TourState> {
break;
}
} catch (e, st) {
debugPrint("FEHLER beim Scannen eines Artikels: $e");
debugPrint("$st");
opBloc.add(FailOperation(message: "Fehler beim Scannen des Artikels"));
debugPrint("FEHLER beim Scannen eines Artikels: $e $st");
_handleError(e, "Fehler beim Scannen des Artikels");
}
}
}
@ -347,17 +329,15 @@ class TourBloc extends Bloc<TourEvent, TourState> {
final currentState = state;
if (currentState is TourLoaded) {
opBloc.add(LoadOperation());
try {
await tourRepository.scanArticle(
event.deliveryId,
event.carId,
event.internalArticleId,
);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint(st.toString());
opBloc.add(FailOperation(message: "Fehler beim Scannen des Artikels"));
debugPrint("$e $st");
_handleError(e, "Fehler beim Scannen des Artikels");
}
}
}
@ -365,34 +345,27 @@ class TourBloc extends Bloc<TourEvent, TourState> {
Future<void> _assignCar(AssignCarEvent event, Emitter<TourState> emit) async {
final currentState = state;
if (currentState is TourLoaded) {
opBloc.add(LoadOperation());
try {
await tourRepository.assignCar(event.deliveryId, event.carId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint(st.toString());
opBloc.add(
FailOperation(message: "Fehler beim Zuweisen des Fahrzeugs"),
);
debugPrint("$e $st");
_handleError(e, "Fehler beim Zuweisen des Fahrzeugs");
}
}
}
Future<void> _load(LoadTour event, Emitter<TourState> emit) async {
opBloc.add(LoadOperation());
try {
emit(TourLoading());
await tourRepository.loadTourOfToday(event.teamId);
await tourRepository.loadPaymentOptions();
opBloc.add(FinishOperation());
} catch (e) {
// go to the error state in order to give the user the chance
// to reload if necessary.
if (e is UserUnauthorized) {
authBloc.add(SessionExpiredEvent());
return;
}
emit(TourLoadingFailed());
opBloc.add(
FailOperation(message: "Fehler beim Laden der heutigen Fahrten"),
);
opBloc.add(FailOperation(message: "Fehler beim Laden der heutigen Fahrten"));
}
}
@ -401,7 +374,6 @@ class TourBloc extends Bloc<TourEvent, TourState> {
Emitter<TourState> emit,
) async {
final currentState = state;
opBloc.add(LoadOperation());
if (currentState is TourLoaded) {
try {
@ -415,11 +387,9 @@ class TourBloc extends Bloc<TourEvent, TourState> {
);
await tourRepository.finishDelivery(event.deliveryId);
opBloc.add(FinishOperation());
} catch (e, st) {
opBloc.add(FailOperation(message: "Failed to update delivery"));
debugPrint(st.toString());
debugPrint("$e $st");
_handleError(e, "Fehler beim Abschließen der Lieferung");
}
}
}
@ -429,14 +399,10 @@ class TourBloc extends Bloc<TourEvent, TourState> {
Emitter<TourState> emit,
) async {
try {
opBloc.add(LoadOperation());
await tourRepository.updatePayment(event.deliveryId, event.payment);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint(st.toString());
opBloc.add(
FailOperation(message: "Fehler beim Aktualisieren des Betrags"),
);
debugPrint("$e $st");
_handleError(e, "Fehler beim Aktualisieren des Betrags");
}
}
@ -445,18 +411,14 @@ class TourBloc extends Bloc<TourEvent, TourState> {
Emitter<TourState> emit,
) async {
try {
opBloc.add(LoadOperation());
await tourRepository.updateOption(
event.deliveryId,
event.key,
event.value,
);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("$st");
opBloc.add(
FailOperation(message: "Fehler beim Aktualisieren der Optionen"),
);
debugPrint("$e $st");
_handleError(e, "Fehler beim Aktualisieren der Optionen");
}
}
@ -464,26 +426,15 @@ class TourBloc extends Bloc<TourEvent, TourState> {
UpdateDiscountEvent event,
Emitter<TourState> emit,
) async {
opBloc.add(LoadOperation());
try {
opBloc.add(FinishOperation());
await tourRepository.updateDiscount(
event.deliveryId,
event.reason,
event.value,
);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint(
"Fehler beim Hinzufügen eins Discounts zur Lieferung: ${event.deliveryId}:",
);
debugPrint("$e");
debugPrint("$st");
opBloc.add(
FailOperation(message: "Fehler beim Hinzufügen des Discounts: $e"),
);
debugPrint("Fehler beim Aktualisieren des Discounts: $e $st");
_handleError(e, "Fehler beim Aktualisieren des Discounts");
}
}
@ -491,51 +442,28 @@ class TourBloc extends Bloc<TourEvent, TourState> {
RemoveDiscountEvent event,
Emitter<TourState> emit,
) async {
opBloc.add(LoadOperation());
try {
await tourRepository.removeDiscount(event.deliveryId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint(
"Fehler beim Löschen des Discounts der Lieferung: ${event.deliveryId}:",
);
debugPrint("$e");
debugPrint("$st");
opBloc.add(
FailOperation(message: "Fehler beim Löschen des Discounts: $e"),
);
debugPrint("Fehler beim Löschen des Discounts: $e $st");
_handleError(e, "Fehler beim Löschen des Discounts");
}
}
void _addDiscount(AddDiscountEvent event, Emitter<TourState> emit) async {
opBloc.add(LoadOperation());
try {
await tourRepository.addDiscount(
event.deliveryId,
event.reason,
event.value,
);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint(
"Fehler beim Hinzufügen eins Discounts zur Lieferung: ${event.deliveryId}:",
);
debugPrint("$e");
debugPrint("$st");
opBloc.add(
FailOperation(message: "Fehler beim Hinzufügen des Discounts: $e"),
);
debugPrint("Fehler beim Hinzufügen des Discounts: $e $st");
_handleError(e, "Fehler beim Hinzufügen des Discounts");
}
}
void _unscan(UnscanArticleEvent event, Emitter<TourState> emit) async {
opBloc.add(LoadOperation());
try {
await tourRepository.unscan(
event.deliveryId,
@ -543,29 +471,18 @@ class TourBloc extends Bloc<TourEvent, TourState> {
event.newAmount,
event.reason,
);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Unscan des Artikels: ${event.articleId}:");
debugPrint("$e");
debugPrint("$st");
opBloc.add(FailOperation(message: "Fehler beim Unscan des Artikels: $e"));
debugPrint("Fehler beim Unscan des Artikels ${event.articleId}: $e $st");
_handleError(e, "Fehler beim Unscan des Artikels");
}
}
void _resetAmount(ResetScanAmountEvent event, Emitter<TourState> emit) async {
opBloc.add(LoadOperation());
try {
await tourRepository.resetScan(event.articleId, event.deliveryId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Unscan des Artikels: ${event.articleId}:");
debugPrint("$e");
debugPrint("$st");
opBloc.add(FailOperation(message: "Fehler beim Zurücksetzen: $e"));
debugPrint("Fehler beim Zurücksetzen Artikel ${event.articleId}: $e $st");
_handleError(e, "Fehler beim Zurücksetzen");
}
}
}

View File

@ -3,6 +3,9 @@ import 'dart:typed_data';
import 'package:flutter/cupertino.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_event.dart';
import 'package:hl_lieferservice/feature/authentication/exceptions.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
import 'package:rxdart/rxdart.dart';
@ -15,6 +18,7 @@ import 'package:hl_lieferservice/feature/delivery/detail/repository/note_reposit
class NoteBloc extends Bloc<NoteEvent, NoteState> {
final NoteRepository repository;
final OperationBloc opBloc;
final AuthBloc authBloc;
final String deliveryId;
StreamSubscription? _combinedSubscription;
@ -22,6 +26,7 @@ class NoteBloc extends Bloc<NoteEvent, NoteState> {
NoteBloc({
required this.repository,
required this.opBloc,
required this.authBloc,
required this.deliveryId,
}) : super(NoteInitial()) {
_combinedSubscription = CombineLatestStream.combine3(
@ -60,10 +65,17 @@ class NoteBloc extends Bloc<NoteEvent, NoteState> {
@override
Future<void> close() {
_combinedSubscription?.cancel();
return super.close();
}
void _handleError(Object e, String fallbackMessage) {
if (e is UserUnauthorized) {
authBloc.add(SessionExpiredEvent());
} else {
opBloc.add(FailOperation(message: fallbackMessage));
}
}
Future<void> _dataUpdated(DataUpdated event, Emitter<NoteState> emit) async {
emit(
NoteLoaded(
@ -82,32 +94,21 @@ class NoteBloc extends Bloc<NoteEvent, NoteState> {
RemoveImageNote event,
Emitter<NoteState> emit,
) async {
opBloc.add(LoadOperation());
try {
await repository.deleteImage(event.deliveryId, event.objectId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Löschen des Bildes: $e");
debugPrint(st.toString());
opBloc.add(FailOperation(message: e.toString()));
debugPrint("Fehler beim Löschen des Bildes: $e $st");
_handleError(e, "Fehler beim Löschen des Bildes");
}
}
Future<void> _upload(AddImageNote event, Emitter<NoteState> emit) async {
opBloc.add(LoadOperation());
try {
Uint8List imageBytes = await event.file.readAsBytes();
await repository.addImage(event.deliveryId, imageBytes);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Hinzufügen des Bildes: $e");
debugPrint(st.toString());
opBloc.add(FailOperation(message: e.toString()));
debugPrint("Fehler beim Hinzufügen des Bildes: $e $st");
_handleError(e, "Fehler beim Hinzufügen des Bildes");
}
}
@ -117,61 +118,41 @@ class NoteBloc extends Bloc<NoteEvent, NoteState> {
try {
await repository.loadNotes(event.delivery.id);
await repository.loadTemplates();
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Herunterladen der Notizen: $e");
debugPrint(st.toString());
opBloc.add(
FailOperation(message: "Notizen konnten nicht heruntergeladen werden."),
);
debugPrint("Fehler beim Herunterladen der Notizen: $e $st");
if (e is UserUnauthorized) {
authBloc.add(SessionExpiredEvent());
return;
}
opBloc.add(FailOperation(message: "Notizen konnten nicht heruntergeladen werden."));
emit.call(NoteLoadingFailed());
}
}
Future<void> _add(AddNote event, Emitter<NoteState> emit) async {
opBloc.add(LoadOperation());
try {
await repository.addNote(event.deliveryId, event.note);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Hinzufügen der Notiz: $e");
debugPrint(st.toString());
opBloc.add(FailOperation(message: e.toString()));
debugPrint("Fehler beim Hinzufügen der Notiz: $e $st");
_handleError(e, "Fehler beim Hinzufügen der Notiz");
}
}
Future<void> _edit(EditNote event, Emitter<NoteState> emit) async {
opBloc.add(LoadOperation());
try {
await repository.editNote(event.noteId, event.content);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Hinzufügen der Notiz: $e");
debugPrint(st.toString());
opBloc.add(FailOperation(message: e.toString()));
debugPrint("Fehler beim Editieren der Notiz: $e $st");
_handleError(e, "Fehler beim Editieren der Notiz");
}
}
Future<void> _remove(RemoveNote event, Emitter<NoteState> emit) async {
opBloc.add(LoadOperation());
try {
await repository.deleteNote(event.noteId);
opBloc.add(FinishOperation());
} catch (e, st) {
debugPrint("Fehler beim Hinzufügen der Notiz: $e");
debugPrint(st.toString());
opBloc.add(
FailOperation(message: "Notizen konnte nicht gelöscht werden."),
);
debugPrint("Fehler beim Löschen der Notiz: $e $st");
_handleError(e, "Notiz konnte nicht gelöscht werden");
}
}
}

View File

@ -8,6 +8,8 @@ import 'package:url_launcher/url_launcher.dart';
import '../../../bloc/tour_bloc.dart';
import '../../../bloc/tour_state.dart';
enum _StatusAction { hold, cancel, reactivate }
class DeliveryStepInfo extends StatefulWidget {
final Delivery delivery;
@ -39,76 +41,73 @@ class _DeliveryStepInfo extends State<DeliveryStepInfo> {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
Widget _deliveryStatusChangeActions() {
List<Widget> actions = [];
Widget _statusOverflow() {
final state = widget.delivery.state;
final List<PopupMenuEntry<_StatusAction>> entries;
if (widget.delivery.state == DeliveryState.ongoing) {
actions = [
Column(
children: [
IconButton(
onPressed: () {
context.read<TourBloc>().add(
HoldDeliveryEvent(deliveryId: widget.delivery.id),
);
Navigator.of(context).pop();
},
icon: Icon(
Icons.change_circle,
color: Colors.orangeAccent,
size: 42,
),
),
Text("Zurückstellen"),
],
if (state == DeliveryState.ongoing) {
entries = const [
PopupMenuItem(
value: _StatusAction.hold,
child: Row(
children: [
Icon(Icons.change_circle, color: Colors.orangeAccent),
SizedBox(width: 12),
Text("Zurückstellen"),
],
),
),
Column(
children: [
IconButton(
onPressed: () {
context.read<TourBloc>().add(
CancelDeliveryEvent(deliveryId: widget.delivery.id),
);
Navigator.of(context).pop();
},
//style: IconButton.styleFrom(backgroundColor: Colors.red),
icon: Icon(Icons.cancel, color: Colors.red, size: 42),
),
Text("Abbrechen"),
],
PopupMenuItem(
value: _StatusAction.cancel,
child: Row(
children: [
Icon(Icons.cancel, color: Colors.red),
SizedBox(width: 12),
Text("Abbrechen"),
],
),
),
];
} else {
entries = const [
PopupMenuItem(
value: _StatusAction.reactivate,
child: Row(
children: [
Icon(Icons.published_with_changes, color: Colors.blueAccent),
SizedBox(width: 12),
Text("Reaktivieren"),
],
),
),
];
}
if (widget.delivery.state == DeliveryState.canceled ||
widget.delivery.state == DeliveryState.onhold ||
widget.delivery.state == DeliveryState.finished) {
actions = [
Column(
children: [
IconButton(
onPressed: () {
context.read<TourBloc>().add(
ReactivateDeliveryEvent(deliveryId: widget.delivery.id),
);
},
icon: Icon(
Icons.published_with_changes,
color: Colors.blueAccent,
size: 42
),
),
Text("Reaktivieren"),
],
),
];
}
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: actions,
return PopupMenuButton<_StatusAction>(
icon: const Icon(Icons.more_vert),
tooltip: "Status ändern",
itemBuilder: (context) => entries,
onSelected: (action) {
switch (action) {
case _StatusAction.hold:
context.read<TourBloc>().add(
HoldDeliveryEvent(deliveryId: widget.delivery.id),
);
Navigator.of(context).pop();
break;
case _StatusAction.cancel:
context.read<TourBloc>().add(
CancelDeliveryEvent(deliveryId: widget.delivery.id),
);
Navigator.of(context).pop();
break;
case _StatusAction.reactivate:
context.read<TourBloc>().add(
ReactivateDeliveryEvent(deliveryId: widget.delivery.id),
);
break;
}
},
);
}
@ -119,55 +118,46 @@ class _DeliveryStepInfo extends State<DeliveryStepInfo> {
color: Theme.of(context).colorScheme.onSecondary,
child: Padding(
padding: const EdgeInsets.all(10),
child: Column(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Column(
children: [
IconButton.filled(
onPressed:
widget.delivery.contactPerson?.phoneNumber != null
? () async {
Expanded(
child: Builder(
builder: (context) {
final phone = widget.delivery.contactPerson?.phoneNumber;
final bool hasPhone = phone != null && phone.isNotEmpty;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filled(
onPressed: hasPhone
? () async {
await launchUrl(
Uri(
scheme: "tel",
path:
widget
.delivery
.contactPerson
?.phoneNumber!,
),
Uri(scheme: "tel", path: phone),
);
}
: null,
icon: Icon(Icons.phone),
),
Text("Anrufen"),
],
),
Column(
children: [
IconButton.filled(
onPressed: () {
_launchMapsUrl("google");
},
icon: Icon(Icons.map_outlined),
),
Text("Google Maps"),
],
),
],
: null,
icon: const Icon(Icons.phone),
),
const Text("Anrufen"),
],
);
},
),
),
const Padding(
padding: EdgeInsets.only(top: 10, bottom: 10),
child: Divider(),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filled(
onPressed: () => _launchMapsUrl("google"),
icon: const Icon(Icons.map_outlined),
),
const Text("Google Maps"),
],
),
),
_deliveryStatusChangeActions(),
_statusOverflow(),
],
),
),
@ -176,6 +166,16 @@ class _DeliveryStepInfo extends State<DeliveryStepInfo> {
}
Widget _customerInformation() {
final phone = widget.delivery.contactPerson?.phoneNumber;
final String phoneText = (phone != null && phone.isNotEmpty)
? phone
: "keine Nummer angegeben";
final email = widget.delivery.customer.email;
final String emailText = (email != null && email.isNotEmpty)
? email
: "keine E-Mail angegeben";
return SizedBox(
width: double.infinity,
child: Card(
@ -228,9 +228,24 @@ class _DeliveryStepInfo extends State<DeliveryStepInfo> {
Icon(Icons.phone, color: Theme.of(context).primaryColor),
Padding(
padding: const EdgeInsets.only(left: 10),
child: Text(
widget.delivery.contactPerson?.phoneNumber.toString() ??
"",
child: Text(phoneText),
),
],
),
),
Padding(
padding: const EdgeInsets.only(top: 15),
child: Row(
children: [
Icon(Icons.mail, color: Theme.of(context).primaryColor),
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 10),
child: Text(
emailText,
overflow: TextOverflow.ellipsis,
),
),
),
],
@ -275,28 +290,66 @@ class _DeliveryStepInfo extends State<DeliveryStepInfo> {
);
}
Widget _deliveryAgreements() {
Widget _agreementsAndDesiredTime() {
String agreements = "keine Vereinbarungen getroffen!";
if (widget.delivery.specialAgreements != null &&
widget.delivery.specialAgreements != "") {
agreements = widget.delivery.specialAgreements!;
}
final desiredTime = widget.delivery.desiredTime;
final bool hasDesiredTime = desiredTime != null && desiredTime.isNotEmpty;
final primary = Theme.of(context).primaryColor;
return Card(
color: Theme.of(context).colorScheme.onSecondary,
child: Padding(
padding: const EdgeInsets.all(10),
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: EdgeInsets.all(15),
child: Icon(
Icons.warning,
color: Theme.of(context).primaryColor,
size: 28,
if (hasDesiredTime) ...[
Row(
children: [
Padding(
padding: const EdgeInsets.all(15),
child: Icon(Icons.schedule, color: primary, size: 28),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Wunschtermin",
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
Text(
desiredTime,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: primary,
),
),
],
),
),
],
),
const Divider(height: 24),
],
Row(
children: [
Padding(
padding: const EdgeInsets.all(15),
child: Icon(Icons.warning, color: primary, size: 28),
),
Expanded(child: Text(agreements)),
],
),
Expanded(child: Text(agreements)),
],
),
),
@ -330,7 +383,7 @@ class _DeliveryStepInfo extends State<DeliveryStepInfo> {
),
Padding(
padding: const EdgeInsets.only(top: 10),
child: _deliveryAgreements(),
child: _agreementsAndDesiredTime(),
),
Padding(

View File

@ -275,10 +275,14 @@ class NoteService {
LocalDocuFrameConfiguration config = getConfig();
return urls.map((url) async {
return (await http.get(
final response = await http.get(
Uri.parse("${config.backendUrl}$url"),
headers: getSessionOrThrow(),
)).bodyBytes;
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
return response.bodyBytes;
}).toList();
} catch (e, st) {
debugPrint("An error occured:");

View File

@ -1,80 +1,86 @@
import 'package:flutter/material.dart';
import 'package:hl_lieferservice/model/delivery.dart' show DeliveryState;
import 'package:hl_lieferservice/model/tour.dart';
import 'package:intl/intl.dart';
class DeliveryInfo extends StatelessWidget {
final Tour tour;
final int? selectedCarId;
const DeliveryInfo({super.key, required this.tour});
const DeliveryInfo({super.key, required this.tour, this.selectedCarId});
@override
Widget build(BuildContext context) {
String date = DateFormat("dd.MM.yyyy").format(tour.date);
String amountDeliveries = tour.deliveries.length.toString();
final String date = DateFormat("dd.MM.yyyy").format(tour.date);
final relevantDeliveries = selectedCarId != null
? tour.deliveries.where((d) => d.carId == selectedCarId).toList()
: tour.deliveries;
final total = relevantDeliveries.length;
final done = relevantDeliveries
.where((d) => d.state == DeliveryState.finished)
.length;
final progress = total > 0 ? done / total : 0.0;
final allDone = total > 0 && done == total;
return Padding(
padding: const EdgeInsets.all(10),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Text(
"Informationen",
style: Theme.of(context).textTheme.headlineSmall,
),
),
SizedBox(
width: double.infinity,
child: Card(
color: Theme.of(context).colorScheme.onSecondary,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 15),
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: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(Icons.calendar_month),
Padding(
padding: const EdgeInsets.only(left: 5),
child: Text("Datum"),
),
],
const Icon(Icons.calendar_month),
const Padding(
padding: EdgeInsets.only(left: 5),
child: Text("Datum"),
),
Text(date),
],
),
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("Lieferungen"),
),
],
),
Text(amountDeliveries),
],
),
),
Text(date),
],
),
),
const SizedBox(height: 15),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(Icons.local_shipping_outlined),
const Padding(
padding: EdgeInsets.only(left: 5),
child: Text("Lieferungen"),
),
],
),
Text("$done / $total"),
],
),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: progress,
minHeight: 6,
backgroundColor:
Theme.of(context).colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
allDone ? Colors.green : Theme.of(context).primaryColor,
),
),
),
],
),
),
],
),
),
);
}

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/model/delivery.dart';
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_detail_page.dart';
@ -18,60 +19,132 @@ class DeliveryListItem extends StatelessWidget {
required this.distance,
});
Widget _leading(BuildContext context) {
if (delivery.state == DeliveryState.finished) {
return Icon(Icons.check_circle, color: Colors.green);
}
if (delivery.state == DeliveryState.canceled) {
return Icon(Icons.cancel_rounded, color: Colors.red);
}
if (delivery.state == DeliveryState.onhold) {
return Icon(Icons.pause_circle, color: Colors.orange);
}
return Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Icon(Icons.location_on, color: Theme.of(context).primaryColor),
Text("${distance.toStringAsFixed(2)}km"),
],
);
}
void _goToDelivery(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder:
(context) => BlocProvider(
create:
(context) => NoteBloc(
deliveryId: delivery.id,
opBloc: context.read<OperationBloc>(),
repository: NoteRepository(
service: NoteService(),
),
),
child: DeliveryDetail(deliveryId: delivery.id),
),
builder: (context) => BlocProvider(
create: (context) => NoteBloc(
deliveryId: delivery.id,
opBloc: context.read<OperationBloc>(),
authBloc: context.read<AuthBloc>(),
repository: NoteRepository(service: NoteService()),
),
child: DeliveryDetail(deliveryId: delivery.id),
),
),
);
}
(Color, Color, IconData, String) _stateStyle(BuildContext context) {
switch (delivery.state) {
case DeliveryState.finished:
return (
Colors.green.withValues(alpha: 0.07),
Colors.green.withValues(alpha: 0.35),
Icons.check_circle_rounded,
"Abgeschlossen",
);
case DeliveryState.canceled:
return (
Colors.red.withValues(alpha: 0.07),
Colors.red.withValues(alpha: 0.35),
Icons.cancel_rounded,
"Storniert",
);
case DeliveryState.onhold:
return (
Colors.orange.withValues(alpha: 0.07),
Colors.orange.withValues(alpha: 0.35),
Icons.pause_circle_rounded,
"Pausiert",
);
case DeliveryState.ongoing:
return (
Theme.of(context).colorScheme.surfaceContainerLow,
Colors.transparent,
Icons.local_shipping_outlined,
"${distance.toStringAsFixed(1)} km",
);
}
}
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(
delivery.customer.name,
style: Theme.of(context).textTheme.titleMedium,
final (cardColor, borderColor, icon, statusLabel) = _stateStyle(context);
final isOngoing = delivery.state == DeliveryState.ongoing;
final iconColor = switch (delivery.state) {
DeliveryState.finished => Colors.green,
DeliveryState.canceled => Colors.red,
DeliveryState.onhold => Colors.orange,
DeliveryState.ongoing => Theme.of(context).primaryColor,
};
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: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => _goToDelivery(context),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(icon, color: iconColor, size: 28),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
delivery.customer.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: isOngoing ? null : iconColor,
),
),
const SizedBox(height: 2),
Text(
delivery.customer.address.toString(),
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
statusLabel,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: isOngoing
? Theme.of(context).colorScheme.onSurfaceVariant
: iconColor,
),
),
const SizedBox(height: 4),
Icon(
Icons.arrow_forward_ios,
size: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
],
),
),
),
leading: _leading(context),
tileColor: Theme.of(context).colorScheme.surfaceContainerHigh,
subtitle: Text(delivery.customer.address.toString()),
trailing: Icon(Icons.arrow_forward_ios),
onTap: () => _goToDelivery(context),
);
}
}

View File

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
import 'package:hl_lieferservice/feature/delivery/overview/model/sorting_information.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_overview.dart';
import 'package:hl_lieferservice/model/delivery.dart';
@ -56,79 +56,66 @@ class _DeliveryListState extends State<DeliveryList> {
builder: (context, state) {
final currentState = state;
if (currentState is TourLoaded) {
List<Delivery> deliveries =
currentState.tour.deliveries
.where(
(delivery) =>
delivery.carId == widget.selectedCarId &&
delivery.allArticlesScanned() &&
delivery.state != DeliveryState.finished,
)
.toList();
if (widget.sortType == SortType.custom) {
return _showCustomSortedList(
currentState.tour.deliveries,
currentState.sortingInformation[widget.selectedCarId.toString()] ?? [],
currentState.distances ?? {},
);
}
List<Delivery> finishedDeliveries =
currentState.tour.deliveries
.where(
(delivery) =>
delivery.state == DeliveryState.finished &&
delivery.carId == widget.selectedCarId,
)
.toList();
final allDeliveries = currentState.tour.deliveries
.where((d) => d.carId == widget.selectedCarId)
.toList();
if (deliveries.isEmpty) {
if (allDeliveries.isEmpty) {
return ListView(
physics: NeverScrollableScrollPhysics(),
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
children: [
Center(child: const Text("Keine Auslieferungen gefunden")),
children: const [
Center(child: Text("Keine Auslieferungen gefunden")),
],
);
}
final ongoing = allDeliveries
.where((d) => d.state == DeliveryState.ongoing)
.toList();
final nonOngoing = allDeliveries
.where((d) => d.state != DeliveryState.ongoing)
.toList();
int Function(Delivery, Delivery) comparator;
switch (widget.sortType) {
case SortType.custom:
return _showCustomSortedList(
currentState.tour.deliveries,
currentState.sortingInformation[widget.selectedCarId.toString()] ?? [],
currentState.distances ?? {},
);
case SortType.nameAsc:
deliveries.sort(
(a, b) => a.customer.name.compareTo(b.customer.name),
);
comparator = (a, b) => a.customer.name.compareTo(b.customer.name);
break;
case SortType.nameDesc:
deliveries.sort(
(a, b) => b.customer.name.compareTo(a.customer.name),
);
comparator = (a, b) => b.customer.name.compareTo(a.customer.name);
break;
case SortType.distance:
deliveries.sort(
(a, b) => (currentState.distances![a.id] ?? 0.0).compareTo(
currentState.distances![b.id] ?? 0.0,
),
);
comparator = (a, b) =>
(currentState.distances?[a.id] ?? 0.0)
.compareTo(currentState.distances?[b.id] ?? 0.0);
break;
default:
comparator = (a, b) => a.customer.name.compareTo(b.customer.name);
}
//deliveries.addAll(finishedDeliveries);
ongoing.sort(comparator);
nonOngoing.sort(comparator);
return ListView.separated(
separatorBuilder: (context, index) => const Divider(height: 0),
final sorted = [...ongoing, ...nonOngoing];
return ListView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
Delivery delivery = deliveries[index];
return DeliveryListItem(
delivery: delivery,
distance: currentState.distances?[delivery.id] ?? 0.0,
);
},
itemCount: deliveries.length,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.only(bottom: 8),
itemCount: sorted.length,
itemBuilder: (context, index) => DeliveryListItem(
delivery: sorted[index],
distance: currentState.distances?[sorted[index].id] ?? 0.0,
),
);
}

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.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/overview/presentation/delivery_info.dart';
@ -34,8 +36,14 @@ class _DeliveryOverviewState extends State<DeliveryOverview> {
void initState() {
super.initState();
// Select the first car for initialization
_selectedCarId = widget.tour.driver.cars.firstOrNull?.id;
// Pre-select today's car from the daily car selection.
// Falls back to the first available car if no selection exists.
final carSelectState = context.read<CarSelectBloc>().state;
if (carSelectState is CarSelectComplete) {
_selectedCarId = carSelectState.selectedCar.id;
} else {
_selectedCarId = widget.tour.driver.cars.firstOrNull?.id;
}
_sortType = SortType.nameAsc;
}
@ -44,54 +52,6 @@ class _DeliveryOverviewState extends State<DeliveryOverview> {
context.read<TourBloc>().add(LoadTour(teamId: state.user.number));
}
Widget _carSelection() {
return SizedBox(
width: double.infinity,
height: 50,
child: ListView(
scrollDirection: Axis.horizontal,
children:
widget.tour.driver.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: () {
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(),
),
);
}
/// Highlight the text of the active sorting type.
TextStyle? _popupItemTextStyle() {
return TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold);
@ -99,17 +59,23 @@ class _DeliveryOverviewState extends State<DeliveryOverview> {
@override
Widget build(BuildContext context) {
return RefreshIndicator(
return BlocListener<CarSelectBloc, CarSelectState>(
listener: (context, carState) {
if (carState is CarSelectComplete) {
setState(() => _selectedCarId = carState.selectedCar.id);
}
},
child: RefreshIndicator(
onRefresh: _loadTour,
child: ListView(
//crossAxisAlignment: CrossAxisAlignment.start,
children: [
DeliveryInfo(tour: widget.tour),
DeliveryInfo(tour: widget.tour, selectedCarId: _selectedCarId),
Padding(
padding: const EdgeInsets.only(
left: 10,
right: 10,
top: 15,
top: 0,
bottom: 10,
),
child: Row(
@ -191,16 +157,13 @@ class _DeliveryOverviewState extends State<DeliveryOverview> {
],
),
),
Padding(
padding: const EdgeInsets.only(left: 10, right: 10, bottom: 20),
child: _carSelection(),
),
DeliveryList(
selectedCarId: _selectedCarId,
sortType: _sortType,
),
],
),
),
);
}
}

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.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/overview/presentation/delivery_fail_page.dart';
import 'package:hl_lieferservice/feature/delivery/overview/presentation/delivery_overview.dart';
@ -16,25 +18,55 @@ class DeliveryOverviewPage extends StatefulWidget {
class _DeliveryOverviewPageState extends State<DeliveryOverviewPage> {
@override
Widget build(BuildContext context) {
return BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
if (state is TourLoaded) {
final currentState = state;
final carState = context.watch<CarSelectBloc>().state;
return Center(
child: DeliveryOverview(
tour: currentState.tour,
distances: currentState.distances ?? {},
return Scaffold(
appBar: AppBar(
title: const Text("Auslieferung"),
centerTitle: false,
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
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,
),
),
],
),
),
);
}
],
),
body: BlocBuilder<TourBloc, TourState>(
builder: (context, state) {
if (state is TourLoaded) {
return DeliveryOverview(
tour: state.tour,
distances: state.distances ?? {},
);
}
if (state is TourLoadingFailed) {
return DeliveryLoadingFailedPage();
}
if (state is TourLoadingFailed) {
return DeliveryLoadingFailedPage();
}
return Container();
},
return const Center(child: CircularProgressIndicator());
},
),
);
}
}

View File

@ -80,6 +80,8 @@ class TourRepository {
if (article.scannedAmount < article.amount) {
article.scannedAmount += 1;
delivery.carId = int.tryParse(carId) ?? delivery.carId;
await service.assignCar(deliveryId, carId);
_tourStream.add(tour);
return ScanResult.scanned;
} else {

View File

@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:hl_lieferservice/dto/delivery_response.dart';
import 'package:hl_lieferservice/dto/delivery_update.dart';
import 'package:hl_lieferservice/dto/delivery_update_response.dart';
@ -271,10 +272,24 @@ class TourService {
Future<BasicResponseDTO> finishDelivery(String deliveryId) async {
try {
// ISO-8601 mit T-Separator: sprachunabhaengig fuer SQL-Server datetime.
// ('yyyy-MM-dd HH:mm:ss' OHNE T wird unter DATEFORMAT=DMY (DE) als YDM
// geparst und schlaegt fuer Tag > 12 fehl.)
// ERPframe arbeitet mit lokaler Zeit -> bewusst keine UTC-Konvertierung.
final String deliveredAt = DateFormat(
"yyyy-MM-dd'T'HH:mm:ss",
).format(DateTime.now());
var headers = {"Content-Type": "application/json"};
headers.addAll(getSessionOrThrow());
var response = await post(
urlBuilder("_web_finishDelivery"),
headers: getSessionOrThrow(),
body: {"delivery_id": deliveryId},
headers: headers,
body: jsonEncode({
"delivery_id": deliveryId,
"delivered_at": deliveredAt,
}),
);
if (response.statusCode == HttpStatus.unauthorized) {

File diff suppressed because it is too large Load Diff

View File

@ -1,472 +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/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/feature/delivery/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/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';
import '../../../widget/home/bloc/navigation_bloc.dart';
import '../../delivery/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
.where(
(delivery) => delivery.state != DeliveryState.finished,
)
.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();
}
}
// Also count aborted or hold deliveries as "delivered"
final allDeliveredOrAllScanned = tour.deliveries
.where((delivery) => delivery.state != DeliveryState.finished)
.every((delivery) => delivery.allArticlesScanned());
if (allDeliveredOrAllScanned) {
return Padding(
padding: const EdgeInsets.all(25),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 25),
child: Icon(
Icons.check_circle_outline,
size: 72,
color: Theme.of(context).colorScheme.primary,
),
),
Text("Alles erledigt - es gibt nichts mehr zu scannen!"),
Padding(
padding: const EdgeInsets.only(top: 25),
child: FilledButton(
onPressed: () {
Navigator.of(context).pop();
context.read<NavigationBloc>().add(
NavigateToIndex(index: 1),
);
},
child: Text("Tour starten"),
),
),
],
),
),
);
}
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];
// Also count aborted or hold deliveries as "delivered"
final allDeliveredOrAllScanned = state.tour.deliveries
.where((delivery) => delivery.state != DeliveryState.finished)
.every((delivery) => delivery.allArticlesScanned());
return Scaffold(
appBar: AppBar(
title:
allDeliveredOrAllScanned
? Text(
"Artikel scannen",
style: TextStyle(
color: Theme.of(context).colorScheme.onSecondary,
),
)
: 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:
allDeliveredOrAllScanned
? Text("")
: Padding(
padding: const EdgeInsets.all(25),
child: _navigation(
state.tour.deliveries
.where(
(delivery) =>
delivery.state == DeliveryState.ongoing,
)
.toList(),
),
),
body: KeyboardListener(
focusNode: _focusNode,
onKeyEvent: _handleKey,
child: _deliveryStepper(state.tour),
),
);
}
return Container();
},
);
}
}

View File

@ -53,11 +53,8 @@ class _BarcodeScannerWidgetState extends State<BarcodeScannerWidget> {
@override
Widget build(BuildContext context) {
final screenHeight = MediaQuery.of(context).size.height;
final scannerHeight = screenHeight / 4;
return Container(
height: scannerHeight,
height: 150,
decoration: BoxDecoration(
border: Border.all(
color: _isDetected ? Colors.green : Colors.grey,

View File

@ -122,10 +122,9 @@ class _SettingsPage extends State<SettingsPage> {
],
),
appBar: AppBar(
title: Text(
"Einstellungen",
style: Theme.of(context).textTheme.headlineMedium,
),
title: const Text("Einstellungen"),
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Theme.of(context).colorScheme.onSecondary,
),
);
}

View File

@ -3,12 +3,17 @@ import 'package:hl_lieferservice/dto/customer.dart';
import 'address.dart';
class Customer {
const Customer({required this.name, required this.address});
const Customer({required this.name, required this.address, this.email});
final String name;
final Address address;
final String? email;
factory Customer.fromDTO(CustomerDTO dto) {
return Customer(name: dto.name, address: Address.fromDTO(dto.address));
return Customer(
name: dto.name,
address: Address.fromDTO(dto.address),
email: dto.eMail,
);
}
}

View File

@ -60,6 +60,23 @@ class Tour {
.length;
}
/// Returns true if the car still has loaded articles assigned to a delivery
/// that has not been finished yet. Scannable articles count when their
/// effective scanned amount (scanned minus removed) is positive; non-scannable
/// articles count when their target amount is greater than zero.
bool hasUndeliveredLoadedArticles(int carId) {
return deliveries.any((delivery) {
if (delivery.carId != carId) return false;
if (delivery.state == DeliveryState.finished) return false;
return delivery.articles.any((article) {
if (article.scannable) {
return article.scannedAmount > article.scannedRemovedAmount;
}
return article.amount > 0;
});
});
}
Tour copyWith({
DateTime? date,
String? discountArticleNumber,

View File

@ -4,7 +4,13 @@ import 'package:hl_lieferservice/bloc/app_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/authentication/presentation/login_enforcer.dart';
import 'package:hl_lieferservice/feature/authentication/service/userinfo.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/presentation/car_selection_enforcer.dart';
import 'package:hl_lieferservice/feature/car_selection/repository/car_selection_repository.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart';
import 'package:hl_lieferservice/feature/cars/presentation/car_management_page.dart';
import 'package:hl_lieferservice/feature/cars/repository/cars_repository.dart';
import 'package:hl_lieferservice/feature/cars/service/cars_service.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/repository/tour_repository.dart';
import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart';
@ -46,11 +52,23 @@ class _DeliveryAppState extends State<DeliveryApp> {
create:
(context) => TourBloc(
opBloc: context.read<OperationBloc>(),
authBloc: context.read<AuthBloc>(),
tourRepository: TourRepository(
service: TourService(),
),
),
),
BlocProvider(
create: (context) =>
CarSelectBloc(repository: CarSelectionRepository()),
),
BlocProvider(
create: (context) => CarsBloc(
repository: CarsRepository(service: CarService()),
opBloc: context.read<OperationBloc>(),
authBloc: context.read<AuthBloc>(),
),
),
],
child: MaterialApp(
home: OperationViewEnforcer(
@ -67,7 +85,9 @@ class _DeliveryAppState extends State<DeliveryApp> {
}
if (state is AppConfigLoaded) {
return LoginEnforcer(child: Home());
return LoginEnforcer(
child: CarSelectionEnforcer(child: Home()),
);
}
return Container();

View File

@ -7,15 +7,12 @@ 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/overview/presentation/delivery_overview_page.dart';
import 'package:hl_lieferservice/feature/scan/presentation/scan_page.dart';
import 'package:hl_lieferservice/widget/app_bar.dart';
import 'package:hl_lieferservice/feature/car_selection/presentation/selected_car_bar.dart';
import 'package:hl_lieferservice/feature/settings/presentation/settings_page.dart';
import 'package:hl_lieferservice/widget/home/bloc/navigation_bloc.dart';
import 'package:hl_lieferservice/widget/home/bloc/navigation_state.dart';
import 'package:hl_lieferservice/widget/navigation_bar/presentation/navigation_bar.dart';
import '../../../feature/cars/bloc/cars_bloc.dart';
import '../../../feature/cars/repository/cars_repository.dart';
import '../../../feature/cars/service/cars_service.dart';
import '../../operations/bloc/operation_bloc.dart';
class Home extends StatefulWidget {
const Home({super.key});
@ -44,14 +41,11 @@ class _HomeState extends State<Home> {
}
if (index == 2) {
return BlocProvider(
create:
(context) => CarsBloc(
repository: CarsRepository(service: CarService()),
opBloc: context.read<OperationBloc>(),
),
child: CarManagementPage(),
);
return CarManagementPage();
}
if (index == 3) {
return SettingsPage();
}
return Container();
@ -64,12 +58,14 @@ class _HomeState extends State<Home> {
final currentState = state as NavigationInfo;
return Scaffold(
appBar: PreferredSize(
preferredSize: Size.fromHeight(kToolbarHeight),
child: CustomAppBar(),
),
body: _buildPage(currentState.navigationIndex),
bottomNavigationBar: AppNavigationBar(),
bottomNavigationBar: Column(
mainAxisSize: MainAxisSize.min,
children: [
SelectedCarBar(),
AppNavigationBar(),
],
),
);
},
);

View File

@ -32,6 +32,11 @@ class _AppNavigationBarState extends State<AppNavigationBar> {
icon: Icon(Icons.local_shipping),
label: "Fahrzeuge",
),
NavigationDestination(
icon: Icon(Icons.settings_outlined),
selectedIcon: Icon(Icons.settings),
label: "Einstellungen",
),
],
onDestinationSelected: (int index) {
context.read<NavigationBloc>().add(NavigateToIndex(index: index));

View File

@ -4,28 +4,19 @@ import 'package:hl_lieferservice/widget/operations/bloc/operation_state.dart';
class OperationBloc extends Bloc<OperationEvent, OperationState> {
OperationBloc() : super(OperationIdle()) {
on<LoadOperation>(_loadOperation);
on<FailOperation>(_failOperation);
on<FinishOperation>(_finishOperation);
}
Future<void> _loadOperation(LoadOperation event, Emitter<OperationState> emit) async {
emit(OperationLoading());
}
Future<void> _failOperation(FailOperation event, Emitter<OperationState> emit) async {
emit(OperationFailed(message: event.message));
await Future.delayed(Duration(seconds: 5));
await Future.delayed(const Duration(seconds: 5));
emit(OperationIdle());
}
Future<void> _finishOperation(FinishOperation event, Emitter<OperationState> emit) async {
emit(OperationFinished(message: event.message));
await Future.delayed(Duration(seconds: 5));
await Future.delayed(const Duration(seconds: 5));
emit(OperationIdle());
}
}
}

View File

@ -1,7 +1,5 @@
abstract class OperationEvent {}
class LoadOperation extends OperationEvent {}
class FailOperation extends OperationEvent {
String message;
@ -12,4 +10,4 @@ class FinishOperation extends OperationEvent {
String? message;
FinishOperation({this.message});
}
}

View File

@ -2,8 +2,6 @@ abstract class OperationState {}
class OperationIdle extends OperationState {}
class OperationLoading extends OperationState {}
class OperationFailed extends OperationState {
String message;
@ -14,4 +12,4 @@ class OperationFinished extends OperationState {
String? message;
OperationFinished({this.message});
}
}

View File

@ -4,47 +4,21 @@ import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
import '../bloc/operation_state.dart';
/// OperationViewEnforcer
///
/// A view that encapsulates the functionality to react to asynchronous operations.
/// It is capable of showing a loading indicator while an operation is ongoing and it shows
/// a error message if the operation failed.
class OperationViewEnforcer extends StatefulWidget {
/// Listens to [OperationBloc] and shows SnackBars for success and error
/// messages. Loading indicators are handled locally by each feature.
class OperationViewEnforcer extends StatelessWidget {
final Widget child;
const OperationViewEnforcer({super.key, required this.child});
@override
State<OperationViewEnforcer> createState() => _OperationViewEnforcerState();
}
class _OperationViewEnforcerState extends State<OperationViewEnforcer> {
OverlayEntry? _overlayEntry;
@override
void dispose() {
_overlayEntry?.remove();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocListener<OperationBloc, OperationState>(
listener: (context, state) {
if (state is OperationLoading) {
if (_overlayEntry == null) {
_overlayEntry = _createOverlayEntry(context);
Overlay.of(context).insert(_overlayEntry!);
}
} else {
_overlayEntry?.remove();
_overlayEntry = null;
}
if (state is OperationFinished) {
if (state.message != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message!)),
);
}
if (state is OperationFinished && state.message != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message!)),
);
}
if (state is OperationFailed) {
@ -53,20 +27,7 @@ class _OperationViewEnforcerState extends State<OperationViewEnforcer> {
);
}
},
child: widget.child,
child: child,
);
}
OverlayEntry _createOverlayEntry(BuildContext context) {
return OverlayEntry(
builder: (context) => DecoratedBox(
decoration: const BoxDecoration(
color: Color.fromRGBO(128, 128, 128, 0.8),
),
child: const Center(
child: CircularProgressIndicator(color: Colors.white),
),
),
);
}
}
}