reorganize and rename widgets

This commit is contained in:
Henry Hiles 2026-11-09 17:25:02 -05:00
commit d5f29dc344
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
7 changed files with 568 additions and 547 deletions

View file

@ -0,0 +1,52 @@
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
import "package:nexus/controllers/invites.dart";
import "package:nexus/helpers/extensions/get_localpart.dart";
import "package:nexus/helpers/extensions/room_summary_display.dart";
import "package:nexus/models/room_summary.dart";
import "package:nexus/widgets/avatars/avatar_or_hash.dart";
import "package:nexus/widgets/rooms/room_summary_sheet.dart";
class const InviteCard(final RoomSummary invite, {super.key})
extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final responding = ref
.watch(InvitesController.respondMutation(invite.roomId))
.isPending;
final name = invite.name ?? invite.canonicalAlias ?? invite.roomId;
final inviterName =
invite.inviterProfile?.displayName ?? invite.invitedBy?.localpart;
return Card(
clipBehavior: .antiAlias,
child: ListTile(
onTap: responding
? null
: () => context.showAdaptiveSheet((_) => RoomSummarySheet(invite)),
trailing: responding
? const SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(),
)
: null,
leading: AvatarOrHash(
invite.avatarUrl,
name,
dimension: 40,
fallback: invite.isDirect ? null : Icon(invite.icon),
),
title: Text(name, maxLines: 1, overflow: .ellipsis),
subtitle: Text(
[
if (inviterName != null)
"${invite.isDirect ? "Direct message from" : "Invited by"} $inviterName",
].join("\n"),
maxLines: 2,
overflow: .ellipsis,
),
),
);
}
}

View file

@ -0,0 +1,90 @@
import "package:nexus/widgets/sidebar/invite_card.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
import "package:nexus/controllers/invites.dart";
import "package:nexus/main.dart";
import "package:nexus/models/nav_page.dart";
class const InvitesList({super.key}) extends ConsumerWidget implements NavPage {
@override
String get title => "Invites";
@override
IconData get icon => Icons.inbox;
@override
int? badgeCount(WidgetRef ref) => switch (ref.watch(
InvitesController.provider.select((invites) => invites.length),
)) {
0 => null,
final count => count,
};
@override
Widget build(BuildContext context, WidgetRef ref) {
final invites = ref.watch(InvitesController.provider);
final decliningAll = ref
.watch(InvitesController.declineAllMutation)
.isPending;
Future<void> declineAll() async {
final confirmed = await context.showAdaptiveSheet<bool>(
(context) => SheetLayout(
title: const Text("Decline All Invites"),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text("Cancel"),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text("Decline All"),
),
],
children: [
Text(
"Are you sure you want to decline all ${invites.length} invites?",
),
],
),
);
if (confirmed != true) return;
await InvitesController.declineAllMutation
.run(
ref,
(_) => ref.read(InvitesController.provider.notifier).declineAll(),
)
.onError(showError);
}
return Scaffold(
backgroundColor: Colors.transparent,
floatingActionButton: invites.isEmpty
? null
: FloatingActionButton.extended(
onPressed: decliningAll ? null : declineAll,
icon: decliningAll
? const SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(),
)
: const Icon(Icons.clear_all),
label: const Text("Decline all"),
),
body: invites.isEmpty
? Center(
child: Text(
"No invites",
style: Theme.of(context).textTheme.headlineMedium,
),
)
: ListView(
// Leave room for the FAB under the last invite
padding: const .only(left: 8, top: 8, right: 8, bottom: 88),
children: invites.map(InviteCard.new).toList(),
),
);
}
}

View file

