better loading indicators for timetables, talk and files
This commit is contained in:
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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()}/';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,49 +8,62 @@ 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) {
|
||||||
duration: animationDuration,
|
final bloc = context.watch<LoadableStateBloc>();
|
||||||
child: AnimatedSwitcher(
|
final isOfflineWithCache = hasContent && bloc.connectivityStatusKnown() && !bloc.isConnected();
|
||||||
duration: animationDuration,
|
final shouldShow = visible || isOfflineWithCache;
|
||||||
transitionBuilder: (Widget child, Animation<double> animation) => SlideTransition(
|
|
||||||
position: Tween<Offset>(
|
return AnimatedSize(
|
||||||
begin: const Offset(0.0, -1.0),
|
duration: animationDuration,
|
||||||
end: Offset.zero,
|
child: AnimatedSwitcher(
|
||||||
).animate(animation),
|
duration: animationDuration,
|
||||||
child: child,
|
transitionBuilder: (Widget child, Animation<double> animation) => SlideTransition(
|
||||||
),
|
position: Tween<Offset>(
|
||||||
child: Visibility(
|
begin: const Offset(0.0, -1.0),
|
||||||
key: Key(visible.hashCode.toString()),
|
end: Offset.zero,
|
||||||
visible: visible,
|
).animate(animation),
|
||||||
replacement: const SizedBox(width: double.infinity),
|
child: child,
|
||||||
child: Builder(
|
),
|
||||||
builder: (context) {
|
child: Visibility(
|
||||||
var bloc = context.watch<LoadableStateBloc>();
|
key: Key(shouldShow.hashCode.toString()),
|
||||||
return InkWell(
|
visible: shouldShow,
|
||||||
onTap: () {
|
replacement: const SizedBox(width: double.infinity),
|
||||||
if(!bloc.isConnected()) return;
|
child: Builder(
|
||||||
InfoDialog.show(context, 'Exception: ${message.toString()}');
|
builder: (context) {
|
||||||
},
|
var bloc = context.watch<LoadableStateBloc>();
|
||||||
child: Container(
|
return InkWell(
|
||||||
height: 20,
|
onTap: () {
|
||||||
decoration: BoxDecoration(
|
if(!bloc.isConnected()) return;
|
||||||
color: bloc.connectionColor(context),
|
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 {
|
class LoadableStateErrorBarText extends StatefulWidget {
|
||||||
|
|||||||
@@ -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(
|
|
||||||
token: token,
|
Object? capturedError;
|
||||||
onUpdate: (data) {
|
GetChatResponse? response;
|
||||||
if (_lastTokenSet.isAfter(requestStart)) return;
|
try {
|
||||||
if ((innerState?.currentToken ?? '') != token) return;
|
response = await repo.data.getChat(
|
||||||
add(DataGathered((s) => s.copyWith(chatResponse: data)));
|
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';
|
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>());
|
||||||
add(DataGathered((s) => s.copyWith(rooms: rooms)));
|
Object? capturedError;
|
||||||
_updateAppBadge(rooms);
|
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 {
|
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);
|
|
||||||
listing.files.removeWhere((file) => file.name.isEmpty || file.name == path.lastOrNull);
|
Object? capturedError;
|
||||||
add(DataGathered((s) => s.copyWith(listing: listing)));
|
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 '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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,108 +33,103 @@ class _ChatViewState extends State<ChatView> {
|
|||||||
context.read<ChatBloc>().setToken(widget.room.token);
|
context.read<ChatBloc>().setToken(widget.room.token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildMessages(GetChatResponse response) {
|
||||||
|
final messages = <Widget>[];
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context) => BlocBuilder<ChatBloc, dynamic>(
|
Widget build(BuildContext context) => Scaffold(
|
||||||
builder: (context, _) {
|
backgroundColor: const Color(0xffefeae2),
|
||||||
final state = context.watch<ChatBloc>().state.data ?? const ChatState();
|
appBar: ClickableAppBar(
|
||||||
final response = state.chatResponse;
|
onTap: () => TalkNavigator.pushSplitView(context, ChatInfo(widget.room)),
|
||||||
final isLoading = response == null;
|
appBar: AppBar(
|
||||||
|
title: Row(
|
||||||
final messages = <Widget>[];
|
children: [
|
||||||
|
widget.avatar,
|
||||||
if (response != null) {
|
const SizedBox(width: 10),
|
||||||
var lastDate = DateTime.now();
|
Expanded(
|
||||||
for (final element in response.sortByTimestamp()) {
|
child: Text(widget.room.displayName, overflow: TextOverflow.ellipsis, maxLines: 1),
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: Container(
|
),
|
||||||
decoration: BoxDecoration(
|
body: Container(
|
||||||
image: DecorationImage(
|
decoration: BoxDecoration(
|
||||||
image: const AssetImage('assets/background/chat.png'),
|
image: DecorationImage(
|
||||||
scale: 1.5,
|
image: const AssetImage('assets/background/chat.png'),
|
||||||
opacity: 1,
|
scale: 1.5,
|
||||||
repeat: ImageRepeat.repeat,
|
opacity: 1,
|
||||||
invertColors: AppTheme.isDarkMode(context),
|
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<ChatBloc, ChatState>(
|
||||||
|
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)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user