Phase C+D-1: Cars-Domain auf Rust-Backend umgestellt

Clean-Arch-Schichten für Cars:
- lib/domain/entity/car.dart: UUID-id, accountId (Personalnummer),
  plate, active. Pendant zum Backend-Schema.
- lib/domain/repository/cars_repository.dart: Port — listMine,
  create, update. Keine teamId/personalnummer-Parameter, der
  Account fließt serverseitig aus dem JWT.
- lib/data/mapper/car_mapper.dart: API-DTO (built_value) → Domain.
- lib/data/repository/cars_repository_impl.dart: konkrete Impl via
  generierter CarsApi (dio), mit DioException → CarsRepositoryException-
  Übersetzung.

Feature-Cars-Refactoring:
- CarsBloc nimmt jetzt die Domain-Repository-Schnittstelle. Events:
  CarLoad/CarAdd/CarEdit/CarDeactivate (statt CarDelete). Keine
  teamId-Parameter mehr. Kein authBloc-Bezug, Session-Expiry läuft
  über den globalen Provider-Stream.
- CarsState sealed mit CarsInitial/Loading/LoadingFailed/Loaded.
- Pages: car_management_page, car_management, car_card, car_fail_page,
  car_selection_page komplett auf die neue Entity und Event-Signaturen.
- Alte lib/feature/cars/service/cars_service.dart und
  lib/feature/cars/repository/cars_repository.dart gelöscht.

CarSelectBloc + Storage:
- CarSelection.selectedCarId von int? auf String? umgestellt.
- CarSelectionRepository persistiert die UUID jetzt als String;
  defensive Migration für noch vorhandene int-Werte (alte
  Pre-Migration-Installations) verwirft den Wert leise und
  erzwingt Neuauswahl.

Konsequenz-Cleanup im Tour-Code (Phase-D-Vorbereitung):
- Delivery.carId String? statt int?.
- Tour.hasUndeliveredLoadedArticles / getFinishedDeliveries auf
  String carId.
- _selectedCarId / int? carId / int selectedCarId in DeliveryOverview,
  LoadingCustomerPage/OverviewPage, Home, DeliverySelection/SortPage,
  DeliveryInfo/List, CustomSortDialog, SortableDeliveryList auf
  String umgestellt.
- TourRepository ersetzt int.parse(carId)/int.tryParse-Zuweisungen
  direkt durch String.
- lib/model/car.dart wird zum Re-Export der neuen Domain-Entity,
  damit Legacy-Imports während Phase-D-Übergang weiter compilieren.

DI:
- app.dart: CarsBloc bekommt CarsRepositoryImpl(locator<HolzleitnerApi>())
  statt der alten CarsRepository(service: CarService()).

Build (flutter build apk --debug) durch, flutter analyze ohne
errors.
This commit is contained in:
Dennis Nemec
2026-05-15 11:55:24 +02:00
parent e369d1ceb2
commit 3ecbc82885
34 changed files with 477 additions and 456 deletions

View File

@ -0,0 +1,23 @@
import 'package:holzleitner_api/holzleitner_api.dart' as api;
import 'package:hl_lieferservice/domain/entity/car.dart';
/// Mapper zwischen dem generierten API-DTO `api.Car` (built_value)
/// und der Domain-Entity [Car].
///
/// Nur Read-Mapper hier — Create/Update geht über die Request-DTOs
/// `api.CreateCarRequest` / `api.UpdateCarRequest`, die die Repository-
/// Impl direkt zusammenbaut.
extension ApiCarMapper on api.Car {
Car toDomain() => Car(
id: id,
accountId: accountId,
plate: plate,
active: active,
);
}
/// Liste-Variante als Convenience.
extension ApiCarIterableMapper on Iterable<api.Car> {
List<Car> toDomainList() => map((c) => c.toDomain()).toList(growable: false);
}

View File

@ -0,0 +1,96 @@
import 'package:dio/dio.dart';
import 'package:holzleitner_api/holzleitner_api.dart' as api;
import 'package:hl_lieferservice/data/mapper/car_mapper.dart';
import 'package:hl_lieferservice/domain/entity/car.dart';
import 'package:hl_lieferservice/domain/repository/cars_repository.dart';
/// Spricht den generierten Holzleitner-API-Client an und mappt die
/// `built_value`-DTOs auf die Domain-Entity.
///
/// Fehler aus dem Backend werden in [CarsRepositoryException]
/// übersetzt — der Bloc kennt die HTTP-Schicht nicht. 401 fliegt
/// ungefangen durch und wird vom übergreifenden
/// HolzleitnerAuthInterceptor-Pfad behandelt (Provider-Stream meldet
/// `AuthSessionExpired` bei Refresh-Failure).
class CarsRepositoryImpl implements CarsRepository {
CarsRepositoryImpl(this._api);
final api.HolzleitnerApi _api;
@override
Future<List<Car>> listMine({bool includeInactive = false}) async {
try {
final response = await _api.getCarsApi().listMyCars(
includeInactive: includeInactive,
);
final cars = response.data?.cars;
if (cars == null) return const [];
return cars.toDomainList();
} on DioException catch (e) {
throw CarsRepositoryException(_describe(e, 'Laden der Fahrzeuge'), e);
}
}
@override
Future<Car> create({required String plate}) async {
try {
final request =
api.CreateCarRequest((b) => b..plate = plate);
final response =
await _api.getCarsApi().createMyCar(createCarRequest: request);
final car = response.data?.car;
if (car == null) {
throw const CarsRepositoryException(
'Server lieferte leere Antwort beim Anlegen',
);
}
return car.toDomain();
} on DioException catch (e) {
throw CarsRepositoryException(_describe(e, 'Anlegen eines Fahrzeugs'), e);
}
}
@override
Future<Car> update({
required String carId,
String? plate,
bool? active,
}) async {
try {
final request = api.UpdateCarRequest((b) {
if (plate != null) b.plate = plate;
if (active != null) b.active = active;
});
final response = await _api.getCarsApi().updateMyCar(
carId: carId,
updateCarRequest: request,
);
final car = response.data?.car;
if (car == null) {
throw const CarsRepositoryException(
'Server lieferte leere Antwort beim Aktualisieren',
);
}
return car.toDomain();
} on DioException catch (e) {
throw CarsRepositoryException(
_describe(e, 'Aktualisieren eines Fahrzeugs'),
e,
);
}
}
/// Übersetzt eine DioException in eine kompakte, UI-taugliche
/// Begründung. Mehr Detail landet im Log, nicht in der Snackbar.
String _describe(DioException e, String operation) {
final status = e.response?.statusCode;
final body = e.response?.data;
if (status == 400 && body is Map && body['message'] != null) {
return '$operation fehlgeschlagen: ${body['message']}';
}
if (status == 401) return 'Sitzung abgelaufen';
if (status == 404) return 'Fahrzeug nicht gefunden';
return '$operation fehlgeschlagen (HTTP ${status ?? 'unbekannt'})';
}
}