@ -0,0 +1,206 @@
import "dart:async";
import "package:m3e_buttons/m3e_buttons.dart";
import "package:material_ui/material_ui.dart";
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/notification_request.dart";
import "package:nexus/controllers/notifications.dart";
import "package:nexus/helpers/extensions/focus_room.dart";
import "package:nexus/models/event.dart";
import "package:nexus/models/nav_page.dart";
import "package:nexus/widgets/common/error_dialog.dart";
import "package:nexus/widgets/common/highlight_wrapper.dart";
import "package:nexus/widgets/common/loading.dart";
import "package:nexus/widgets/renderers/event.dart";
import "package:super_sliver_list/super_sliver_list.dart";
class const NotificationsList({super.key})
extends HookConsumerWidget
implements NavPage {
@override
String get title => "Notifications";
@override
IconData get icon => Icons.notifications;
@override
Null badgeCount(_) => null;
@override
Widget build(BuildContext context, WidgetRef ref) {
final options = <M3EToggleButtonGroupAction, UnreadType>{
M3EToggleButtonGroupAction(
checkedLabel: Text("Mentions"),
icon: Icon(Icons.alternate_email),
): .highlight,
M3EToggleButtonGroupAction(
checkedLabel: Text("All Notifications"),
icon: Icon(Icons.notifications),
): .notify,
};
final allNotificationsIndex = options.values.toList().indexOf(.notify);
final initialRequest = useMemoized(
() => ref.read(NotificationRequestController.provider),
);
final unreadTypeIndex = useState(
initialRequest == null ? 0 : allNotificationsIndex,
);
final highlightedId = useState(initialRequest);
final listController = useRef(ListController());
final scrollController = useScrollController();
final provider = NotificationsController.provider((
options.values.toList()[unreadTypeIndex.value],
null,
));
final notifications = ref.watch(provider);
final notifier = ref.watch(provider.notifier);
void consumeRequest() => Future.microtask(
ref.read(NotificationRequestController.provider.notifier).consume,
);
useEffect(() {
if (initialRequest != null) consumeRequest();
return null;
}, []);
ref.listen(NotificationRequestController.provider, (_, next) {
if (next == null) return;
unreadTypeIndex.value = allNotificationsIndex;
highlightedId.value = next;
consumeRequest();
});
useEffect(() {
final target = highlightedId.value;
if (target == null) return null;
Timer? timer;
var done = false;
void listener() => WidgetsBinding.instance.addPostFrameCallback((
_,
) async {
if (done || !listController.value.isAttached) return;
final notifications = await ref.read(provider.future);
final index = notifications.indexWhere((tuple) => tuple.$1 == target);
if (done || index == -1) return;
done = true;
listController.value.removeListener(listener);
listController.value.animateToItem(
index: index,
scrollController: scrollController,
alignment: 0.5,
duration: (_) => Durations.extralong1,
curve: (_) => Curves.easeInOut,
);
timer = Timer(Durations.extralong4, () {
highlightedId.value = null;
});
});
listController.value.addListener(listener);
// The list may already be laid out, in which case no change is notified
listener();
return () {
done = true;
listController.value.removeListener(listener);
timer?.cancel();
};
}, [highlightedId.value]);
useEffect(() {
Future<void> listener() async {
if (!scrollController.hasClients || notifications.isLoading) return;
if (scrollController.position.pixels >=
scrollController.position.maxScrollExtent) {
await notifier.loadOlder();
}
}
scrollController.addListener(listener);
return () => scrollController.removeListener(listener);
}, [scrollController, notifications]);
return Stack(
children: [
Column(
children: [
if (notifications is AsyncLoading && notifications.value != null)
const LinearProgressIndicator(minHeight: 2),
Expanded(
child: switch (notifications) {
AsyncData(:final value) || AsyncLoading(:final value?) =>
value.isEmpty
? Center(
child: Text(
"No notifications yet",
style: Theme.of(context).textTheme.headlineMedium,
),
)
: SuperListView.builder(
listController: listController.value,
controller: scrollController,
itemCount: value.length,
padding: const .symmetric(
horizontal: 8,
vertical: 12,
),
reverse: true,
itemBuilder: (context, index) {
final (rowId, roomId) = value[index];
final isHighlighted = rowId == highlightedId.value;
return Padding(
padding: .only(top: 8),
child: HighlightWrapper(
InkWell(
onTap: () async {
final navigator = Navigator.of(context);
if (navigator.canPop()) navigator.pop();
await ref.focusRoom(roomId, rowId);
},
child: IgnorePointer(
child: EventRenderer(rowId, roomId: roomId),
),
),
isHighlighted: isHighlighted,
),
);
},
),
AsyncLoading() => const Loading(),
AsyncError(:final error, :final stackTrace) => ErrorDialog(
error,
stackTrace,
),
},
),
],
),
Align(
alignment: .topRight,
child: Padding(
padding: .all(16),
child: M3EToggleButtonGroup(
selectedIndex: unreadTypeIndex.value,
onSelectedIndexChanged: (index) =>
unreadTypeIndex.value = index ?? unreadTypeIndex.value,
actions: options.keys.toList(),
),
),
),
],
);
}
}

