From db9c3386f180948886c1b38775a96ad3ad158dae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Tue, 5 May 2026 21:07:48 +0200 Subject: [PATCH] better loading indicators for timetables, talk and files --- lib/api/apiError.dart | 2 +- .../autocomplete/autocompleteApi.dart | 3 +- .../files-sharing/fileSharingApi.dart | 3 +- .../marianumcloud/talk/chat/getChatCache.dart | 10 +- .../marianumcloud/talk/room/getRoomCache.dart | 11 +- lib/api/marianumcloud/talk/talkApi.dart | 3 +- .../webdav/queries/listFiles/listFiles.dart | 15 +- .../queries/listFiles/listFilesCache.dart | 14 +- lib/api/marianumcloud/webdav/webdavApi.dart | 6 +- lib/api/requestCache.dart | 23 ++- lib/model/accountData.dart | 11 +- .../view/loadable_state_consumer.dart | 45 +++- .../view/loadable_state_error_bar.dart | 85 ++++---- .../app/modules/chat/bloc/chat_bloc.dart | 48 +++-- .../chat/dataProvider/chat_data_provider.dart | 24 ++- .../modules/chatList/bloc/chat_list_bloc.dart | 41 +++- .../dataProvider/chat_list_data_provider.dart | 22 +- .../app/modules/files/bloc/files_bloc.dart | 36 +++- .../dataProvider/files_data_provider.dart | 29 ++- lib/storage/general/modulesSettings.g.dart | 1 + lib/view/pages/files/fileElement.dart | 5 +- lib/view/pages/files/files.dart | 4 +- lib/view/pages/talk/chatView.dart | 195 +++++++++--------- .../pages/talk/components/chatMessage.dart | 3 +- lib/widget/userAvatar.dart | 3 +- 25 files changed, 439 insertions(+), 203 deletions(-) diff --git a/lib/api/apiError.dart b/lib/api/apiError.dart index 42fe5c2..9a56012 100644 --- a/lib/api/apiError.dart +++ b/lib/api/apiError.dart @@ -1,4 +1,4 @@ -class ApiError { +class ApiError implements Exception { String message; ApiError(this.message); diff --git a/lib/api/marianumcloud/autocomplete/autocompleteApi.dart b/lib/api/marianumcloud/autocomplete/autocompleteApi.dart index f11b91c..883168b 100644 --- a/lib/api/marianumcloud/autocomplete/autocompleteApi.dart +++ b/lib/api/marianumcloud/autocomplete/autocompleteApi.dart @@ -20,8 +20,9 @@ class AutocompleteApi { var headers = {}; headers.putIfAbsent('Accept', () => 'application/json'); headers.putIfAbsent('OCS-APIRequest', () => 'true'); + headers.putIfAbsent('Authorization', AccountData().getBasicAuthHeader); - var endpoint = Uri.https('${AccountData().buildHttpAuthString()}@${EndpointData().nextcloud().domain}', '${EndpointData().nextcloud().path}/ocs/v2.php/core/autocomplete/get', getParameters); + var endpoint = Uri.https(EndpointData().nextcloud().domain, '${EndpointData().nextcloud().path}/ocs/v2.php/core/autocomplete/get', getParameters); var response = await http.get(endpoint, headers: headers); if(response.statusCode != HttpStatus.ok) throw Exception('Api call failed with ${response.statusCode}: ${response.body}'); diff --git a/lib/api/marianumcloud/files-sharing/fileSharingApi.dart b/lib/api/marianumcloud/files-sharing/fileSharingApi.dart index 42d5dd8..a2b8317 100644 --- a/lib/api/marianumcloud/files-sharing/fileSharingApi.dart +++ b/lib/api/marianumcloud/files-sharing/fileSharingApi.dart @@ -11,8 +11,9 @@ class FileSharingApi { var headers = {}; headers.putIfAbsent('Accept', () => 'application/json'); headers.putIfAbsent('OCS-APIRequest', () => 'true'); + headers.putIfAbsent('Authorization', AccountData().getBasicAuthHeader); - var endpoint = Uri.https('${AccountData().buildHttpAuthString()}@${EndpointData().nextcloud().domain}', '${EndpointData().nextcloud().path}/ocs/v2.php/apps/files_sharing/api/v1/shares', query.toJson().map((key, value) => MapEntry(key, value.toString()))); + var endpoint = Uri.https(EndpointData().nextcloud().domain, '${EndpointData().nextcloud().path}/ocs/v2.php/apps/files_sharing/api/v1/shares', query.toJson().map((key, value) => MapEntry(key, value.toString()))); var response = await http.post(endpoint, headers: headers); if(response.statusCode != HttpStatus.ok) { diff --git a/lib/api/marianumcloud/talk/chat/getChatCache.dart b/lib/api/marianumcloud/talk/chat/getChatCache.dart index 60de7c1..f564a48 100644 --- a/lib/api/marianumcloud/talk/chat/getChatCache.dart +++ b/lib/api/marianumcloud/talk/chat/getChatCache.dart @@ -8,7 +8,15 @@ import 'getChatResponse.dart'; class GetChatCache extends RequestCache { String chatToken; - GetChatCache({required void Function(GetChatResponse) onUpdate, required this.chatToken}) : super(RequestCache.cacheNothing, onUpdate) { + GetChatCache({ + required void Function(GetChatResponse) onUpdate, + void Function(Exception)? onError, + required this.chatToken, + }) : super( + RequestCache.cacheNothing, + onUpdate, + onError: onError ?? RequestCache.ignore, + ) { start('nc-chat-$chatToken'); } diff --git a/lib/api/marianumcloud/talk/room/getRoomCache.dart b/lib/api/marianumcloud/talk/room/getRoomCache.dart index 54f8578..d632b9e 100644 --- a/lib/api/marianumcloud/talk/room/getRoomCache.dart +++ b/lib/api/marianumcloud/talk/room/getRoomCache.dart @@ -7,7 +7,16 @@ import 'getRoomParams.dart'; import 'getRoomResponse.dart'; class GetRoomCache extends RequestCache { - GetRoomCache({void Function(GetRoomResponse)? onUpdate, bool? renew}) : super(RequestCache.cacheMinute, onUpdate, renew: renew) { + GetRoomCache({ + void Function(GetRoomResponse)? onUpdate, + void Function(Exception)? onError, + bool? renew, + }) : super( + RequestCache.cacheMinute, + onUpdate, + onError: onError ?? RequestCache.ignore, + renew: renew, + ) { start('nc-rooms'); } diff --git a/lib/api/marianumcloud/talk/talkApi.dart b/lib/api/marianumcloud/talk/talkApi.dart index e79340f..a72b5f5 100644 --- a/lib/api/marianumcloud/talk/talkApi.dart +++ b/lib/api/marianumcloud/talk/talkApi.dart @@ -34,11 +34,12 @@ abstract class TalkApi extends ApiRequest { getParameters?.update(key, (value) => value.toString()); }); - var endpoint = Uri.https('${AccountData().buildHttpAuthString()}@${EndpointData().nextcloud().domain}', '${EndpointData().nextcloud().path}/ocs/v2.php/apps/spreed/api/$path', getParameters); + var endpoint = Uri.https(EndpointData().nextcloud().domain, '${EndpointData().nextcloud().path}/ocs/v2.php/apps/spreed/api/$path', getParameters); headers ??= {}; headers?.putIfAbsent('Accept', () => 'application/json'); headers?.putIfAbsent('OCS-APIRequest', () => 'true'); + headers?.putIfAbsent('Authorization', AccountData().getBasicAuthHeader); http.Response? data; diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFiles.dart b/lib/api/marianumcloud/webdav/queries/listFiles/listFiles.dart index 438204d..8582989 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFiles.dart +++ b/lib/api/marianumcloud/webdav/queries/listFiles/listFiles.dart @@ -11,9 +11,22 @@ class ListFiles extends WebdavApi { ListFiles(this.params) : super(params); + // The Nextcloud root listing is significantly slower than subdirectories on + // our instance, so it gets a much longer ceiling. Subfolders fall back to a + // tighter timeout to keep the UI responsive. + static const Duration _rootTimeout = Duration(minutes: 3); + static const Duration _subfolderTimeout = Duration(seconds: 30); + + bool get _isRoot { + final p = params.path.replaceAll('/', '').trim(); + return p.isEmpty; + } + @override Future run() async { - var davFiles = (await (await WebdavApi.webdav).propfind(PathUri.parse(params.path))).toWebDavFiles(); + final webdav = await WebdavApi.webdav; + final timeout = _isRoot ? _rootTimeout : _subfolderTimeout; + final davFiles = (await webdav.propfind(PathUri.parse(params.path)).timeout(timeout)).toWebDavFiles(); var files = davFiles.map(CacheableFile.fromDavFile).toSet(); // webdav handles subdirectories wrong, this is a fix diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart b/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart index 2a3e2fc..ac7d5b3 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart +++ b/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart @@ -9,7 +9,19 @@ import 'listFilesResponse.dart'; class ListFilesCache extends RequestCache { String path; - ListFilesCache({required void Function(ListFilesResponse) onUpdate, required this.path}) : super(RequestCache.cacheNothing, onUpdate) { + ListFilesCache({ + required void Function(ListFilesResponse) onUpdate, + void Function(ListFilesResponse)? onCacheData, + void Function(ListFilesResponse)? onNetworkData, + void Function(Exception)? onError, + required this.path, + }) : super( + RequestCache.cacheNothing, + onUpdate, + onError: onError ?? RequestCache.ignore, + onCacheData: onCacheData, + onNetworkData: onNetworkData, + ) { var bytes = utf8.encode('MarianumMobile-$path'); var cacheName = md5.convert(bytes).toString(); start('wd-folder-$cacheName'); diff --git a/lib/api/marianumcloud/webdav/webdavApi.dart b/lib/api/marianumcloud/webdav/webdavApi.dart index cfa7159..1baf291 100644 --- a/lib/api/marianumcloud/webdav/webdavApi.dart +++ b/lib/api/marianumcloud/webdav/webdavApi.dart @@ -15,9 +15,11 @@ abstract class WebdavApi extends ApiRequest { Future run(); static Future webdav = establishWebdavConnection(); - static Future webdavConnectString = buildWebdavConnectString(); static Future establishWebdavConnection() async => NextcloudClient(Uri.parse('https://${EndpointData().nextcloud().full()}'), password: AccountData().getPassword(), loginName: AccountData().getUsername()).webdav; - static Future buildWebdavConnectString() async => 'https://${AccountData().buildHttpAuthString()}@${EndpointData().nextcloud().full()}/remote.php/dav/files/${AccountData().getUsername()}/'; + /// Builds the WebDAV download URL without embedded credentials. Callers must + /// authenticate via the [AccountData.authHeaders] header instead. + static String buildWebdavUrl() => + 'https://${EndpointData().nextcloud().full()}/remote.php/dav/files/${AccountData().getUsername()}/'; } diff --git a/lib/api/requestCache.dart b/lib/api/requestCache.dart index 2588a22..acfe990 100644 --- a/lib/api/requestCache.dart +++ b/lib/api/requestCache.dart @@ -15,6 +15,15 @@ abstract class RequestCache { int maxCacheTime; void Function(T)? onUpdate; + + /// Called only when [start] finds a cached payload in localstore. Use this + /// (instead of [onUpdate]) when callers need to distinguish stale-but-fast + /// cache hits from authoritative network responses. + void Function(T)? onCacheData; + + /// Called only when [start] receives a fresh payload from the network. + void Function(T)? onNetworkData; + void Function(Exception) onError; bool? renew; @@ -26,7 +35,14 @@ abstract class RequestCache { /// attempt have settled. Future get ready => _ready.future; - RequestCache(this.maxCacheTime, this.onUpdate, {this.onError = ignore, this.renew = false}); + RequestCache( + this.maxCacheTime, + this.onUpdate, { + this.onError = ignore, + this.renew = false, + this.onCacheData, + this.onNetworkData, + }); static void ignore(Exception e) {} @@ -34,7 +50,9 @@ abstract class RequestCache { try { final tableData = await Localstore.instance.collection(collection).doc(document).get(); if (tableData != null) { - onUpdate?.call(onLocalData(tableData['json'])); + final cached = onLocalData(tableData['json']); + onUpdate?.call(cached); + onCacheData?.call(cached); } if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) < (tableData?['lastupdate'] ?? 0)) { @@ -44,6 +62,7 @@ abstract class RequestCache { try { final newValue = await onLoad(); onUpdate?.call(newValue); + onNetworkData?.call(newValue); Localstore.instance.collection(collection).doc(document).set({ 'json': jsonEncode(newValue), 'lastupdate': DateTime.now().millisecondsSinceEpoch, diff --git a/lib/model/accountData.dart b/lib/model/accountData.dart index 03e2f53..66db8c8 100644 --- a/lib/model/accountData.dart +++ b/lib/model/accountData.dart @@ -98,8 +98,15 @@ class AccountData { bool isPopulated() => _username != null && _password != null; - String buildHttpAuthString() { + /// Returns the value for an HTTP `Authorization` header using HTTP Basic. + /// Prefer this over embedding credentials in URLs — error logs and crash + /// reports often capture the URL but not headers. + String getBasicAuthHeader() { if (!isPopulated()) throw Exception('AccountData (e.g. username or password) is not initialized!'); - return '$_username:$_password'; + return 'Basic ${base64Encode(utf8.encode('$_username:$_password'))}'; } + + /// Convenience wrapper around [getBasicAuthHeader] returning a single-entry + /// header map ready to merge into HTTP request headers. + Map authHeaders() => {'Authorization': getBasicAuthHeader()}; } diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart b/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart index 4b5b557..ce45fcd 100644 --- a/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart +++ b/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart @@ -17,7 +17,21 @@ class LoadableStateConsumer 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 AnimatedSize( - duration: animationDuration, - child: AnimatedSwitcher( - duration: animationDuration, - transitionBuilder: (Widget child, Animation animation) => SlideTransition( - position: Tween( - 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(); - 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(); + final isOfflineWithCache = hasContent && bloc.connectivityStatusKnown() && !bloc.isConnected(); + final shouldShow = visible || isOfflineWithCache; + + return AnimatedSize( + duration: animationDuration, + child: AnimatedSwitcher( + duration: animationDuration, + transitionBuilder: (Widget child, Animation animation) => SlideTransition( + position: Tween( + 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(); + 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 { diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart index e89d7a9..c3a45ca 100644 --- a/lib/state/app/modules/chat/bloc/chat_bloc.dart +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -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 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 s.copyWith(currentToken: token, chatResponse: null))); + add(RefetchStarted()); _loadChat(token); } @@ -41,19 +47,37 @@ class ChatBloc extends LoadableHydratedBloc()); + _loadChat(token); } - void _loadChat(String token) { + Future _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, + ))); + } } } diff --git a/lib/state/app/modules/chat/dataProvider/chat_data_provider.dart b/lib/state/app/modules/chat/dataProvider/chat_data_provider.dart index dab8899..25bdccc 100644 --- a/lib/state/app/modules/chat/dataProvider/chat_data_provider.dart +++ b/lib/state/app/modules/chat/dataProvider/chat_data_provider.dart @@ -2,10 +2,26 @@ import '../../../../../api/marianumcloud/talk/chat/getChatCache.dart'; import '../../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; class ChatDataProvider { - void getChat({ + Future 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'); } } diff --git a/lib/state/app/modules/chatList/bloc/chat_list_bloc.dart b/lib/state/app/modules/chatList/bloc/chat_list_bloc.dart index 2080fae..e4be8fa 100644 --- a/lib/state/app/modules/chatList/bloc/chat_list_bloc.dart +++ b/lib/state/app/modules/chatList/bloc/chat_list_bloc.dart @@ -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 { + bool _forceRenew = false; + + @override + void retry() { + _forceRenew = true; + super.retry(); + } + @override ChatListRepository repository() => ChatListRepository(); @@ -21,15 +30,39 @@ class ChatListBloc extends LoadableHydratedBloc 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 refresh({bool renew = true}) async { - final rooms = await repo.data.getRooms(renew: renew); - add(DataGathered((s) => s.copyWith(rooms: rooms))); - _updateAppBadge(rooms); + add(RefetchStarted()); + 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 createDirectChat(String invite) async { diff --git a/lib/state/app/modules/chatList/dataProvider/chat_list_data_provider.dart b/lib/state/app/modules/chatList/dataProvider/chat_list_data_provider.dart index d7bf80e..3bf5c62 100644 --- a/lib/state/app/modules/chatList/dataProvider/chat_list_data_provider.dart +++ b/lib/state/app/modules/chatList/dataProvider/chat_list_data_provider.dart @@ -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 getRooms({bool renew = false}) { - final completer = Completer(); - GetRoomCache( + Future 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 createDirectRoom(String invite) => diff --git a/lib/state/app/modules/files/bloc/files_bloc.dart b/lib/state/app/modules/files/bloc/files_bloc.dart index 98eff6e..3c6406b 100644 --- a/lib/state/app/modules/files/bloc/files_bloc.dart +++ b/lib/state/app/modules/files/bloc/files_bloc.dart @@ -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 refresh() async { + add(RefetchStarted()); final path = innerState?.currentPath ?? initialPath; await _query(path); } Future setPath(List path) async { add(Emit((s) => s.copyWith(currentPath: path, listing: null))); + add(RefetchStarted()); await _query(path); } @@ -45,8 +49,34 @@ class FilesBloc extends LoadableHydratedBloc _query(List 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, + ))); + } } } diff --git a/lib/state/app/modules/files/dataProvider/files_data_provider.dart b/lib/state/app/modules/files/dataProvider/files_data_provider.dart index dda4716..8b1fef4 100644 --- a/lib/state/app/modules/files/dataProvider/files_data_provider.dart +++ b/lib/state/app/modules/files/dataProvider/files_data_provider.dart @@ -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 listFiles(String path) { - final completer = Completer(); - 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 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 createFolder(String fullPath) async { diff --git a/lib/storage/general/modulesSettings.g.dart b/lib/storage/general/modulesSettings.g.dart index c5a7262..dbc7318 100644 --- a/lib/storage/general/modulesSettings.g.dart +++ b/lib/storage/general/modulesSettings.g.dart @@ -33,4 +33,5 @@ const _$ModulesEnumMap = { Modules.roomPlan: 'roomPlan', Modules.gradeAveragesCalculator: 'gradeAveragesCalculator', Modules.holidays: 'holidays', + Modules.marianumDates: 'marianumDates', }; diff --git a/lib/view/pages/files/fileElement.dart b/lib/view/pages/files/fileElement.dart index cd8b772..6743547 100644 --- a/lib/view/pages/files/fileElement.dart +++ b/lib/view/pages/files/fileElement.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:dio/dio.dart'; import 'package:filesize/filesize.dart'; import 'package:flowder/flowder.dart'; import 'package:flutter/foundation.dart'; @@ -12,6 +13,7 @@ import 'package:path_provider/path_provider.dart'; import '../../../api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart'; import '../../../api/marianumcloud/webdav/webdavApi.dart'; +import '../../../model/accountData.dart'; import '../../../model/endpointData.dart'; import '../../../widget/centeredLeading.dart'; import '../../../widget/confirmDialog.dart'; @@ -41,6 +43,7 @@ class FileElement extends StatefulWidget { file: File(local), progress: ProgressImplementation(), deleteOnCancel: true, + client: Dio(BaseOptions(headers: AccountData().authHeaders())), onDone: () { //Future result = OpenFile.open(local); // TODO legacy - refactor: remove onDone parameter Navigator.of(context).push(MaterialPageRoute(builder: (context) => FileViewer(path: local))); @@ -52,7 +55,7 @@ class FileElement extends StatefulWidget { ); return await Flowder.download( - '${await WebdavApi.webdavConnectString}$encodedPath', + '${WebdavApi.buildWebdavUrl()}$encodedPath', options, ); } diff --git a/lib/view/pages/files/files.dart b/lib/view/pages/files/files.dart index 83420bc..257006c 100644 --- a/lib/view/pages/files/files.dart +++ b/lib/view/pages/files/files.dart @@ -164,9 +164,9 @@ class _FilesViewState extends State<_FilesView> { child: const Icon(Icons.add), ), body: LoadableStateConsumer( + isReady: (state) => state.listing != null, child: (state, _) { - final listing = state.listing; - if (listing == null) return const SizedBox.shrink(); + final listing = state.listing!; if (listing.files.isEmpty) { return const PlaceholderView(icon: Icons.folder_off_rounded, text: 'Der Ordner ist leer'); } diff --git a/lib/view/pages/talk/chatView.dart b/lib/view/pages/talk/chatView.dart index 63bf7cc..e570405 100644 --- a/lib/view/pages/talk/chatView.dart +++ b/lib/view/pages/talk/chatView.dart @@ -4,11 +4,11 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import '../../../api/marianumcloud/talk/chat/getChatResponse.dart'; import '../../../api/marianumcloud/talk/room/getRoomResponse.dart'; import '../../../extensions/dateTime.dart'; +import '../../../state/app/infrastructure/loadableState/view/loadable_state_consumer.dart'; import '../../../state/app/modules/chat/bloc/chat_bloc.dart'; import '../../../state/app/modules/chat/bloc/chat_state.dart'; import '../../../theming/appTheme.dart'; import '../../../widget/clickableAppBar.dart'; -import '../../../widget/loadingSpinner.dart'; import '../../../widget/userAvatar.dart'; import 'chatDetails/chatInfo.dart'; import 'components/chatBubble.dart'; @@ -33,108 +33,103 @@ class _ChatViewState extends State { context.read().setToken(widget.room.token); } + List _buildMessages(GetChatResponse response) { + final messages = []; + var lastDate = DateTime.now(); + for (final element in response.sortByTimestamp()) { + final elementDate = DateTime.fromMillisecondsSinceEpoch(element.timestamp * 1000); + + if (element.systemMessage.contains('reaction')) continue; + if (element.systemMessage.contains('poll_voted')) continue; + final commonRead = int.parse(response.headers?['x-chat-last-common-read'] ?? '0'); + + if (!elementDate.isSameDay(lastDate)) { + lastDate = elementDate; + messages.add(ChatBubble( + context: context, + isSender: false, + bubbleData: GetChatResponseObject.getDateDummy(element.timestamp), + chatData: widget.room, + refetch: ({bool renew = false}) => _refresh(), + )); + } + + messages.add(ChatBubble( + context: context, + isSender: element.actorId == widget.selfId && + element.messageType == GetRoomResponseObjectMessageType.comment, + bubbleData: element, + chatData: widget.room, + refetch: ({bool renew = false}) => _refresh(), + isRead: element.id <= commonRead, + selfId: widget.selfId, + )); + } + + if (response.data.length >= 200) { + messages.insert(0, ChatBubble( + context: context, + isSender: false, + bubbleData: GetChatResponseObject.getTextDummy( + 'Zurzeit können in dieser App nur die letzten 200 vergangenen Nachrichten angezeigt werden. ' + 'Um ältere Nachrichten abzurufen verwende die Webversion unter https://cloud.marianum-fulda.de', + ), + chatData: widget.room, + refetch: ({bool renew = false}) => _refresh(), + )); + } + + return messages; + } + @override - Widget build(BuildContext context) => BlocBuilder( - builder: (context, _) { - final state = context.watch().state.data ?? const ChatState(); - final response = state.chatResponse; - final isLoading = response == null; - - final messages = []; - - if (response != null) { - var lastDate = DateTime.now(); - for (final element in response.sortByTimestamp()) { - final elementDate = DateTime.fromMillisecondsSinceEpoch(element.timestamp * 1000); - - if (element.systemMessage.contains('reaction')) continue; - if (element.systemMessage.contains('poll_voted')) continue; - final commonRead = int.parse(response.headers?['x-chat-last-common-read'] ?? '0'); - - if (!elementDate.isSameDay(lastDate)) { - lastDate = elementDate; - messages.add(ChatBubble( - context: context, - isSender: false, - bubbleData: GetChatResponseObject.getDateDummy(element.timestamp), - chatData: widget.room, - refetch: ({bool renew = false}) => _refresh(), - )); - } - - messages.add(ChatBubble( - context: context, - isSender: element.actorId == widget.selfId && - element.messageType == GetRoomResponseObjectMessageType.comment, - bubbleData: element, - chatData: widget.room, - refetch: ({bool renew = false}) => _refresh(), - isRead: element.id <= commonRead, - selfId: widget.selfId, - )); - } - - if (response.data.length >= 200) { - messages.insert(0, ChatBubble( - context: context, - isSender: false, - bubbleData: GetChatResponseObject.getTextDummy( - 'Zurzeit können in dieser App nur die letzten 200 vergangenen Nachrichten angezeigt werden. ' - 'Um ältere Nachrichten abzurufen verwende die Webversion unter https://cloud.marianum-fulda.de', - ), - chatData: widget.room, - refetch: ({bool renew = false}) => _refresh(), - )); - } - } - - return Scaffold( - backgroundColor: const Color(0xffefeae2), - appBar: ClickableAppBar( - onTap: () => TalkNavigator.pushSplitView(context, ChatInfo(widget.room)), - appBar: AppBar( - title: Row( - children: [ - widget.avatar, - const SizedBox(width: 10), - Expanded( - child: Text(widget.room.displayName, overflow: TextOverflow.ellipsis, maxLines: 1), - ), - ], - ), + Widget build(BuildContext context) => Scaffold( + backgroundColor: const Color(0xffefeae2), + appBar: ClickableAppBar( + onTap: () => TalkNavigator.pushSplitView(context, ChatInfo(widget.room)), + appBar: AppBar( + title: Row( + children: [ + widget.avatar, + const SizedBox(width: 10), + Expanded( + child: Text(widget.room.displayName, overflow: TextOverflow.ellipsis, maxLines: 1), + ), + ], ), ), - body: Container( - decoration: BoxDecoration( - image: DecorationImage( - image: const AssetImage('assets/background/chat.png'), - scale: 1.5, - opacity: 1, - repeat: ImageRepeat.repeat, - invertColors: AppTheme.isDarkMode(context), - ), + ), + body: Container( + decoration: BoxDecoration( + image: DecorationImage( + image: const AssetImage('assets/background/chat.png'), + scale: 1.5, + opacity: 1, + repeat: ImageRepeat.repeat, + invertColors: AppTheme.isDarkMode(context), ), - child: isLoading - ? const LoadingSpinner() - : Column( - children: [ - Expanded( - child: ListView( - reverse: true, - controller: _listController, - children: messages.reversed.toList(), - ), - ), - Container( - color: Theme.of(context).colorScheme.surface, - child: TalkNavigator.isSecondaryVisible(context) - ? ChatTextfield(widget.room.token, selfId: widget.selfId) - : SafeArea(child: ChatTextfield(widget.room.token, selfId: widget.selfId)), - ), - ], - ), ), - ); - }, - ); + child: Column( + children: [ + Expanded( + child: LoadableStateConsumer( + isReady: (state) => + state.chatResponse != null && state.currentToken == widget.room.token, + child: (state, _) => ListView( + reverse: true, + controller: _listController, + children: _buildMessages(state.chatResponse!).reversed.toList(), + ), + ), + ), + Container( + color: Theme.of(context).colorScheme.surface, + child: TalkNavigator.isSecondaryVisible(context) + ? ChatTextfield(widget.room.token, selfId: widget.selfId) + : SafeArea(child: ChatTextfield(widget.room.token, selfId: widget.selfId)), + ), + ], + ), + ), + ); } diff --git a/lib/view/pages/talk/components/chatMessage.dart b/lib/view/pages/talk/components/chatMessage.dart index 468805f..f545fa4 100644 --- a/lib/view/pages/talk/components/chatMessage.dart +++ b/lib/view/pages/talk/components/chatMessage.dart @@ -63,7 +63,8 @@ class ChatMessage { fadeInDuration: Duration.zero, fadeOutDuration: Duration.zero, errorListener: (value) {}, - imageUrl: 'https://${AccountData().buildHttpAuthString()}@${EndpointData().nextcloud().full()}/index.php/core/preview?fileId=${file!.id}&x=130&y=-1&a=1', + httpHeaders: AccountData().authHeaders(), + imageUrl: 'https://${EndpointData().nextcloud().full()}/index.php/core/preview?fileId=${file!.id}&x=130&y=-1&a=1', ), if(originalMessage != '{file}') ...[ SizedBox(height: 5), diff --git a/lib/widget/userAvatar.dart b/lib/widget/userAvatar.dart index 4a9cb8b..bab843b 100644 --- a/lib/widget/userAvatar.dart +++ b/lib/widget/userAvatar.dart @@ -60,11 +60,10 @@ class _UserAvatarState extends State { Future<_AvatarPayload?> _fetch(String url) async { try { - final auth = base64Encode(utf8.encode(AccountData().buildHttpAuthString())); final response = await http.get( Uri.parse(url), headers: { - 'Authorization': 'Basic $auth', + 'Authorization': AccountData().getBasicAuthHeader(), 'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml', }, );