Push sidebar if needed and scroll to notif when tapped

Fixes #116
This commit is contained in:
Henry Hiles 2026-10-14 16:51:14 -04:00
commit 8fda43ccf8
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
6 changed files with 159 additions and 87 deletions

View file

@ -61,6 +61,8 @@ When possible, use immutable collections instead of the mutable equivalent. For
When possible, we prefer not to create global variables or methods. You can usually replace a global variable with a Riverpod controller, and a global method with an extension method. When possible, we prefer not to create global variables or methods. You can usually replace a global variable with a Riverpod controller, and a global method with an extension method.
I'm also not a big fan of private methods or variables, but this is contextual.
## LLM/AI Assisted Contributions ## LLM/AI Assisted Contributions
LLM generated code is NOT allowed. All contributions should be written by humans. Using LLMs for interacting with others, e.g. for Comments, PRs, etc, is also not allowed. LLM generated code is NOT allowed. All contributions should be written by humans. Using LLMs for interacting with others, e.g. for Comments, PRs, etc, is also not allowed.

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/notifications_list.dart"; import "package:nexus/controllers/notification_request.dart";
import "package:xdg_desktop_portal/xdg_desktop_portal.dart"; import "package:xdg_desktop_portal/xdg_desktop_portal.dart";
class NotificationController class NotificationController
@ -34,16 +34,10 @@ class NotificationController
if (details.payload == null) return; if (details.payload == null) return;
if (int.tryParse(details.payload!) case final rowId?) { if (int.tryParse(details.payload!) case final rowId?) {
if (navigatorKey.currentContext case final context?) { navigatorKey.currentState?.popUntil((route) => route.isFirst);
Navigator.of(context).push( ref
MaterialPageRoute( .read(NotificationRequestController.provider.notifier)
builder: (_) => NotificationsList( .request(rowId);
highlightedEvent: rowId,
defaultToAllNotifications: true,
),
),
);
}
} }
}, },
); );
@ -58,16 +52,10 @@ class NotificationController
} }
if (int.tryParse(call.arguments) case int rowId) { if (int.tryParse(call.arguments) case int rowId) {
if (navigatorKey.currentContext case final context?) { navigatorKey.currentState?.popUntil((route) => route.isFirst);
Navigator.of(context).push( ref
MaterialPageRoute( .read(NotificationRequestController.provider.notifier)
builder: (_) => NotificationsList( .request(rowId);
highlightedEvent: rowId,
defaultToAllNotifications: true,
),
),
);
}
} }
}); });

View file

@ -0,0 +1,14 @@
import "package:flutter_riverpod/flutter_riverpod.dart";
class NotificationRequestController extends Notifier<int?> {
@override
int? build() => null;
void request(int rowId) => state = rowId;
void consume() => state = null;
static final provider = NotifierProvider<NotificationRequestController, int?>(
NotificationRequestController.new,
);
}

View file

