Reklamationen der Fahrer: Meldungen verdeckten Buttons, und nach dem Abschluss stand „Lieferung nicht in der Tour gefunden" ohne Ausweg da. Meldungen: - Erfolgs-SnackBars entfernt, bei Erfolg bleibt der Spinner - einheitliche, schwebende Meldungen statt Stau über den Buttons - Server-Rohtexte durch verständliche Texte ersetzt (api_failure_text) Laden: - fehlt eine geöffnete Lieferung, wird die Tour einmal neu geholt; die Meldung kommt nur nach erfolgreichem Abruf ohne die Lieferung - Endlos-Spinner auf Lieferdetail, Filial-Abholung und Beladen ersetzt durch Kopfzeile, Fehlergrund und „Erneut versuchen" - Tour wird nach einem Tageswechsel nachgeladen, offene Seiten bleiben dabei unberührt - Abschluss und Zuweisung überschreiben kein zwischenzeitliches Neuladen, alte Antworten drehen einen Abschluss nicht mehr zurück - leere Server-Antworten gelten als Fehler statt als „keine Daten" Anmeldung: - Token-Erneuerung wartet höchstens 12 s, der native Aufruf läuft weiter - App-Start ohne Netz zeigt einen Hinweis mit „Erneut versuchen" - Fehlerseite der Fahrzeugauswahl bietet „Abmelden" Außerdem Podfile.lock an die Plugins angeglichen. Tests für die neuen Fehlerpfade ergänzt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
643 lines
20 KiB
Dart
643 lines
20 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:dio/dio.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_appauth/flutter_appauth.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:holzleitner_api/holzleitner_api.dart' show HolzleitnerApi;
|
|
|
|
import 'package:hl_lieferservice/data/cache/attachment_cache.dart';
|
|
import 'package:hl_lieferservice/data/network/auth_session_event.dart';
|
|
import 'package:hl_lieferservice/data/network/auth_token_provider.dart';
|
|
import 'package:hl_lieferservice/data/network/backend_config.dart';
|
|
import 'package:hl_lieferservice/data/network/keycloak_oidc_token_provider.dart';
|
|
import 'package:hl_lieferservice/data/repository/cars_repository_impl.dart';
|
|
import 'package:hl_lieferservice/data/repository/payment_methods_repository_impl.dart';
|
|
import 'package:hl_lieferservice/data/repository/tour_repository_impl.dart';
|
|
import 'package:hl_lieferservice/domain/entity/address.dart';
|
|
import 'package:hl_lieferservice/domain/entity/delivery.dart';
|
|
import 'package:hl_lieferservice/domain/entity/tour.dart';
|
|
import 'package:hl_lieferservice/domain/entity/tour_details.dart';
|
|
import 'package:hl_lieferservice/domain/repository/cars_repository.dart';
|
|
import 'package:hl_lieferservice/domain/repository/payment_methods_repository.dart';
|
|
import 'package:hl_lieferservice/domain/repository/tour_repository.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/bloc/auth_state.dart';
|
|
import 'package:hl_lieferservice/feature/delivery/bloc/tour_bloc.dart';
|
|
import 'package:hl_lieferservice/feature/delivery/bloc/tour_event.dart';
|
|
import 'package:hl_lieferservice/feature/delivery/bloc/tour_state.dart';
|
|
import 'package:hl_lieferservice/feature/delivery/detail/presentation/delivery_detail_page.dart';
|
|
import 'package:hl_lieferservice/feature/delivery/overview/presentation/tour_not_loaded_scaffold.dart';
|
|
import 'package:hl_lieferservice/feature/delivery/pickup/presentation/filiale_pickup_scan_page.dart';
|
|
import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
|
|
|
|
const _gone =
|
|
'Diese Lieferung ist in der geladenen Tour nicht enthalten. '
|
|
'Bitte das Tour-Datum prüfen.';
|
|
const _offline = 'Server nicht erreichbar. Bitte Verbindung prüfen';
|
|
|
|
Delivery _delivery(String id, {DeliveryState state = DeliveryState.active}) =>
|
|
Delivery(
|
|
id: id,
|
|
tourId: 't1',
|
|
customerId: 'c1',
|
|
contactPersonIds: const [],
|
|
deliveryAddressSnapshot: const Address(
|
|
street: 'Konstantinplatz',
|
|
houseNumber: '3',
|
|
postalCode: '41849',
|
|
city: 'Wassenberg',
|
|
country: 'DE',
|
|
),
|
|
erpBelegartId: 24,
|
|
erpBelegnummer: 'V-$id',
|
|
state: state,
|
|
sortOrder: 1,
|
|
prepaidAmount: 0,
|
|
paymentMethodId: 'm-cash',
|
|
items: const [],
|
|
);
|
|
|
|
TourDetails _tour(List<Delivery> deliveries) => TourDetails(
|
|
tour: Tour(
|
|
id: 't1',
|
|
accountId: 414,
|
|
date: DateTime(2026, 10, 30),
|
|
syncedAt: DateTime(2026, 10, 30),
|
|
),
|
|
deliveries: deliveries,
|
|
customers: const {},
|
|
contacts: const {},
|
|
articles: const {},
|
|
warehouses: const {},
|
|
notesByDeliveryId: const {},
|
|
creditsByDeliveryId: const {},
|
|
paymentsByDeliveryId: const {},
|
|
services: const [],
|
|
serviceValuesByDeliveryId: const {},
|
|
contactSourcesByDeliveryId: const {},
|
|
contactChannelsBySourceId: const {},
|
|
);
|
|
|
|
/// Antwortet der Reihe nach mit den vorbereiteten Ergebnissen. Ein
|
|
/// [Completer] bleibt offen, bis der Test ihn abschließt.
|
|
class _ScriptedTours implements TourRepository {
|
|
_ScriptedTours(this.answers);
|
|
|
|
final List<Object?> answers;
|
|
|
|
@override
|
|
Future<TourDetails?> getMyTourDetails({DateTime? date}) {
|
|
final next = answers.removeAt(0);
|
|
if (next is Completer<TourDetails?>) return next.future;
|
|
if (next is Exception) return Future.error(next);
|
|
return Future.value(next as TourDetails?);
|
|
}
|
|
|
|
@override
|
|
Future<Delivery> cancelDelivery({
|
|
required String deliveryId,
|
|
required String reason,
|
|
}) async => _delivery(deliveryId, state: DeliveryState.canceled);
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
class _NoCache implements AttachmentCache {
|
|
@override
|
|
Future<void> retainOnly(Set<String> ids) async {}
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
TourBloc _bloc(List<Object?> answers) => TourBloc(
|
|
tourRepository: _ScriptedTours(answers),
|
|
opBloc: OperationBloc(),
|
|
attachmentCache: _NoCache(),
|
|
);
|
|
|
|
const _networkDown = TourRepositoryException(_offline);
|
|
|
|
/// Lässt die Bloc-Handler laufen, ohne auf ein festes Ereignis zu warten.
|
|
Future<void> _settle() => Future<void>.delayed(const Duration(milliseconds: 5));
|
|
|
|
void main() {
|
|
group('TourBloc bei Verbindungsfehlern', () {
|
|
test('Neuladen scheitert: die alte Tour bleibt mit Fehlertext stehen',
|
|
() async {
|
|
final bloc = _bloc([
|
|
_tour([_delivery('d1')]),
|
|
_networkDown,
|
|
]);
|
|
bloc.add(const LoadTour());
|
|
await _settle();
|
|
bloc.add(const RefreshTour());
|
|
await _settle();
|
|
|
|
final state = bloc.state as TourLoaded;
|
|
expect(state.details.deliveries.single.id, 'd1');
|
|
expect(state.refreshError, _offline);
|
|
expect(state.isRefreshing, isFalse);
|
|
});
|
|
|
|
test('erstes Laden scheitert: Fehlerzustand mit dem Grund', () async {
|
|
final bloc = _bloc([_networkDown]);
|
|
bloc.add(const LoadTour());
|
|
await _settle();
|
|
|
|
expect((bloc.state as TourLoadFailed).message, _offline);
|
|
});
|
|
|
|
test('alte Antwort eines Neuladens dreht einen Abbruch nicht zurück',
|
|
() async {
|
|
final slow = Completer<TourDetails?>();
|
|
final bloc = _bloc([
|
|
_tour([_delivery('d1')]),
|
|
slow,
|
|
]);
|
|
bloc.add(const LoadTour());
|
|
await _settle();
|
|
bloc.add(const RefreshTour());
|
|
await _settle();
|
|
bloc.add(const CancelDelivery(deliveryId: 'd1', reason: 'Kunde fehlt'));
|
|
await _settle();
|
|
|
|
// Die Antwort kam vom Server, bevor er den Abbruch kannte.
|
|
slow.complete(_tour([_delivery('d1')]));
|
|
await _settle();
|
|
|
|
final state = bloc.state as TourLoaded;
|
|
expect(state.details.deliveries.single.state, DeliveryState.canceled);
|
|
expect(state.isRefreshing, isFalse);
|
|
});
|
|
|
|
test('Abbruch überschreibt kein zwischenzeitlich fertiges Neuladen',
|
|
() async {
|
|
final repo = _SlowCancelTours(
|
|
_tour([_delivery('d1')]),
|
|
_tour([_delivery('d1'), _delivery('neu')]),
|
|
);
|
|
final bloc = TourBloc(
|
|
tourRepository: repo,
|
|
opBloc: OperationBloc(),
|
|
attachmentCache: _NoCache(),
|
|
);
|
|
bloc.add(const LoadTour());
|
|
await _settle();
|
|
bloc.add(const CancelDelivery(deliveryId: 'd1', reason: 'Kunde fehlt'));
|
|
await _settle();
|
|
bloc.add(const RefreshTour());
|
|
await _settle();
|
|
repo.cancel.complete(_delivery('d1', state: DeliveryState.canceled));
|
|
await _settle();
|
|
|
|
final deliveries = (bloc.state as TourLoaded).details.deliveries;
|
|
expect(deliveries.map((d) => d.id), ['d1', 'neu']);
|
|
expect(deliveries.first.state, DeliveryState.canceled);
|
|
});
|
|
});
|
|
|
|
group('Unterseiten einer Lieferung', () {
|
|
/// Öffnet [page] über einer Startseite, wie aus der Übersicht heraus.
|
|
Future<void> open(
|
|
WidgetTester tester,
|
|
TourBloc bloc,
|
|
Widget page,
|
|
) async {
|
|
await tester.pumpWidget(
|
|
BlocProvider.value(
|
|
value: bloc,
|
|
child: MaterialApp(
|
|
home: Builder(
|
|
builder: (context) => Scaffold(
|
|
body: TextButton(
|
|
onPressed: () => Navigator.of(context).push(
|
|
MaterialPageRoute<void>(builder: (_) => page),
|
|
),
|
|
child: const Text('Übersicht'),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.tap(find.text('Übersicht'));
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
/// `FailOperation` hält seinen Zustand fünf Sekunden; danach darf im Test
|
|
/// kein Timer mehr offen sein.
|
|
Future<void> finish(WidgetTester tester) =>
|
|
tester.pump(const Duration(seconds: 6));
|
|
|
|
for (final entry in <String, Widget>{
|
|
'Lieferdetail': const DeliveryDetail(deliveryId: 'd1'),
|
|
'Filial-Abholung': const FilialePickupScanPage(deliveryId: 'd1'),
|
|
}.entries) {
|
|
testWidgets(
|
|
'${entry.key}: Fehlschlag zeigt Grund, Neuladen und Zurück, '
|
|
'und fehlt die Lieferung auch im zweiten Abruf, geht es zur Übersicht',
|
|
(tester) async {
|
|
final bloc = _bloc([
|
|
_networkDown,
|
|
_tour(const []),
|
|
_tour(const []),
|
|
]);
|
|
addTearDown(bloc.close);
|
|
|
|
await tester.runAsync(() async {
|
|
bloc.add(const LoadTour());
|
|
await _settle();
|
|
});
|
|
await open(tester, bloc, entry.value);
|
|
|
|
expect(find.text(_offline), findsOneWidget);
|
|
expect(find.byType(BackButton), findsOneWidget);
|
|
expect(find.byType(CircularProgressIndicator), findsNothing);
|
|
|
|
await tester.tap(find.text('Erneut versuchen'));
|
|
await tester.runAsync(_settle);
|
|
await tester.pump();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Übersicht'), findsOneWidget);
|
|
expect(find.text(_gone), findsOneWidget);
|
|
expect(find.textContaining('nicht in der Tour'), findsNothing);
|
|
await tester.pump(const Duration(seconds: 5));
|
|
await tester.pumpAndSettle();
|
|
await finish(tester);
|
|
});
|
|
|
|
testWidgets('${entry.key}: Server meldet keine Tour, zurück zur Übersicht',
|
|
(tester) async {
|
|
final bloc = _bloc([
|
|
_networkDown,
|
|
null,
|
|
null,
|
|
]);
|
|
addTearDown(bloc.close);
|
|
|
|
await tester.runAsync(() async {
|
|
bloc.add(const LoadTour());
|
|
await _settle();
|
|
});
|
|
await open(tester, bloc, entry.value);
|
|
|
|
await tester.tap(find.text('Erneut versuchen'));
|
|
await tester.runAsync(_settle);
|
|
await tester.pump();
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text('Übersicht'), findsOneWidget);
|
|
expect(find.text(_gone), findsOneWidget);
|
|
await tester.pump(const Duration(seconds: 5));
|
|
await tester.pumpAndSettle();
|
|
await finish(tester);
|
|
});
|
|
}
|
|
});
|
|
|
|
group('fehlende Lieferung: erst neu laden, dann melden', () {
|
|
late _CountingTours tours;
|
|
late TourBloc bloc;
|
|
|
|
Future<void> pumpGuarded(WidgetTester tester) async {
|
|
tours = _CountingTours();
|
|
bloc = TourBloc(
|
|
tourRepository: tours,
|
|
opBloc: OperationBloc(),
|
|
attachmentCache: _NoCache(),
|
|
);
|
|
tours.answers.add(_tour([_delivery('d1')]));
|
|
await tester.runAsync(() async {
|
|
bloc.add(const LoadTour());
|
|
await _settle();
|
|
});
|
|
await tester.pumpWidget(
|
|
BlocProvider.value(
|
|
value: bloc,
|
|
child: MaterialApp(
|
|
home: Builder(
|
|
builder: (context) => Scaffold(
|
|
body: TextButton(
|
|
onPressed: () => Navigator.of(context).push(
|
|
MaterialPageRoute<void>(builder: (_) => const _GuardedPage()),
|
|
),
|
|
child: const Text('Übersicht'),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
await tester.tap(find.text('Übersicht'));
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('Lieferung sichtbar'), findsOneWidget);
|
|
}
|
|
|
|
/// Ein Neuladen liefert die vorbereiteten Antworten der Reihe nach.
|
|
Future<void> reload(WidgetTester tester) async {
|
|
bloc.add(const RefreshTour());
|
|
await tester.runAsync(_settle);
|
|
await tester.pump();
|
|
await tester.runAsync(_settle);
|
|
await tester.pump();
|
|
await tester.pump(const Duration(seconds: 1));
|
|
}
|
|
|
|
testWidgets('zweiter Abruf enthält sie: keine Meldung, Seite bleibt',
|
|
(tester) async {
|
|
await pumpGuarded(tester);
|
|
tours.answers
|
|
..add(_tour(const []))
|
|
..add(_tour([_delivery('d1')]));
|
|
|
|
await reload(tester);
|
|
|
|
expect(tours.loads, 3, reason: 'genau ein zusätzlicher Abruf');
|
|
expect(find.text('Lieferung sichtbar'), findsOneWidget);
|
|
expect(find.text(_gone), findsNothing);
|
|
});
|
|
|
|
testWidgets('zweiter Abruf enthält sie nicht: erst dann die Meldung',
|
|
(tester) async {
|
|
await pumpGuarded(tester);
|
|
tours.answers
|
|
..add(_tour(const []))
|
|
..add(_tour(const []));
|
|
|
|
await reload(tester);
|
|
|
|
expect(tours.loads, 3, reason: 'kein dritter Versuch');
|
|
expect(find.text(_gone), findsOneWidget);
|
|
expect(find.text('Übersicht'), findsOneWidget);
|
|
await tester.pump(const Duration(seconds: 5));
|
|
await tester.pumpAndSettle();
|
|
});
|
|
|
|
testWidgets('zweiter Abruf scheitert: keine Meldung, Fehler und Wiederholung',
|
|
(tester) async {
|
|
await pumpGuarded(tester);
|
|
tours.answers
|
|
..add(_tour(const []))
|
|
..add(_networkDown);
|
|
|
|
await reload(tester);
|
|
|
|
expect(find.text(_gone), findsNothing);
|
|
expect(find.text(_offline), findsOneWidget);
|
|
expect(find.text('Erneut versuchen'), findsOneWidget);
|
|
|
|
// Netz ist wieder da und die Lieferung gehört zur Tour.
|
|
tours.answers.add(_tour([_delivery('d1')]));
|
|
await tester.tap(find.text('Erneut versuchen'));
|
|
await tester.runAsync(_settle);
|
|
await tester.pump();
|
|
|
|
expect(find.text('Lieferung sichtbar'), findsOneWidget);
|
|
expect(find.text(_gone), findsNothing);
|
|
});
|
|
|
|
testWidgets('später erneut verschwunden: wieder ein zusätzlicher Abruf',
|
|
(tester) async {
|
|
await pumpGuarded(tester);
|
|
tours.answers
|
|
..add(_tour(const []))
|
|
..add(_tour([_delivery('d1')]));
|
|
await reload(tester);
|
|
|
|
tours.answers
|
|
..add(_tour(const []))
|
|
..add(_tour([_delivery('d1')]));
|
|
await reload(tester);
|
|
|
|
expect(tours.loads, 5);
|
|
expect(find.text('Lieferung sichtbar'), findsOneWidget);
|
|
});
|
|
});
|
|
|
|
group('leere Server-Antworten sind Fehler', () {
|
|
HolzleitnerApi emptyApi() {
|
|
final dio = Dio()
|
|
..interceptors.add(
|
|
InterceptorsWrapper(
|
|
onRequest: (options, handler) => handler.resolve(
|
|
Response<Object>(requestOptions: options, statusCode: 200),
|
|
),
|
|
),
|
|
);
|
|
return HolzleitnerApi(dio: dio, interceptors: const []);
|
|
}
|
|
|
|
test('Tour', () {
|
|
expect(
|
|
TourRepositoryImpl(emptyApi()).getMyTourSummary(),
|
|
throwsA(isA<TourRepositoryException>()),
|
|
);
|
|
});
|
|
|
|
test('Fahrzeuge', () {
|
|
expect(
|
|
CarsRepositoryImpl(emptyApi()).listMine(),
|
|
throwsA(isA<CarsRepositoryException>()),
|
|
);
|
|
});
|
|
|
|
test('Zahlungsmethoden', () {
|
|
expect(
|
|
PaymentMethodsRepositoryImpl(emptyApi()).list(),
|
|
throwsA(isA<PaymentMethodsRepositoryException>()),
|
|
);
|
|
});
|
|
});
|
|
|
|
group('Token-Erneuerung', () {
|
|
KeycloakOidcTokenProvider provider(_HangingAppAuth appAuth) =>
|
|
KeycloakOidcTokenProvider(
|
|
config: const BackendConfig(
|
|
apiBaseUrl: 'http://localhost',
|
|
keycloakIssuerUrl: 'http://localhost/realms/x',
|
|
keycloakClientId: 'app',
|
|
keycloakRedirectUrl: 'holzleitner://oauth2redirect',
|
|
),
|
|
issuerUrl: 'http://localhost/realms/x',
|
|
redirectUrl: 'holzleitner://oauth2redirect',
|
|
appAuth: appAuth,
|
|
storage: _StoredRefreshToken(),
|
|
refreshTimeout: const Duration(milliseconds: 10),
|
|
);
|
|
|
|
test('hängender Refresh hält den Request nicht auf', () async {
|
|
final auth = provider(_HangingAppAuth());
|
|
expect(
|
|
await auth.restoreSession(),
|
|
SessionRestoreResult.temporarilyUnavailable,
|
|
);
|
|
await expectLater(
|
|
auth.currentAccessToken(),
|
|
throwsA(isA<AuthTemporarilyUnavailableException>()),
|
|
);
|
|
});
|
|
|
|
test('später Abschluss wird übernommen, ohne zweiten Refresh', () async {
|
|
final appAuth = _HangingAppAuth();
|
|
final auth = provider(appAuth);
|
|
await auth.restoreSession();
|
|
expect(appAuth.calls, 1);
|
|
|
|
appAuth.finish(
|
|
TokenResponse(
|
|
'access-neu',
|
|
'refresh-neu',
|
|
DateTime.now().add(const Duration(hours: 1)),
|
|
null,
|
|
null,
|
|
null,
|
|
null,
|
|
),
|
|
);
|
|
await _settle();
|
|
|
|
expect(await auth.currentAccessToken(), 'access-neu');
|
|
expect(appAuth.calls, 1);
|
|
});
|
|
});
|
|
|
|
group('Sitzung beim App-Start', () {
|
|
AuthBloc authBloc(_ScriptedRestore provider) =>
|
|
AuthBloc(tokenProvider: provider, operationBloc: OperationBloc());
|
|
|
|
test('ohne Verbindung: Login-Seite mit Hinweis, Session bleibt', () async {
|
|
final bloc = authBloc(
|
|
_ScriptedRestore([SessionRestoreResult.temporarilyUnavailable]),
|
|
);
|
|
bloc.add(const RestoreSessionRequested());
|
|
await _settle();
|
|
|
|
expect((bloc.state as Unauthenticated).restoreUnavailable, isTrue);
|
|
});
|
|
|
|
test('ohne Session: normale Login-Seite ohne Hinweis', () async {
|
|
final bloc = authBloc(_ScriptedRestore([SessionRestoreResult.noSession]));
|
|
bloc.add(const RestoreSessionRequested());
|
|
await _settle();
|
|
|
|
expect((bloc.state as Unauthenticated).restoreUnavailable, isFalse);
|
|
});
|
|
|
|
test('erneuter Versuch zeigt wieder den Start-Bildschirm', () async {
|
|
final bloc = authBloc(
|
|
_ScriptedRestore([
|
|
SessionRestoreResult.temporarilyUnavailable,
|
|
SessionRestoreResult.restored,
|
|
]),
|
|
);
|
|
bloc.add(const RestoreSessionRequested());
|
|
await _settle();
|
|
bloc.add(const RestoreSessionRequested());
|
|
await _settle();
|
|
|
|
expect(bloc.state, isA<AuthBootstrapping>());
|
|
});
|
|
});
|
|
}
|
|
|
|
class _CountingTours extends _ScriptedTours {
|
|
_CountingTours() : super([]);
|
|
|
|
int loads = 0;
|
|
|
|
@override
|
|
Future<TourDetails?> getMyTourDetails({DateTime? date}) {
|
|
loads++;
|
|
return super.getMyTourDetails(date: date);
|
|
}
|
|
}
|
|
|
|
/// Kleinstmögliche Seite mit dem Guard. Die echten Seiten brauchen für eine
|
|
/// vorhandene Lieferung den ganzen Bloc-Baum.
|
|
class _GuardedPage extends StatefulWidget {
|
|
const _GuardedPage();
|
|
|
|
@override
|
|
State<_GuardedPage> createState() => _GuardedPageState();
|
|
}
|
|
|
|
class _GuardedPageState extends State<_GuardedPage> with DeliveryPresenceGuard {
|
|
@override
|
|
String get guardedDeliveryId => 'd1';
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocConsumer<TourBloc, TourState>(
|
|
listener: deliveryStillPresent,
|
|
builder: (context, state) {
|
|
final present = state is TourLoaded &&
|
|
state.details.deliveries.any((d) => d.id == guardedDeliveryId);
|
|
if (!present) {
|
|
return TourNotLoadedScaffold(title: 'Lieferung', state: state);
|
|
}
|
|
return const Scaffold(body: Text('Lieferung sichtbar'));
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SlowCancelTours extends _ScriptedTours {
|
|
_SlowCancelTours(TourDetails first, TourDetails second)
|
|
: super([first, second]);
|
|
|
|
final cancel = Completer<Delivery>();
|
|
|
|
@override
|
|
Future<Delivery> cancelDelivery({
|
|
required String deliveryId,
|
|
required String reason,
|
|
}) => cancel.future;
|
|
}
|
|
|
|
class _HangingAppAuth implements FlutterAppAuth {
|
|
final _result = Completer<TokenResponse>();
|
|
int calls = 0;
|
|
|
|
void finish(TokenResponse response) => _result.complete(response);
|
|
|
|
@override
|
|
Future<TokenResponse> token(TokenRequest request) {
|
|
calls++;
|
|
return _result.future;
|
|
}
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|
|
|
|
class _StoredRefreshToken implements FlutterSecureStorage {
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) {
|
|
if (invocation.memberName == #read) return Future<String?>.value('rt');
|
|
if (invocation.memberName == #write) return Future<void>.value();
|
|
return super.noSuchMethod(invocation);
|
|
}
|
|
}
|
|
|
|
class _ScriptedRestore implements KeycloakOidcTokenProvider {
|
|
_ScriptedRestore(this.results);
|
|
|
|
final List<SessionRestoreResult> results;
|
|
|
|
@override
|
|
Stream<AuthSessionEvent> get events => const Stream.empty();
|
|
|
|
@override
|
|
Future<SessionRestoreResult> restoreSession() async => results.removeAt(0);
|
|
|
|
@override
|
|
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
|
}
|