move NotificationsPage into sidebar via NavigationBar

This commit is contained in:
Henry Hiles 2026-10-02 11:17:37 -04:00
commit 45d69b0b9f
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
6 changed files with 458 additions and 389 deletions

View file

@ -7,7 +7,7 @@ import "package:nexus/controllers/portal.dart";
import "package:nexus/main.dart"; import "package:nexus/main.dart";
import "package:flutter_local_notifications/flutter_local_notifications.dart"; import "package:flutter_local_notifications/flutter_local_notifications.dart";
import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:nexus/widgets/pages/notifications.dart"; import "package:nexus/widgets/notifications_list.dart";
import "package:xdg_desktop_portal/xdg_desktop_portal.dart"; import "package:xdg_desktop_portal/xdg_desktop_portal.dart";
class NotificationController class NotificationController
@ -37,7 +37,7 @@ class NotificationController
if (navigatorKey.currentContext case final context?) { if (navigatorKey.currentContext case final context?) {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => NotificationsPage( builder: (_) => NotificationsList(
highlightedEvent: rowId, highlightedEvent: rowId,
defaultToAllNotifications: true, defaultToAllNotifications: true,
), ),
@ -61,7 +61,7 @@ class NotificationController
if (navigatorKey.currentContext case final context?) { if (navigatorKey.currentContext case final context?) {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => NotificationsPage( builder: (_) => NotificationsList(
highlightedEvent: rowId, highlightedEvent: rowId,
defaultToAllNotifications: true, defaultToAllNotifications: true,
), ),

8
lib/models/nav_page.dart Normal file
View file

@ -0,0 +1,8 @@
import "package:flutter/widgets.dart";
import "package:flutter_riverpod/flutter_riverpod.dart";
abstract class const NavPage({super.key}) extends Widget {
String get title;
IconData get icon;
Future<int>? badgeBuilder(WidgetRef ref) => null;
}

View file

@ -0,0 +1,22 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
import "package:material_ui/material_ui.dart";
import "package:nexus/models/nav_page.dart";
class const InvitesList({super.key})
extends StatelessWidget
implements NavPage {
@override
String get title => "Invites";
@override
IconData get icon => Icons.inbox;
// TODO: Show notification badge here
@override
Future<int>? badgeBuilder(WidgetRef ref) => null;
@override
Widget build(BuildContext context) {
return const Placeholder();
}
}

View file

@ -7,18 +7,28 @@ import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/notifications.dart"; import "package:nexus/controllers/notifications.dart";
import "package:nexus/helpers/extensions/focus_room.dart"; import "package:nexus/helpers/extensions/focus_room.dart";
import "package:nexus/models/event.dart"; import "package:nexus/models/event.dart";
import "package:nexus/widgets/appbar.dart"; import "package:nexus/models/nav_page.dart";
import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/highlight_wrapper.dart"; import "package:nexus/widgets/highlight_wrapper.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/renderers/event.dart"; import "package:nexus/widgets/renderers/event.dart";
import "package:super_sliver_list/super_sliver_list.dart"; import "package:super_sliver_list/super_sliver_list.dart";
class const NotificationsPage({ class const NotificationsList({
final int? highlightedEvent, final int? highlightedEvent,
final bool defaultToAllNotifications = false, final bool defaultToAllNotifications = false,
super.key, super.key,
}) extends HookConsumerWidget { }) extends HookConsumerWidget implements NavPage {
@override
String get title => "Notifications";
@override
IconData get icon => Icons.notifications;
// TODO: Show notification badge here
@override
Future<int>? badgeBuilder(WidgetRef ref) => null;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final options = <M3EToggleButtonGroupAction, UnreadType>{ final options = <M3EToggleButtonGroupAction, UnreadType>{
@ -91,81 +101,74 @@ class const NotificationsPage({
return () => scrollController.removeListener(listener); return () => scrollController.removeListener(listener);
}, [scrollController, notifications]); }, [scrollController, notifications]);
return Scaffold( return Stack(
appBar: Appbar(title: Text("Notifications")), children: [
body: Stack( Column(
children: [ children: [
Column( if (notifications is AsyncLoading && notifications.value != null)
children: [ const LinearProgressIndicator(minHeight: 2),
if (notifications is AsyncLoading && notifications.value != null) Expanded(
const LinearProgressIndicator(minHeight: 2), child: switch (notifications) {
Expanded( AsyncData(:final value) || AsyncLoading(:final value?) =>
child: switch (notifications) { value.isEmpty
AsyncData(:final value) || AsyncLoading(:final value?) => ? Center(
value.isEmpty child: Text(
? Center( "No notifications yet",
child: Text( style: Theme.of(context).textTheme.headlineMedium,
"No notifications yet",
style: Theme.of(context).textTheme.headlineMedium,
),
)
: SuperListView.builder(
listController: listController.value,
controller: scrollController,
itemCount: value.length,
padding: const EdgeInsets.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 {
Navigator.of(context).pop();
await ref.focusRoom(roomId, rowId);
},
child: IgnorePointer(
child: EventRenderer(
rowId,
roomId: roomId,
),
),
),
isHighlighted: isHighlighted,
),
);
},
), ),
AsyncLoading() => const Loading(), )
AsyncError(:final error, :final stackTrace) => ErrorDialog( : SuperListView.builder(
error, listController: listController.value,
stackTrace, controller: scrollController,
), itemCount: value.length,
}, padding: const EdgeInsets.symmetric(
), horizontal: 8,
], vertical: 12,
), ),
Align( reverse: true,
alignment: .topRight, itemBuilder: (context, index) {
child: Padding( final (rowId, roomId) = value[index];
padding: .all(16), final isHighlighted = rowId == highlightedId.value;
child: M3EToggleButtonGroup(
selectedIndex: unreadTypeIndex.value, return Padding(
onSelectedIndexChanged: (index) => padding: .only(top: 8),
unreadTypeIndex.value = index ?? unreadTypeIndex.value, child: HighlightWrapper(
actions: options.keys.toList(), InkWell(
), onTap: () async {
Navigator.of(context).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(),
), ),
), ),
], ),
), ],
); );
} }
} }

325
lib/widgets/room_list.dart Normal file
View file

@ -0,0 +1,325 @@
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:nexus/models/nav_page.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/divider_widget.dart";
import "package:nexus/widgets/pages/settings.dart";
import "package:nexus/widgets/join_dialog.dart";
import "package:nexus/widgets/room_menu.dart";
import "package:nexus/controllers/key.dart";
import "package:nexus/controllers/spaces.dart";
import "package:nexus/models/room.dart";
import "package:collection/collection.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
// Needed for navigation_rail_m3e (#65).
import "package:flutter/material.dart" as old_mat;
class const RoomList({super.key})
extends HookConsumerWidget
implements NavPage {
@override
String get title => "Rooms";
@override
IconData get icon => Icons.list;
// TODO: Show unread badge here and on sidebar hamburger
@override
Future<int>? badgeBuilder(WidgetRef ref) => null;
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedSpaceProvider = KeyController.provider(
KeyController.spaceKey,
);
final selectedSpaceId = ref.watch(selectedSpaceProvider).requireValue;
final selectedSpaceIdNotifier = ref.watch(selectedSpaceProvider.notifier);
final selectedRoomController = KeyController.provider(
KeyController.roomKey,
);
final selectedRoomId = ref.watch(selectedRoomController).requireValue;
final selectedRoomIdNotifier = ref.watch(selectedRoomController.notifier);
final spaces = ref.watch(SpacesController.provider);
final indexOfSelected = spaces.indexWhere(
(space) => space.id == selectedSpaceId,
);
final selectedIndex = indexOfSelected == -1 ? 0 : indexOfSelected;
final selectedSpace =
spaces.firstWhereOrNull((space) => space.id == selectedSpaceId) ??
spaces.first;
final indexOfSelectedRoom = selectedSpace.children
.addAll(
selectedSpace.subSpaces.map((element) => element.children).flattened,
)
.indexWhere((room) => room.metadata?.id == selectedRoomId);
final selectedRoomIndex = indexOfSelectedRoom == -1
? null
: indexOfSelectedRoom;
List<NavigationRailM3EDestination> roomsToDestinations(IList<Room> rooms) =>
rooms
.map(
(room) => NavigationRailM3EDestination(
label: room.metadata?.name ?? "Unnamed Room",
badgeCount: switch (room.metadata?.unreadNotifications) {
0 || null => room.metadata?.unreadMessages == 0 ? null : 0,
int unread => unread,
},
icon: AvatarOrHash(
room.metadata?.avatar,
room.metadata?.name ?? "Unnamed Room",
fallback: selectedSpaceId == "dms"
? null
: Icon(Icons.numbers),
),
),
)
.toList();
return Row(
children: [
MaterialUiCompatibilityBridge(
child: Builder(
builder: (context) => old_mat.Theme(
data: old_mat.Theme.of(context).copyWith(
extensions: [
NavigationRailM3ETheme(
itemCollapsedHeight: 48,
itemVerticalGap: 0,
),
],
),
child: Container(
color: NavigationRailTokensAdapter(context).containerColor,
padding: EdgeInsets.only(top: 16),
child: NavigationRailM3E(
type: .alwaysCollapse,
labelBehavior: .alwaysHide,
scrollable: true,
onDestinationSelected: (value) {
selectedSpaceIdNotifier.set(spaces[value].id);
selectedRoomIdNotifier.set(
spaces[value].children.firstOrNull?.metadata?.id,
);
},
sections: [
.new(
destinations: spaces
.map(
(space) => NavigationRailM3EDestination(
badgeCount: switch (space.children
.addAll(
space.subSpaces
.map((element) => element.children)
.flattened,
)
.fold(
0,
(previousValue, room) =>
previousValue +
(room.metadata?.unreadNotifications ??
0),
)) {
0 =>
space.children
.addAll(
space.subSpaces
.map(
(element) => element.children,
)
.flattened,
)
.any(
(room) =>
room.metadata?.unreadMessages !=
0,
)
? 0
: null,
int badgeCount => badgeCount,
},
short: true,
icon: AvatarOrHash(
height: 28,
space.room?.metadata?.avatar,
fallback: space.icon == null
? null
: Icon(space.icon),
space.title,
),
label: space.title,
),
)
.toList(),
),
],
selectedIndex: selectedIndex,
trailingAtBottom: true,
trailing: Padding(
padding: .symmetric(vertical: 16),
child: Column(
children: [
PopupMenuButton(
itemBuilder: (_) => [
PopupMenuItem(
onTap: () => showDialog(
context: context,
builder: (_) => JoinDialog(ref),
),
child: ListTile(
title: Text("Join an existing room (or space)"),
leading: Icon(Icons.numbers),
),
),
PopupMenuItem(
onTap: null,
child: ListTile(
title: Text("Create a new room"),
leading: Icon(Icons.add),
),
),
],
icon: Icon(Icons.add),
),
IconButton(
tooltip: "Explore other rooms",
onPressed: null,
icon: Icon(Icons.explore),
),
// IconButton(
// tooltip: "Open notifications",
// onPressed: () {
// if (Navigator.of(context).canPop())
// Navigator.of(context).pop();
// Navigator.of(context).push(
// MaterialPageRoute(
// builder: (_) => NotificationsPage(),
// ),
// );
// },
// icon: Icon(Icons.notifications),
// ),
IconButton(
tooltip: "Open settings",
onPressed: () => showDialog(
context: context,
builder: (_) => SettingsPage(),
),
icon: Icon(Icons.settings),
),
],
),
),
),
),
),
),
),
Expanded(
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
leading: AvatarOrHash(
selectedSpace.room?.metadata?.avatar,
fallback: selectedSpace.icon == null
? null
: Icon(selectedSpace.icon),
selectedSpace.title,
),
title: Text(selectedSpace.title, overflow: .ellipsis),
backgroundColor: Colors.transparent,
actions: [
RoomMenu(
selectedSpace.room,
children: selectedSpace.children.addAll(
selectedSpace.subSpaces
.map((element) => element.children)
.flattened,
),
),
],
),
body: MaterialUiCompatibilityBridge(
child: Builder(
builder: (context) => old_mat.Theme(
data: old_mat.Theme.of(context).copyWith(
extensions: [
NavigationRailM3ETheme(
itemExpandedHeight: 48,
iconLabelGap: 16,
),
],
),
child: NavigationRailM3E(
expandedWidth: double.infinity,
scrollable: true,
background: Colors.transparent,
type: .alwaysExpand,
selectedIndex: selectedRoomIndex ?? 0,
sections: [
.new(
header: selectedSpace.room == null
? null
: DividerWidget(Text("Rooms")),
destinations: roomsToDestinations(
selectedSpace.children,
),
),
for (final subSpace in selectedSpace.subSpaces)
.new(
header: DividerWidget(
Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
if (subSpace.room.metadata?.avatar != null)
AvatarOrHash(
subSpace.room.metadata?.avatar,
subSpace.room.metadata?.name ??
"Unnamed Room",
height: 16,
),
Flexible(
child: Text(
subSpace.room.metadata?.name ??
"Unnamed Space",
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
destinations: roomsToDestinations(subSpace.children),
),
],
onDestinationSelected: (value) {
final children = selectedSpace.children.addAll(
selectedSpace.subSpaces
.map((element) => element.children)
.flattened,
);
selectedRoomIdNotifier.set(
children[value].metadata?.id, //
);
if (Navigator.of(context).canPop())
Navigator.of(context).pop();
},
),
),
),
),
),
),
],
);
}
}

View file

@ -1,323 +1,34 @@
import "package:collection/collection.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:fast_immutable_collections/fast_immutable_collections.dart";
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/models/nav_page.dart";
import "package:navigation_rail_m3e/navigation_rail_m3e.dart"; import "package:nexus/widgets/invites_list.dart";
import "package:nexus/controllers/key.dart"; import "package:nexus/widgets/notifications_list.dart";
import "package:nexus/controllers/spaces.dart"; import "package:nexus/widgets/room_list.dart";
import "package:nexus/models/room.dart";
import "package:nexus/widgets/pages/notifications.dart";
import "package:nexus/widgets/pages/settings.dart";
import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/divider_widget.dart";
import "package:nexus/widgets/join_dialog.dart";
import "package:nexus/widgets/room_menu.dart";
// Needed for navigation_rail_m3e (#65). class const Sidebar({super.key}) extends HookWidget {
import "package:flutter/material.dart" as old_mat;
class const Sidebar({super.key}) extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context) {
final selectedSpaceProvider = KeyController.provider( final selectedIndex = useState(0);
KeyController.spaceKey, const pages = <NavPage>[RoomList(), NotificationsList(), InvitesList()];
);
final selectedSpaceId = ref.watch(selectedSpaceProvider).requireValue;
final selectedSpaceIdNotifier = ref.watch(selectedSpaceProvider.notifier);
final selectedRoomController = KeyController.provider(
KeyController.roomKey,
);
final selectedRoomId = ref.watch(selectedRoomController).requireValue;
final selectedRoomIdNotifier = ref.watch(selectedRoomController.notifier);
final spaces = ref.watch(SpacesController.provider);
final indexOfSelected = spaces.indexWhere(
(space) => space.id == selectedSpaceId,
);
final selectedIndex = indexOfSelected == -1 ? 0 : indexOfSelected;
final selectedSpace =
spaces.firstWhereOrNull((space) => space.id == selectedSpaceId) ??
spaces.first;
final indexOfSelectedRoom = selectedSpace.children
.addAll(
selectedSpace.subSpaces.map((element) => element.children).flattened,
)
.indexWhere((room) => room.metadata?.id == selectedRoomId);
final selectedRoomIndex = indexOfSelectedRoom == -1
? null
: indexOfSelectedRoom;
List<NavigationRailM3EDestination> roomsToDestinations(IList<Room> rooms) =>
rooms
.map(
(room) => NavigationRailM3EDestination(
label: room.metadata?.name ?? "Unnamed Room",
badgeCount: switch (room.metadata?.unreadNotifications) {
0 || null => room.metadata?.unreadMessages == 0 ? null : 0,
int unread => unread,
},
icon: AvatarOrHash(
room.metadata?.avatar,
room.metadata?.name ?? "Unnamed Room",
fallback: selectedSpaceId == "dms"
? null
: Icon(Icons.numbers),
),
),
)
.toList();
return Drawer( return Drawer(
width: 330, width: 330,
shape: Border(), shape: Border(),
child: Row( child: Scaffold(
children: [ backgroundColor: Colors.transparent,
MaterialUiCompatibilityBridge( body: pages[selectedIndex.value],
child: Builder( bottomNavigationBar: NavigationBar(
builder: (context) => old_mat.Theme( selectedIndex: selectedIndex.value,
data: old_mat.Theme.of(context).copyWith( destinations: pages
extensions: [ .map(
NavigationRailM3ETheme( (element) => NavigationDestination(
itemCollapsedHeight: 48, icon: Icon(element.icon),
itemVerticalGap: 0, label: element.title,
),
],
), ),
child: Container( )
color: NavigationRailTokensAdapter(context).containerColor, .toList(),
padding: EdgeInsets.only(top: 16), onDestinationSelected: (value) => selectedIndex.value = value,
child: NavigationRailM3E( ),
type: .alwaysCollapse,
labelBehavior: .alwaysHide,
scrollable: true,
onDestinationSelected: (value) {
selectedSpaceIdNotifier.set(spaces[value].id);
selectedRoomIdNotifier.set(
spaces[value].children.firstOrNull?.metadata?.id,
);
},
sections: [
.new(
destinations: spaces
.map(
(space) => NavigationRailM3EDestination(
badgeCount: switch (space.children
.addAll(
space.subSpaces
.map((element) => element.children)
.flattened,
)
.fold(
0,
(previousValue, room) =>
previousValue +
(room.metadata?.unreadNotifications ??
0),
)) {
0 =>
space.children
.addAll(
space.subSpaces
.map(
(element) =>
element.children,
)
.flattened,
)
.any(
(room) =>
room
.metadata
?.unreadMessages !=
0,
)
? 0
: null,
int badgeCount => badgeCount,
},
short: true,
icon: AvatarOrHash(
height: 28,
space.room?.metadata?.avatar,
fallback: space.icon == null
? null
: Icon(space.icon),
space.title,
),
label: space.title,
),
)
.toList(),
),
],
selectedIndex: selectedIndex,
trailingAtBottom: true,
trailing: Padding(
padding: .symmetric(vertical: 16),
child: Column(
children: [
PopupMenuButton(
itemBuilder: (_) => [
PopupMenuItem(
onTap: () => showDialog(
context: context,
builder: (_) => JoinDialog(ref),
),
child: ListTile(
title: Text(
"Join an existing room (or space)",
),
leading: Icon(Icons.numbers),
),
),
PopupMenuItem(
onTap: null,
child: ListTile(
title: Text("Create a new room"),
leading: Icon(Icons.add),
),
),
],
icon: Icon(Icons.add),
),
IconButton(
tooltip: "Explore other rooms",
onPressed: null,
icon: Icon(Icons.explore),
),
IconButton(
tooltip: "Open notifications",
onPressed: () {
if (Navigator.of(context).canPop())
Navigator.of(context).pop();
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => NotificationsPage(),
),
);
},
icon: Icon(Icons.notifications),
),
IconButton(
tooltip: "Open settings",
onPressed: () => showDialog(
context: context,
builder: (_) => SettingsPage(),
),
icon: Icon(Icons.settings),
),
],
),
),
),
),
),
),
),
Expanded(
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: AppBar(
leading: AvatarOrHash(
selectedSpace.room?.metadata?.avatar,
fallback: selectedSpace.icon == null
? null
: Icon(selectedSpace.icon),
selectedSpace.title,
),
title: Text(selectedSpace.title, overflow: .ellipsis),
backgroundColor: Colors.transparent,
actions: [
RoomMenu(
selectedSpace.room,
children: selectedSpace.children.addAll(
selectedSpace.subSpaces
.map((element) => element.children)
.flattened,
),
),
],
),
body: MaterialUiCompatibilityBridge(
child: Builder(
builder: (context) => old_mat.Theme(
data: old_mat.Theme.of(context).copyWith(
extensions: [
NavigationRailM3ETheme(
itemExpandedHeight: 48,
iconLabelGap: 16,
),
],
),
child: NavigationRailM3E(
expandedWidth: double.infinity,
scrollable: true,
background: Colors.transparent,
type: .alwaysExpand,
selectedIndex: selectedRoomIndex ?? 0,
sections: [
.new(
header: selectedSpace.room == null
? null
: DividerWidget(Text("Rooms")),
destinations: roomsToDestinations(
selectedSpace.children,
),
),
for (final subSpace in selectedSpace.subSpaces)
.new(
header: DividerWidget(
Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
if (subSpace.room.metadata?.avatar != null)
AvatarOrHash(
subSpace.room.metadata?.avatar,
subSpace.room.metadata?.name ??
"Unnamed Room",
height: 16,
),
Flexible(
child: Text(
subSpace.room.metadata?.name ??
"Unnamed Space",
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
destinations: roomsToDestinations(
subSpace.children,
),
),
],
onDestinationSelected: (value) {
final children = selectedSpace.children.addAll(
selectedSpace.subSpaces
.map((element) => element.children)
.flattened,
);
selectedRoomIdNotifier.set(
children[value].metadata?.id, //
);
if (Navigator.of(context).canPop())
Navigator.of(context).pop();
},
),
),
),
),
),
),
],
), ),
); );
} }