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 {
+36 -12
View File
@@ -1,3 +1,5 @@
import '../../../../../api/marianumcloud/talk/chat/getChatResponse.dart';
import '../../../infrastructure/loadableState/loading_error.dart';
import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart';
import '../repository/chat_repository.dart';
@@ -22,8 +24,11 @@ class ChatBloc extends LoadableHydratedBloc<ChatEvent, ChatState, ChatRepository
@override
Future<void> gatherData() async {
final token = innerState?.currentToken ?? '';
if (token.isEmpty) return;
_loadChat(token);
if (token.isEmpty) {
add(DataGathered((s) => s));
return;
}
await _loadChat(token);
}
void setToken(String token) {
@@ -32,6 +37,7 @@ class ChatBloc extends LoadableHydratedBloc<ChatEvent, ChatState, ChatRepository
return;
}
add(Emit((s) => s.copyWith(currentToken: token, chatResponse: null)));
add(RefetchStarted<ChatState>());
_loadChat(token);
}
@@ -41,19 +47,37 @@ class ChatBloc extends LoadableHydratedBloc<ChatEvent, ChatState, ChatRepository
void refresh() {
final token = innerState?.currentToken ?? '';
if (token.isNotEmpty) _loadChat(token);
if (token.isEmpty) return;
add(RefetchStarted<ChatState>());
_loadChat(token);
}
void _loadChat(String token) {
Future<void> _loadChat(String token) async {
final requestStart = DateTime.now();
_lastTokenSet = requestStart;
repo.data.getChat(
token: token,
onUpdate: (data) {
if (_lastTokenSet.isAfter(requestStart)) return;
if ((innerState?.currentToken ?? '') != token) return;
add(DataGathered((s) => s.copyWith(chatResponse: data)));
},
);
Object? capturedError;
GetChatResponse? response;
try {
response = await repo.data.getChat(
token: token,
onError: (e) => capturedError = e,
);
} catch (e) {
capturedError = e;
}
if (_lastTokenSet.isAfter(requestStart)) return;
if ((innerState?.currentToken ?? '') != token) return;
if (response != null) {
add(DataGathered((s) => s.copyWith(chatResponse: response)));
}
if (capturedError != null) {
add(Error(LoadingError(
message: capturedError.toString(),
allowRetry: true,
)));
}
}
}
@@ -2,10 +2,26 @@ import '../../../../../api/marianumcloud/talk/chat/getChatCache.dart';
import '../../../../../api/marianumcloud/talk/chat/getChatResponse.dart';
class ChatDataProvider {
void getChat({
Future<GetChatResponse> getChat({
required String token,
required void Function(GetChatResponse data) onUpdate,
}) {
GetChatCache(chatToken: token, onUpdate: onUpdate);
void Function(GetChatResponse data)? onUpdate,
void Function(Object)? onError,
}) async {
GetChatResponse? latest;
Object? capturedError;
final cache = GetChatCache(
chatToken: token,
onUpdate: (data) {
latest = data;
onUpdate?.call(data);
},
onError: (e) {
capturedError = e;
onError?.call(e);
},
);
await cache.ready;
if (latest != null) return latest!;
throw capturedError ?? Exception('No data and no error from getChat');
}
}
@@ -1,5 +1,6 @@
import 'package:flutter_app_badge/flutter_app_badge.dart';
import '../../../infrastructure/loadableState/loading_error.dart';
import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart';
import '../repository/chat_list_repository.dart';
@@ -7,6 +8,14 @@ import 'chat_list_event.dart';
import 'chat_list_state.dart';
class ChatListBloc extends LoadableHydratedBloc<ChatListEvent, ChatListState, ChatListRepository> {
bool _forceRenew = false;
@override
void retry() {
_forceRenew = true;
super.retry();
}
@override
ChatListRepository repository() => ChatListRepository();
@@ -21,15 +30,39 @@ class ChatListBloc extends LoadableHydratedBloc<ChatListEvent, ChatListState, Ch
@override
Future<void> gatherData() async {
final rooms = await repo.data.getRooms();
final renew = _forceRenew;
_forceRenew = false;
Object? capturedError;
final rooms = await repo.data.getRooms(
renew: renew,
onError: (e) => capturedError = e,
);
add(DataGathered((s) => s.copyWith(rooms: rooms)));
_updateAppBadge(rooms);
if (capturedError != null) throw capturedError!;
}
Future<void> refresh({bool renew = true}) async {
final rooms = await repo.data.getRooms(renew: renew);
add(DataGathered((s) => s.copyWith(rooms: rooms)));
_updateAppBadge(rooms);
add(RefetchStarted<ChatListState>());
Object? capturedError;
try {
final rooms = await repo.data.getRooms(
renew: renew,
onError: (e) => capturedError = e,
);
add(DataGathered((s) => s.copyWith(rooms: rooms)));
_updateAppBadge(rooms);
} catch (e) {
capturedError = e;
}
if (capturedError != null) {
add(Error(LoadingError(
message: capturedError.toString(),
allowRetry: true,
)));
}
}
Future<void> createDirectChat(String invite) async {
@@ -1,20 +1,26 @@
import 'dart:async';
import '../../../../../api/marianumcloud/talk/room/getRoomCache.dart';
import '../../../../../api/marianumcloud/talk/room/getRoomResponse.dart';
import '../../../../../api/marianumcloud/talk/createRoom/createRoom.dart';
import '../../../../../api/marianumcloud/talk/createRoom/createRoomParams.dart';
class ChatListDataProvider {
Future<GetRoomResponse> getRooms({bool renew = false}) {
final completer = Completer<GetRoomResponse>();
GetRoomCache(
Future<GetRoomResponse> getRooms({
void Function(Object)? onError,
bool renew = false,
}) async {
GetRoomResponse? latest;
Object? capturedError;
final cache = GetRoomCache(
renew: renew,
onUpdate: (data) {
if (!completer.isCompleted) completer.complete(data);
onUpdate: (data) => latest = data,
onError: (e) {
capturedError = e;
onError?.call(e);
},
);
return completer.future;
await cache.ready;
if (latest != null) return latest!;
throw capturedError ?? Exception('No data and no error from getRooms');
}
Future<void> createDirectRoom(String invite) =>
@@ -1,3 +1,5 @@
import '../../../../../api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart';
import '../../../infrastructure/loadableState/loading_error.dart';
import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart';
import '../repository/files_repository.dart';
@@ -28,12 +30,14 @@ class FilesBloc extends LoadableHydratedBloc<FilesEvent, FilesState, FilesReposi
}
Future<void> refresh() async {
add(RefetchStarted<FilesState>());
final path = innerState?.currentPath ?? initialPath;
await _query(path);
}
Future<void> setPath(List<String> path) async {
add(Emit((s) => s.copyWith(currentPath: path, listing: null)));
add(RefetchStarted<FilesState>());
await _query(path);
}
@@ -45,8 +49,34 @@ class FilesBloc extends LoadableHydratedBloc<FilesEvent, FilesState, FilesReposi
Future<void> _query(List<String> path) async {
final pathString = path.isEmpty ? '/' : path.join('/');
final listing = await repo.data.listFiles(pathString);
listing.files.removeWhere((file) => file.name.isEmpty || file.name == path.lastOrNull);
add(DataGathered((s) => s.copyWith(listing: listing)));
Object? capturedError;
ListFilesResponse? listing;
try {
listing = await repo.data.listFiles(
pathString,
onCacheData: (cached) {
// Cached payload arrives before the network call settles. Surface it
// immediately via Emit so the listing is visible while isLoading
// stays true and the top loading bar keeps spinning.
cached.files.removeWhere((file) => file.name.isEmpty || file.name == path.lastOrNull);
add(Emit((s) => s.copyWith(listing: cached)));
},
onError: (e) => capturedError = e,
);
} catch (e) {
capturedError = e;
}
if (listing != null) {
listing.files.removeWhere((file) => file.name.isEmpty || file.name == path.lastOrNull);
add(DataGathered((s) => s.copyWith(listing: listing)));
}
if (capturedError != null) {
add(Error(LoadingError(
message: capturedError.toString(),
allowRetry: true,
)));
}
}
}
@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:nextcloud/nextcloud.dart';
import '../../../../../api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart';
@@ -7,15 +5,30 @@ import '../../../../../api/marianumcloud/webdav/queries/listFiles/listFilesRespo
import '../../../../../api/marianumcloud/webdav/webdavApi.dart';
class FilesDataProvider {
Future<ListFilesResponse> listFiles(String path) {
final completer = Completer<ListFilesResponse>();
ListFilesCache(
/// Lists files at [path]. Cached payload is delivered via [onCacheData] as
/// soon as it is read from disk, so callers can render stale data while the
/// network call is still pending. The Future itself resolves once both the
/// cache lookup and the network attempt have settled, throwing if no payload
/// could be obtained at all.
Future<ListFilesResponse> listFiles(
String path, {
void Function(ListFilesResponse)? onCacheData,
void Function(Object)? onError,
}) async {
ListFilesResponse? latest;
Object? capturedError;
final cache = ListFilesCache(
path: path,
onUpdate: (data) {
if (!completer.isCompleted) completer.complete(data);
onUpdate: (data) => latest = data,
onCacheData: onCacheData,
onError: (e) {
capturedError = e;
onError?.call(e);
},
);
return completer.future;
await cache.ready;
if (latest != null) return latest!;
throw capturedError ?? Exception('No data and no error from listFiles');
}
Future<void> createFolder(String fullPath) async {