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.
I'm also not a big fan of private methods or variables, but this is contextual.
## 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.

View file

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

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: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";
@ -14,11 +15,9 @@ import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/renderers/event.dart";
import "package:super_sliver_list/super_sliver_list.dart";
class const NotificationsList({
final int? highlightedEvent,
final bool defaultToAllNotifications = false,
super.key,
}) extends HookConsumerWidget implements NavPage {
class const NotificationsList({super.key})
extends HookConsumerWidget
implements NavPage {
@override
String get title => "Notifications";
@ -41,9 +40,15 @@ class const NotificationsList({
icon: Icon(Icons.notifications),
): .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 scrollController = useScrollController();
@ -54,21 +59,41 @@ class const NotificationsList({
final notifications = ref.watch(provider);
final notifier = ref.watch(provider.notifier);
useEffect(() {
if (highlightedId.value == null) return null;
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!,
void consumeRequest() => Future.microtask(
ref.read(NotificationRequestController.provider.notifier).consume,
);
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(
index: index,
@ -80,12 +105,18 @@ class const NotificationsList({
timer = Timer(.new(seconds: 1), () {
highlightedId.value = null;
});
listController.value.removeListener(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(() {
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:hooks_riverpod/hooks_riverpod.dart";
import "package:nexus/controllers/init_complete.dart";
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/room_chat/room_chat.dart";
@ -9,17 +11,37 @@ import "package:nexus/widgets/loading.dart";
class const ChatPage({super.key}) extends HookConsumerWidget {
@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) {
final isDesktop = constraints.maxWidth > 650;
final showMembersByDefault = constraints.maxWidth > 1000;
final initComplete = ref.watch(InitCompleteController.provider);
final roomId = ref
.watch(KeyController.provider(KeyController.roomKey))
.requireValue;
return SafeArea(
child: Scaffold(
key: scaffoldKey,
appBar: initComplete ? null : Appbar(),
body: initComplete
? Row(
@ -47,3 +69,4 @@ class const ChatPage({super.key}) extends HookConsumerWidget {
},
);
}
}

View file

@ -1,15 +1,29 @@
import "package:flutter_hooks/flutter_hooks.dart";
import "package:hooks_riverpod/hooks_riverpod.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/widgets/invites_list.dart";
import "package:nexus/widgets/notifications_list.dart";
import "package:nexus/widgets/room_list.dart";
class const Sidebar({super.key}) extends HookWidget {
class const Sidebar({super.key}) extends HookConsumerWidget {
@override
Widget build(BuildContext context) {
final selectedIndex = useState(0);
Widget build(BuildContext context, WidgetRef ref) {
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(
width: 330,