View file

@ -0,0 +1,59 @@
import "package:nexus/widgets/sidebar/spaces_rail.dart";
import "package:nexus/widgets/sidebar/rooms_pane.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/models/nav_page.dart";
import "package:nexus/widgets/common/error_dialog.dart";
import "package:nexus/controllers/rooms.dart";
import "package:nexus/controllers/spaces.dart";
class const RoomList({super.key}) extends ConsumerWidget implements NavPage {
@override
String get title => "Rooms";
@override
IconData get icon => Icons.list;
@override
int? badgeCount(WidgetRef ref) {
final (notifications, hasUnread) = ref.watch(
RoomsController.provider.select(
(rooms) => rooms.values.fold(
(0, false),
(total, room) => (
total.$1 + (room.metadata?.unreadNotifications ?? 0),
total.$2 || (room.metadata?.unreadMessages ?? 0) > 0,
),
),
),
);
return notifications > 0
? notifications
: hasUnread
? 0
: null;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final spacesAsync = ref.watch(SpacesController.provider);
if (spacesAsync.value == null) {
return switch (spacesAsync) {
AsyncError(:final error, :final stackTrace) => ErrorDialog(
error,
stackTrace,
),
_ => const Center(child: CircularProgressIndicator()),
};
}
return const Row(
children: [
SpacesRail(),
Expanded(child: RoomsPane()),
],
);
}
}

View file

