Added components to article

This commit is contained in:
Dennis Nemec
2026-05-11 17:12:05 +02:00
parent 2470299a10
commit ac6b03227d
37 changed files with 1189 additions and 513 deletions

View File

@ -71,28 +71,31 @@ class _DeliveryAppState extends State<DeliveryApp> {
),
],
child: MaterialApp(
home: OperationViewEnforcer(
child: BlocBuilder<AppBloc, AppState>(
builder: (context, state) {
if (state is AppConfigLoading) {
return Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
// Wrap the Navigator (not just the home route) so the loading
// overlay covers every pushed route — DeliveryDetail, Cars,
// dialogs, etc. — not only the initial home tree.
builder: (context, child) =>
OperationViewEnforcer(child: child ?? const SizedBox.shrink()),
home: BlocBuilder<AppBloc, AppState>(
builder: (context, state) {
if (state is AppConfigLoading) {
return Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
if (state is AppConfigLoadingFailed) {
return Scaffold(body: Center(child: Text(state.message)));
}
if (state is AppConfigLoadingFailed) {
return Scaffold(body: Center(child: Text(state.message)));
}
if (state is AppConfigLoaded) {
return LoginEnforcer(
child: CarSelectionEnforcer(child: Home()),
);
}
if (state is AppConfigLoaded) {
return LoginEnforcer(
child: CarSelectionEnforcer(child: Home()),
);
}
return Container();
},
),
return Container();
},
),
routes: {"/cars": (context) => CarManagementPage()},
),

View File

@ -3,20 +3,79 @@ import 'package:hl_lieferservice/widget/operations/bloc/operation_event.dart';
import 'package:hl_lieferservice/widget/operations/bloc/operation_state.dart';
class OperationBloc extends Bloc<OperationEvent, OperationState> {
/// Counts how many in-flight mutations want to show the blocking overlay.
/// Allows multiple parallel mutations without one prematurely closing the
/// overlay before the others complete.
int _inFlightCount = 0;
/// When the current overlay session began (set when [_inFlightCount]
/// transitions 0 → 1). Used to enforce [_minimumDisplayDuration].
DateTime? _overlayStartedAt;
/// Minimum time the overlay stays visible, even if the underlying request
/// completes faster. Prevents a "did anything happen?" UX where a sub-100 ms
/// roundtrip flashes the overlay for one frame.
static const Duration _minimumDisplayDuration = Duration(milliseconds: 350);
OperationBloc() : super(OperationIdle()) {
on<StartOperation>(_startOperation);
on<FailOperation>(_failOperation);
on<FinishOperation>(_finishOperation);
}
Future<void> _failOperation(FailOperation event, Emitter<OperationState> emit) async {
emit(OperationFailed(message: event.message));
await Future.delayed(const Duration(seconds: 5));
emit(OperationIdle());
Future<void> _startOperation(
StartOperation event,
Emitter<OperationState> emit,
) async {
if (_inFlightCount == 0) {
_overlayStartedAt = DateTime.now();
}
_inFlightCount += 1;
emit(OperationInProgress(message: event.message));
}
Future<void> _finishOperation(FinishOperation event, Emitter<OperationState> emit) async {
emit(OperationFinished(message: event.message));
Future<void> _finishOperation(
FinishOperation event,
Emitter<OperationState> emit,
) async {
_inFlightCount = (_inFlightCount - 1).clamp(0, 1 << 30);
if (event.message != null) {
emit(OperationFinished(message: event.message));
await Future.delayed(const Duration(seconds: 5));
}
if (_inFlightCount > 0) {
emit(OperationInProgress());
} else {
await _awaitMinimumOverlayDuration();
_overlayStartedAt = null;
emit(OperationIdle());
}
}
Future<void> _failOperation(
FailOperation event,
Emitter<OperationState> emit,
) async {
_inFlightCount = (_inFlightCount - 1).clamp(0, 1 << 30);
emit(OperationFailed(message: event.message));
await Future.delayed(const Duration(seconds: 5));
emit(OperationIdle());
if (_inFlightCount > 0) {
emit(OperationInProgress());
} else {
_overlayStartedAt = null;
emit(OperationIdle());
}
}
Future<void> _awaitMinimumOverlayDuration() async {
final startedAt = _overlayStartedAt;
if (startedAt == null) return;
final elapsed = DateTime.now().difference(startedAt);
if (elapsed < _minimumDisplayDuration) {
await Future.delayed(_minimumDisplayDuration - elapsed);
}
}
}

View File

@ -1,5 +1,11 @@
abstract class OperationEvent {}
class StartOperation extends OperationEvent {
String? message;
StartOperation({this.message});
}
class FailOperation extends OperationEvent {
String message;

View File

@ -2,6 +2,12 @@ abstract class OperationState {}
class OperationIdle extends OperationState {}
class OperationInProgress extends OperationState {
String? message;
OperationInProgress({this.message});
}
class OperationFailed extends OperationState {
String message;

View File

@ -4,8 +4,11 @@ import 'package:hl_lieferservice/widget/operations/bloc/operation_bloc.dart';
import '../bloc/operation_state.dart';
/// Listens to [OperationBloc] and shows SnackBars for success and error
/// messages. Loading indicators are handled locally by each feature.
/// Listens to [OperationBloc] and shows:
/// - SnackBars for success and error messages.
/// - A blocking modal barrier with a spinner while a mutation is in flight,
/// so the user gets unambiguous "wait" feedback and cannot double-tap or
/// navigate away mid-request.
class OperationViewEnforcer extends StatelessWidget {
final Widget child;
@ -13,7 +16,7 @@ class OperationViewEnforcer extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocListener<OperationBloc, OperationState>(
return BlocConsumer<OperationBloc, OperationState>(
listener: (context, state) {
if (state is OperationFinished && state.message != null) {
ScaffoldMessenger.of(context).showSnackBar(
@ -27,7 +30,44 @@ class OperationViewEnforcer extends StatelessWidget {
);
}
},
child: child,
builder: (context, state) {
final isInProgress = state is OperationInProgress;
final progressMessage =
isInProgress ? state.message : null;
return Stack(
children: [
child,
if (isInProgress)
PopScope(
canPop: false,
child: Stack(
children: [
const ModalBarrier(
dismissible: false,
color: Colors.black54,
),
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
if (progressMessage != null) ...[
const SizedBox(height: 16),
Text(
progressMessage,
style: const TextStyle(color: Colors.white),
),
],
],
),
),
],
),
),
],
);
},
);
}
}