diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index d9c7c9d..f14b4ef 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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. diff --git a/lib/controllers/notification.dart b/lib/controllers/notification.dart index fe631d4..e3a0f14 100644 --- a/lib/controllers/notification.dart +++ b/lib/controllers/notification.dart @@ -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); } }); diff --git a/lib/controllers/notification_request.dart b/lib/controllers/notification_request.dart new file mode 100644 index 0000000..072ba99 --- /dev/null +++ b/lib/controllers/notification_request.dart @@ -0,0 +1,14 @@ +import "package:flutter_riverpod/flutter_riverpod.dart"; + +class NotificationRequestController extends Notifier { + @override + int? build() => null; + + void request(int rowId) => state = rowId; + + void consume() => state = null; + + static final provider = NotifierProvider( + NotificationRequestController.new, + ); +} diff --git a/lib/widgets/notifications_list.dart b/lib/widgets/notifications_list.dart index fde1bd5..ee1cb14 100644 --- a/lib/widgets/notifications_list.dart +++ b/lib/widgets/notifications_list.dart @@ -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,38 +59,64 @@ class const NotificationsList({ final notifications = ref.watch(provider); final notifier = ref.watch(provider.notifier); + void consumeRequest() => Future.microtask( + ref.read(NotificationRequestController.provider.notifier).consume, + ); + 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; + var done = false; - void listener() => - WidgetsBinding.instance.addPostFrameCallback((_) async { - if (!listController.value.isAttached) return; + void listener() => WidgetsBinding.instance.addPostFrameCallback(( + _, + ) 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( - (tuple) => tuple.$1 == highlightedId.value!, - ); + final index = notifications.indexWhere((tuple) => tuple.$1 == target); - if (index == -1) return; + if (done || index == -1) return; + done = true; + listController.value.removeListener(listener); - listController.value.animateToItem( - index: index, - scrollController: scrollController, - alignment: 0.5, - duration: (_) => .new(milliseconds: 700), - curve: (_) => Curves.easeInOut, - ); - timer = Timer(.new(seconds: 1), () { - highlightedId.value = null; - }); - listController.value.removeListener(listener); - }); + listController.value.animateToItem( + index: index, + scrollController: scrollController, + alignment: 0.5, + duration: (_) => .new(milliseconds: 700), + curve: (_) => Curves.easeInOut, + ); + timer = Timer(.new(seconds: 1), () { + highlightedId.value = null; + }); + }); 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 listener() async { diff --git a/lib/widgets/pages/chat.dart b/lib/widgets/pages/chat.dart index 74aea14..11cb712 100644 --- a/lib/widgets/pages/chat.dart +++ b/lib/widgets/pages/chat.dart @@ -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,41 +11,62 @@ import "package:nexus/widgets/loading.dart"; class const ChatPage({super.key}) extends HookConsumerWidget { @override - Widget build(BuildContext context, WidgetRef ref) => 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; + Widget build(BuildContext context, WidgetRef ref) { + final scaffoldKey = useMemoized(GlobalKey.new); + final initComplete = ref.watch(InitCompleteController.provider); + final notificationRequest = ref.watch( + NotificationRequestController.provider, + ); - return SafeArea( - child: Scaffold( - appBar: initComplete ? null : Appbar(), - body: initComplete - ? Row( - children: [ - if (isDesktop) Sidebar(), - Expanded( - child: RoomChat( - key: ValueKey(roomId), - roomId: roomId, - isDesktop: isDesktop, - showMembersByDefault: showMembersByDefault, + // 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 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(), + ), + ); + }, + ); + } } diff --git a/lib/widgets/sidebar.dart b/lib/widgets/sidebar.dart index 2d1c728..c748490 100644 --- a/lib/widgets/sidebar.dart +++ b/lib/widgets/sidebar.dart @@ -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 = [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,