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

@ -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)

View file

@ -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(),
),

View file

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

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