diff --git a/lib/controllers/gomuks_listener.dart b/lib/controllers/gomuks_listener.dart index d0cbf59..07e02f6 100644 --- a/lib/controllers/gomuks_listener.dart +++ b/lib/controllers/gomuks_listener.dart @@ -85,10 +85,6 @@ class GomuksListenerController extends AsyncNotifier { .read(SpaceEdgesController.provider.notifier) .set(spaceEdges); } - - // ref - // .read(SyncStatusController.provider.notifier) - // .set(SyncStatus.fromJson(decodedMuksEvent)); default: debugPrint("Unhandled event: $muksEventType"); } diff --git a/lib/models/sync_status.dart b/lib/models/sync_status.dart index 57bd882..237cf3e 100644 --- a/lib/models/sync_status.dart +++ b/lib/models/sync_status.dart @@ -1,4 +1,5 @@ import "package:freezed_annotation/freezed_annotation.dart"; +import "package:nexus/models/epoch_date_time_converter.dart"; part "sync_status.freezed.dart"; part "sync_status.g.dart"; @@ -7,8 +8,9 @@ part "sync_status.g.dart"; @JsonSerializable() class const SyncStatus({ required final SyncStatusType type, - required final String? error, required final int errorCount, + final String? error, + @EpochDateTimeConverter() final DateTime? lastSync, }) with _$SyncStatus { Map toJson() => _$SyncStatusToJson(this); diff --git a/lib/widgets/error_dialog.dart b/lib/widgets/error_dialog.dart index ddc4c98..a12a3b4 100644 --- a/lib/widgets/error_dialog.dart +++ b/lib/widgets/error_dialog.dart @@ -13,7 +13,9 @@ final class const ErrorDialog( return AlertDialog( title: Text("An Error Occurred"), content: SingleChildScrollView( - child: SelectableText("$error\n\n$stackTrace"), + child: SelectableText( + "$error${stackTrace == null ? "" : "\n\n$stackTrace"}", + ), ), actions: [ if (provider != null) diff --git a/lib/widgets/pages/chat.dart b/lib/widgets/pages/chat.dart index 11cb712..5b39636 100644 --- a/lib/widgets/pages/chat.dart +++ b/lib/widgets/pages/chat.dart @@ -6,6 +6,7 @@ import "package:nexus/controllers/key.dart"; import "package:nexus/controllers/notification_request.dart"; import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/sidebar.dart"; +import "package:nexus/widgets/sync_status_banner.dart"; import "package:nexus/widgets/room_chat/room_chat.dart"; import "package:nexus/widgets/loading.dart"; @@ -57,11 +58,18 @@ class const ChatPage({super.key}) extends HookConsumerWidget { ), ], ) - : Center( - child: Column( - mainAxisSize: .min, - children: [Loading(), Text("Syncing...")], - ), + : Column( + children: [ + const SyncStatusBanner(), + Expanded( + child: Center( + child: Column( + mainAxisSize: .min, + children: [Loading(), Text("Syncing...")], + ), + ), + ), + ], ), drawer: isDesktop || !initComplete ? null : Sidebar(), ), diff --git a/lib/widgets/room_chat/room_chat.dart b/lib/widgets/room_chat/room_chat.dart index 74a0977..f02a743 100644 --- a/lib/widgets/room_chat/room_chat.dart +++ b/lib/widgets/room_chat/room_chat.dart @@ -18,6 +18,7 @@ import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/pinned_events_drawer.dart"; import "package:nexus/widgets/member_list.dart"; import "package:nexus/widgets/room_appbar.dart"; +import "package:nexus/widgets/sync_status_banner.dart"; import "package:nexus/main.dart"; import "package:nexus/widgets/room_chat/chat_timeline.dart"; import "package:nexus/helpers/extensions/build_event_options.dart"; @@ -62,7 +63,12 @@ final class const RoomChat({ isDesktop: isDesktop, onOpenDrawer: Scaffold.of(context).openDrawer, ), - body: nothing, + body: Column( + children: [ + const SyncStatusBanner(), + Expanded(child: nothing), + ], + ), ); } @@ -199,79 +205,91 @@ final class const RoomChat({ }, onOpenPinnedMessagesList: Scaffold.of(middleContext).openEndDrawer, ), - body: Row( + body: Column( children: [ + const SyncStatusBanner(), Expanded( - child: Stack( + child: Row( children: [ - Positioned.fill( - child: Padding( - padding: .symmetric(horizontal: 4), - child: switch (controllerData) { - AsyncLoading(value: _?) || - AsyncData(value: _) => ChatTimeline( - scroll: scroll, - roomId: roomId, - jumpToEvent: jumpToEvent, - getEventOptions: getEventOptions, - highlightedEvent: highlightedEvent.value, - composerHeight: composerSize.value, + Expanded( + child: Stack( + children: [ + Positioned.fill( + child: Padding( + padding: .symmetric(horizontal: 4), + child: switch (controllerData) { + AsyncLoading(value: _?) || + AsyncData(value: _) => ChatTimeline( + scroll: scroll, + roomId: roomId, + jumpToEvent: jumpToEvent, + getEventOptions: getEventOptions, + highlightedEvent: highlightedEvent.value, + composerHeight: composerSize.value, + ), + AsyncLoading _ => Loading(), + AsyncError(:final error, :final stackTrace) => + ErrorDialog(error, stackTrace), + }, + ), ), - AsyncLoading _ => Loading(), - AsyncError(:final error, :final stackTrace) => - ErrorDialog(error, stackTrace), - }, - ), - ), - Positioned( - right: 16, - bottom: composerSize.value, - child: IgnorePointer( - ignoring: scroll.atBottom, - child: AnimatedOpacity( - opacity: scroll.atBottom ? 0 : 1, - duration: const Duration(milliseconds: 200), - child: FloatingActionButton.small( - onPressed: scroll.jumpToBottom, - child: const Icon(Icons.keyboard_arrow_down), + Positioned( + right: 16, + bottom: composerSize.value, + child: IgnorePointer( + ignoring: scroll.atBottom, + child: AnimatedOpacity( + opacity: scroll.atBottom ? 0 : 1, + duration: const Duration(milliseconds: 200), + child: FloatingActionButton.small( + onPressed: scroll.jumpToBottom, + child: const Icon(Icons.keyboard_arrow_down), + ), + ), + ), ), - ), - ), - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: MeasureSize( - onChange: (size) => composerSize.value = size.height, - child: Composer( - roomId, - node: composerNode, - onSend: - (text, {required shouldMention, required tags}) => - notifier - .send( - text, - tags: tags, - relationType: relationType.value, - shouldMention: shouldMention, - relatedEventRowId: relatedEvent.value, - ) - .onError(showError), - relationType: relationType.value, - relatedEvent: relatedEvent.value, - onDismiss: () { - relatedEvent.value = null; - composerNode.requestFocus(); - }, - ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: MeasureSize( + onChange: (size) => + composerSize.value = size.height, + child: Composer( + roomId, + node: composerNode, + onSend: + ( + text, { + required shouldMention, + required tags, + }) => notifier + .send( + text, + tags: tags, + relationType: relationType.value, + shouldMention: shouldMention, + relatedEventRowId: relatedEvent.value, + ) + .onError(showError), + relationType: relationType.value, + relatedEvent: relatedEvent.value, + onDismiss: () { + relatedEvent.value = null; + composerNode.requestFocus(); + }, + ), + ), + ), + ], ), ), + + if (memberListOpened && showMembersByDefault) + MemberList(roomId), ], ), ), - - if (memberListOpened && showMembersByDefault) MemberList(roomId), ], ), ), diff --git a/lib/widgets/sync_status_banner.dart b/lib/widgets/sync_status_banner.dart new file mode 100644 index 0000000..977022f --- /dev/null +++ b/lib/widgets/sync_status_banner.dart @@ -0,0 +1,85 @@ +import "package:hooks_riverpod/hooks_riverpod.dart"; +import "package:material_ui/material_ui.dart"; +import "package:nexus/controllers/sync_status.dart"; +import "package:nexus/widgets/error_dialog.dart"; +import "package:timeago/timeago.dart"; + +class const SyncStatusBanner({super.key}) extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final status = ref.watch(SyncStatusController.provider); + final colorScheme = Theme.of(context).colorScheme; + + final banner = switch (status?.type) { + null || .ok => null, + .waiting => ( + title: "Waiting for sync...", + icon: Icons.sync, + background: colorScheme.primary, + foreground: colorScheme.onPrimary, + ), + .erroring => ( + title: "Sync is failing, retrying...", + icon: Icons.sync_problem, + background: colorScheme.error, + foreground: colorScheme.onError, + ), + .permanentlyFailed => ( + title: "Sync has permanently failed", + icon: Icons.cloud_off, + background: colorScheme.error, + foreground: colorScheme.onError, + ), + }; + + final details = status == null + ? null + : [ + ?status.error, + if (status.errorCount > 0) "Failed attempts: ${status.errorCount}.", + if (status.lastSync case final lastSync?) + "Last synced ${format(lastSync)}.", + ]; + + return AnimatedSize( + duration: const .new(milliseconds: 200), + alignment: .topCenter, + child: banner == null + ? const SizedBox(width: double.infinity) + : Material( + color: banner.background, + child: InkWell( + onTap: details == null + ? null + : () => showDialog( + context: context, + builder: (context) => + ErrorDialog(details.join("\n\n"), null), + ), + child: Padding( + padding: const .symmetric(horizontal: 16, vertical: 8), + child: Row( + spacing: 12, + children: [ + Icon(banner.icon, size: 20, color: banner.foreground), + Expanded( + child: Text( + banner.title, + maxLines: 1, + overflow: .ellipsis, + style: TextStyle( + color: banner.foreground, + fontWeight: .bold, + ), + ), + ), + if (details != null) + Icon(Icons.info_outline, color: banner.foreground), + ], + ), + ), + ), + ), + ); + } +}