View File

@ -0,0 +1,56 @@
/// Fahrzeug eines Subunternehmer-Accounts — Domain-Entity.
///
/// Im Gegensatz zum alten `lib/model/car.dart` (int-ID,
/// ERPframe-Welt) hält die neue Entity:
/// * `id` als UUID-String (Backend-Konvention),
/// * `accountId` als Personalnummer (für Audit/Cross-Check, ist
/// redundant zur JWT-Identität aber explizit im Payload),
/// * `active`-Flag (Soft-Delete statt physisches Löschen).
class Car {
const Car({
required this.id,
required this.accountId,
required this.plate,
required this.active,
});
/// UUID des Fahrzeugs.
final String id;
/// Personalnummer des Account-Inhabers.
final int accountId;
/// Kennzeichen (z. B. "BGL-HZ 100").
final String plate;
/// Inaktive Fahrzeuge tauchen in `GET /me/cars` standardmäßig
/// nicht auf — sie bleiben aber als FK-Anker für historische
/// Audit-Einträge in der Datenbank.
final bool active;
Car copyWith({String? id, int? accountId, String? plate, bool? active}) {
return Car(
id: id ?? this.id,
accountId: accountId ?? this.accountId,
plate: plate ?? this.plate,
active: active ?? this.active,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is Car &&
runtimeType == other.runtimeType &&
id == other.id &&
accountId == other.accountId &&
plate == other.plate &&
active == other.active;
@override
int get hashCode => Object.hash(id, accountId, plate, active);
@override
String toString() =>
'Car(id: $id, accountId: $accountId, plate: $plate, active: $active)';
}

View File

@ -0,0 +1,43 @@
import 'package:hl_lieferservice/domain/entity/car.dart';
/// Port für Fahrzeug-Stammdaten des angemeldeten Fahrers.
///
/// Bewusst **kein** `personalnummer`/`teamId`-Parameter: der Account
/// wird serverseitig aus dem JWT abgeleitet, der Client muss nichts
/// mitschicken. Eine Methode, die einen Account-Filter ermöglicht,
/// ist konsequent nicht vorgesehen.
///
/// Implementierungen werfen anwendungs-spezifische Exceptions
/// (z. B. `CarsRepositoryException`) — der Aufrufer fängt sie und
/// übersetzt in UI-Zustand.
abstract interface class CarsRepository {
/// Liste der Fahrzeuge des angemeldeten Accounts.
/// `includeInactive=false` blendet deaktivierte Fahrzeuge aus
/// (Default für die App-UI).
Future<List<Car>> listMine({bool includeInactive = false});
/// Legt ein neues Fahrzeug mit dem gegebenen Kennzeichen an.
/// Wirft, wenn das Kennzeichen für den Account schon existiert.
Future<Car> create({required String plate});
/// Aktualisiert ein bestehendes Fahrzeug.
/// Beide Optional-Parameter `null` ist ein No-Op-PATCH.
/// Soft-Delete erfolgt über `update(carId: ..., active: false)`.
Future<Car> update({
required String carId,
String? plate,
bool? active,
});
}
/// Allgemeine Repository-Exception. Konkrete Implementierungen
/// können spezifischere Subtypen werfen (z. B. `CarsUnauthorized`).
class CarsRepositoryException implements Exception {
const CarsRepositoryException(this.message, [this.cause]);
final String message;
final Object? cause;
@override
String toString() => 'CarsRepositoryException: $message';
}

View File

@ -40,7 +40,13 @@ class CarSelectBloc extends Bloc<CarSelectEvent, CarSelectState> {
CarSelectComplete(
selectedCar: Car(
id: stored.selectedCarId!,
// accountId/active fließen aus der lokalen Selection
// nicht durch — wir persistieren nur (id, plate) als
// UI-Pointer. Die Tour-Logik holt die vollständigen
// Car-Felder weiterhin aus dem CarsBloc-Listing.
accountId: 0,
plate: stored.selectedCarPlate!,
active: true,
),
),
);

View File

@ -33,19 +33,15 @@ class _CarSelectionPageState extends State<CarSelectionPage> {
void initState() {
super.initState();
_selectedCar = widget.previousCar;
final authState = context.read<AuthBloc>().state as Authenticated;
context.read<CarsBloc>().add(CarLoad(teamId: authState.user.number));
context.read<CarsBloc>().add(const CarLoad());
}
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),
);
context.read<CarsBloc>().add(CarAdd(plate: plate));
},
),
);
@ -97,12 +93,9 @@ class _CarSelectionPageState extends State<CarSelectionPage> {
);
}
final authState = context.read<AuthBloc>().state as Authenticated;
return RefreshIndicator(
onRefresh: () async {
context.read<CarsBloc>().add(
CarLoad(teamId: authState.user.number, force: true),
);
context.read<CarsBloc>().add(const CarLoad(force: true));
},
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
@ -207,14 +200,9 @@ class _CarSelectionPageState extends State<CarSelectionPage> {
const SizedBox(height: 16),
FilledButton(
onPressed: () {
final authState =
context.read<AuthBloc>().state
as Authenticated;
context.read<CarsBloc>().add(
CarLoad(
teamId: authState.user.number,
),
);
const CarLoad(force: true),
);
},
child: const Text("Erneut versuchen"),
),

View File

@ -1,21 +1,34 @@
import 'package:hl_lieferservice/feature/cars/model/selection.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Persistiert die Tagesfahrzeug-Auswahl lokal auf dem Gerät pro
/// Account. Nach der Backend-Migration sind die Car-IDs UUIDs
/// (Strings). Alte Pre-Phase-D-Installations können noch int-Werte
/// unter `_car_id` liegen haben — die werden beim Lesen
/// stillschweigend ignoriert (Migration durch "neu auswählen").
class CarSelectionRepository {
static String _keyDate(String userId) => 'car_selection_${userId}_date';
static String _keyCarId(String userId) => 'car_selection_${userId}_car_id';
static String _keyCarPlate(String userId) =>
'car_selection_${userId}_car_plate';
/// Returns the stored [CarSelection] for the given user, or null if nothing
/// has been saved yet for that user.
Future<CarSelection?> getSelection(String userId) async {
final prefs = await SharedPreferences.getInstance();
final dateString = prefs.getString(_keyDate(userId));
final carId = prefs.getInt(_keyCarId(userId));
final plate = prefs.getString(_keyCarPlate(userId));
// Versuche zuerst die neue String-Variante. Falls noch ein altes
// int unter dem Key liegt (pre-Migration), wirft getString —
// dann beste Strategie: alte Daten droppen, Re-Auswahl erzwingen.
String? carId;
try {
carId = prefs.getString(_keyCarId(userId));
} catch (_) {
await prefs.remove(_keyCarId(userId));
carId = null;
}
if (dateString == null || carId == null || plate == null) return null;
return CarSelection(
@ -25,12 +38,11 @@ class CarSelectionRepository {
);
}
/// Persists the given [selection] for the given user locally on this device.
Future<void> saveSelection(String userId, CarSelection selection) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_keyDate(userId), selection.date.toIso8601String());
await prefs.setInt(_keyCarId(userId), selection.selectedCarId!);
await prefs.setString(_keyCarId(userId), selection.selectedCarId!);
await prefs.setString(_keyCarPlate(userId), selection.selectedCarPlate!);
}
}

