isolate home workspace navigation

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-03 17:06:38 +01:00
parent b6dd0f5105
commit 11a4837ce1
4 changed files with 106 additions and 208 deletions
@@ -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 }
@@ -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<int> onDestinationSelected;
final ValueChanged<Channel> onChannelSelected;
final ValueChanged<String> onChannelSelected;
final String? selectedChannelId;
final VoidCallback onProfileSelected;
final bool isCommunitySwitching;
final ValueChanged<String?> onCommunitySwitchStart;
final List<_HomeDestination> destinations;
final String? pendingCommunityId;
final VoidCallback onCommunitySwitchComplete;
final List<WideNavigationDestination> 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 <Channel>[])
.where((channel) => !channel.isArchived)
.toList();
@@ -68,7 +107,7 @@ class _WideNavigationSidebar extends HookConsumerWidget {
Future<void> createChannel() async {
final channel = await showCreateChannelSheet(context);
if (channel != null) onChannelSelected(channel);
if (channel != null) onChannelSelected(channel.id);
}
Future<void> 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<Channel?>().firstWhere(
(channel) => channel?.id == channelId,
orElse: () => null,
);
if (channel == null) return const SizedBox.shrink();
final navigatorKey = useMemoized(GlobalKey<NavigatorState>.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,
+23 -77
View File
@@ -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<Channel?>(null);
final selectedChannelId = useState<String?>(null);
final pendingCommunityId = useState<String?>(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<String?>(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<void>(
@@ -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<int> onDestinationSelected;
final List<_HomeDestination> destinations;
final List<WideNavigationDestination> 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;
@@ -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.