@ -4,6 +4,7 @@ import "package:m3e_buttons/m3e_buttons.dart";
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/notification_request.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";
@ -14,11 +15,9 @@ 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 NotificationsList({ class const NotificationsList({super.key})
final int? highlightedEvent, extends HookConsumerWidget
final bool defaultToAllNotifications = false, implements NavPage {
super.key,
}) extends HookConsumerWidget implements NavPage {
@override @override
String get title => "Notifications"; String get title => "Notifications";
@ -41,9 +40,15 @@ class const NotificationsList({
icon: Icon(Icons.notifications), icon: Icon(Icons.notifications),
): .notify, ): .notify,
}; };
final unreadTypeIndex = useState(defaultToAllNotifications ? 1 : 0); final allNotificationsIndex = options.values.toList().indexOf(.notify);
final highlightedId = useState(highlightedEvent); final initialRequest = useMemoized(
() => ref.read(NotificationRequestController.provider),
);
final unreadTypeIndex = useState(
initialRequest == null ? 0 : allNotificationsIndex,
);
final highlightedId = useState(initialRequest);
final listController = useRef(ListController()); final listController = useRef(ListController());
final scrollController = useScrollController(); final scrollController = useScrollController();
@ -54,38 +59,64 @@ class const NotificationsList({
final notifications = ref.watch(provider); final notifications = ref.watch(provider);
final notifier = ref.watch(provider.notifier); final notifier = ref.watch(provider.notifier);
void consumeRequest() => Future.microtask(
ref.read(NotificationRequestController.provider.notifier).consume,
);
useEffect(() { useEffect(() {
if (highlightedId.value == null) return null; 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; Timer? timer;
var done = false;
void listener() => void listener() => WidgetsBinding.instance.addPostFrameCallback((
WidgetsBinding.instance.addPostFrameCallback((_) async { _,
if (!listController.value.isAttached) return; ) async {
if (done || !listController.value.isAttached) return;
final notifications = await ref.watch(provider.future); final notifications = await ref.read(provider.future);
final index = notifications.indexWhere( final index = notifications.indexWhere((tuple) => tuple.$1 == target);
(tuple) => tuple.$1 == highlightedId.value!,
);
if (index == -1) return; if (done || index == -1) return;
done = true;
listController.value.removeListener(listener);
listController.value.animateToItem( listController.value.animateToItem(
index: index, index: index,
scrollController: scrollController, scrollController: scrollController,
alignment: 0.5, alignment: 0.5,
duration: (_) => .new(milliseconds: 700), duration: (_) => .new(milliseconds: 700),
curve: (_) => Curves.easeInOut, curve: (_) => Curves.easeInOut,
); );
timer = Timer(.new(seconds: 1), () { timer = Timer(.new(seconds: 1), () {
highlightedId.value = null; highlightedId.value = null;
}); });
listController.value.removeListener(listener); });
});
listController.value.addListener(listener); listController.value.addListener(listener);
return timer?.cancel; // 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(() { useEffect(() {
Future<void> listener() async { Future<void> listener() async {

View file

@ -1,7 +1,9 @@
import "package:flutter_hooks/flutter_hooks.dart";
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/init_complete.dart"; import "package:nexus/controllers/init_complete.dart";
import "package:nexus/controllers/key.dart"; import "package:nexus/controllers/key.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/room_chat/room_chat.dart"; import "package:nexus/widgets/room_chat/room_chat.dart";
@ -9,41 +11,62 @@ import "package:nexus/widgets/loading.dart";
class const ChatPage({super.key}) extends HookConsumerWidget { class const ChatPage({super.key}) extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) => LayoutBuilder( Widget build(BuildContext context, WidgetRef ref) {
builder: (context, constraints) { final scaffoldKey = useMemoized(GlobalKey<ScaffoldState>.new);
final isDesktop = constraints.maxWidth > 650; final initComplete = ref.watch(InitCompleteController.provider);
final showMembersByDefault = constraints.maxWidth > 1000; final notificationRequest = ref.watch(
final initComplete = ref.watch(InitCompleteController.provider); NotificationRequestController.provider,
final roomId = ref );
.watch(KeyController.provider(KeyController.roomKey))
.requireValue;
return SafeArea( // On mobile, open the drawer so the sidebar can show the tapped notification
child: Scaffold( useEffect(() {
appBar: initComplete ? null : Appbar(), if (notificationRequest == null || !initComplete) return null;
body: initComplete
? Row( WidgetsBinding.instance.addPostFrameCallback((_) {
children: [ final scaffold = scaffoldKey.currentState;
if (isDesktop) Sidebar(), if (scaffold != null && scaffold.hasDrawer && !scaffold.isDrawerOpen) {
Expanded( scaffold.openDrawer();
child: RoomChat( }
key: ValueKey(roomId), });
roomId: roomId, return null;
isDesktop: isDesktop, }, [notificationRequest, initComplete]);
showMembersByDefault: showMembersByDefault,
return LayoutBuilder(
builder: (context, constraints) {
final isDesktop = constraints.maxWidth > 650;
final showMembersByDefault = constraints.maxWidth > 1000;
final roomId = ref
.watch(KeyController.provider(KeyController.roomKey))
.requireValue;
return SafeArea(
child: Scaffold(
key: scaffoldKey,
appBar: initComplete ? null : Appbar(),
body: initComplete
? Row(
children: [
if (isDesktop) Sidebar(),
Expanded(
child: RoomChat(
key: ValueKey(roomId),
roomId: roomId,
isDesktop: isDesktop,
showMembersByDefault: showMembersByDefault,
),
), ),
],
)
: Center(
child: Column(
mainAxisSize: .min,
children: [Loading(), Text("Syncing...")],
), ),
],
)
: Center(
child: Column(
mainAxisSize: .min,
children: [Loading(), Text("Syncing...")],
), ),
), drawer: isDesktop || !initComplete ? null : Sidebar(),
drawer: isDesktop || !initComplete ? null : Sidebar(), ),
), );
); },
}, );
); }
} }

View file

@ -1,15 +1,29 @@
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.dart";
import "package:material_ui/material_ui.dart"; import "package:material_ui/material_ui.dart";
import "package:nexus/controllers/notification_request.dart";
import "package:nexus/models/nav_page.dart"; import "package:nexus/models/nav_page.dart";
import "package:nexus/widgets/invites_list.dart"; import "package:nexus/widgets/invites_list.dart";
import "package:nexus/widgets/notifications_list.dart"; import "package:nexus/widgets/notifications_list.dart";
import "package:nexus/widgets/room_list.dart"; import "package:nexus/widgets/room_list.dart";
class const Sidebar({super.key}) extends HookWidget { class const Sidebar({super.key}) extends HookConsumerWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final selectedIndex = useState(0);
const pages = <NavPage>[RoomList(), NotificationsList(), InvitesList()]; const pages = <NavPage>[RoomList(), NotificationsList(), InvitesList()];
final notificationsIndex = pages.indexWhere(
(page) => page is NotificationsList,
);
final selectedIndex = useState(
ref.read(NotificationRequestController.provider) == null
? 0
: notificationsIndex,
);
ref.listen(NotificationRequestController.provider, (_, next) {
if (next != null) selectedIndex.value = notificationsIndex;
});
return Drawer( return Drawer(
width: 330, width: 330,