add syncing waiting / error / failed indicator

This commit is contained in:
Henry Hiles 2026-10-14 23:21:31 -04:00
commit 07700c1eff
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
6 changed files with 186 additions and 75 deletions

View file

@ -85,10 +85,6 @@ class GomuksListenerController extends AsyncNotifier<void> {
.read(SpaceEdgesController.provider.notifier) .read(SpaceEdgesController.provider.notifier)
.set(spaceEdges); .set(spaceEdges);
} }
// ref
// .read(SyncStatusController.provider.notifier)
// .set(SyncStatus.fromJson(decodedMuksEvent));
default: default:
debugPrint("Unhandled event: $muksEventType"); debugPrint("Unhandled event: $muksEventType");
} }

View file

@ -1,4 +1,5 @@
import "package:freezed_annotation/freezed_annotation.dart"; import "package:freezed_annotation/freezed_annotation.dart";
import "package:nexus/models/epoch_date_time_converter.dart";
part "sync_status.freezed.dart"; part "sync_status.freezed.dart";
part "sync_status.g.dart"; part "sync_status.g.dart";
@ -7,8 +8,9 @@ part "sync_status.g.dart";
@JsonSerializable() @JsonSerializable()
class const SyncStatus({ class const SyncStatus({
required final SyncStatusType type, required final SyncStatusType type,
required final String? error,
required final int errorCount, required final int errorCount,
final String? error,
@EpochDateTimeConverter() final DateTime? lastSync,
}) with _$SyncStatus { }) with _$SyncStatus {
Map<String, Object?> toJson() => _$SyncStatusToJson(this); Map<String, Object?> toJson() => _$SyncStatusToJson(this);

View file

@ -13,7 +13,9 @@ final class const ErrorDialog(
return AlertDialog( return AlertDialog(
title: Text("An Error Occurred"), title: Text("An Error Occurred"),
content: SingleChildScrollView( content: SingleChildScrollView(
child: SelectableText("$error\n\n$stackTrace"), child: SelectableText(
"$error${stackTrace == null ? "" : "\n\n$stackTrace"}",
),
), ),
actions: [ actions: [
if (provider != null) if (provider != null)

View file

@ -6,6 +6,7 @@ import "package:nexus/controllers/key.dart";
import "package:nexus/controllers/notification_request.dart"; import "package:nexus/controllers/notification_request.dart";
import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/appbar.dart";
import "package:nexus/widgets/sidebar.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/room_chat/room_chat.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
@ -57,11 +58,18 @@ class const ChatPage({super.key}) extends HookConsumerWidget {
), ),
], ],
) )
: Center( : Column(
child: Column( children: [
mainAxisSize: .min, const SyncStatusBanner(),
children: [Loading(), Text("Syncing...")], Expanded(
), child: Center(
child: Column(
mainAxisSize: .min,
children: [Loading(), Text("Syncing...")],
),
),
),
],
), ),
drawer: isDesktop || !initComplete ? null : Sidebar(), drawer: isDesktop || !initComplete ? null : Sidebar(),
), ),

View file

@ -18,6 +18,7 @@ import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/pinned_events_drawer.dart"; import "package:nexus/widgets/pinned_events_drawer.dart";
import "package:nexus/widgets/member_list.dart"; import "package:nexus/widgets/member_list.dart";
import "package:nexus/widgets/room_appbar.dart"; import "package:nexus/widgets/room_appbar.dart";
import "package:nexus/widgets/sync_status_banner.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:nexus/widgets/room_chat/chat_timeline.dart"; import "package:nexus/widgets/room_chat/chat_timeline.dart";
import "package:nexus/helpers/extensions/build_event_options.dart"; import "package:nexus/helpers/extensions/build_event_options.dart";
@ -62,7 +63,12 @@ final class const RoomChat({
isDesktop: isDesktop, isDesktop: isDesktop,
onOpenDrawer: Scaffold.of(context).openDrawer, 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, onOpenPinnedMessagesList: Scaffold.of(middleContext).openEndDrawer,
), ),
body: Row( body: Column(
children: [ children: [
const SyncStatusBanner(),
Expanded( Expanded(
child: Stack( child: Row(
children: [ children: [
Positioned.fill( Expanded(
child: Padding( child: Stack(
padding: .symmetric(horizontal: 4), children: [
child: switch (controllerData) { Positioned.fill(
AsyncLoading(value: _?) || child: Padding(
AsyncData(value: _) => ChatTimeline( padding: .symmetric(horizontal: 4),
scroll: scroll, child: switch (controllerData) {
roomId: roomId, AsyncLoading(value: _?) ||
jumpToEvent: jumpToEvent, AsyncData(value: _) => ChatTimeline(
getEventOptions: getEventOptions, scroll: scroll,
highlightedEvent: highlightedEvent.value, roomId: roomId,
composerHeight: composerSize.value, jumpToEvent: jumpToEvent,
getEventOptions: getEventOptions,
highlightedEvent: highlightedEvent.value,
composerHeight: composerSize.value,
),
AsyncLoading _ => Loading(),
AsyncError(:final error, :final stackTrace) =>
ErrorDialog(error, stackTrace),
},
),
), ),
AsyncLoading _ => Loading(), Positioned(
AsyncError(:final error, :final stackTrace) => right: 16,
ErrorDialog(error, stackTrace), bottom: composerSize.value,
}, child: IgnorePointer(
), ignoring: scroll.atBottom,
), child: AnimatedOpacity(
Positioned( opacity: scroll.atBottom ? 0 : 1,
right: 16, duration: const Duration(milliseconds: 200),
bottom: composerSize.value, child: FloatingActionButton.small(
child: IgnorePointer( onPressed: scroll.jumpToBottom,
ignoring: scroll.atBottom, child: const Icon(Icons.keyboard_arrow_down),
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,
Positioned( right: 0,
bottom: 0, child: MeasureSize(
left: 0, onChange: (size) =>
right: 0, composerSize.value = size.height,
child: MeasureSize( child: Composer(
onChange: (size) => composerSize.value = size.height, roomId,
child: Composer( node: composerNode,
roomId, onSend:
node: composerNode, (
onSend: text, {
(text, {required shouldMention, required tags}) => required shouldMention,
notifier required tags,
.send( }) => notifier
text, .send(
tags: tags, text,
relationType: relationType.value, tags: tags,
shouldMention: shouldMention, relationType: relationType.value,
relatedEventRowId: relatedEvent.value, shouldMention: shouldMention,
) relatedEventRowId: relatedEvent.value,
.onError(showError), )
relationType: relationType.value, .onError(showError),
relatedEvent: relatedEvent.value, relationType: relationType.value,
onDismiss: () { relatedEvent: relatedEvent.value,
relatedEvent.value = null; onDismiss: () {
composerNode.requestFocus(); relatedEvent.value = null;
}, composerNode.requestFocus();
), },
),
),
),
],
), ),
), ),
if (memberListOpened && showMembersByDefault)
MemberList(roomId),
], ],
), ),
), ),
if (memberListOpened && showMembersByDefault) MemberList(roomId),
], ],
), ),
), ),

View file

@ -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),
],
),
),
),
),
);
}
}