better loading indicators for timetables, talk and files

This commit is contained in:
2026-05-05 21:07:48 +02:00
parent bee5c02a4f
commit db9c3386f1
25 changed files with 439 additions and 203 deletions
@@ -17,7 +17,21 @@ class LoadableStateConsumer<TController extends Bloc<LoadableHydratedBlocEvent<T
final Widget Function(TState state, bool loading) child;
final void Function(TState state)? onLoad;
final bool wrapWithScrollView;
const LoadableStateConsumer({required this.child, this.onLoad, this.wrapWithScrollView = false, super.key});
/// Optional predicate for callers whose [TState] always contains a non-null
/// envelope but where actual content (e.g. a nested response) is loaded
/// lazily. When provided, this overrides the default `data != null` check
/// so primary loading / error screens / content visibility correctly reflect
/// whether the inner content is ready.
final bool Function(TState state)? isReady;
const LoadableStateConsumer({
required this.child,
this.onLoad,
this.wrapWithScrollView = false,
this.isReady,
super.key,
});
static Duration animationDuration = const Duration(milliseconds: 200);
@@ -30,6 +44,16 @@ class LoadableStateConsumer<TController extends Bloc<LoadableHydratedBlocEvent<T
WidgetsBinding.instance.addPostFrameCallback((timeStamp) => onLoad!(loadedData));
}
final typedData = loadedData is TState ? loadedData : null;
final hasContent = typedData != null && (isReady?.call(typedData) ?? loadableState.showContent());
final hasError = loadableState.error != null;
final isLoading = loadableState.isLoading;
final showPrimaryLoading = isLoading && !hasContent;
final showBackgroundLoading = isLoading && hasContent;
final showError = hasError && !hasContent;
final showErrorBar = hasError && hasContent;
var childWidget = ConditionalWrapper(
condition: loadableState.reFetch != null,
wrapper: (child) => RefreshIndicator(
@@ -48,8 +72,8 @@ class LoadableStateConsumer<TController extends Bloc<LoadableHydratedBlocEvent<T
),
child: SizedBox(
height: MediaQuery.of(context).size.height,
child: loadableState.showContent() && loadedData is TState
? child(loadedData, loadableState.isLoading)
child: hasContent
? child(typedData as TState, isLoading)
: const SizedBox.shrink(),
),
);
@@ -60,16 +84,21 @@ class LoadableStateConsumer<TController extends Bloc<LoadableHydratedBlocEvent<T
bloc.reFetch = loadableState.reFetch;
return Column(
children: [
LoadableStateErrorBar(visible: loadableState.showErrorBar(), message: loadableState.error?.message, lastUpdated: loadableState.lastFetch),
LoadableStateErrorBar(
visible: showErrorBar,
hasContent: hasContent,
message: loadableState.error?.message,
lastUpdated: loadableState.lastFetch,
),
Expanded(
child: Stack(
children: [
LoadableStatePrimaryLoading(visible: loadableState.showPrimaryLoading()),
LoadableStateBackgroundLoading(visible: loadableState.showBackgroundLoading()),
LoadableStateErrorScreen(visible: loadableState.showError(), message: loadableState.error?.message),
LoadableStatePrimaryLoading(visible: showPrimaryLoading),
LoadableStateBackgroundLoading(visible: showBackgroundLoading),
LoadableStateErrorScreen(visible: showError, message: loadableState.error?.message),
AnimatedOpacity(
opacity: loadableState.showContent() ? 1.0 : 0.0,
opacity: hasContent ? 1.0 : 0.0,
duration: animationDuration,
curve: Curves.easeInOut,
child: childWidget,
@@ -8,49 +8,62 @@ import '../bloc/loadable_state_bloc.dart';
class LoadableStateErrorBar extends StatelessWidget {
final bool visible;
final bool hasContent;
final String? message;
final int? lastUpdated;
const LoadableStateErrorBar({required this.visible, this.message, this.lastUpdated, super.key});
const LoadableStateErrorBar({
required this.visible,
this.hasContent = false,
this.message,
this.lastUpdated,
super.key,
});
final Duration animationDuration = const Duration(milliseconds: 200);
@override
Widget build(BuildContext context) => AnimatedSize(
duration: animationDuration,
child: AnimatedSwitcher(
duration: animationDuration,
transitionBuilder: (Widget child, Animation<double> animation) => SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, -1.0),
end: Offset.zero,
).animate(animation),
child: child,
),
child: Visibility(
key: Key(visible.hashCode.toString()),
visible: visible,
replacement: const SizedBox(width: double.infinity),
child: Builder(
builder: (context) {
var bloc = context.watch<LoadableStateBloc>();
return InkWell(
onTap: () {
if(!bloc.isConnected()) return;
InfoDialog.show(context, 'Exception: ${message.toString()}');
},
child: Container(
height: 20,
decoration: BoxDecoration(
color: bloc.connectionColor(context),
Widget build(BuildContext context) {
final bloc = context.watch<LoadableStateBloc>();
final isOfflineWithCache = hasContent && bloc.connectivityStatusKnown() && !bloc.isConnected();
final shouldShow = visible || isOfflineWithCache;
return AnimatedSize(
duration: animationDuration,
child: AnimatedSwitcher(
duration: animationDuration,
transitionBuilder: (Widget child, Animation<double> animation) => SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, -1.0),
end: Offset.zero,
).animate(animation),
child: child,
),
child: Visibility(
key: Key(shouldShow.hashCode.toString()),
visible: shouldShow,
replacement: const SizedBox(width: double.infinity),
child: Builder(
builder: (context) {
var bloc = context.watch<LoadableStateBloc>();
return InkWell(
onTap: () {
if(!bloc.isConnected()) return;
InfoDialog.show(context, 'Exception: ${message.toString()}');
},
child: Container(
height: 20,
decoration: BoxDecoration(
color: bloc.connectionColor(context),
),
child: LoadableStateErrorBarText(lastUpdated: lastUpdated),
),
child: LoadableStateErrorBarText(lastUpdated: lastUpdated),
),
);
},
)
)
),
);
);
},
)
)
),
);
}
}
class LoadableStateErrorBarText extends StatefulWidget {