Added components to article
This commit is contained in:
@ -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()},
|
||||
),
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
abstract class OperationEvent {}
|
||||
|
||||
class StartOperation extends OperationEvent {
|
||||
String? message;
|
||||
|
||||
StartOperation({this.message});
|
||||
}
|
||||
|
||||
class FailOperation extends OperationEvent {
|
||||
String message;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user