View File

@ -1,125 +1,125 @@
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/domain/entity/car.dart';
import 'package:hl_lieferservice/domain/repository/cars_repository.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
import '../../../model/car.dart';
import 'cars_event.dart';
import 'cars_state.dart';
/// Bloc für die Fahrzeugverwaltung des angemeldeten Fahrers.
///
/// `personalnummer`/`teamId` wird **nicht mehr** in den Events
/// übergeben — der Account kommt serverseitig aus dem JWT.
/// Session-Expiry geht über den globalen Provider-Stream
/// (KeycloakOidcTokenProvider → AuthSessionExpired), nicht über
/// dieses Bloc.
class CarsBloc extends Bloc<CarEvents, CarsState> {
CarsRepository repository;
OperationBloc opBloc;
AuthBloc authBloc;
CarsBloc({required this.repository, required this.opBloc, required this.authBloc})
: super(CarsInitial()) {
on<CarAdd>(_carAdd);
on<CarEdit>(_carEdit);
on<CarDelete>(_carDelete);
on<CarLoad>(_carLoad);
CarsBloc({required this.repository, required this.opBloc})
: super(const CarsInitial()) {
on<CarLoad>(_handleLoad);
on<CarAdd>(_handleAdd);
on<CarEdit>(_handleEdit);
on<CarDeactivate>(_handleDeactivate);
}
void _handleError(Object e, String fallbackMessage) {
if (e is UserUnauthorized) {
authBloc.add(SessionExpiredEvent());
} else {
opBloc.add(FailOperation(message: fallbackMessage));
}
}
final CarsRepository repository;
final OperationBloc opBloc;
Future<void> _carLoad(CarLoad event, Emitter<CarsState> emit) async {
// Skip the API call if cars are already loaded and no force-refresh requested.
Future<void> _handleLoad(CarLoad event, Emitter<CarsState> emit) async {
if (state is CarsLoaded && !event.force) return;
emit(const CarsLoading());
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());
final cars = await repository.listMine();
emit(CarsLoaded(cars: cars));
} on CarsRepositoryException catch (e) {
emit(CarsLoadingFailed(message: e.message));
} catch (_) {
emit(const CarsLoadingFailed());
}
}
Future<void> _carAdd(CarAdd event, Emitter<CarsState> emit) async {
final currentState = state;
Future<void> _handleAdd(CarAdd event, Emitter<CarsState> emit) async {
final current = state;
opBloc.add(StartOperation());
try {
Car newCar = await repository.add(event.teamId, event.plate);
if (currentState is CarsLoaded) {
emit(
currentState.copyWith(
cars: List<Car>.from(currentState.cars)..add(newCar),
),
);
final created = await repository.create(plate: event.plate);
if (current is CarsLoaded) {
emit(current.copyWith(cars: [...current.cars, created]));
}
opBloc.add(FinishOperation(message: "Auto erfolgreich hinzugefügt"));
} catch (e) {
_handleError(e, "Fehler beim Hinzufügen eines Autos");
opBloc.add(FinishOperation(message: "Fahrzeug hinzugefügt"));
} on CarsRepositoryException catch (e) {
opBloc.add(FailOperation(message: e.message));
} catch (_) {
opBloc.add(
FailOperation(message: "Fehler beim Anlegen eines Fahrzeugs"),
);
}
}
Future<void> _carEdit(CarEdit event, Emitter<CarsState> emit) async {
final currentState = state;
Future<void> _handleEdit(CarEdit event, Emitter<CarsState> emit) async {
final current = state;
opBloc.add(StartOperation());
try {
await repository.edit(event.teamId, event.newCar);
if (currentState is CarsLoaded) {
emit(
currentState.copyWith(
cars:
List<Car>.from(currentState.cars).map((car) {
if (car.id == event.newCar.id) {
return event.newCar;
}
return car;
}).toList(),
),
);
}
opBloc.add(FinishOperation(message: "Auto erfolgreich editiert"));
} catch (e) {
_handleError(e, "Fehler beim Editieren des Autos");
final updated = await repository.update(
carId: event.carId,
plate: event.plate,
active: event.active,
);
_emitReplaced(current, updated, emit);
opBloc.add(FinishOperation(message: "Fahrzeug aktualisiert"));
} on CarsRepositoryException catch (e) {
opBloc.add(FailOperation(message: e.message));
} catch (_) {
opBloc.add(
FailOperation(message: "Fehler beim Aktualisieren des Fahrzeugs"),
);
}
}
Future<void> _carDelete(CarDelete event, Emitter<CarsState> emit) async {
final currentState = state;
Future<void> _handleDeactivate(
CarDeactivate event,
Emitter<CarsState> emit,
) async {
final current = state;
opBloc.add(StartOperation());
try {
await repository.delete(event.carId, event.teamId);
if (currentState is CarsLoaded) {
await repository.update(carId: event.carId, active: false);
if (current is CarsLoaded) {
// Inaktive Fahrzeuge raus aus der UI-Liste — die Liste zeigt
// nur aktive (Default).
emit(
CarsLoaded(
cars: [
...currentState.cars.where(
(car) => car.id != int.parse(event.carId),
),
],
teamId: currentState.teamId,
current.copyWith(
cars: current.cars
.where((c) => c.id != event.carId)
.toList(growable: false),
),
);
}
opBloc.add(FinishOperation(message: "Auto erfolgreich gelöscht"));
} catch (e) {
_handleError(e, "Fehler beim Löschen des Autos");
opBloc.add(FinishOperation(message: "Fahrzeug deaktiviert"));
} on CarsRepositoryException catch (e) {
opBloc.add(FailOperation(message: e.message));
} catch (_) {
opBloc.add(
FailOperation(message: "Fehler beim Deaktivieren des Fahrzeugs"),
);
}
}
void _emitReplaced(
CarsState current,
Car updated,
Emitter<CarsState> emit,
) {
if (current is! CarsLoaded) return;
emit(
current.copyWith(
cars: current.cars
.map((c) => c.id == updated.id ? updated : c)
.toList(growable: false),
),
);
}
}

View File

@ -1,34 +1,35 @@
import '../../../model/car.dart';
abstract class CarEvents {}
class CarLoad extends CarEvents {
String 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});
/// Events des CarsBloc nach der Backend-Migration.
///
/// Anders als zuvor brauchen die Events **keinen** `teamId`/
/// `personalnummer`-Parameter — der Server leitet den Account aus dem
/// JWT ab. Statt `CarDelete` gibt's nur noch `CarDeactivate` (Soft-
/// Delete via `active=false`).
sealed class CarEvents {
const CarEvents();
}
class CarEdit extends CarEvents {
Car newCar;
String teamId;
CarEdit({required this.newCar, required this.teamId});
class CarLoad extends CarEvents {
/// Pull-to-Refresh setzt `force=true` und umgeht den Cache.
final bool force;
const CarLoad({this.force = false});
}
class CarAdd extends CarEvents {
String teamId;
String plate;
CarAdd({required this.teamId, required this.plate});
final String plate;
const CarAdd({required this.plate});
}
class CarDelete extends CarEvents {
String carId;
String teamId;
/// PATCH: Kennzeichen ändern und/oder aktivieren-Status setzen.
class CarEdit extends CarEvents {
final String carId;
final String? plate;
final bool? active;
const CarEdit({required this.carId, this.plate, this.active});
}
CarDelete({required this.carId, required this.teamId});
}
/// Soft-Delete (setzt `active=false`). Hartes Löschen ist nicht
/// vorgesehen — Fahrzeuge bleiben als FK-Anker für Audit-Einträge.
class CarDeactivate extends CarEvents {
final String carId;
const CarDeactivate({required this.carId});
}

View File

@ -1,38 +1,26 @@
import 'package:hl_lieferservice/model/car.dart';
import 'package:hl_lieferservice/domain/entity/car.dart';
abstract class CarsState {}
class CarsInitial extends CarsState {}
class CarsLoading extends CarsState {}
class CarsLoadingFailed extends CarsState {}
class CarAdded extends CarsState {
Car car;
CarAdded({required this.car});
sealed class CarsState {
const CarsState();
}
class CarDeleted extends CarsState {
String plate;
CarDeleted({required this.plate});
class CarsInitial extends CarsState {
const CarsInitial();
}
class CarEdited extends CarsState {
Car car;
class CarsLoading extends CarsState {
const CarsLoading();
}
CarEdited({required this.car});
class CarsLoadingFailed extends CarsState {
const CarsLoadingFailed({this.message});
final String? message;
}
class CarsLoaded extends CarsState {
List<Car> cars;
String teamId;
const CarsLoaded({required this.cars});
final List<Car> cars;
CarsLoaded({required this.cars, required this.teamId});
CarsLoaded copyWith({List<Car>? cars, String? teamId}) {
return CarsLoaded(cars: cars ?? this.cars, teamId: teamId ?? this.teamId);
}
CarsLoaded copyWith({List<Car>? cars}) =>
CarsLoaded(cars: cars ?? this.cars);
}

View File

@ -1,9 +1,9 @@
/*
Settings for the driver to select a car for the current workday.
*/
/// Lokal persistierte Fahrzeug-Auswahl des Fahrers für den aktuellen
/// Arbeitstag. UUID-basiert (Backend-Konvention).
class CarSelection {
final DateTime date;
final int? selectedCarId;
/// UUID des heute ausgewählten Fahrzeugs.
final String? selectedCarId;
final String? selectedCarPlate;
CarSelection({

View File

@ -1,7 +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/feature/authentication/bloc/auth_state.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_bloc.dart';
import 'package:hl_lieferservice/feature/cars/bloc/cars_event.dart';
@ -9,8 +8,7 @@ class CarsLoadingFailedPage extends StatelessWidget {
const CarsLoadingFailedPage({super.key});
void _onRetry(BuildContext context) {
Authenticated state = context.read<AuthBloc>().state as Authenticated;
context.read<CarsBloc>().add(CarLoad(teamId: state.user.number));
context.read<CarsBloc>().add(const CarLoad(force: true));
}
@override
@ -21,9 +19,13 @@ class CarsLoadingFailedPage extends StatelessWidget {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 72, color: Theme.of(context).colorScheme.error,),
Padding(
padding: const EdgeInsets.only(top: 30),
Icon(
Icons.error_outline,
size: 72,
color: Theme.of(context).colorScheme.error,
),
const Padding(
padding: EdgeInsets.only(top: 30),
child: Text(
"Leider ist es beim Laden der Fahrzeuge zu einem Fehler gekommen.",
),
@ -32,7 +34,7 @@ class CarsLoadingFailedPage extends StatelessWidget {
padding: const EdgeInsets.only(top: 30),
child: FilledButton(
onPressed: () => _onRetry(context),
child: Text("Erneut versuchen"),
child: const Text("Erneut versuchen"),
),
),
],

View File

@ -1,12 +1,13 @@
import 'package:hl_lieferservice/feature/cars/presentation/car_card.dart';
import '../../../model/car.dart';
import 'car_dialog.dart';
import 'package:flutter/material.dart';
import 'package:hl_lieferservice/domain/entity/car.dart';
import 'package:hl_lieferservice/feature/cars/presentation/car_card.dart';
import 'package:hl_lieferservice/feature/cars/presentation/car_dialog.dart';
class CarManagementOverview extends StatefulWidget {
final List<Car> cars;
final int? selectedCarId;
/// UUID des aktuell für heute ausgewählten Fahrzeugs (oder `null`).
final String? selectedCarId;
final Function(String plate) onAdd;
final Function(String id) onDelete;
final Function(String id, String plate) onEdit;
@ -36,12 +37,12 @@ class _CarManagementOverviewState extends State<CarManagementOverview> {
);
}
void _removeCar(Car car) async {
widget.onDelete(car.id.toString());
void _removeCar(Car car) {
widget.onDelete(car.id);
}
void _editCar(Car car, String newName) async {
widget.onEdit(car.id.toString(), newName);
void _editCar(Car car, String newName) {
widget.onEdit(car.id, newName);
}
@override

View File

@ -1,7 +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/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';
@ -9,10 +8,6 @@ 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_fail_page.dart';
import 'package:hl_lieferservice/feature/cars/presentation/car_management.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
import 'package:hl_lieferservice/model/car.dart';
class CarManagementPage extends StatefulWidget {
const CarManagementPage({super.key});
@ -22,38 +17,34 @@ class CarManagementPage extends StatefulWidget {
}
class _CarManagementPageState extends State<CarManagementPage> {
late Authenticated _authState;
@override
void initState() {
super.initState();
// Load cars
_authState = context.read<AuthBloc>().state as Authenticated;
context.read<CarsBloc>().add(CarLoad(teamId: _authState.user.number));
// Account-Identifizierung läuft serverseitig über den JWT — keine
// teamId mehr aus dem AuthState extrahieren.
context.read<CarsBloc>().add(const CarLoad());
}
void _add(String plate) {
context.read<CarsBloc>().add(
CarAdd(teamId: _authState.user.number, plate: plate),
);
context.read<CarsBloc>().add(CarAdd(plate: plate));
}
Future<void> _refresh() async {
context.read<CarsBloc>().add(CarLoad(teamId: _authState.user.number, force: true));
context.read<CarsBloc>().add(const CarLoad(force: true));
}
void _remove(String id) {
final carId = int.parse(id);
void _remove(String carId) {
// Schutz: wenn dieses Fahrzeug aktuell für heute ausgewählt ist,
// darf es nicht deaktiviert werden.
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.",
"Dieses Fahrzeug ist aktuell ausgewählt und kann nicht "
"deaktiviert werden. Bitte wähle zuerst ein anderes Fahrzeug "
"aus.",
),
duration: Duration(seconds: 4),
),
@ -61,68 +52,32 @@ class _CarManagementPageState extends State<CarManagementPage> {
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;
}
// TODO Phase D: Schutz wieder einhängen, sobald TourBloc/Domain
// mit UUID-Car-Ids arbeiten und prüfen, ob das Fahrzeug noch
// beladene, nicht ausgelieferte Artikel hat.
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),
);
context.read<CarsBloc>().add(CarDeactivate(carId: carId));
}
void _edit(String id, String plate) {
context.read<CarsBloc>().add(
CarEdit(
newCar: Car(id: int.parse(id), plate: plate),
teamId: _authState.user.number,
),
);
void _edit(String carId, String plate) {
context.read<CarsBloc>().add(CarEdit(carId: carId, plate: plate));
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: BlocConsumer<CarsBloc, CarsState>(
listener: (context, state) {
if (state is CarsLoaded &&
context.read<TourBloc>().state is TourLoaded) {
context.read<TourBloc>().add(CarsLoadedEvent(cars: state.cars));
}
},
body: BlocBuilder<CarsBloc, CarsState>(
builder: (context, state) {
if (state is CarsLoading) {
return Center(child: const CircularProgressIndicator());
if (state is CarsLoading || state is CarsInitial) {
return const Center(child: CircularProgressIndicator());
}
if (state is CarsLoaded) {
return BlocBuilder<CarSelectBloc, CarSelectState>(
builder: (context, selectState) {
final int? selectedCarId = selectState is CarSelectComplete
? selectState.selectedCar.id
: null;
final String? selectedCarId =
selectState is CarSelectComplete
? selectState.selectedCar.id
: null;
return CarManagementOverview(
cars: state.cars,
selectedCarId: selectedCarId,
@ -134,12 +89,10 @@ class _CarManagementPageState extends State<CarManagementPage> {
},
);
}
if (state is CarsLoadingFailed) {
return CarsLoadingFailedPage();
return const CarsLoadingFailedPage();
}
return Container();
return const SizedBox.shrink();
},
),
);

View File

@ -1,25 +0,0 @@
import 'package:hl_lieferservice/feature/cars/service/cars_service.dart';
import '../../../model/car.dart';
class CarsRepository {
CarService service;
CarsRepository({required this.service});
Future<List<Car>> getAll(String teamId) async {
return service.getCars(int.parse(teamId));
}
Future<void> delete(String carId, String teamId) async {
return service.removeCar(int.parse(carId), int.parse(teamId));
}
Future<void> edit(String teamId, Car newCar) async {
return service.editCar(newCar);
}
Future<Car> add(String teamId, String plate) async {
return service.addCar(plate, int.parse(teamId));
}
}

View File

@ -1,141 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:hl_lieferservice/feature/authentication/exceptions.dart';
import 'package:hl_lieferservice/util.dart';
import 'package:http/http.dart';
import '../../../dto/basic_response.dart';
import '../../../dto/car_add_response.dart';
import '../../../dto/car_get_response.dart';
import '../../../model/car.dart';
class CarService {
CarService();
Future<Car> addCar(String plate, int teamId) async {
try {
debugPrint(jsonEncode({"team_id": teamId.toString(), "plate": plate}));
var response = await post(
urlBuilder("_web_addCar"),
headers: getSessionOrThrow(),
body: {"team_id": teamId.toString(), "plate": plate},
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> responseJson = jsonDecode(response.body);
CarAddResponseDTO responseDto = CarAddResponseDTO.fromJson(responseJson);
if (responseDto.succeeded == true) {
return Car(
id: int.parse(responseDto.car.id),
plate: responseDto.car.plate,
);
} else {
throw responseDto.message;
}
} catch (e, st) {
debugPrint("ERROR WHILE ADDING CAR");
debugPrint(e.toString());
debugPrint(st.toString());
rethrow;
}
}
Future<void> editCar(Car car) async {
try {
var response = await post(
urlBuilder("_web_editCar"),
headers: getSessionOrThrow(),
body: {"id": car.id.toString(), "plate": car.plate},
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> responseJson = jsonDecode(response.body);
BasicResponseDTO responseDto = BasicResponseDTO.fromJson(responseJson);
if (responseDto.succeeded == true) {
return;
} else {
throw responseDto.message;
}
} catch (e, st) {
debugPrint("ERROR WHILE EDITING CAR ${car.id}");
debugPrint("$e");
debugPrint(st.toString());
rethrow;
}
}
Future<void> removeCar(int carId, int teamId) async {
try {
var response = await post(
urlBuilder("_web_removeCar"),
headers: getSessionOrThrow(),
body: {"team_id": teamId.toString(), "id": carId.toString()},
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> responseJson = jsonDecode(response.body);
BasicResponseDTO responseDto = BasicResponseDTO.fromJson(responseJson);
if (responseDto.succeeded == true) {
return;
} else {
throw responseDto.message;
}
} catch (e, st) {
debugPrint("ERROR WHILE REMOVING CAR");
debugPrint(e.toString());
debugPrint(st.toString());
rethrow;
}
}
Future<List<Car>> getCars(int teamId) async {
try {
debugPrint(teamId.toString());
var response = await post(
urlBuilder("_web_getCars"),
headers: getSessionOrThrow(),
body: {"team_id": teamId.toString()},
);
if (response.statusCode == HttpStatus.unauthorized) {
throw UserUnauthorized();
}
Map<String, dynamic> responseJson = jsonDecode(response.body);
CarGetResponseDTO responseDto = CarGetResponseDTO.fromJson(responseJson);
if (responseDto.succeeded == true) {
return responseDto.cars!
.map((carDto) => Car(id: int.parse(carDto.id), plate: carDto.plate))
.toList();
} else {
throw responseDto.message;
}
} catch (e, st) {
debugPrint("ERROR WHILE FETCHING CARS");
debugPrint(e.toString());
debugPrint(st.toString());
rethrow;
}
}
}

View File

@ -5,7 +5,7 @@ import 'package:intl/intl.dart';
class DeliveryInfo extends StatelessWidget {
final Tour tour;
final int? selectedCarId;
final String? selectedCarId;
const DeliveryInfo({super.key, required this.tour, this.selectedCarId});

View File

@ -9,7 +9,7 @@ import 'package:hl_lieferservice/model/delivery.dart';
import 'delivery_item.dart';
class DeliveryList extends StatefulWidget {
final int? selectedCarId;
final String? selectedCarId;
final SortType sortType;
const DeliveryList({super.key, this.selectedCarId, required this.sortType});

View File

@ -27,7 +27,7 @@ class DeliveryOverview extends StatefulWidget {
}
class _DeliveryOverviewState extends State<DeliveryOverview> {
int? _selectedCarId;
String? _selectedCarId;
late SortType _sortType;
@override

View File

@ -9,7 +9,7 @@ import 'package:hl_lieferservice/model/delivery.dart';
class CustomSortDialog extends StatefulWidget {
const CustomSortDialog({super.key, this.selectedCarId});
final int? selectedCarId;
final String? selectedCarId;
@override
State<StatefulWidget> createState() => _CustomSortDialogState();

View File

@ -32,7 +32,7 @@ class DeliverySelectionPage extends StatefulWidget {
/// ID des aktuell gewählten Fahrzeugs (Eigene Lieferungen / Ziel von
/// Übernahmen).
final int selectedCarId;
final String selectedCarId;
@override
State<StatefulWidget> createState() => _DeliverySelectionPageState();
@ -52,7 +52,7 @@ class _DeliverySelectionPageState extends State<DeliverySelectionPage> {
/// Sucht das Plate eines Autos in der Tour-Driver-Liste. Liefert "?" als
/// Fallback, falls die Zuordnung nicht (mehr) im Team enthalten ist —
/// z. B. nach Personalwechsel zwischen Tour-Synchronisationen.
String _plateFor(int? carId, Tour tour) {
String _plateFor(String? carId, Tour tour) {
if (carId == null) return "?";
final car = tour.driver.cars.firstWhereOrNull((c) => c.id == carId);
return car?.plate ?? "?";

View File

@ -22,7 +22,7 @@ class DeliverySortPage extends StatefulWidget {
this.onPhaseAdvanced,
});
final int selectedCarId;
final String selectedCarId;
/// Optionaler Hook, damit die übergeordnete Routing-Stelle nach Erfolg
/// auf die nächste Phase wechseln kann (rerender der Beladungs-Page).

View File

@ -21,7 +21,7 @@ class SortableDeliveryList extends StatefulWidget {
this.controller,
});
final int? selectedCarId;
final String? selectedCarId;
/// Optionaler Controller zum Zurücksetzen der Liste durch Eltern-Widgets
/// (z. B. Button "Zurücksetzen" in der Page).

View File

@ -48,7 +48,7 @@ class TourRepository {
final index = tour.deliveries.indexWhere(
(delivery) => delivery.id == deliveryId,
);
tour.deliveries[index].carId = int.parse(carId);
tour.deliveries[index].carId = carId;
_tourStream.add(tour);
}
@ -80,7 +80,7 @@ class TourRepository {
if (article.scannedAmount < article.amount) {
article.scannedAmount += 1;
delivery.carId = int.tryParse(carId) ?? delivery.carId;
delivery.carId = carId;
await service.assignCar(deliveryId, carId);
_tourStream.add(tour);
return ScanResult.scanned;
@ -126,7 +126,7 @@ class TourRepository {
if (parentArticle.isFullyScanned) {
await service.scanArticle(parentArticle.internalId.toString());
parentArticle.scannedAmount += 1;
delivery.carId = int.tryParse(carId) ?? delivery.carId;
delivery.carId = carId;
await service.assignCar(deliveryId, carId);
}

View File

@ -126,8 +126,17 @@ class TourService {
cars:
responseDto.driver.cars
.map(
(carDto) =>
Car(id: int.parse(carDto.id), plate: carDto.plate),
// Legacy: alte ERPframe-CarDto hat int-IDs, neue
// Domain-Entity erwartet UUID-Strings. Wir
// stringifizieren die int-ID und füllen
// accountId/active mit Stub-Werten — der ganze
// Service wird in Phase D entfernt.
(carDto) => Car(
id: carDto.id,
accountId: 0,
plate: carDto.plate,
active: true,
),
)
.toList(),
teamNumber: int.parse(responseDto.driver.id),

View File

@ -82,7 +82,7 @@ class _LoadingCustomerPageState extends State<LoadingCustomerPage> {
/// Aktuell gewähltes Fahrzeug. Wird über den CarSelectBloc synchronisiert,
/// einmalig in initState bevor erster build.
int? _selectedCarId;
String? _selectedCarId;
/// Erkennt den Übergang "Lieferung läuft → abgeschlossen", damit der
/// Listener auch dann robust reagiert, wenn der TourBloc zwischendurch
@ -244,7 +244,7 @@ class _LoadingCustomerPageState extends State<LoadingCustomerPage> {
// Datenaufbau
// ---------------------------------------------------------------------------
String? _lookupCarPlate(int? carId, Tour tour) {
String? _lookupCarPlate(String? carId, Tour tour) {
if (carId == null) return null;
return tour.driver.cars.firstWhereOrNull((c) => c.id == carId)?.plate;
}

View File

@ -22,7 +22,7 @@ import 'package:hl_lieferservice/widget/phase_stepper/phase_stepper.dart';
class LoadingOverviewPage extends StatelessWidget {
const LoadingOverviewPage({super.key});
String? _lookupCarPlate(int? carId, Tour tour) {
String? _lookupCarPlate(String? carId, Tour tour) {
if (carId == null) return null;
return tour.driver.cars.firstWhereOrNull((c) => c.id == carId)?.plate;
}

View File

@ -1,6 +1,6 @@
class Car {
Car({required this.id, required this.plate});
int id;
String plate;
}
/// Backward-compat-Re-Export: alle Aufrufer, die noch
/// `package:hl_lieferservice/model/car.dart` importieren, bekommen
/// jetzt die neue Domain-Entity. Mit Phase D wandert dieses File raus,
/// sobald die letzten Legacy-Aufrufer (tour_service.dart,
/// tour_event.dart) auf die echte Domain-Schicht umgestellt sind.
export 'package:hl_lieferservice/domain/entity/car.dart';

View File

@ -202,7 +202,10 @@ class Delivery implements Comparable<Delivery> {
DeliveryState state;
String? specialAgreements;
PaymentOptions paymentOptions;
int? carId;
/// UUID des zugewiesenen Fahrzeugs (Backend-Konvention).
/// War vor der Backend-Migration `int?` — die Konversion ist Teil
/// der Phase-D-Vorbereitung.
String? carId;
List<Note> notes;
List<ImageNote> images;
double prepayment;
@ -230,7 +233,7 @@ class Delivery implements Comparable<Delivery> {
DeliveryState? state,
String? specialAgreements,
PaymentOptions? paymentOptions,
int? carId,
String? carId,
List<Note>? notes,
List<ImageNote>? images,
double? prepayment,
@ -371,7 +374,10 @@ class Delivery implements Comparable<Delivery> {
),
paymentAtDelivery: double.tryParse(dto.paymentAtDelivery) ?? 0.0,
images: dto.images.map(ImageNote.fromDTO).toList(),
carId: int.tryParse(dto.carId),
// Legacy: ERPframe-Backend liefert int-Strings; im neuen Backend
// sind das UUID-Strings. Beide werden hier transparent
// weitergereicht.
carId: dto.carId.isEmpty ? null : dto.carId,
totalGrossValue: double.parse(
dto.totalGrossValue == "" ? "0" : dto.totalGrossValue,
),

View File

@ -52,7 +52,7 @@ class Tour {
Map<String, List<Delivery>> deliveriesPerCar;
int getFinishedDeliveries(int carId) {
int getFinishedDeliveries(String carId) {
return deliveries
.where((delivery) => delivery.carId == carId)
.where((delivery) => delivery.state == DeliveryState.finished)
@ -64,7 +64,7 @@ class Tour {
/// 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) {
bool hasUndeliveredLoadedArticles(String carId) {
return deliveries.any((delivery) {
if (delivery.carId != carId) return false;
if (delivery.state == DeliveryState.finished) return false;

View File

@ -9,10 +9,10 @@ import 'package:hl_lieferservice/main.dart' show locator;
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/data/repository/cars_repository_impl.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:holzleitner_api/holzleitner_api.dart' show HolzleitnerApi;
import 'package:hl_lieferservice/feature/delivery/bloc/phase_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
@ -69,10 +69,13 @@ class _DeliveryAppState extends State<DeliveryApp> {
CarSelectBloc(repository: CarSelectionRepository()),
),
BlocProvider(
// Phase-D-Migration: produktive CarsRepository-Impl
// gegen das generierte Rust-Backend-API. Account-Filter
// serverseitig aus dem JWT, deshalb braucht der Bloc
// keinen AuthBloc-Bezug mehr.
create: (context) => CarsBloc(
repository: CarsRepository(service: CarService()),
repository: CarsRepositoryImpl(locator<HolzleitnerApi>()),
opBloc: context.read<OperationBloc>(),
authBloc: context.read<AuthBloc>(),
),
),
BlocProvider(

View File

@ -108,7 +108,7 @@ class _HomeState extends State<Home> {
Widget _buildForPhase(
BuildContext context,
DeliveryPhase phase,
int selectedCarId,
String selectedCarId,
) {
switch (phase) {
case DeliveryPhase.auswaehlen: