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,21 +59,41 @@ 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);
useEffect(() { void consumeRequest() => Future.microtask(
if (highlightedId.value == null) return null; ref.read(NotificationRequestController.provider.notifier).consume,
Timer? timer;
void listener() =>
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!listController.value.isAttached) return;
final notifications = await ref.watch(provider.future);
final index = notifications.indexWhere(
(tuple) => tuple.$1 == highlightedId.value!,
); );
if (index == -1) return; 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( listController.value.animateToItem(
index: index, index: index,
@ -80,12 +105,18 @@ class const NotificationsList({
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,17 +11,37 @@ 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) {
final scaffoldKey = useMemoized(GlobalKey<ScaffoldState>.new);
final initComplete = ref.watch(InitCompleteController.provider);
final notificationRequest = ref.watch(
NotificationRequestController.provider,
);
// On mobile, open the drawer so the sidebar can show the tapped notification
useEffect(() {
if (notificationRequest == null || !initComplete) return null;
WidgetsBinding.instance.addPostFrameCallback((_) {
final scaffold = scaffoldKey.currentState;
if (scaffold != null && scaffold.hasDrawer && !scaffold.isDrawerOpen) {
scaffold.openDrawer();
}
});
return null;
}, [notificationRequest, initComplete]);
return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isDesktop = constraints.maxWidth > 650; final isDesktop = constraints.maxWidth > 650;
final showMembersByDefault = constraints.maxWidth > 1000; final showMembersByDefault = constraints.maxWidth > 1000;
final initComplete = ref.watch(InitCompleteController.provider);
final roomId = ref final roomId = ref
.watch(KeyController.provider(KeyController.roomKey)) .watch(KeyController.provider(KeyController.roomKey))
.requireValue; .requireValue;
return SafeArea( return SafeArea(
child: Scaffold( child: Scaffold(
key: scaffoldKey,
appBar: initComplete ? null : Appbar(), appBar: initComplete ? null : Appbar(),
body: initComplete body: initComplete
? Row( ? Row(
@ -46,4 +68,5 @@ class const ChatPage({super.key}) extends HookConsumerWidget {
); );
}, },
); );
}
} }

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,