fix: gespeicherte Fahrzeugauswahl gegen Server-Fahrzeuge prüfen

Die Tagesauswahl liegt lokal pro Fahrer und wurde ungeprüft
wiederhergestellt. Durch den alten Session-Fehler hatte 414 auf dem
Gerät das Fahrzeug von 423 gespeichert — das erschien weiter, obwohl
„Fahrzeug verwalten" (frisch vom Server) leer war.

CarSelectBloc lädt beim Wiederherstellen die Fahrzeuge des Accounts vom
Server und übernimmt die Auswahl nur, wenn das Fahrzeug dazugehört und
aktiv ist; sonst wird sie verworfen und neu abgefragt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-25 16:53:37 +02:00
parent dce4447fcc
commit a5215367fd
5 changed files with 115 additions and 18 deletions

View File

@ -2,7 +2,7 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter_bloc/flutter_bloc.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/car_selection/repository/car_selection_repository.dart';
import 'package:hl_lieferservice/feature/cars/model/selection.dart'; import 'package:hl_lieferservice/feature/cars/model/selection.dart';
import 'package:hl_lieferservice/domain/entity/car.dart'; import 'package:hl_lieferservice/domain/repository/cars_repository.dart';
import 'events.dart'; import 'events.dart';
import 'state.dart'; import 'state.dart';
@ -10,7 +10,12 @@ import 'state.dart';
class CarSelectBloc extends Bloc<CarSelectEvent, CarSelectState> { class CarSelectBloc extends Bloc<CarSelectEvent, CarSelectState> {
final CarSelectionRepository repository; final CarSelectionRepository repository;
CarSelectBloc({required this.repository}) : super(CarSelectInitial()) { /// Fahrzeuge des angemeldeten Accounts vom Server — gegen sie wird eine
/// gespeicherte Tagesauswahl geprüft.
final CarsRepository carsRepository;
CarSelectBloc({required this.repository, required this.carsRepository})
: super(CarSelectInitial()) {
on<CarSelectLoad>(_load); on<CarSelectLoad>(_load);
on<CarSelectConfirm>(_confirm); on<CarSelectConfirm>(_confirm);
on<CarSelectChange>(_change); on<CarSelectChange>(_change);
@ -36,23 +41,21 @@ class CarSelectBloc extends Bloc<CarSelectEvent, CarSelectState> {
stored.date.day == today.day; stored.date.day == today.day;
if (validForToday) { if (validForToday) {
emit( // Die lokale Auswahl ist nur ein Zeiger. Sie gilt nur, wenn das
CarSelectComplete( // Fahrzeug laut Server (frisch geladen) zu diesem Account gehört
selectedCar: Car( // und aktiv ist — sonst z. B. ein Fahrzeug eines anderen Fahrers,
id: stored.selectedCarId!, // das auf diesem Gerät gewählt wurde.
// accountId/active fließen aus der lokalen Selection final ownCars = await carsRepository.listMine();
// nicht durch — wir persistieren nur (id, plate) als final car = ownCars
// UI-Pointer. Die Tour-Logik holt die vollständigen .where((c) => c.id == stored.selectedCarId && c.active)
// Car-Felder weiterhin aus dem CarsBloc-Listing. .firstOrNull;
accountId: 0, if (car != null) {
plate: stored.selectedCarPlate!, emit(CarSelectComplete(selectedCar: car));
active: true, return;
), }
), await repository.clearSelection(event.userId);
);
} else {
emit(CarSelectRequired());
} }
emit(CarSelectRequired());
} catch (e, st) { } catch (e, st) {
debugPrint('CarSelectBloc._load failed: $e'); debugPrint('CarSelectBloc._load failed: $e');
debugPrint('Stacktrace: $st'); debugPrint('Stacktrace: $st');

View File

@ -49,6 +49,15 @@ class CarSelectionRepository {
); );
} }
/// Verwirft die gespeicherte Auswahl (z. B. Fahrzeug gehört nicht mehr
/// zum Account) — der Fahrer muss neu wählen.
Future<void> clearSelection(String userId) async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyDate(userId));
await prefs.remove(_keyCarId(userId));
await prefs.remove(_keyCarPlate(userId));
}
Future<void> saveSelection(String userId, CarSelection selection) async { Future<void> saveSelection(String userId, CarSelection selection) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();

View File

@ -94,6 +94,7 @@ class _SessionScopeState extends State<SessionScope> {
repository: CarSelectionRepository( repository: CarSelectionRepository(
environmentId: widget.environmentId, environmentId: widget.environmentId,
), ),
carsRepository: CarsRepositoryImpl(locator<HolzleitnerApi>()),
), ),
), ),
BlocProvider( BlocProvider(

View File

@ -0,0 +1,74 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:hl_lieferservice/domain/entity/car.dart';
import 'package:hl_lieferservice/domain/repository/cars_repository.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/repository/car_selection_repository.dart';
/// Fahrzeuge „laut Server" für den angemeldeten Account.
class _ServerCars implements CarsRepository {
_ServerCars(this.cars);
final List<Car> cars;
@override
Future<List<Car>> listMine({bool includeInactive = false}) async => cars;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
const _fremdesAuto = 'b918b721-0000-0000-0000-000000000423';
Map<String, Object> _gespeichert(String userId, String carId) => {
'flutter.car_selection_test_${userId}_date': DateTime.now().toIso8601String(),
'flutter.car_selection_test_${userId}_car_id': carId,
'flutter.car_selection_test_${userId}_car_plate': 'test',
};
Future<CarSelectState> _laden(String userId, List<Car> serverCars) async {
final bloc = CarSelectBloc(
repository: CarSelectionRepository(environmentId: 'test'),
carsRepository: _ServerCars(serverCars),
)..add(CarSelectLoad(userId: userId));
final state = await bloc.stream.firstWhere(
(s) => s is! CarSelectLoading,
);
await bloc.close();
return state;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('gespeichertes Fahrzeug eines anderen Fahrers wird verworfen', () async {
// 414 hat auf diesem Gerät das Auto von 423 als Tagesfahrzeug gespeichert.
SharedPreferences.setMockInitialValues(_gespeichert('414', _fremdesAuto));
final state = await _laden('414', const []);
expect(state, isA<CarSelectRequired>());
final prefs = await SharedPreferences.getInstance();
expect(prefs.getString('car_selection_test_414_car_id'), isNull);
});
test('eigenes aktives Fahrzeug wird übernommen, mit Serverdaten', () async {
SharedPreferences.setMockInitialValues(_gespeichert('423', _fremdesAuto));
const auto = Car(id: _fremdesAuto, accountId: 423, plate: 'test', active: true);
final state = await _laden('423', const [auto]);
expect(state, isA<CarSelectComplete>());
expect((state as CarSelectComplete).selectedCar.accountId, 423);
});
test('deaktiviertes Fahrzeug gilt nicht mehr', () async {
SharedPreferences.setMockInitialValues(_gespeichert('423', _fremdesAuto));
const auto =
Car(id: _fremdesAuto, accountId: 423, plate: 'test', active: false);
expect(await _laden('423', const [auto]), isA<CarSelectRequired>());
});
}

View File

@ -12,8 +12,10 @@ import 'package:hl_lieferservice/feature/authentication/bloc/auth_bloc.dart';
import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart'; import 'package:hl_lieferservice/feature/car_selection/bloc/bloc.dart';
import 'package:hl_lieferservice/feature/delivery/bloc/tour_date_cubit.dart'; import 'package:hl_lieferservice/feature/delivery/bloc/tour_date_cubit.dart';
import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart'; import 'package:hl_lieferservice/feature/payment_methods/bloc/payment_methods_cubit.dart';
import 'package:hl_lieferservice/main.dart' show locator;
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart'; import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
import 'package:hl_lieferservice/widget/session_scope.dart'; import 'package:hl_lieferservice/widget/session_scope.dart';
import 'package:holzleitner_api/holzleitner_api.dart' show HolzleitnerApi;
/// Token-Provider ohne Keycloak: der Test steuert Login/Logout über [emit]. /// Token-Provider ohne Keycloak: der Test steuert Login/Logout über [emit].
class _FakeTokenProvider implements KeycloakOidcTokenProvider { class _FakeTokenProvider implements KeycloakOidcTokenProvider {
@ -45,6 +47,14 @@ AuthLoggedIn _login(int personalnummer) =>
AuthLoggedIn({'personalnummer': personalnummer, 'name': 'Fahrer'}); AuthLoggedIn({'personalnummer': personalnummer, 'name': 'Fahrer'});
void main() { void main() {
// Session-Blocs bauen ihre Repositories gegen den API-Client; im Test
// reicht ein unbenutzter Client (es wird nichts geladen).
setUpAll(() {
if (!locator.isRegistered<HolzleitnerApi>()) {
locator.registerSingleton<HolzleitnerApi>(HolzleitnerApi());
}
});
late _FakeTokenProvider provider; late _FakeTokenProvider provider;
late _CountingPaymentMethods payments; late _CountingPaymentMethods payments;
late List<CarSelectBloc> carSelections; late List<CarSelectBloc> carSelections;