From 11a4837ce1567d76c2271bc6a6b0b3bd2fd37eb4 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 3 Aug 2026 17:06:38 +0100 Subject: [PATCH] isolate home workspace navigation Signed-off-by: kenny lopez --- .../lib/features/channels/channels_page.dart | 4 + .../channels_page}/wide_navigation.dart | 119 ++++++++++++------ mobile/lib/features/home/home_page.dart | 100 ++++----------- .../home_page/wide_navigation_skeletons.dart | 91 -------------- 4 files changed, 106 insertions(+), 208 deletions(-) rename mobile/lib/features/{home/home_page => channels/channels_page}/wide_navigation.dart (90%) diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index a9b75ce0a..48bdf147f 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -6,6 +6,7 @@ import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter/physics.dart'; +import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -26,6 +27,8 @@ import '../profile/profile_avatar.dart'; import '../profile/profile_provider.dart'; import '../profile/presence_cache_provider.dart'; import '../profile/user_cache_provider.dart'; +import '../profile/user_profile.dart'; +import '../profile/user_status_provider.dart'; import '../pairing/pairing_page.dart'; import '../pairing/pairing_provider.dart'; import 'channel.dart'; @@ -53,6 +56,7 @@ part 'channels_page/skeleton.dart'; part 'channels_page/community.dart'; part 'channels_page/quick_actions.dart'; part 'channels_page/quick_actions_launcher.dart'; +part 'channels_page/wide_navigation.dart'; enum _QuickAction { createChannel, newDm } diff --git a/mobile/lib/features/home/home_page/wide_navigation.dart b/mobile/lib/features/channels/channels_page/wide_navigation.dart similarity index 90% rename from mobile/lib/features/home/home_page/wide_navigation.dart rename to mobile/lib/features/channels/channels_page/wide_navigation.dart index 6a8b7aa58..4efa30ea9 100644 --- a/mobile/lib/features/home/home_page/wide_navigation.dart +++ b/mobile/lib/features/channels/channels_page/wide_navigation.dart @@ -1,16 +1,42 @@ -part of '../home_page.dart'; +part of '../channels_page.dart'; -class _WideNavigationSidebar extends HookConsumerWidget { +const _wideNavigationWidth = 280.0; +const _wideNavigationIconSize = 20.0; +const _wideNavigationDmAvatarSize = 24.0; +const _wideNavigationPrimaryRowHeight = 52.0; +const _wideNavigationChannelRowHeight = 48.0; +const _wideNavigationLabelGap = Grid.twelve - Grid.quarter; +const _wideNavigationDmLabelGap = _wideNavigationLabelGap - Grid.quarter; +const _wideNavigationIdentityAvatarInset = + Grid.xs + Grid.half - (Grid.quarter / 2); +const _wideNavigationIdentityLabelGap = Grid.half + (Grid.quarter / 2); + +class WideNavigationDestination { + final IconData icon; + final IconData selectedIcon; + final String label; + + const WideNavigationDestination({ + required this.icon, + required this.selectedIcon, + required this.label, + }); +} + +class WideChannelsNavigation extends HookConsumerWidget { final int? selectedIndex; final ValueChanged onDestinationSelected; - final ValueChanged onChannelSelected; + final ValueChanged onChannelSelected; final String? selectedChannelId; final VoidCallback onProfileSelected; final bool isCommunitySwitching; final ValueChanged onCommunitySwitchStart; - final List<_HomeDestination> destinations; + final String? pendingCommunityId; + final VoidCallback onCommunitySwitchComplete; + final List destinations; - const _WideNavigationSidebar({ + const WideChannelsNavigation({ + super.key, required this.selectedIndex, required this.onDestinationSelected, required this.onChannelSelected, @@ -18,6 +44,8 @@ class _WideNavigationSidebar extends HookConsumerWidget { required this.onProfileSelected, required this.isCommunitySwitching, required this.onCommunitySwitchStart, + required this.pendingCommunityId, + required this.onCommunitySwitchComplete, required this.destinations, }); @@ -32,6 +60,17 @@ class _WideNavigationSidebar extends HookConsumerWidget { final currentPubkey = ref.watch(profileProvider).value?.pubkey; final profiles = ref.watch(userCacheProvider); final channelsAsync = ref.watch(channelsProvider); + final activeCommunityId = ref.watch(activeCommunityProvider).value?.id; + useEffect(() { + if (pendingCommunityId != activeCommunityId || channelsAsync.isLoading) { + return null; + } + final timer = Timer( + const Duration(milliseconds: 250), + onCommunitySwitchComplete, + ); + return timer.cancel; + }, [pendingCommunityId, activeCommunityId, channelsAsync.isLoading]); final channels = (channelsAsync.asData?.value ?? const []) .where((channel) => !channel.isArchived) .toList(); @@ -68,7 +107,7 @@ class _WideNavigationSidebar extends HookConsumerWidget { Future createChannel() async { final channel = await showCreateChannelSheet(context); - if (channel != null) onChannelSelected(channel); + if (channel != null) onChannelSelected(channel.id); } Future createDirectMessage() async { @@ -76,12 +115,12 @@ class _WideNavigationSidebar extends HookConsumerWidget { context, currentPubkey: currentPubkey, ); - if (channel != null) onChannelSelected(channel); + if (channel != null) onChannelSelected(channel.id); } return SizedBox( key: const Key('wide-navigation-sidebar'), - width: HomePage._wideNavigationWidth, + width: _wideNavigationWidth, child: DecoratedBox( key: const Key('wide-navigation-sidebar-background'), decoration: BoxDecoration( @@ -109,7 +148,7 @@ class _WideNavigationSidebar extends HookConsumerWidget { Expanded( child: SkeletonReveal( loading: isCommunitySwitching, - skeleton: const _WideNavigationSidebarSkeleton( + skeleton: const SizedBox( key: Key('wide-navigation-community-switch-skeleton'), ), content: Column( @@ -158,7 +197,7 @@ class _WideNavigationSidebar extends HookConsumerWidget { profiles: profiles, ), selected: channel.id == selectedChannelId, - onTap: () => onChannelSelected(channel), + onTap: () => onChannelSelected(channel.id), ), const SizedBox(height: Grid.xs), _WideNavigationSectionLabel( @@ -182,7 +221,7 @@ class _WideNavigationSidebar extends HookConsumerWidget { profiles: profiles, ), selected: channel.id == selectedChannelId, - onTap: () => onChannelSelected(channel), + onTap: () => onChannelSelected(channel.id), ), ], ), @@ -399,7 +438,7 @@ class _WideNavigationCommunitySwitcher extends HookConsumerWidget { alignment: Alignment.centerLeft, child: Padding( padding: const EdgeInsets.only( - left: HomePage._wideNavigationIdentityAvatarInset, + left: _wideNavigationIdentityAvatarInset, right: Grid.sm, ), child: Row( @@ -429,9 +468,7 @@ class _WideNavigationCommunitySwitcher extends HookConsumerWidget { }, onVerticalDragCancel: resetSwipe, ), - const SizedBox( - width: HomePage._wideNavigationIdentityLabelGap, - ), + const SizedBox(width: _wideNavigationIdentityLabelGap), ConstrainedBox( constraints: const BoxConstraints(maxWidth: 192), // The avatar motion carries the change. Keeping the @@ -599,15 +636,13 @@ class _WideNavigationProfile extends ConsumerWidget { onTap: onTap, child: Padding( padding: const EdgeInsets.only( - left: HomePage._wideNavigationIdentityAvatarInset, + left: _wideNavigationIdentityAvatarInset, right: Grid.half, ), child: Row( children: [ const ProfileAvatar(), - const SizedBox( - width: HomePage._wideNavigationIdentityLabelGap, - ), + const SizedBox(width: _wideNavigationIdentityLabelGap), Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -692,7 +727,7 @@ class _WideChannelDestination extends StatelessWidget { child: Padding( padding: const EdgeInsets.only(bottom: Grid.quarter), child: SizedBox( - height: HomePage._wideNavigationChannelRowHeight, + height: _wideNavigationChannelRowHeight, child: Material( color: selected ? palette.activeSurface : Colors.transparent, borderRadius: BorderRadius.circular(Radii.sm), @@ -704,16 +739,13 @@ class _WideChannelDestination extends StatelessWidget { child: Row( children: [ SizedBox( - width: channel.isDm - ? HomePage._wideNavigationDmAvatarSize - : HomePage._tabIconSize, + width: channel.isDm ? _wideNavigationDmAvatarSize : 22, child: Align( alignment: Alignment.centerLeft, child: channel.isDm ? AvatarImage( imageUrl: directMessageProfile?.avatarUrl, - radius: - HomePage._wideNavigationDmAvatarSize / 2, + radius: _wideNavigationDmAvatarSize / 2, backgroundColor: context.colors.primaryContainer, fallback: Text( @@ -727,14 +759,14 @@ class _WideChannelDestination extends StatelessWidget { : Icon( icon, color: foregroundColor, - size: HomePage._wideNavigationIconSize, + size: _wideNavigationIconSize, ), ), ), SizedBox( width: channel.isDm - ? HomePage._wideNavigationDmLabelGap - : HomePage._wideNavigationLabelGap, + ? _wideNavigationDmLabelGap + : _wideNavigationLabelGap, ), Expanded( child: Text( @@ -760,22 +792,29 @@ class _WideChannelDestination extends StatelessWidget { } } -class _WideChannelContent extends HookWidget { - final Channel channel; +class WideChannelContent extends HookConsumerWidget { + final String channelId; final VoidCallback onChannelLeft; - const _WideChannelContent({ - required this.channel, + const WideChannelContent({ + super.key, + required this.channelId, required this.onChannelLeft, }); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final channels = ref.watch(channelsProvider).asData?.value; + final channel = channels?.cast().firstWhere( + (channel) => channel?.id == channelId, + orElse: () => null, + ); + if (channel == null) return const SizedBox.shrink(); final navigatorKey = useMemoized(GlobalKey.new, [ - channel.id, + channelId, ]); return NavigatorPopHandler( - key: ValueKey('wide-channel-detail-${channel.id}'), + key: ValueKey('wide-channel-detail-$channelId'), onPopWithResult: (_) => navigatorKey.currentState?.maybePop(), child: Navigator( key: navigatorKey, @@ -789,7 +828,7 @@ class _WideChannelContent extends HookWidget { } class _WideNavigationDestination extends StatelessWidget { - final _HomeDestination destination; + final WideNavigationDestination destination; final bool selected; final VoidCallback onTap; @@ -817,7 +856,7 @@ class _WideNavigationDestination extends StatelessWidget { // channel list directly below it. padding: const EdgeInsets.only(bottom: Grid.quarter), child: SizedBox( - height: HomePage._wideNavigationPrimaryRowHeight, + height: _wideNavigationPrimaryRowHeight, child: Material( color: selected ? palette.activeSurface : Colors.transparent, borderRadius: BorderRadius.circular(Radii.md), @@ -829,7 +868,7 @@ class _WideNavigationDestination extends StatelessWidget { child: Row( children: [ SizedBox( - width: HomePage._tabIconSize, + width: 22, child: Align( alignment: Alignment.centerLeft, child: Icon( @@ -838,11 +877,11 @@ class _WideNavigationDestination extends StatelessWidget { // background, and label weight instead. destination.selectedIcon, color: foregroundColor, - size: HomePage._wideNavigationIconSize, + size: _wideNavigationIconSize, ), ), ), - const SizedBox(width: HomePage._wideNavigationLabelGap), + const SizedBox(width: _wideNavigationLabelGap), Expanded( child: Text( label, diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index 2a8051506..9e896364c 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -7,27 +7,14 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; -import '../../shared/community/community.dart'; -import '../../shared/community/community_icon_provider.dart'; import '../../shared/community/community_provider.dart'; import '../../shared/theme/theme.dart'; -import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/mobile_tab_footer_backdrop.dart'; import '../../shared/widgets/skeleton.dart'; import '../activity/activity_page.dart'; -import '../channels/channel.dart'; -import '../channels/channel_detail_page.dart'; import '../channels/channels_page.dart'; -import '../channels/channels_provider.dart'; -import '../channels/dm_channel_labels.dart'; -import '../profile/profile_avatar.dart'; -import '../profile/profile_provider.dart'; -import '../profile/user_cache_provider.dart'; -import '../profile/user_profile.dart'; -import '../profile/user_status_provider.dart'; import '../search/search_page.dart'; -part 'home_page/wide_navigation.dart'; part 'home_page/wide_navigation_skeletons.dart'; class HomePage extends HookConsumerWidget { @@ -44,36 +31,24 @@ class HomePage extends HookConsumerWidget { static const double _tabBarHorizontalMargin = Grid.gutter; static const double _tabDestinationHorizontalPadding = Grid.sm; static const double _tabIconSize = 22; - static const double _wideNavigationIconSize = 20; - static const double _wideNavigationDmAvatarSize = 24; static const double _fabClearance = _tabBarHeight + _tabBarBottomGap; static const Duration _tabIconWeightDuration = Duration(milliseconds: 120); static const double _wideNavigationBreakpoint = 840; - static const double _wideNavigationWidth = 280; static const double _wideContentInset = Grid.half + Grid.quarter; static const double _wideContentRadius = 24; - static const double _wideNavigationPrimaryRowHeight = 52; - static const double _wideNavigationChannelRowHeight = 48; - static const double _wideNavigationLabelGap = Grid.twelve - Grid.quarter; - static const double _wideNavigationDmLabelGap = - _wideNavigationLabelGap - Grid.quarter; - static const double _wideNavigationIdentityAvatarInset = - Grid.xs + Grid.half - (Grid.quarter / 2); - static const double _wideNavigationIdentityLabelGap = - Grid.half + (Grid.quarter / 2); static const _destinations = [ - _HomeDestination( + WideNavigationDestination( icon: LucideIcons.house300, selectedIcon: LucideIcons.house500, label: 'Home', ), - _HomeDestination( + WideNavigationDestination( icon: LucideIcons.inbox300, selectedIcon: LucideIcons.inbox500, label: 'Activity', ), - _HomeDestination( + WideNavigationDestination( icon: LucideIcons.search300, selectedIcon: LucideIcons.search500, label: 'Search', @@ -87,7 +62,7 @@ class HomePage extends HookConsumerWidget { // On iPad, the persistent sidebar replaces the phone's Home tab. Start at // Inbox, which is the first top-level destination in that layout. final tabIndex = useState(isWide ? 1 : 0); - final selectedChannel = useState(null); + final selectedChannelId = useState(null); final pendingCommunityId = useState(null); // Clear the tablet selection as soon as the active-community state begins // changing. The channel provider intentionally retains its last result @@ -100,30 +75,9 @@ class HomePage extends HookConsumerWidget { ); final selectedChannelCommunityId = useRef(activeCommunityId); if (selectedChannelCommunityId.value != activeCommunityId) { - selectedChannel.value = null; + selectedChannelId.value = null; selectedChannelCommunityId.value = activeCommunityId; } - final channelsAsync = ref.watch(channelsProvider); - final hasActivatedPendingCommunity = - pendingCommunityId.value != null && - activeCommunityId == pendingCommunityId.value; - useEffect( - () { - if (!hasActivatedPendingCommunity || channelsAsync.isLoading) { - return null; - } - - final timer = Timer(const Duration(milliseconds: 250), () { - pendingCommunityId.value = null; - }); - return timer.cancel; - }, - [ - pendingCommunityId.value, - hasActivatedPendingCommunity, - channelsAsync.isLoading, - ], - ); final isCommunitySwitching = pendingCommunityId.value != null; final systemBottomInset = MediaQuery.paddingOf(context).bottom; final navigationBarWidth = _floatingTabBarWidth( @@ -133,10 +87,10 @@ class HomePage extends HookConsumerWidget { final useSidebarLayout = isWide; final pages = [ - if (isWide && selectedChannel.value != null) - _WideChannelContent( - channel: selectedChannel.value!, - onChannelLeft: () => selectedChannel.value = null, + if (isWide && selectedChannelId.value != null) + WideChannelContent( + channelId: selectedChannelId.value!, + onChannelLeft: () => selectedChannelId.value = null, ) else ChannelsPage(settingsPageBuilder: settingsPageBuilder), @@ -148,9 +102,9 @@ class HomePage extends HookConsumerWidget { // returning from a nested route must land back in Inbox rather than reveal // the phone's ChannelsPage inside the desktop-style workspace. final wideFallbackToInbox = - isWide && selectedChannel.value == null && tabIndex.value == 0; + isWide && selectedChannelId.value == null && tabIndex.value == 0; final wideContentIndex = wideFallbackToInbox ? 1 : tabIndex.value; - final wideSidebarSelection = selectedChannel.value != null + final wideSidebarSelection = selectedChannelId.value != null ? null : wideFallbackToInbox ? 1 @@ -182,11 +136,11 @@ class HomePage extends HookConsumerWidget { child: useSidebarLayout ? Row( children: [ - _WideNavigationSidebar( + WideChannelsNavigation( selectedIndex: wideSidebarSelection, onDestinationSelected: (index) { final hadSelectedChannel = - selectedChannel.value != null; + selectedChannelId.value != null; if (index == tabIndex.value && !(hadSelectedChannel && index != 0)) { return; @@ -195,18 +149,18 @@ class HomePage extends HookConsumerWidget { // the tablet workspace, so they must also clear // the channel selection that owns the sidebar // highlight. - selectedChannel.value = null; + selectedChannelId.value = null; unawaited(HapticFeedback.selectionClick()); tabIndex.value = index; }, - onChannelSelected: (channel) { - selectedChannel.value = channel; + onChannelSelected: (channelId) { + selectedChannelId.value = channelId; if (tabIndex.value != 0) { unawaited(HapticFeedback.selectionClick()); } tabIndex.value = 0; }, - selectedChannelId: selectedChannel.value?.id, + selectedChannelId: selectedChannelId.value, onProfileSelected: () => unawaited( Navigator.of(context).push( MaterialPageRoute( @@ -218,6 +172,10 @@ class HomePage extends HookConsumerWidget { onCommunitySwitchStart: (communityId) { pendingCommunityId.value = communityId; }, + pendingCommunityId: pendingCommunityId.value, + onCommunitySwitchComplete: () { + pendingCommunityId.value = null; + }, destinations: _destinations, ), Expanded( @@ -380,22 +338,10 @@ MediaQueryData _mediaQueryWithFloatingTabBarClearance( ); } -class _HomeDestination { - final IconData icon; - final IconData selectedIcon; - final String label; - - const _HomeDestination({ - required this.icon, - required this.selectedIcon, - required this.label, - }); -} - class _FloatingTabBar extends StatelessWidget { final int selectedIndex; final ValueChanged onDestinationSelected; - final List<_HomeDestination> destinations; + final List destinations; const _FloatingTabBar({ required this.selectedIndex, @@ -520,7 +466,7 @@ class _FloatingTabBar extends StatelessWidget { } class _FloatingTabDestination extends StatelessWidget { - final _HomeDestination destination; + final WideNavigationDestination destination; final bool selected; final VoidCallback onTap; diff --git a/mobile/lib/features/home/home_page/wide_navigation_skeletons.dart b/mobile/lib/features/home/home_page/wide_navigation_skeletons.dart index e4c7ca846..ee2f15748 100644 --- a/mobile/lib/features/home/home_page/wide_navigation_skeletons.dart +++ b/mobile/lib/features/home/home_page/wide_navigation_skeletons.dart @@ -1,96 +1,5 @@ part of '../home_page.dart'; -class _WideNavigationSidebarSkeleton extends StatelessWidget { - const _WideNavigationSidebarSkeleton({super.key}); - - @override - Widget build(BuildContext context) { - Widget row({double labelWidth = 112, bool avatar = false}) { - return SizedBox( - height: HomePage._wideNavigationChannelRowHeight, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.sm), - child: Row( - children: [ - SkeletonBar( - width: HomePage._wideNavigationIconSize, - height: HomePage._wideNavigationIconSize, - borderRadius: BorderRadius.circular( - avatar ? Radii.full : Radii.sm, - ), - ), - const SizedBox(width: HomePage._wideNavigationLabelGap), - SkeletonBar(width: labelWidth, height: 14), - ], - ), - ), - ); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - row(labelWidth: 64), - row(labelWidth: 72), - const SizedBox(height: Grid.xxs), - Expanded( - child: ListView( - physics: const NeverScrollableScrollPhysics(), - padding: EdgeInsets.zero, - children: [ - const Padding( - padding: EdgeInsets.only(left: Grid.sm, top: Grid.sm), - child: SkeletonBar(width: 68, height: 12), - ), - const SizedBox(height: Grid.xs), - row(labelWidth: 128), - row(labelWidth: 104), - row(labelWidth: 144), - const SizedBox(height: Grid.xs), - const Padding( - padding: EdgeInsets.only(left: Grid.sm, top: Grid.sm), - child: SkeletonBar(width: 32, height: 12), - ), - const SizedBox(height: Grid.xs), - row(labelWidth: 116, avatar: true), - row(labelWidth: 92, avatar: true), - ], - ), - ), - const SizedBox(height: Grid.xxs), - Padding( - padding: const EdgeInsets.only(bottom: Grid.xs), - child: SizedBox( - height: 48, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.sm), - child: Row( - children: [ - SkeletonBar( - width: 32, - height: 32, - borderRadius: BorderRadius.circular(Radii.full), - ), - const SizedBox(width: Grid.twelve), - const Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SkeletonBar(width: 104, height: 14), - SizedBox(height: Grid.half), - SkeletonBar(width: 72, height: 11), - ], - ), - ], - ), - ), - ), - ), - ], - ); - } -} - /// Covers the active tablet pane while the relay changes. The left list and /// right timeline preserve the Inbox/channel spatial structure, so no old /// community text is visible during the handoff.