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
+1 -1
View File
@@ -1,4 +1,4 @@
class ApiError { class ApiError implements Exception {
String message; String message;
ApiError(this.message); ApiError(this.message);
@@ -20,8 +20,9 @@ class AutocompleteApi {
var headers = <String, String>{}; var headers = <String, String>{};
headers.putIfAbsent('Accept', () => 'application/json'); headers.putIfAbsent('Accept', () => 'application/json');
headers.putIfAbsent('OCS-APIRequest', () => 'true'); 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); var response = await http.get(endpoint, headers: headers);
if(response.statusCode != HttpStatus.ok) throw Exception('Api call failed with ${response.statusCode}: ${response.body}'); if(response.statusCode != HttpStatus.ok) throw Exception('Api call failed with ${response.statusCode}: ${response.body}');
@@ -11,8 +11,9 @@ class FileSharingApi {
var headers = <String, String>{}; var headers = <String, String>{};
headers.putIfAbsent('Accept', () => 'application/json'); headers.putIfAbsent('Accept', () => 'application/json');
headers.putIfAbsent('OCS-APIRequest', () => 'true'); 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); var response = await http.post(endpoint, headers: headers);
if(response.statusCode != HttpStatus.ok) { if(response.statusCode != HttpStatus.ok) {
@@ -8,7 +8,15 @@ import 'getChatResponse.dart';
class GetChatCache extends RequestCache<GetChatResponse> { class GetChatCache extends RequestCache<GetChatResponse> {
String chatToken; 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'); start('nc-chat-$chatToken');
} }
@@ -7,7 +7,16 @@ import 'getRoomParams.dart';
import 'getRoomResponse.dart'; import 'getRoomResponse.dart';
class GetRoomCache extends RequestCache<GetRoomResponse> { class GetRoomCache extends RequestCache<GetRoomResponse> {
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'); start('nc-rooms');
} }
+2 -1
View File
@@ -34,11 +34,12 @@ abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
getParameters?.update(key, (value) => value.toString()); 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 ??= {};
headers?.putIfAbsent('Accept', () => 'application/json'); headers?.putIfAbsent('Accept', () => 'application/json');
headers?.putIfAbsent('OCS-APIRequest', () => 'true'); headers?.putIfAbsent('OCS-APIRequest', () => 'true');
headers?.putIfAbsent('Authorization', AccountData().getBasicAuthHeader);
http.Response? data; http.Response? data;
@@ -11,9 +11,22 @@ class ListFiles extends WebdavApi<ListFilesParams> {
ListFiles(this.params) : super(params); 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 @override
Future<ListFilesResponse> run() async { Future<ListFilesResponse> 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(); var files = davFiles.map(CacheableFile.fromDavFile).toSet();
// webdav handles subdirectories wrong, this is a fix // webdav handles subdirectories wrong, this is a fix
@@ -9,7 +9,19 @@ import 'listFilesResponse.dart';
class ListFilesCache extends RequestCache<ListFilesResponse> { class ListFilesCache extends RequestCache<ListFilesResponse> {
String path; 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 bytes = utf8.encode('MarianumMobile-$path');
var cacheName = md5.convert(bytes).toString(); var cacheName = md5.convert(bytes).toString();
start('wd-folder-$cacheName'); start('wd-folder-$cacheName');
+4 -2
View File
@@ -15,9 +15,11 @@ abstract class WebdavApi<T> extends ApiRequest {
Future<ApiResponse> run(); Future<ApiResponse> run();
static Future<WebDavClient> webdav = establishWebdavConnection(); static Future<WebDavClient> webdav = establishWebdavConnection();
static Future<String> webdavConnectString = buildWebdavConnectString();
static Future<WebDavClient> establishWebdavConnection() async => NextcloudClient(Uri.parse('https://${EndpointData().nextcloud().full()}'), password: AccountData().getPassword(), loginName: AccountData().getUsername()).webdav; static Future<WebDavClient> establishWebdavConnection() async => NextcloudClient(Uri.parse('https://${EndpointData().nextcloud().full()}'), password: AccountData().getPassword(), loginName: AccountData().getUsername()).webdav;
static Future<String> 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()}/';
} }
+21 -2
View File
@@ -15,6 +15,15 @@ abstract class RequestCache<T extends ApiResponse?> {
int maxCacheTime; int maxCacheTime;
void Function(T)? onUpdate; 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; void Function(Exception) onError;
bool? renew; bool? renew;
@@ -26,7 +35,14 @@ abstract class RequestCache<T extends ApiResponse?> {
/// attempt have settled. /// attempt have settled.
Future<void> get ready => _ready.future; Future<void> 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) {} static void ignore(Exception e) {}
@@ -34,7 +50,9 @@ abstract class RequestCache<T extends ApiResponse?> {
try { try {
final tableData = await Localstore.instance.collection(collection).doc(document).get(); final tableData = await Localstore.instance.collection(collection).doc(document).get();
if (tableData != null) { 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)) { if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) < (tableData?['lastupdate'] ?? 0)) {
@@ -44,6 +62,7 @@ abstract class RequestCache<T extends ApiResponse?> {
try { try {
final newValue = await onLoad(); final newValue = await onLoad();
onUpdate?.call(newValue); onUpdate?.call(newValue);
onNetworkData?.call(newValue);
Localstore.instance.collection(collection).doc(document).set({ Localstore.instance.collection(collection).doc(document).set({
'json': jsonEncode(newValue), 'json': jsonEncode(newValue),
'lastupdate': DateTime.now().millisecondsSinceEpoch, 'lastupdate': DateTime.now().millisecondsSinceEpoch,
+9 -2
View File
@@ -98,8 +98,15 @@ class AccountData {
bool isPopulated() => _username != null && _password != null; 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!'); 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<String, String> authHeaders() => {'Authorization': getBasicAuthHeader()};
} }
@@ -17,7 +17,21 @@ class LoadableStateConsumer<TController extends Bloc<LoadableHydratedBlocEvent<T
final Widget Function(TState state, bool loading) child; final Widget Function(TState state, bool loading) child;
final void Function(TState state)? onLoad; final void Function(TState state)? onLoad;
final bool wrapWithScrollView; 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); static Duration animationDuration = const Duration(milliseconds: 200);
@@ -30,6 +44,16 @@ class LoadableStateConsumer<TController extends Bloc<LoadableHydratedBlocEvent<T
WidgetsBinding.instance.addPostFrameCallback((timeStamp) => onLoad!(loadedData)); 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( var childWidget = ConditionalWrapper(
condition: loadableState.reFetch != null, condition: loadableState.reFetch != null,
wrapper: (child) => RefreshIndicator( wrapper: (child) => RefreshIndicator(
@@ -48,8 +72,8 @@ class LoadableStateConsumer<TController extends Bloc<LoadableHydratedBlocEvent<T
), ),
child: SizedBox( child: SizedBox(
height: MediaQuery.of(context).size.height, height: MediaQuery.of(context).size.height,
child: loadableState.showContent() && loadedData is TState child: hasContent
? child(loadedData, loadableState.isLoading) ? child(typedData as TState, isLoading)
: const SizedBox.shrink(), : const SizedBox.shrink(),
), ),
); );
@@ -60,16 +84,21 @@ class LoadableStateConsumer<TController extends Bloc<LoadableHydratedBlocEvent<T
bloc.reFetch = loadableState.reFetch; bloc.reFetch = loadableState.reFetch;
return Column( return Column(
children: [ 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( Expanded(
child: Stack( child: Stack(
children: [ children: [
LoadableStatePrimaryLoading(visible: loadableState.showPrimaryLoading()), LoadableStatePrimaryLoading(visible: showPrimaryLoading),
LoadableStateBackgroundLoading(visible: loadableState.showBackgroundLoading()), LoadableStateBackgroundLoading(visible: showBackgroundLoading),
LoadableStateErrorScreen(visible: loadableState.showError(), message: loadableState.error?.message), LoadableStateErrorScreen(visible: showError, message: loadableState.error?.message),
AnimatedOpacity( AnimatedOpacity(
opacity: loadableState.showContent() ? 1.0 : 0.0, opacity: hasContent ? 1.0 : 0.0,
duration: animationDuration, duration: animationDuration,
curve: Curves.easeInOut, curve: Curves.easeInOut,
child: childWidget, child: childWidget,
@@ -8,14 +8,26 @@ import '../bloc/loadable_state_bloc.dart';
class LoadableStateErrorBar extends StatelessWidget { class LoadableStateErrorBar extends StatelessWidget {
final bool visible; final bool visible;
final bool hasContent;
final String? message; final String? message;
final int? lastUpdated; 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); final Duration animationDuration = const Duration(milliseconds: 200);
@override @override
Widget build(BuildContext context) => AnimatedSize( Widget build(BuildContext context) {
final bloc = context.watch<LoadableStateBloc>();
final isOfflineWithCache = hasContent && bloc.connectivityStatusKnown() && !bloc.isConnected();
final shouldShow = visible || isOfflineWithCache;
return AnimatedSize(
duration: animationDuration, duration: animationDuration,
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: animationDuration, duration: animationDuration,
@@ -27,8 +39,8 @@ class LoadableStateErrorBar extends StatelessWidget {
child: child, child: child,
), ),
child: Visibility( child: Visibility(
key: Key(visible.hashCode.toString()), key: Key(shouldShow.hashCode.toString()),
visible: visible, visible: shouldShow,
replacement: const SizedBox(width: double.infinity), replacement: const SizedBox(width: double.infinity),
child: Builder( child: Builder(
builder: (context) { builder: (context) {
@@ -51,6 +63,7 @@ class LoadableStateErrorBar extends StatelessWidget {
) )
), ),
); );
}
} }
class LoadableStateErrorBarText extends StatefulWidget { class LoadableStateErrorBarText extends StatefulWidget {
+33 -9
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.dart';
import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart';
import '../repository/chat_repository.dart'; import '../repository/chat_repository.dart';
@@ -22,8 +24,11 @@ class ChatBloc extends LoadableHydratedBloc<ChatEvent, ChatState, ChatRepository
@override @override
Future<void> gatherData() async { Future<void> gatherData() async {
final token = innerState?.currentToken ?? ''; final token = innerState?.currentToken ?? '';
if (token.isEmpty) return; if (token.isEmpty) {
_loadChat(token); add(DataGathered((s) => s));
return;
}
await _loadChat(token);
} }
void setToken(String token) { void setToken(String token) {
@@ -32,6 +37,7 @@ class ChatBloc extends LoadableHydratedBloc<ChatEvent, ChatState, ChatRepository
return; return;
} }
add(Emit((s) => s.copyWith(currentToken: token, chatResponse: null))); add(Emit((s) => s.copyWith(currentToken: token, chatResponse: null)));
add(RefetchStarted<ChatState>());
_loadChat(token); _loadChat(token);
} }
@@ -41,19 +47,37 @@ class ChatBloc extends LoadableHydratedBloc<ChatEvent, ChatState, ChatRepository
void refresh() { void refresh() {
final token = innerState?.currentToken ?? ''; 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(); final requestStart = DateTime.now();
_lastTokenSet = requestStart; _lastTokenSet = requestStart;
repo.data.getChat(
Object? capturedError;
GetChatResponse? response;
try {
response = await repo.data.getChat(
token: token, token: token,
onUpdate: (data) { onError: (e) => capturedError = e,
);
} catch (e) {
capturedError = e;
}
if (_lastTokenSet.isAfter(requestStart)) return; if (_lastTokenSet.isAfter(requestStart)) return;
if ((innerState?.currentToken ?? '') != token) return; if ((innerState?.currentToken ?? '') != token) return;
add(DataGathered((s) => s.copyWith(chatResponse: data)));
}, 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'; import '../../../../../api/marianumcloud/talk/chat/getChatResponse.dart';
class ChatDataProvider { class ChatDataProvider {
void getChat({ Future<GetChatResponse> getChat({
required String token, required String token,
required void Function(GetChatResponse data) onUpdate, void Function(GetChatResponse data)? onUpdate,
}) { void Function(Object)? onError,
GetChatCache(chatToken: token, onUpdate: onUpdate); }) 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 '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.dart';
import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart';
import '../repository/chat_list_repository.dart'; import '../repository/chat_list_repository.dart';
@@ -7,6 +8,14 @@ import 'chat_list_event.dart';
import 'chat_list_state.dart'; import 'chat_list_state.dart';
class ChatListBloc extends LoadableHydratedBloc<ChatListEvent, ChatListState, ChatListRepository> { class ChatListBloc extends LoadableHydratedBloc<ChatListEvent, ChatListState, ChatListRepository> {
bool _forceRenew = false;
@override
void retry() {
_forceRenew = true;
super.retry();
}
@override @override
ChatListRepository repository() => ChatListRepository(); ChatListRepository repository() => ChatListRepository();
@@ -21,15 +30,39 @@ class ChatListBloc extends LoadableHydratedBloc<ChatListEvent, ChatListState, Ch
@override @override
Future<void> gatherData() async { 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))); add(DataGathered((s) => s.copyWith(rooms: rooms)));
_updateAppBadge(rooms); _updateAppBadge(rooms);
if (capturedError != null) throw capturedError!;
} }
Future<void> refresh({bool renew = true}) async { Future<void> refresh({bool renew = true}) async {
final rooms = await repo.data.getRooms(renew: renew); 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))); add(DataGathered((s) => s.copyWith(rooms: rooms)));
_updateAppBadge(rooms); _updateAppBadge(rooms);
} catch (e) {
capturedError = e;
}
if (capturedError != null) {
add(Error(LoadingError(
message: capturedError.toString(),
allowRetry: true,
)));
}
} }
Future<void> createDirectChat(String invite) async { 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/getRoomCache.dart';
import '../../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; import '../../../../../api/marianumcloud/talk/room/getRoomResponse.dart';
import '../../../../../api/marianumcloud/talk/createRoom/createRoom.dart'; import '../../../../../api/marianumcloud/talk/createRoom/createRoom.dart';
import '../../../../../api/marianumcloud/talk/createRoom/createRoomParams.dart'; import '../../../../../api/marianumcloud/talk/createRoom/createRoomParams.dart';
class ChatListDataProvider { class ChatListDataProvider {
Future<GetRoomResponse> getRooms({bool renew = false}) { Future<GetRoomResponse> getRooms({
final completer = Completer<GetRoomResponse>(); void Function(Object)? onError,
GetRoomCache( bool renew = false,
}) async {
GetRoomResponse? latest;
Object? capturedError;
final cache = GetRoomCache(
renew: renew, renew: renew,
onUpdate: (data) { onUpdate: (data) => latest = data,
if (!completer.isCompleted) completer.complete(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) => 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.dart';
import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart';
import '../repository/files_repository.dart'; import '../repository/files_repository.dart';
@@ -28,12 +30,14 @@ class FilesBloc extends LoadableHydratedBloc<FilesEvent, FilesState, FilesReposi
} }
Future<void> refresh() async { Future<void> refresh() async {
add(RefetchStarted<FilesState>());
final path = innerState?.currentPath ?? initialPath; final path = innerState?.currentPath ?? initialPath;
await _query(path); await _query(path);
} }
Future<void> setPath(List<String> path) async { Future<void> setPath(List<String> path) async {
add(Emit((s) => s.copyWith(currentPath: path, listing: null))); add(Emit((s) => s.copyWith(currentPath: path, listing: null)));
add(RefetchStarted<FilesState>());
await _query(path); await _query(path);
} }
@@ -45,8 +49,34 @@ class FilesBloc extends LoadableHydratedBloc<FilesEvent, FilesState, FilesReposi
Future<void> _query(List<String> path) async { Future<void> _query(List<String> path) async {
final pathString = path.isEmpty ? '/' : path.join('/'); final pathString = path.isEmpty ? '/' : path.join('/');
final listing = await repo.data.listFiles(pathString);
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); listing.files.removeWhere((file) => file.name.isEmpty || file.name == path.lastOrNull);
add(DataGathered((s) => s.copyWith(listing: listing))); 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 'package:nextcloud/nextcloud.dart';
import '../../../../../api/marianumcloud/webdav/queries/listFiles/listFilesCache.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'; import '../../../../../api/marianumcloud/webdav/webdavApi.dart';
class FilesDataProvider { class FilesDataProvider {
Future<ListFilesResponse> listFiles(String path) { /// Lists files at [path]. Cached payload is delivered via [onCacheData] as
final completer = Completer<ListFilesResponse>(); /// soon as it is read from disk, so callers can render stale data while the
ListFilesCache( /// 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, path: path,
onUpdate: (data) { onUpdate: (data) => latest = data,
if (!completer.isCompleted) completer.complete(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 { Future<void> createFolder(String fullPath) async {
@@ -33,4 +33,5 @@ const _$ModulesEnumMap = {
Modules.roomPlan: 'roomPlan', Modules.roomPlan: 'roomPlan',
Modules.gradeAveragesCalculator: 'gradeAveragesCalculator', Modules.gradeAveragesCalculator: 'gradeAveragesCalculator',
Modules.holidays: 'holidays', Modules.holidays: 'holidays',
Modules.marianumDates: 'marianumDates',
}; };
+4 -1
View File
@@ -1,5 +1,6 @@
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart';
import 'package:filesize/filesize.dart'; import 'package:filesize/filesize.dart';
import 'package:flowder/flowder.dart'; import 'package:flowder/flowder.dart';
import 'package:flutter/foundation.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/queries/listFiles/cacheableFile.dart';
import '../../../api/marianumcloud/webdav/webdavApi.dart'; import '../../../api/marianumcloud/webdav/webdavApi.dart';
import '../../../model/accountData.dart';
import '../../../model/endpointData.dart'; import '../../../model/endpointData.dart';
import '../../../widget/centeredLeading.dart'; import '../../../widget/centeredLeading.dart';
import '../../../widget/confirmDialog.dart'; import '../../../widget/confirmDialog.dart';
@@ -41,6 +43,7 @@ class FileElement extends StatefulWidget {
file: File(local), file: File(local),
progress: ProgressImplementation(), progress: ProgressImplementation(),
deleteOnCancel: true, deleteOnCancel: true,
client: Dio(BaseOptions(headers: AccountData().authHeaders())),
onDone: () { onDone: () {
//Future<OpenResult> result = OpenFile.open(local); // TODO legacy - refactor: remove onDone parameter //Future<OpenResult> result = OpenFile.open(local); // TODO legacy - refactor: remove onDone parameter
Navigator.of(context).push(MaterialPageRoute(builder: (context) => FileViewer(path: local))); Navigator.of(context).push(MaterialPageRoute(builder: (context) => FileViewer(path: local)));
@@ -52,7 +55,7 @@ class FileElement extends StatefulWidget {
); );
return await Flowder.download( return await Flowder.download(
'${await WebdavApi.webdavConnectString}$encodedPath', '${WebdavApi.buildWebdavUrl()}$encodedPath',
options, options,
); );
} }
+2 -2
View File
@@ -164,9 +164,9 @@ class _FilesViewState extends State<_FilesView> {
child: const Icon(Icons.add), child: const Icon(Icons.add),
), ),
body: LoadableStateConsumer<FilesBloc, FilesState>( body: LoadableStateConsumer<FilesBloc, FilesState>(
isReady: (state) => state.listing != null,
child: (state, _) { child: (state, _) {
final listing = state.listing; final listing = state.listing!;
if (listing == null) return const SizedBox.shrink();
if (listing.files.isEmpty) { if (listing.files.isEmpty) {
return const PlaceholderView(icon: Icons.folder_off_rounded, text: 'Der Ordner ist leer'); return const PlaceholderView(icon: Icons.folder_off_rounded, text: 'Der Ordner ist leer');
} }
+13 -18
View File
@@ -4,11 +4,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../api/marianumcloud/talk/chat/getChatResponse.dart'; import '../../../api/marianumcloud/talk/chat/getChatResponse.dart';
import '../../../api/marianumcloud/talk/room/getRoomResponse.dart'; import '../../../api/marianumcloud/talk/room/getRoomResponse.dart';
import '../../../extensions/dateTime.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_bloc.dart';
import '../../../state/app/modules/chat/bloc/chat_state.dart'; import '../../../state/app/modules/chat/bloc/chat_state.dart';
import '../../../theming/appTheme.dart'; import '../../../theming/appTheme.dart';
import '../../../widget/clickableAppBar.dart'; import '../../../widget/clickableAppBar.dart';
import '../../../widget/loadingSpinner.dart';
import '../../../widget/userAvatar.dart'; import '../../../widget/userAvatar.dart';
import 'chatDetails/chatInfo.dart'; import 'chatDetails/chatInfo.dart';
import 'components/chatBubble.dart'; import 'components/chatBubble.dart';
@@ -33,16 +33,8 @@ class _ChatViewState extends State<ChatView> {
context.read<ChatBloc>().setToken(widget.room.token); context.read<ChatBloc>().setToken(widget.room.token);
} }
@override List<Widget> _buildMessages(GetChatResponse response) {
Widget build(BuildContext context) => BlocBuilder<ChatBloc, dynamic>(
builder: (context, _) {
final state = context.watch<ChatBloc>().state.data ?? const ChatState();
final response = state.chatResponse;
final isLoading = response == null;
final messages = <Widget>[]; final messages = <Widget>[];
if (response != null) {
var lastDate = DateTime.now(); var lastDate = DateTime.now();
for (final element in response.sortByTimestamp()) { for (final element in response.sortByTimestamp()) {
final elementDate = DateTime.fromMillisecondsSinceEpoch(element.timestamp * 1000); final elementDate = DateTime.fromMillisecondsSinceEpoch(element.timestamp * 1000);
@@ -86,9 +78,12 @@ class _ChatViewState extends State<ChatView> {
refetch: ({bool renew = false}) => _refresh(), refetch: ({bool renew = false}) => _refresh(),
)); ));
} }
return messages;
} }
return Scaffold( @override
Widget build(BuildContext context) => Scaffold(
backgroundColor: const Color(0xffefeae2), backgroundColor: const Color(0xffefeae2),
appBar: ClickableAppBar( appBar: ClickableAppBar(
onTap: () => TalkNavigator.pushSplitView(context, ChatInfo(widget.room)), onTap: () => TalkNavigator.pushSplitView(context, ChatInfo(widget.room)),
@@ -114,15 +109,17 @@ class _ChatViewState extends State<ChatView> {
invertColors: AppTheme.isDarkMode(context), invertColors: AppTheme.isDarkMode(context),
), ),
), ),
child: isLoading child: Column(
? const LoadingSpinner()
: Column(
children: [ children: [
Expanded( Expanded(
child: ListView( child: LoadableStateConsumer<ChatBloc, ChatState>(
isReady: (state) =>
state.chatResponse != null && state.currentToken == widget.room.token,
child: (state, _) => ListView(
reverse: true, reverse: true,
controller: _listController, controller: _listController,
children: messages.reversed.toList(), children: _buildMessages(state.chatResponse!).reversed.toList(),
),
), ),
), ),
Container( Container(
@@ -135,6 +132,4 @@ class _ChatViewState extends State<ChatView> {
), ),
), ),
); );
},
);
} }
@@ -63,7 +63,8 @@ class ChatMessage {
fadeInDuration: Duration.zero, fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero, fadeOutDuration: Duration.zero,
errorListener: (value) {}, 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}') ...[ if(originalMessage != '{file}') ...[
SizedBox(height: 5), SizedBox(height: 5),
+1 -2
View File
@@ -60,11 +60,10 @@ class _UserAvatarState extends State<UserAvatar> {
Future<_AvatarPayload?> _fetch(String url) async { Future<_AvatarPayload?> _fetch(String url) async {
try { try {
final auth = base64Encode(utf8.encode(AccountData().buildHttpAuthString()));
final response = await http.get( final response = await http.get(
Uri.parse(url), Uri.parse(url),
headers: { headers: {
'Authorization': 'Basic $auth', 'Authorization': AccountData().getBasicAuthHeader(),
'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml', 'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml',
}, },
); );