@ -0,0 +1,143 @@
import "dart:math";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
import "package:nexus/helpers/extensions/get_all_child_rooms.dart";
import "package:nexus/helpers/extensions/room_destinations.dart";
import "package:nexus/widgets/common/appbar.dart";
import "package:nexus/widgets/rooms/room_title.dart";
import "package:nexus/widgets/avatars/avatar_or_hash.dart";
import "package:nexus/widgets/common/divider_widget.dart";
import "package:nexus/widgets/common/m3e_rail_theme.dart";
import "package:nexus/widgets/rooms/room_summary_sheet.dart";
import "package:nexus/widgets/rooms/room_menu_button.dart";
import "package:nexus/widgets/rooms/space_hierarchy_sheet.dart";
import "package:nexus/controllers/key.dart";
import "package:nexus/controllers/selected_room.dart";
import "package:nexus/controllers/selected_space.dart";
class const RoomsPane({super.key}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedRoomId = ref.watch(SelectedRoomController.provider);
final selectedSpace = ref.watch(SelectedSpaceController.provider)!;
final isDm = selectedSpace.id == "dms";
final spaceMetadata = selectedSpace.room?.metadata;
// In the order the rail shows them, with the space's own timeline first
final destinationIds = IList<String?>([?spaceMetadata?.id])
.addAll(selectedSpace.allChildRooms.map((room) => room.metadata?.id));
return Scaffold(
backgroundColor: Colors.transparent,
appBar: Appbar(
// Home and DMs aren't real spaces, so they don't have a summary.
onTap: spaceMetadata == null
? null
: () => context.showAdaptiveSheet(
(_) => RoomSummarySheet(.fromMetadata(spaceMetadata)),
),
windowControls: false,
leading: AvatarOrHash(
spaceMetadata?.avatar,
fallback: selectedSpace.icon == null
? null
: Icon(selectedSpace.icon),
selectedSpace.title,
),
title: RoomTitle(selectedSpace.title, topic: spaceMetadata?.topic),
backgroundColor: Colors.transparent,
actions: .new([
RoomMenuButton(
selectedSpace.room,
children: selectedSpace.allChildRooms,
),
]),
),
body: M3ERailTheme(
extensions: [
NavigationRailM3ETheme(
itemExpandedHeight: 48,
iconLabelGap: 16,
sectionHeaderSpacingTop: 0,
),
],
builder: (context) => Padding(
padding: const .only(top: 4),
child: NavigationRailM3E(
fab: selectedSpace.room == null
? null
: .new(
icon: const Icon(Icons.explore),
label: "Browse space",
tooltip: "Browse and join rooms in this space",
onPressed: () => context.showAdaptiveSheet(
(_) => SpaceHierarchySheet(selectedSpace.id),
),
),
expandedWidth: double.infinity,
scrollable: true,
background: Colors.transparent,
type: .alwaysExpand,
selectedIndex: max(0, destinationIds.indexOf(selectedRoomId)),
sections: [
if (selectedSpace.room case final room?
when spaceMetadata != null)
.new(
destinations: [
room.toDestination(
isDm: isDm,
label: "Space timeline",
icon: const Icon(Icons.forum),
),
],
),
.new(
header: selectedSpace.room == null
? null
: const DividerWidget(Text("Rooms")),
destinations: selectedSpace.children.toDestinations(isDm: isDm),
),
...selectedSpace.subSpaces.map(
(subSpace) => NavigationRailM3ESection(
header: DividerWidget(
Row(
mainAxisSize: .min,
spacing: 8,
children: [
if (subSpace.room.metadata?.avatar != null)
AvatarOrHash(
subSpace.room.metadata?.avatar,
subSpace.room.metadata?.name ?? "Unnamed Room",
dimension: 16,
),
Flexible(
child: Text(
subSpace.room.metadata?.name ?? "Unnamed Space",
maxLines: 1,
overflow: .ellipsis,
),
),
],
),
),
destinations: subSpace.children.toDestinations(isDm: isDm),
),
),
],
onDestinationSelected: (value) {
ref
.read(KeyController.provider(KeyController.roomKey).notifier)
.set(destinationIds[value]);
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
}
},
),
),
),
);
}
}

View file

@ -0,0 +1,61 @@
import "package:collection/collection.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/sidebar_page.dart";
import "package:nexus/models/nav_page.dart";
import "package:nexus/models/sidebar_page.dart";
import "package:nexus/widgets/common/count_badge.dart";
import "package:nexus/widgets/sidebar/invites_list.dart";
import "package:nexus/widgets/sidebar/notifications_list.dart";
import "package:nexus/widgets/sidebar/room_list.dart";
class const Sidebar({super.key}) extends ConsumerWidget {
static NavPage pageFor(SidebarPage page) => switch (page) {
.rooms => const RoomList(),
.notifications => const NotificationsList(),
.invites => const InvitesList(),
};
/// The badges of every tab added together, e.g. for the drawer button.
///
/// Like [NavPage.badgeCount], null means no badges and 0 means only dots.
static int? totalBadgeCount(WidgetRef ref) {
final counts = SidebarPage.values
.map((page) => pageFor(page).badgeCount(ref))
.nonNulls;
return counts.isEmpty ? null : counts.sum;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final selected = ref.watch(SidebarPageController.provider);
final pages = SidebarPage.values.map(pageFor);
return Drawer(
width: 330,
shape: Border(),
child: Scaffold(
backgroundColor: Colors.transparent,
body: pages.elementAt(selected.index),
bottomNavigationBar: NavigationBar(
selectedIndex: selected.index,
destinations: pages
.map(
(page) => NavigationDestination(
icon: CountBadge(
page.badgeCount(ref),
child: Icon(page.icon),
),
label: page.title,
),
)
.toList(),
onDestinationSelected: (index) => ref
.read(SidebarPageController.provider.notifier)
.set(SidebarPage.values[index]),
),
),
);
}
}

View file

@ -0,0 +1,142 @@
import "dart:math";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:navigation_rail_m3e/navigation_rail_m3e.dart";
import "package:material_adaptive_sheets/material_adaptive_sheets.dart";
import "package:nexus/helpers/extensions/get_all_child_rooms.dart";
import "package:nexus/widgets/common/account_switcher.dart";
import "package:nexus/widgets/avatars/avatar_or_hash.dart";
import "package:nexus/widgets/rooms/create_room_sheet.dart";
import "package:nexus/widgets/users/start_dm_sheet.dart";
import "package:nexus/widgets/pages/explore.dart";
import "package:nexus/widgets/pages/settings.dart";
import "package:nexus/widgets/rooms/join_sheet.dart";
import "package:nexus/widgets/common/m3e_rail_theme.dart";
import "package:nexus/controllers/key.dart";
import "package:nexus/controllers/spaces.dart";
class const SpacesRail({super.key}) extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final spaces = ref.watch(
SpacesController.provider.select((async) => async.requireValue),
);
final selectedSpaceId = ref.watch(
KeyController.provider(KeyController.spaceKey)
.select((async) => async.requireValue),
);
return M3ERailTheme(
extensions: [
NavigationRailM3ETheme(itemCollapsedHeight: 48, itemVerticalGap: 0),
],
builder: (context) => Container(
color: NavigationRailTokensAdapter(context).containerColor,
padding: .only(top: 16),
child: NavigationRailM3E(
type: .alwaysCollapse,
labelBehavior: .alwaysHide,
scrollable: true,
onDestinationSelected: (value) {
ref
.read(KeyController.provider(KeyController.spaceKey).notifier)
.set(spaces[value].id);
ref
.read(KeyController.provider(KeyController.roomKey).notifier)
.set(spaces[value].children.firstOrNull?.metadata?.id);
},
sections: [
.new(
destinations: spaces.map((space) {
final notifications = space.allChildRooms.fold(
0,
(sum, room) =>
sum + (room.metadata?.unreadNotifications ?? 0),
);
return NavigationRailM3EDestination(
badgeCount: notifications > 0
? notifications
: space.allChildRooms.any(
(room) => room.metadata?.unreadMessages != 0,
)
? 0
: null,
short: true,
icon: AvatarOrHash(
dimension: 28,
space.room?.metadata?.avatar,
fallback: space.icon == null ? null : Icon(space.icon),
space.title,
),
label: space.title,
);
}).toList(),
),
],
selectedIndex: max(
0,
spaces.indexWhere((space) => space.id == selectedSpaceId),
),
trailingAtBottom: true,
trailing: Padding(
padding: .only(top: 8),
child: Column(
children: [
AdaptiveMenuButton(
tooltip: "Add a room",
itemBuilder: (_) => [
PopupMenuItem(
onTap: () =>
context.showAdaptiveSheet((_) => JoinSheet(ref)),
child: const ListTile(
title: Text("Join an existing room (or space)"),
leading: Icon(Icons.numbers),
),
),
PopupMenuItem(
onTap: () => context.showAdaptiveSheet(
(_) => const CreateRoomSheet(),
),
child: const ListTile(
title: Text("Create a new room (or space)"),
leading: Icon(Icons.add),
),
),
PopupMenuItem(
onTap: () => context.showAdaptiveSheet(
(_) => const StartDmSheet(),
),
child: const ListTile(
title: Text("Start a direct message"),
leading: Icon(Icons.person_add),
),
),
],
icon: const Icon(Icons.add),
),
const AccountSwitcher(),
IconButton(
tooltip: "Explore other rooms",
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const ExplorePage()),
),
icon: const Icon(Icons.explore),
),
IconButton(
tooltip: "Open settings",
onPressed: () => showDialog(
context: context,
builder: (_) => SettingsPage(),
),
icon: const Icon(Icons.settings),
),
],
),
),
),
),
);
}
}