diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj
index d965f4469..5c98a29ba 100644
--- a/mobile/ios/Runner.xcodeproj/project.pbxproj
+++ b/mobile/ios/Runner.xcodeproj/project.pbxproj
@@ -495,7 +495,7 @@
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
- TARGETED_DEVICE_FAMILY = 1;
+ TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Profile;
@@ -631,7 +631,7 @@
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
- TARGETED_DEVICE_FAMILY = 1;
+ TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
@@ -683,7 +683,7 @@
SUPPORTED_PLATFORMS = iphoneos;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
- TARGETED_DEVICE_FAMILY = 1;
+ TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
diff --git a/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
index e3773d42e..66f171ad2 100644
--- a/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
+++ b/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -15,7 +15,7 @@
@@ -32,7 +32,7 @@
@@ -68,7 +68,7 @@
@@ -85,7 +85,7 @@
diff --git a/mobile/ios/Runner/SceneDelegate.swift b/mobile/ios/Runner/SceneDelegate.swift
index b9ce8ea2b..ef29dd6d2 100644
--- a/mobile/ios/Runner/SceneDelegate.swift
+++ b/mobile/ios/Runner/SceneDelegate.swift
@@ -1,6 +1,4 @@
import Flutter
import UIKit
-class SceneDelegate: FlutterSceneDelegate {
-
-}
+class SceneDelegate: FlutterSceneDelegate {}
diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart
index aecef6329..0ab65cc6c 100644
--- a/mobile/lib/features/activity/activity_page.dart
+++ b/mobile/lib/features/activity/activity_page.dart
@@ -27,6 +27,7 @@ import '../profile/user_cache_provider.dart';
import '../profile/user_profile.dart';
import 'activity_provider.dart';
import 'compose_drafts_provider.dart';
+import 'feed_item.dart';
import 'inbox_item.dart';
import 'inbox_local_state_provider.dart';
import 'inbox_read_state.dart';
@@ -37,6 +38,10 @@ part 'activity_page/inbox_row.dart';
part 'activity_page/lists.dart';
part 'activity_page/status_views.dart';
+const _wideInboxBreakpoint = 1000.0;
+const _wideInboxMinListWidth = 300.0;
+const _wideInboxMaxListWidth = 400.0;
+
/// Conversation-oriented Activity inbox.
///
/// Matches desktop's Home inbox item design and semantics (see
@@ -54,6 +59,9 @@ class ActivityPage extends HookConsumerWidget {
final channelsAsync = ref.watch(channelsProvider);
final filter = useState(InboxFilter.all);
final unreadOnly = useState(false);
+ final selectedItemId = useState(null);
+ final isWideInbox =
+ MediaQuery.sizeOf(context).width >= _wideInboxBreakpoint;
final headerTitleStyle = context.textTheme.titleMedium?.copyWith(
fontSize: 22,
fontWeight: FontWeight.w600,
@@ -87,6 +95,21 @@ class ActivityPage extends HookConsumerWidget {
(!unreadOnly.value || !isDone(item)))
item,
];
+ final visibleItemIdsKey = visibleItems
+ .map((item) => item.id)
+ .join('\u0000');
+ useEffect(() {
+ if (!isWideInbox || visibleItems.isEmpty) return null;
+ final hasSelectedItem = visibleItems.any(
+ (item) => item.id == selectedItemId.value,
+ );
+ if (!hasSelectedItem) selectedItemId.value = visibleItems.first.id;
+ return null;
+ }, [isWideInbox, visibleItemIdsKey]);
+ final selectedItem = visibleItems.cast().firstWhere(
+ (item) => item?.id == selectedItemId.value,
+ orElse: () => null,
+ );
// Preload sender profiles for visible rows.
final preloadPubkeys = {
@@ -164,6 +187,12 @@ class ActivityPage extends HookConsumerWidget {
? null
: thread.parentId;
+ if (isWideInbox) {
+ selectedItemId.value = item.id;
+ markItemRead(item);
+ return;
+ }
+
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ChannelDetailPage(
@@ -280,6 +309,7 @@ class ActivityPage extends HookConsumerWidget {
channel: channel,
currentPubkey: myPk,
isDone: isDone(item),
+ selected: isWideInbox && item.id == selectedItemId.value,
onTap: () => openItem(item),
onMarkRead: () => markItemRead(item),
onMarkUnread: () => markItemUnread(item),
@@ -291,24 +321,27 @@ class ActivityPage extends HookConsumerWidget {
);
}
- return FrostedScaffold(
+ Widget inboxListPane(String title) => FrostedScaffold(
backgroundColor: Colors.transparent,
appBar: FrostedAppBar(
gradient: context.appColors.topSectionGradient,
automaticallyImplyLeading: false,
- title: const Text('Activity'),
+ title: Text(title),
titleStyle: headerTitleStyle,
actions: [
_FilterMenuButton(
filter: filter.value,
dueReminderCount: dueReminderCount,
draftCount: drafts.length,
- onChanged: (f) => filter.value = f,
+ onChanged: (nextFilter) {
+ selectedItemId.value = null;
+ filter.value = nextFilter;
+ },
),
_InboxOptionsButton(
unreadOnly: unreadOnly.value,
unreadCount: unreadVisibleCount,
- onUnreadOnlyChanged: (v) => unreadOnly.value = v,
+ onUnreadOnlyChanged: (value) => unreadOnly.value = value,
onMarkAllRead: () {
for (final item in visibleItems) {
if (!isDone(item)) markItemRead(item);
@@ -327,5 +360,123 @@ class ActivityPage extends HookConsumerWidget {
),
),
);
+
+ if (isWideInbox) {
+ FeedItem? detailTarget(InboxItem? inboxItem) {
+ if (inboxItem == null) return null;
+ return inboxItem.deepLinkTarget(
+ resolveInboxItemReadAt(inboxItem, markerOf: markerOf),
+ );
+ }
+
+ final selectedTarget = detailTarget(selectedItem);
+ final selectedChannelId = selectedTarget?.channelId;
+ final selectedChannel = selectedChannelId == null
+ ? null
+ : channelById[selectedChannelId];
+ final selectedThread = selectedTarget == null
+ ? null
+ : isBroadcastReply(selectedTarget.tags)
+ ? null
+ : threadReferenceOf(selectedTarget.tags).parentId;
+
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final listWidth = (constraints.maxWidth / 3)
+ .clamp(_wideInboxMinListWidth, _wideInboxMaxListWidth)
+ .toDouble();
+ return Row(
+ children: [
+ SizedBox(
+ key: const Key('wide-activity-inbox-list'),
+ width: listWidth,
+ child: inboxListPane('Inbox'),
+ ),
+ VerticalDivider(width: 1, color: context.colors.outlineVariant),
+ Expanded(
+ child: _WideActivityDetail(
+ item: selectedItem,
+ channel: selectedChannel,
+ initialMessageId: selectedTarget?.id,
+ initialThreadRootId: selectedThread,
+ ),
+ ),
+ ],
+ );
+ },
+ );
+ }
+
+ return inboxListPane('Activity');
+ }
+}
+
+class _WideActivityDetail extends StatelessWidget {
+ final InboxItem? item;
+ final Channel? channel;
+ final String? initialMessageId;
+ final String? initialThreadRootId;
+
+ const _WideActivityDetail({
+ required this.item,
+ required this.channel,
+ required this.initialMessageId,
+ required this.initialThreadRootId,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ if (item == null || channel == null || initialMessageId == null) {
+ return const _WideActivityEmptyDetail();
+ }
+
+ return Navigator(
+ key: ValueKey('wide-activity-detail-${item!.id}'),
+ onGenerateRoute: (_) => MaterialPageRoute(
+ builder: (_) => ChannelDetailPage(
+ channel: channel!,
+ initialMessageId: initialMessageId,
+ initialThreadRootId: initialThreadRootId,
+ ),
+ ),
+ );
+ }
+}
+
+class _WideActivityEmptyDetail extends StatelessWidget {
+ const _WideActivityEmptyDetail();
+
+ @override
+ Widget build(BuildContext context) {
+ return ColoredBox(
+ color: context.colors.surface,
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ LucideIcons.inbox,
+ color: context.colors.onSurfaceVariant,
+ size: 32,
+ ),
+ const SizedBox(height: Grid.sm),
+ Text(
+ 'Select an inbox item',
+ style: context.textTheme.titleMedium?.copyWith(
+ color: context.colors.onSurface,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ const SizedBox(height: Grid.xxs),
+ Text(
+ 'Its conversation will open here.',
+ style: context.textTheme.bodyMedium?.copyWith(
+ color: context.colors.onSurfaceVariant,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
}
}
diff --git a/mobile/lib/features/activity/activity_page/inbox_row.dart b/mobile/lib/features/activity/activity_page/inbox_row.dart
index 9dd8b6ddf..e18fa60bd 100644
--- a/mobile/lib/features/activity/activity_page/inbox_row.dart
+++ b/mobile/lib/features/activity/activity_page/inbox_row.dart
@@ -42,6 +42,7 @@ class _InboxRow extends ConsumerWidget {
final Channel? channel;
final String? currentPubkey;
final bool isDone;
+ final bool selected;
final VoidCallback onTap;
final VoidCallback onMarkRead;
final VoidCallback onMarkUnread;
@@ -51,6 +52,7 @@ class _InboxRow extends ConsumerWidget {
required this.channel,
required this.currentPubkey,
required this.isDone,
+ this.selected = false,
required this.onTap,
required this.onMarkRead,
required this.onMarkUnread,
@@ -100,112 +102,119 @@ class _InboxRow extends ConsumerWidget {
? context.colors.tertiary
: mutedColor;
- return InkWell(
- key: ValueKey('inbox-row-${item.id}'),
- onTap: onTap,
- onLongPress: () => _showRowActions(context),
- child: Padding(
- padding: const EdgeInsets.symmetric(
- horizontal: Grid.gutter,
- vertical: Grid.twelve,
- ),
- child: Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- _RowAvatar(pubkey: item.item.pubkey, profile: profile),
- const SizedBox(width: messageAvatarContentGap),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // Sender + unread dot + timestamp.
- Row(
- children: [
- Expanded(
- child: MessageAuthorMeta(
- displayName: senderLabel,
- username: messageUsernameLabel(profile),
- timestamp: _inboxTimestamp(item.latestActivityAt),
- nameColor: context.colors.onSurface,
- metadataColor: mutedColor,
- nameStyle: activityUsernameTextStyle,
- metadataStyle: activityTimestampTextStyle,
- displayNameKey: ValueKey(
- 'activity-author-${item.id}',
- ),
- usernameKey: ValueKey('activity-username-${item.id}'),
- timestampKey: ValueKey(
- 'activity-timestamp-${item.id}',
- ),
- ),
- ),
- if (!isDone) ...[
- const SizedBox(width: Grid.xxs),
- Container(
- key: ValueKey('inbox-unread-dot-${item.id}'),
- width: 6,
- height: 6,
- decoration: BoxDecoration(
- shape: BoxShape.circle,
- color: context.colors.primary,
- ),
- ),
- ],
- ],
- ),
- const SizedBox(height: Grid.quarter),
- // Contextual label: "Mentioned in #channel" etc.
- Row(
- children: [
- Flexible(
- child: Text(
- label.text,
- style: activityContextTextStyle.copyWith(
- color: labelColor,
- ),
- overflow: TextOverflow.ellipsis,
- ),
- ),
- if (label.channelLabel != null) ...[
- const SizedBox(width: Grid.half),
- Flexible(
- child: Container(
- padding: const EdgeInsets.symmetric(
- horizontal: Grid.half + Grid.quarter,
- vertical: Grid.quarter / 2,
+ return Material(
+ color: selected
+ ? context.colors.surfaceContainerHighest
+ : Colors.transparent,
+ child: InkWell(
+ key: ValueKey('inbox-row-${item.id}'),
+ onTap: onTap,
+ onLongPress: () => _showRowActions(context),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: Grid.gutter,
+ vertical: Grid.twelve,
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _RowAvatar(pubkey: item.item.pubkey, profile: profile),
+ const SizedBox(width: messageAvatarContentGap),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // Sender + unread dot + timestamp.
+ Row(
+ children: [
+ Expanded(
+ child: MessageAuthorMeta(
+ displayName: senderLabel,
+ username: messageUsernameLabel(profile),
+ timestamp: _inboxTimestamp(item.latestActivityAt),
+ nameColor: context.colors.onSurface,
+ metadataColor: mutedColor,
+ nameStyle: activityUsernameTextStyle,
+ metadataStyle: activityTimestampTextStyle,
+ displayNameKey: ValueKey(
+ 'activity-author-${item.id}',
),
+ usernameKey: ValueKey(
+ 'activity-username-${item.id}',
+ ),
+ timestampKey: ValueKey(
+ 'activity-timestamp-${item.id}',
+ ),
+ ),
+ ),
+ if (!isDone) ...[
+ const SizedBox(width: Grid.xxs),
+ Container(
+ key: ValueKey('inbox-unread-dot-${item.id}'),
+ width: 6,
+ height: 6,
decoration: BoxDecoration(
- color: context.colors.surfaceContainerHighest,
- borderRadius: BorderRadius.circular(Grid.half),
- ),
- child: Text(
- '#${label.channelLabel}',
- style: activityContextTextStyle.copyWith(
- color: mutedColor,
- ),
- overflow: TextOverflow.ellipsis,
+ shape: BoxShape.circle,
+ color: context.colors.primary,
),
),
- ),
+ ],
],
- ],
- ),
- const SizedBox(height: Grid.half),
- // Message preview.
- MessageContent(
- content: item.item.displayContent,
- mentionNames: mentionNames,
- agentMentionPubkeys: agentMentionPubkeys,
- tags: item.item.tags,
- maxLines: 2,
- baseStyle: activityPreviewTextStyle.copyWith(
- color: context.colors.onSurface,
),
- ),
- ],
+ const SizedBox(height: Grid.quarter),
+ // Contextual label: "Mentioned in #channel" etc.
+ Row(
+ children: [
+ Flexible(
+ child: Text(
+ label.text,
+ style: activityContextTextStyle.copyWith(
+ color: labelColor,
+ ),
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ if (label.channelLabel != null) ...[
+ const SizedBox(width: Grid.half),
+ Flexible(
+ child: Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: Grid.half + Grid.quarter,
+ vertical: Grid.quarter / 2,
+ ),
+ decoration: BoxDecoration(
+ color: context.colors.surfaceContainerHighest,
+ borderRadius: BorderRadius.circular(Grid.half),
+ ),
+ child: Text(
+ '#${label.channelLabel}',
+ style: activityContextTextStyle.copyWith(
+ color: mutedColor,
+ ),
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ),
+ ],
+ ],
+ ),
+ const SizedBox(height: Grid.half),
+ // Message preview.
+ MessageContent(
+ content: item.item.displayContent,
+ mentionNames: mentionNames,
+ agentMentionPubkeys: agentMentionPubkeys,
+ tags: item.item.tags,
+ maxLines: 2,
+ baseStyle: activityPreviewTextStyle.copyWith(
+ color: context.colors.onSurface,
+ ),
+ ),
+ ],
+ ),
),
- ),
- ],
+ ],
+ ),
),
),
);
diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart
index 044342d10..c7ae6d77e 100644
--- a/mobile/lib/features/channels/channel_detail_page.dart
+++ b/mobile/lib/features/channels/channel_detail_page.dart
@@ -48,6 +48,7 @@ import 'send_message_provider.dart';
import '../profile/user_profile_sheet.dart';
import 'small_avatar.dart';
import 'thread_detail_page.dart';
+import 'thread_replies_provider.dart';
import 'timeline_message.dart';
part 'channel_detail_page/message_list.dart';
diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart
index cb6ab6706..282400515 100644
--- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart
+++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart
@@ -233,13 +233,18 @@ class _MessageBubble extends ConsumerWidget {
ReactionRow(
messageId: message.id,
reactions: message.reactions,
- onToggle: (emoji) =>
- toggleReaction(ref, message, emoji),
+ onToggle: (emoji) => toggleReaction(
+ ref,
+ message,
+ emoji,
+ channelId: currentChannelId,
+ ),
showAddButton: isMember && !isArchived,
onAddReaction: () => showAddReactionPicker(
context: context,
ref: ref,
message: message,
+ channelId: currentChannelId,
),
),
],
diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart
index eacba5856..2a0386b25 100644
--- a/mobile/lib/features/channels/channel_detail_page/message_list.dart
+++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart
@@ -40,6 +40,31 @@ class _MessageList extends HookConsumerWidget {
final previousLatestEntryId = useRef(null);
final didOpenInitialThread = useRef(false);
final didJumpToInitialMessage = useRef(false);
+ final initialThreadHead = initialThreadRootId == null
+ ? null
+ : allMessages
+ .where((message) => message.id == initialThreadRootId)
+ .firstOrNull;
+ final initialThreadQueryRootId = initialThreadHead == null
+ ? null
+ : initialThreadHead.rootId ?? initialThreadHead.id;
+ final initialThreadReplies = initialThreadQueryRootId == null
+ ? null
+ : ref.watch(
+ threadRepliesWithLocalProvider(
+ ThreadRepliesArgs(
+ channelId: channelId,
+ rootId: initialThreadQueryRootId,
+ ),
+ ),
+ );
+ // An Inbox deep link initially has only a channel-window snapshot. Wait
+ // for the canonical thread query before pushing the route so the first
+ // frame matches a thread opened normally from its channel.
+ final initialThreadIsReady =
+ initialThreadReplies == null ||
+ initialThreadReplies.hasValue ||
+ initialThreadReplies.hasError;
int? reversedIndexOf(String? messageId) {
if (messageId == null) return null;
@@ -128,33 +153,40 @@ class _MessageList extends HookConsumerWidget {
);
}, [channelId, entries.length, itemPositionsListener]);
- useEffect(() {
- if (initialThreadRootId == null || didOpenInitialThread.value) {
- return null;
- }
- final threadHead = allMessages
- .where((message) => message.id == initialThreadRootId)
- .firstOrNull;
- if (threadHead == null) return null;
- didOpenInitialThread.value = true;
- WidgetsBinding.instance.addPostFrameCallback((_) {
- if (!context.mounted) return;
- Navigator.of(context).push(
- MaterialPageRoute(
- builder: (_) => ThreadDetailPage(
- threadHead: threadHead,
- allMessages: allMessages,
- channelId: channelId,
- currentPubkey: currentPubkey,
- isMember: isMember,
- isArchived: isArchived,
- initialMessageId: initialMessageId,
+ useEffect(
+ () {
+ if (initialThreadRootId == null ||
+ didOpenInitialThread.value ||
+ initialThreadHead == null ||
+ !initialThreadIsReady) {
+ return null;
+ }
+ didOpenInitialThread.value = true;
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (!context.mounted) return;
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => ThreadDetailPage(
+ threadHead: initialThreadHead,
+ allMessages: allMessages,
+ channelId: channelId,
+ currentPubkey: currentPubkey,
+ isMember: isMember,
+ isArchived: isArchived,
+ initialMessageId: initialMessageId,
+ ),
),
- ),
- );
- });
- return null;
- }, [initialThreadRootId, allMessages]);
+ );
+ });
+ return null;
+ },
+ [
+ initialThreadRootId,
+ initialThreadHead,
+ initialThreadIsReady,
+ allMessages,
+ ],
+ );
useEffect(() {
final targetIndex = reversedIndexOf(initialMessageId);
diff --git a/mobile/lib/features/channels/channel_detail_page/system_rows.dart b/mobile/lib/features/channels/channel_detail_page/system_rows.dart
index 0372690e1..510bd9b39 100644
--- a/mobile/lib/features/channels/channel_detail_page/system_rows.dart
+++ b/mobile/lib/features/channels/channel_detail_page/system_rows.dart
@@ -57,7 +57,7 @@ class _SystemMessageRow extends ConsumerWidget {
),
);
if (reactedMessages.isEmpty) {
- actions.addReaction(message.id, emoji);
+ actions.addReaction(message.id, emoji, channelId: channelId);
return;
}
for (final source in reactedMessages) {
@@ -67,7 +67,11 @@ class _SystemMessageRow extends ConsumerWidget {
candidate.reactedByCurrentUser &&
candidate.currentUserReactionId != null,
);
- actions.removeReaction(reaction.currentUserReactionId!, emoji);
+ actions.removeReaction(
+ reaction.currentUserReactionId!,
+ emoji,
+ channelId: channelId,
+ );
}
}
@@ -143,7 +147,12 @@ class _SystemMessageRow extends ConsumerWidget {
messageId: message.id,
reactions: reactions,
onToggle: groupedMessages == null
- ? (emoji) => toggleReaction(ref, message, emoji)
+ ? (emoji) => toggleReaction(
+ ref,
+ message,
+ emoji,
+ channelId: channelId,
+ )
: toggleGroupedReaction,
),
),
diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart
index b990194d1..5f36be3c9 100644
--- a/mobile/lib/features/channels/channel_management_provider.dart
+++ b/mobile/lib/features/channels/channel_management_provider.dart
@@ -11,6 +11,7 @@ import '../../shared/mentions/agent_identity_provider.dart';
import '../../shared/relay/relay.dart';
import '../profile/profile_provider.dart';
import 'channel.dart';
+import 'channel_messages_provider.dart';
import 'channels_provider.dart';
@immutable
@@ -682,31 +683,75 @@ class ChannelActions {
_ref.invalidate(channelBotPubkeysProvider(channelId));
}
- Future addReaction(String eventId, String emoji) async {
+ Future addReaction(
+ String eventId,
+ String emoji, {
+ String? channelId,
+ }) async {
final shortcode = normalizeShortcode(emoji);
final emojiUrl = reactionEmojiUrl(
emoji,
_ref.read(customEmojiListProvider),
);
- await _signedEventRelay.submit(
- kind: EventKind.reaction,
- content: emoji,
- tags: [
- ['e', eventId],
- if (shortcode != null && emojiUrl != null)
- ['emoji', shortcode, emojiUrl],
- ],
- );
+ NostrEvent? localReaction;
+ try {
+ await _signedEventRelay.submit(
+ kind: EventKind.reaction,
+ content: emoji,
+ tags: [
+ ['e', eventId],
+ if (shortcode != null && emojiUrl != null)
+ ['emoji', shortcode, emojiUrl],
+ ],
+ onSigned: (event) {
+ localReaction = event;
+ if (channelId != null) {
+ _ref
+ .read(channelMessagesProvider(channelId).notifier)
+ .addLocalAuxEvent(event);
+ }
+ },
+ );
+ } catch (_) {
+ if (channelId != null && localReaction != null) {
+ _ref
+ .read(channelMessagesProvider(channelId).notifier)
+ .removeLocalAuxEvent(localReaction!.id);
+ }
+ rethrow;
+ }
}
- Future removeReaction(String reactionEventId, String emoji) async {
- await _signedEventRelay.submit(
- kind: EventKind.deletion,
- content: '',
- tags: [
- ['e', reactionEventId],
- ],
- );
+ Future removeReaction(
+ String reactionEventId,
+ String emoji, {
+ String? channelId,
+ }) async {
+ NostrEvent? localDeletion;
+ try {
+ await _signedEventRelay.submit(
+ kind: EventKind.deletion,
+ content: '',
+ tags: [
+ ['e', reactionEventId],
+ ],
+ onSigned: (event) {
+ localDeletion = event;
+ if (channelId != null) {
+ _ref
+ .read(channelMessagesProvider(channelId).notifier)
+ .addLocalAuxEvent(event);
+ }
+ },
+ );
+ } catch (_) {
+ if (channelId != null && localDeletion != null) {
+ _ref
+ .read(channelMessagesProvider(channelId).notifier)
+ .removeLocalAuxEvent(localDeletion!.id);
+ }
+ rethrow;
+ }
}
Future editMessage({
diff --git a/mobile/lib/features/channels/channel_messages_provider.dart b/mobile/lib/features/channels/channel_messages_provider.dart
index fbcbca895..b9b1ec2d6 100644
--- a/mobile/lib/features/channels/channel_messages_provider.dart
+++ b/mobile/lib/features/channels/channel_messages_provider.dart
@@ -315,6 +315,42 @@ class ChannelMessagesNotifier extends Notifier>> {
_handleLiveEvent(event, authoritative: false);
}
+ /// Applies a just-signed auxiliary event, such as a reaction or deletion,
+ /// before its relay echo arrives. Thread queries intentionally contain only
+ /// message content, so they rely on this channel stream for their reaction
+ /// state as well.
+ void addLocalAuxEvent(NostrEvent event) {
+ if (!EventKind.channelAuxEventKinds.contains(event.kind)) {
+ throw ArgumentError.value(
+ event.kind,
+ 'event.kind',
+ 'Expected a channel auxiliary event.',
+ );
+ }
+ _handleLiveEvent(event, authoritative: false);
+ }
+
+ /// Removes an optimistic auxiliary event when its publish is rejected.
+ void removeLocalAuxEvent(String eventId) {
+ final nextAux = _windowStore.liveAux
+ .where((event) => event.id != eventId)
+ .toList();
+ if (nextAux.length != _windowStore.liveAux.length) {
+ _windowStore = ChannelWindowStore(
+ pages: _windowStore.pages,
+ liveOverlay: _windowStore.liveOverlay,
+ liveAux: nextAux,
+ liveThreadSummaries: _windowStore.liveThreadSummaries,
+ );
+ }
+
+ final current = state.value ?? _lastKnownMessages ?? const [];
+ final next = current.where((event) => event.id != eventId).toList();
+ if (next.length == current.length) return;
+ _lastKnownMessages = next;
+ state = AsyncData(next);
+ }
+
/// Releases rollback ownership after the publish future succeeds. The
/// optimistic row (and any thread overlay) remains visible until relay data
/// replaces it, because OK and EVENT delivery are unordered.
diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart
index ba5d4ebf9..a9b75ce0a 100644
--- a/mobile/lib/features/channels/channels_page.dart
+++ b/mobile/lib/features/channels/channels_page.dart
@@ -237,11 +237,7 @@ class ChannelsPage extends HookConsumerWidget {
leading: _CommunityIndicator(
onTap: () {
ref.invalidate(communityIconProvider);
- showModalBottomSheet(
- context: context,
- showDragHandle: true,
- builder: (_) => const _CommunitySwitcherSheet(),
- );
+ unawaited(showCommunitySwitcherSheet(context));
},
),
title: const SizedBox.shrink(),
diff --git a/mobile/lib/features/channels/channels_page/community.dart b/mobile/lib/features/channels/channels_page/community.dart
index 73935f463..6c6cb6879 100644
--- a/mobile/lib/features/channels/channels_page/community.dart
+++ b/mobile/lib/features/channels/channels_page/community.dart
@@ -1,5 +1,15 @@
part of '../channels_page.dart';
+/// Opens the community switcher from either the compact channel header or the
+/// expanded iPad sidebar identity row.
+Future showCommunitySwitcherSheet(BuildContext context) {
+ return showModalBottomSheet(
+ context: context,
+ showDragHandle: true,
+ builder: (_) => const _CommunitySwitcherSheet(),
+ );
+}
+
class _CommunitySwitcherSheet extends HookConsumerWidget {
const _CommunitySwitcherSheet();
diff --git a/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart b/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart
index 365393208..408e7e251 100644
--- a/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart
+++ b/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart
@@ -5,6 +5,32 @@ const _kQuickActionsTabMotionCurve = Cubic(0.77, 0, 0.175, 1);
const _kQuickActionsHiddenOverlap = Grid.half;
const _kQuickActionsHiddenScale = 0.8;
+/// Opens the stream-creation flow used by both the compact floating action
+/// button and the expanded iPad sidebar.
+Future showCreateChannelSheet(BuildContext context) {
+ return showModalBottomSheet(
+ context: context,
+ constraints: _quickActionSheetConstraints(context),
+ isScrollControlled: true,
+ showDragHandle: true,
+ builder: (_) => const _CreateChannelSheet(channelType: 'stream'),
+ );
+}
+
+/// Opens the direct-message flow used by both navigation layouts.
+Future showNewDirectMessageSheet(
+ BuildContext context, {
+ required String? currentPubkey,
+}) {
+ return showModalBottomSheet(
+ context: context,
+ constraints: _quickActionSheetConstraints(context),
+ isScrollControlled: true,
+ showDragHandle: true,
+ builder: (_) => _NewDirectMessageSheet(currentPubkey: currentPubkey),
+ );
+}
+
/// Places the channel quick-actions button beside mobile navigation.
///
/// The button remains available only on Home and moves behind the navigation
@@ -85,24 +111,14 @@ class ChannelQuickActionsLauncher extends HookConsumerWidget {
switch (action) {
case _QuickAction.createChannel:
- final created = await showModalBottomSheet(
- context: context,
- constraints: _quickActionSheetConstraints(context),
- isScrollControlled: true,
- showDragHandle: true,
- builder: (_) => const _CreateChannelSheet(channelType: 'stream'),
- );
+ final created = await showCreateChannelSheet(context);
if (created != null && context.mounted) {
await openChannel(created);
}
case _QuickAction.newDm:
- final opened = await showModalBottomSheet(
- context: context,
- constraints: _quickActionSheetConstraints(context),
- isScrollControlled: true,
- showDragHandle: true,
- builder: (_) =>
- _NewDirectMessageSheet(currentPubkey: currentPubkey),
+ final opened = await showNewDirectMessageSheet(
+ context,
+ currentPubkey: currentPubkey,
);
if (opened != null && context.mounted) {
await openChannel(opened);
diff --git a/mobile/lib/features/channels/compose_bar/attachments.dart b/mobile/lib/features/channels/compose_bar/attachments.dart
index 7c53ae109..bb617b5c7 100644
--- a/mobile/lib/features/channels/compose_bar/attachments.dart
+++ b/mobile/lib/features/channels/compose_bar/attachments.dart
@@ -9,6 +9,8 @@ const _attachmentMenuItemSpacing = Grid.xxs;
const _attachmentMenuIconSize = 24.0;
const _attachmentMenuIconSlotWidth = 28.0;
const _attachmentExpandedHeight = 372.0;
+const _tabletAttachmentCameraMaxHeight = 640.0;
+const _tabletShortestSideBreakpoint = 600.0;
@immutable
class _AttachmentMenuLayout {
@@ -170,7 +172,17 @@ class _AttachmentSurfacePanel extends HookWidget {
return LayoutBuilder(
builder: (context, constraints) {
final expandedWidth = constraints.maxWidth;
- const expandedHeight = _attachmentExpandedHeight;
+ final isTablet =
+ MediaQuery.sizeOf(context).shortestSide >=
+ _tabletShortestSideBreakpoint;
+ final expandedHeight =
+ visibleExpandedSurface == _AttachmentSurface.camera && isTablet
+ // Use the available chat-pane width rather than a fixed landscape
+ // height. On a tablet's three-pane layout this keeps the camera
+ // preview close to square, even though the pane itself is narrower
+ // than the full device width.
+ ? math.min(expandedWidth, _tabletAttachmentCameraMaxHeight)
+ : _attachmentExpandedHeight;
final width =
_attachmentMenuWidth +
((expandedWidth - _attachmentMenuWidth) * sizeProgress);
diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart
index 519d25898..479947577 100644
--- a/mobile/lib/features/channels/message_actions.dart
+++ b/mobile/lib/features/channels/message_actions.dart
@@ -68,6 +68,7 @@ void showMessageActions({
children: [
_QuickReactionRow(
message: message,
+ channelId: channelId,
sheetContext: sheetContext,
pageContext: context,
pageRef: ref,
@@ -592,6 +593,7 @@ class _FastActionTile extends StatelessWidget {
/// the row is full on a fresh install.
class _QuickReactionRow extends ConsumerWidget {
final TimelineMessage message;
+ final String channelId;
/// The sheet's context, popped before the reaction fires.
final BuildContext sheetContext;
@@ -606,6 +608,7 @@ class _QuickReactionRow extends ConsumerWidget {
const _QuickReactionRow({
required this.message,
+ required this.channelId,
required this.sheetContext,
required this.pageContext,
required this.pageRef,
@@ -632,7 +635,9 @@ class _QuickReactionRow extends ConsumerWidget {
// The sheet is on its way out, so the burst can't come from this tile —
// hand it to the pill that's about to appear in the timeline.
armReactionBurst(pageRef, message, value);
- pageRef.read(channelActionsProvider).addReaction(message.id, value);
+ pageRef
+ .read(channelActionsProvider)
+ .addReaction(message.id, value, channelId: channelId);
}
return Row(
diff --git a/mobile/lib/features/channels/reaction_row.dart b/mobile/lib/features/channels/reaction_row.dart
index a9511c587..22f02bde9 100644
--- a/mobile/lib/features/channels/reaction_row.dart
+++ b/mobile/lib/features/channels/reaction_row.dart
@@ -37,16 +37,25 @@ const _pillGlyphSize = 16.0;
///
/// Used by channel detail, thread detail, and system message rows to avoid
/// duplicating the toggle wiring.
-void toggleReaction(WidgetRef ref, TimelineMessage message, String emoji) {
+void toggleReaction(
+ WidgetRef ref,
+ TimelineMessage message,
+ String emoji, {
+ String? channelId,
+}) {
final actions = ref.read(channelActionsProvider);
final reaction = message.reactions.firstWhere((r) => r.emoji == emoji);
if (reaction.reactedByCurrentUser && reaction.currentUserReactionId != null) {
- actions.removeReaction(reaction.currentUserReactionId!, emoji);
+ actions.removeReaction(
+ reaction.currentUserReactionId!,
+ emoji,
+ channelId: channelId,
+ );
} else {
// Only adding counts as a use — removing a reaction shouldn't promote the
// emoji in the frequently-used ranking.
ref.read(recentEmojiProvider.notifier).record(emoji);
- actions.addReaction(message.id, emoji);
+ actions.addReaction(message.id, emoji, channelId: channelId);
}
}
@@ -73,13 +82,16 @@ void showAddReactionPicker({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
+ String? channelId,
}) {
showEmojiPicker(
context: context,
onSelect: (emoji) {
ref.read(recentEmojiProvider.notifier).record(emoji);
armReactionBurst(ref, message, emoji);
- ref.read(channelActionsProvider).addReaction(message.id, emoji);
+ ref
+ .read(channelActionsProvider)
+ .addReaction(message.id, emoji, channelId: channelId);
},
);
}
diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart
index 810861aa0..3f94e33eb 100644
--- a/mobile/lib/features/channels/thread_detail_page.dart
+++ b/mobile/lib/features/channels/thread_detail_page.dart
@@ -596,6 +596,7 @@ class _ThreadMessage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
+ final isTabletLayout = MediaQuery.sizeOf(context).width >= 840;
final pk = message.pubkey.toLowerCase();
final profile =
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
@@ -634,7 +635,12 @@ class _ThreadMessage extends ConsumerWidget {
);
return Padding(
- padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
+ // In the tablet's wider thread pane, the first author header follows a
+ // day divider. Halve that handoff gap so the timestamp sits with the
+ // message rather than floating below the divider.
+ padding: EdgeInsets.only(
+ top: showAuthor ? (isTabletLayout ? Grid.half : Grid.xs) : 0,
+ ),
child: DecoratedBox(
key: ValueKey('thread-message-${message.id}'),
decoration: BoxDecoration(
@@ -791,8 +797,12 @@ class _ThreadMessage extends ConsumerWidget {
ReactionRow(
messageId: message.id,
reactions: message.reactions,
- onToggle: (emoji) =>
- toggleReaction(ref, message, emoji),
+ onToggle: (emoji) => toggleReaction(
+ ref,
+ message,
+ emoji,
+ channelId: channelId,
+ ),
showAddButton:
isMember &&
!isArchived &&
@@ -801,6 +811,7 @@ class _ThreadMessage extends ConsumerWidget {
context: context,
ref: ref,
message: message,
+ channelId: channelId,
),
),
],
diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart
index a40e31746..8ede13dae 100644
--- a/mobile/lib/features/home/home_page.dart
+++ b/mobile/lib/features/home/home_page.dart
@@ -7,12 +7,30 @@ 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/channel_management_provider.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 {
const HomePage({required this.settingsPageBuilder, super.key});
@@ -27,8 +45,23 @@ 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(
@@ -50,18 +83,63 @@ class HomePage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
- final tabIndex = useState(0);
+ final isWide =
+ MediaQuery.sizeOf(context).width >= HomePage._wideNavigationBreakpoint;
+ // 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 pendingCommunityId = useState(null);
+ final activeCommunityId = ref.watch(activeCommunityProvider).value?.id;
+ 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(
MediaQuery.sizeOf(context).width,
_destinations.length,
);
+ final useSidebarLayout = isWide;
final pages = [
- ChannelsPage(settingsPageBuilder: settingsPageBuilder),
+ if (isWide && selectedChannel.value != null)
+ _WideChannelContent(channel: selectedChannel.value!)
+ else
+ ChannelsPage(settingsPageBuilder: settingsPageBuilder),
const ActivityPage(),
const SearchPage(),
];
+ // The phone's Home destination is represented by index zero. On a tablet,
+ // that destination only exists while a channel is selected; otherwise
+ // 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;
+ final wideContentIndex = wideFallbackToInbox ? 1 : tabIndex.value;
+ final wideSidebarSelection = selectedChannel.value != null
+ ? null
+ : wideFallbackToInbox
+ ? 1
+ : tabIndex.value;
return Scaffold(
backgroundColor: Colors.transparent,
@@ -73,46 +151,182 @@ class HomePage extends HookConsumerWidget {
child: Stack(
fit: StackFit.expand,
children: [
- Positioned.fill(child: ColoredBox(color: context.colors.surface)),
Positioned.fill(
- child: MediaQuery(
- data: _mediaQueryWithFloatingTabBarClearance(
- context,
- HomePage._fabClearance,
- ),
- child: IndexedStack(index: tabIndex.value, children: pages),
- ),
- ),
- Align(
- alignment: Alignment.bottomCenter,
- child: IgnorePointer(
- child: MobileTabFooterBackdrop(
- height: mobileTabFooterBackdropHeight(context),
- ),
- ),
+ child: useSidebarLayout
+ ? DecoratedBox(
+ decoration: BoxDecoration(
+ color: context.appColors.topSectionGradient == null
+ ? context.colors.surfaceContainerLowest
+ : null,
+ gradient: context.appColors.topSectionGradient,
+ ),
+ )
+ : ColoredBox(color: context.colors.surface),
),
Positioned.fill(
- child: ChannelQuickActionsLauncher(
- visible: tabIndex.value == 0,
- navigationBarHeight: HomePage._tabBarHeight,
- navigationBarBottomGap: HomePage._tabBarBottomGap,
- navigationBarWidth: navigationBarWidth,
- systemBottomInset: systemBottomInset,
- rightInset: Grid.sm,
+ child: useSidebarLayout
+ ? Row(
+ children: [
+ _WideNavigationSidebar(
+ selectedIndex: wideSidebarSelection,
+ onDestinationSelected: (index) {
+ final hadSelectedChannel =
+ selectedChannel.value != null;
+ if (index == tabIndex.value &&
+ !(hadSelectedChannel && index != 0)) {
+ return;
+ }
+ // Search and Inbox replace a selected channel in
+ // the tablet workspace, so they must also clear
+ // the channel selection that owns the sidebar
+ // highlight.
+ selectedChannel.value = null;
+ unawaited(HapticFeedback.selectionClick());
+ tabIndex.value = index;
+ },
+ onChannelSelected: (channel) {
+ selectedChannel.value = channel;
+ if (tabIndex.value != 0) {
+ unawaited(HapticFeedback.selectionClick());
+ }
+ tabIndex.value = 0;
+ },
+ selectedChannelId: selectedChannel.value?.id,
+ onProfileSelected: () => unawaited(
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: settingsPageBuilder,
+ ),
+ ),
+ ),
+ isCommunitySwitching: isCommunitySwitching,
+ onCommunitySwitchStart: (communityId) {
+ pendingCommunityId.value = communityId;
+ },
+ destinations: _destinations,
+ ),
+ Expanded(
+ child: Padding(
+ key: const Key('wide-navigation-content-inset'),
+ padding: const EdgeInsets.only(
+ top: HomePage._wideContentInset,
+ right: HomePage._wideContentInset,
+ bottom: HomePage._wideContentInset,
+ ),
+ child: DecoratedBox(
+ key: const Key('wide-navigation-content-surface'),
+ decoration: BoxDecoration(
+ color: context.colors.surface,
+ borderRadius: BorderRadius.circular(
+ HomePage._wideContentRadius,
+ ),
+ // Matches desktop's Buzz content surface: a
+ // hairline on the upper-left edge plus a very
+ // soft lift into the exposed gradient.
+ boxShadow:
+ context.theme.brightness == Brightness.light
+ ? [
+ BoxShadow(
+ color: context.colors.outlineVariant
+ .withValues(alpha: 0.45),
+ offset: const Offset(-1, -1),
+ ),
+ BoxShadow(
+ color: Colors.black.withValues(
+ alpha: 0.07,
+ ),
+ blurRadius: 4,
+ ),
+ ]
+ : null,
+ ),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(
+ HomePage._wideContentRadius,
+ ),
+ child: MediaQuery(
+ // The desktop-like canvas is inset below the
+ // system top edge. Remove that same amount
+ // from the workspace's top safe padding so
+ // its titles stay aligned with the sidebar
+ // community switcher.
+ data: MediaQuery.of(context).copyWith(
+ padding: MediaQuery.paddingOf(context)
+ .copyWith(
+ top:
+ (MediaQuery.paddingOf(
+ context,
+ ).top -
+ HomePage
+ ._wideContentInset)
+ .clamp(0, double.infinity),
+ ),
+ ),
+ child: SkeletonReveal(
+ loading: isCommunitySwitching,
+ skeleton: const _WideCommunityContentSkeleton(
+ key: Key(
+ 'wide-community-switch-content-skeleton',
+ ),
+ ),
+ content: IndexedStack(
+ index: wideContentIndex,
+ children: pages,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ )
+ : MediaQuery(
+ data: _mediaQueryWithFloatingTabBarClearance(
+ context,
+ HomePage._fabClearance,
+ ),
+ child: IndexedStack(
+ index: tabIndex.value,
+ children: pages,
+ ),
+ ),
+ ),
+ if (!useSidebarLayout)
+ Align(
+ alignment: Alignment.bottomCenter,
+ child: IgnorePointer(
+ child: MobileTabFooterBackdrop(
+ height: mobileTabFooterBackdropHeight(context),
+ ),
+ ),
),
+ Positioned.fill(
+ child: useSidebarLayout
+ ? const SizedBox.shrink()
+ : ChannelQuickActionsLauncher(
+ visible: tabIndex.value == 0,
+ navigationBarHeight: HomePage._tabBarHeight,
+ navigationBarBottomGap: HomePage._tabBarBottomGap,
+ navigationBarWidth: navigationBarWidth,
+ systemBottomInset: systemBottomInset,
+ rightInset: Grid.sm,
+ ),
),
],
),
),
- bottomNavigationBar: _FloatingTabBar(
- selectedIndex: tabIndex.value,
- onDestinationSelected: (i) {
- if (i == tabIndex.value) return;
- unawaited(HapticFeedback.selectionClick());
- tabIndex.value = i;
- },
- destinations: _destinations,
- ),
+ bottomNavigationBar: useSidebarLayout
+ ? null
+ : _FloatingTabBar(
+ selectedIndex: tabIndex.value,
+ onDestinationSelected: (i) {
+ if (i == tabIndex.value) return;
+ unawaited(HapticFeedback.selectionClick());
+ tabIndex.value = i;
+ },
+ destinations: _destinations,
+ ),
);
}
}
@@ -196,6 +410,7 @@ class _FloatingTabBar extends StatelessWidget {
);
return SafeArea(
+ key: const Key('floating-tab-bar'),
minimum: const EdgeInsets.fromLTRB(
HomePage._tabBarHorizontalMargin,
0,
diff --git a/mobile/lib/features/home/home_page/wide_navigation.dart b/mobile/lib/features/home/home_page/wide_navigation.dart
new file mode 100644
index 000000000..7643b01e4
--- /dev/null
+++ b/mobile/lib/features/home/home_page/wide_navigation.dart
@@ -0,0 +1,881 @@
+part of '../home_page.dart';
+
+class _WideNavigationSidebar extends HookConsumerWidget {
+ final int? selectedIndex;
+ final ValueChanged onDestinationSelected;
+ final ValueChanged onChannelSelected;
+ final String? selectedChannelId;
+ final VoidCallback onProfileSelected;
+ final bool isCommunitySwitching;
+ final ValueChanged onCommunitySwitchStart;
+ final List<_HomeDestination> destinations;
+
+ const _WideNavigationSidebar({
+ required this.selectedIndex,
+ required this.onDestinationSelected,
+ required this.onChannelSelected,
+ required this.selectedChannelId,
+ required this.onProfileSelected,
+ required this.isCommunitySwitching,
+ required this.onCommunitySwitchStart,
+ required this.destinations,
+ });
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final colors = context.colors;
+ final sidebarGradient = context.appColors.topSectionGradient;
+ final sidebarPalette = _wideSidebarPalette(context);
+ final currentPubkey = ref.watch(currentPubkeyProvider);
+ final profiles = ref.watch(userCacheProvider);
+ final channelsAsync = ref.watch(channelsProvider);
+ final channels = (channelsAsync.asData?.value ?? const [])
+ .where((channel) => !channel.isArchived)
+ .toList();
+ final streamChannels = channels.where((channel) => !channel.isDm).toList();
+ final directMessages = channels.where((channel) => channel.isDm).toList();
+ streamChannels.sort(
+ (left, right) => left.displayLabel().compareTo(right.displayLabel()),
+ );
+ directMessages.sort(
+ (left, right) =>
+ _wideSidebarChannelLabel(
+ left,
+ currentPubkey: currentPubkey,
+ profiles: profiles,
+ ).compareTo(
+ _wideSidebarChannelLabel(
+ right,
+ currentPubkey: currentPubkey,
+ profiles: profiles,
+ ),
+ ),
+ );
+ final participantPubkeys = [
+ for (final channel in directMessages)
+ for (final pubkey in channel.participantPubkeys)
+ if (pubkey.toLowerCase() != currentPubkey?.toLowerCase())
+ pubkey.toLowerCase(),
+ ];
+ final participantPubkeysKey = participantPubkeys.toSet().join('\u0000');
+ useEffect(() {
+ ref.read(userCacheProvider.notifier).preload(participantPubkeys);
+ return null;
+ }, [participantPubkeysKey]);
+
+ Future createChannel() async {
+ final channel = await showCreateChannelSheet(context);
+ if (channel != null) onChannelSelected(channel);
+ }
+
+ Future createDirectMessage() async {
+ final channel = await showNewDirectMessageSheet(
+ context,
+ currentPubkey: currentPubkey,
+ );
+ if (channel != null) onChannelSelected(channel);
+ }
+
+ return SizedBox(
+ key: const Key('wide-navigation-sidebar'),
+ width: HomePage._wideNavigationWidth,
+ child: DecoratedBox(
+ key: const Key('wide-navigation-sidebar-background'),
+ decoration: BoxDecoration(
+ color: sidebarGradient == null ? colors.surfaceContainerLowest : null,
+ gradient: sidebarGradient,
+ ),
+ child: SafeArea(
+ right: false,
+ bottom: false,
+ child: Padding(
+ padding: const EdgeInsets.only(left: Grid.xxs, right: Grid.xxs),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ _WideNavigationCommunitySwitcher(
+ onTap: () => unawaited(showCommunitySwitcherSheet(context)),
+ onCommunitySwitchStart: onCommunitySwitchStart,
+ ),
+ const SizedBox(height: Grid.xxs),
+ Expanded(
+ child: SkeletonReveal(
+ loading: isCommunitySwitching,
+ skeleton: const _WideNavigationSidebarSkeleton(
+ key: Key('wide-navigation-community-switch-skeleton'),
+ ),
+ content: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ // The desktop sidebar presents Search before Inbox.
+ // Keep these source indexes so the phone tab order and
+ // each destination's route stay unchanged.
+ for (final index in const [2, 1])
+ _WideNavigationDestination(
+ destination: destinations[index],
+ selected: index == selectedIndex,
+ onTap: () => onDestinationSelected(index),
+ ),
+ const SizedBox(height: Grid.xxs),
+ Expanded(
+ child: ListView(
+ key: const Key('wide-navigation-channel-list'),
+ padding: EdgeInsets.zero,
+ children: [
+ _WideNavigationSectionLabel(
+ 'Channels',
+ actionTooltip: 'Create channel',
+ onAction: () => unawaited(createChannel()),
+ ),
+ if (channelsAsync.isLoading)
+ Padding(
+ padding: EdgeInsets.all(Grid.sm),
+ child: Center(
+ child: SizedBox(
+ width: Grid.md,
+ height: Grid.md,
+ child: CircularProgressIndicator(
+ strokeWidth: 2,
+ color: sidebarPalette.mutedForeground,
+ ),
+ ),
+ ),
+ ),
+ for (final channel in streamChannels)
+ _WideChannelDestination(
+ channel: channel,
+ label: _wideSidebarChannelLabel(
+ channel,
+ currentPubkey: currentPubkey,
+ profiles: profiles,
+ ),
+ selected: channel.id == selectedChannelId,
+ onTap: () => onChannelSelected(channel),
+ ),
+ const SizedBox(height: Grid.xs),
+ _WideNavigationSectionLabel(
+ 'DMs',
+ actionTooltip: 'New direct message',
+ onAction: () =>
+ unawaited(createDirectMessage()),
+ ),
+ for (final channel in directMessages)
+ _WideChannelDestination(
+ channel: channel,
+ label: _wideSidebarChannelLabel(
+ channel,
+ currentPubkey: currentPubkey,
+ profiles: profiles,
+ ),
+ directMessageProfile:
+ _wideSidebarDirectMessageProfile(
+ channel,
+ currentPubkey: currentPubkey,
+ profiles: profiles,
+ ),
+ selected: channel.id == selectedChannelId,
+ onTap: () => onChannelSelected(channel),
+ ),
+ ],
+ ),
+ ),
+ const SizedBox(height: Grid.xxs),
+ Padding(
+ padding: const EdgeInsets.only(bottom: Grid.xs),
+ child: _WideNavigationProfile(
+ onTap: onProfileSelected,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _WideSidebarPalette {
+ final Color foreground;
+ final Color mutedForeground;
+ final Color activeForeground;
+ final Color activeSurface;
+
+ const _WideSidebarPalette({
+ required this.foreground,
+ required this.mutedForeground,
+ required this.activeForeground,
+ required this.activeSurface,
+ });
+}
+
+_WideSidebarPalette _wideSidebarPalette(BuildContext context) {
+ final colors = context.colors;
+ if (context.appColors.topSectionGradient == null) {
+ return _WideSidebarPalette(
+ foreground: colors.onSurfaceVariant,
+ mutedForeground: colors.onSurfaceVariant,
+ activeForeground: colors.primary,
+ activeSurface: colors.primary.withValues(alpha: 0.12),
+ );
+ }
+
+ final foreground = colors.onSurface;
+ return _WideSidebarPalette(
+ foreground: foreground,
+ mutedForeground: foreground.withValues(alpha: 0.4),
+ activeForeground: foreground,
+ activeSurface: foreground.withValues(
+ alpha: colors.brightness == Brightness.dark ? 0.16 : 0.07,
+ ),
+ );
+}
+
+/// Element-shaped loading state for a community switch. It mirrors the wide
+/// navigation's normal hierarchy so a relay change reads as a new workspace,
+/// rather than leaving the previous community's rows in place.
+class _WideNavigationSectionLabel extends StatelessWidget {
+ final String label;
+ final String actionTooltip;
+ final VoidCallback onAction;
+
+ const _WideNavigationSectionLabel(
+ this.label, {
+ required this.actionTooltip,
+ required this.onAction,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final palette = _wideSidebarPalette(context);
+ return SizedBox(
+ height: 44,
+ child: Padding(
+ padding: const EdgeInsets.only(left: Grid.sm),
+ child: Row(
+ children: [
+ Expanded(
+ child: Text(
+ label,
+ style: context.textTheme.labelMedium?.copyWith(
+ color: palette.mutedForeground,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ IconButton(
+ onPressed: onAction,
+ tooltip: actionTooltip,
+ color: palette.mutedForeground,
+ icon: const Icon(LucideIcons.plus, size: 20),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _WideNavigationCommunitySwitcher extends HookConsumerWidget {
+ final VoidCallback onTap;
+ final ValueChanged onCommunitySwitchStart;
+
+ const _WideNavigationCommunitySwitcher({
+ required this.onTap,
+ required this.onCommunitySwitchStart,
+ });
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final palette = _wideSidebarPalette(context);
+ final community = ref.watch(activeCommunityProvider).value;
+ final communities =
+ ref.watch(communityListProvider).value ?? const [];
+ final activeIndex = communities.indexWhere(
+ (candidate) => candidate.id == community?.id,
+ );
+ final calculatedNextCommunity = communities.length < 2
+ ? null
+ : communities[activeIndex < 0
+ ? 0
+ : (activeIndex + 1) % communities.length];
+ // Keep the selected destination stable while the provider changes the
+ // active community. Without this, the incoming slot can briefly be
+ // recalculated as the *following* community during the handoff.
+ final switchTarget = useState(null);
+ final nextCommunity = switchTarget.value ?? calculatedNextCommunity;
+ final displayedCommunity = switchTarget.value ?? community;
+ final communityName = displayedCommunity?.name ?? 'Community';
+ final relayUrl = community?.relayUrl;
+ final iconUrl = relayUrl == null
+ ? null
+ : ref.watch(communityIconProvider(relayUrl)).value;
+ final nextRelayUrl = nextCommunity?.relayUrl;
+ final nextIconUrl = nextRelayUrl == null
+ ? null
+ : ref.watch(communityIconProvider(nextRelayUrl)).value;
+ final reduceMotion = MediaQuery.disableAnimationsOf(context);
+ final swipeProgress = useAnimationController(
+ duration: const Duration(milliseconds: 120),
+ reverseDuration: const Duration(milliseconds: 160),
+ );
+ final swipeValue = useAnimation(swipeProgress);
+ final isSwitching = useState(false);
+
+ Future switchToNextCommunity() async {
+ if (isSwitching.value || nextCommunity == null) return;
+ isSwitching.value = true;
+ switchTarget.value = nextCommunity;
+
+ if (reduceMotion) {
+ swipeProgress.value = 1;
+ } else {
+ await swipeProgress.animateTo(1, curve: Curves.easeOutCubic);
+ }
+
+ // Let the incoming community settle for a painted frame before the
+ // workspace skeleton takes over; otherwise its completed arrival is
+ // visually skipped by the loading transition.
+ if (!reduceMotion) {
+ await Future.delayed(const Duration(milliseconds: 120));
+ }
+
+ if (!context.mounted) return;
+ onCommunitySwitchStart(nextCommunity.id);
+ unawaited(HapticFeedback.selectionClick());
+ await ref
+ .read(communityListProvider.notifier)
+ .switchCommunity(nextCommunity.id);
+ if (!context.mounted) return;
+ swipeProgress.value = 0;
+ switchTarget.value = null;
+ isSwitching.value = false;
+ }
+
+ void resetSwipe() {
+ if (isSwitching.value) return;
+ if (reduceMotion) {
+ swipeProgress.value = 0;
+ } else {
+ unawaited(swipeProgress.reverse());
+ }
+ }
+
+ return Semantics(
+ key: const Key('wide-navigation-community-switcher'),
+ button: true,
+ label: 'Switch community from $communityName',
+ child: SizedBox(
+ height: 48,
+ child: Material(
+ color: Colors.transparent,
+ borderRadius: BorderRadius.circular(Radii.md),
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ onTap: onTap,
+ child: Align(
+ alignment: Alignment.centerLeft,
+ child: Padding(
+ padding: const EdgeInsets.only(
+ left: HomePage._wideNavigationIdentityAvatarInset,
+ right: Grid.sm,
+ ),
+ child: Row(
+ key: const Key('wide-navigation-community-identity'),
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ _WideNavigationCommunityIcon(
+ iconUrl: iconUrl,
+ nextIconUrl: nextIconUrl,
+ hasNextCommunity: nextCommunity != null,
+ dragProgress: reduceMotion ? 0 : swipeValue,
+ onVerticalDragStart: () => swipeProgress.stop(),
+ onVerticalDragUpdate: (delta) {
+ if (isSwitching.value) return;
+ swipeProgress.value =
+ (swipeProgress.value + (delta / 80)).clamp(0, 1);
+ },
+ onVerticalDragEnd: (velocity) {
+ final shouldSwitch =
+ communities.length > 1 &&
+ (swipeProgress.value >= 0.45 || velocity > 600);
+ if (shouldSwitch) {
+ unawaited(switchToNextCommunity());
+ } else {
+ resetSwipe();
+ }
+ },
+ onVerticalDragCancel: resetSwipe,
+ ),
+ const SizedBox(
+ width: HomePage._wideNavigationIdentityLabelGap,
+ ),
+ ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 192),
+ // The avatar motion carries the change. Keeping the
+ // label in a fixed slot avoids a competing crossfade and
+ // relayout while the user is dragging.
+ child: Text(
+ communityName,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: context.textTheme.bodyMedium?.copyWith(
+ color: palette.foreground,
+ fontSize: 18,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _WideNavigationCommunityIcon extends StatelessWidget {
+ final String? iconUrl;
+ final String? nextIconUrl;
+ final bool hasNextCommunity;
+ final double dragProgress;
+ final VoidCallback onVerticalDragStart;
+ final ValueChanged onVerticalDragUpdate;
+ final ValueChanged onVerticalDragEnd;
+ final VoidCallback onVerticalDragCancel;
+
+ const _WideNavigationCommunityIcon({
+ required this.iconUrl,
+ required this.nextIconUrl,
+ required this.hasNextCommunity,
+ required this.dragProgress,
+ required this.onVerticalDragStart,
+ required this.onVerticalDragUpdate,
+ required this.onVerticalDragEnd,
+ required this.onVerticalDragCancel,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ // A completed first phase leaves no visual overlap: the current community
+ // exits before its replacement starts to appear.
+ final outgoingProgress = dragProgress < 0.5 ? dragProgress * 2 : 1.0;
+ final incomingProgress = dragProgress > 0.5
+ ? (dragProgress - 0.5) * 2
+ : 0.0;
+
+ Widget iconImage(String? imageUrl, Key key) {
+ return ClipRRect(
+ key: key,
+ borderRadius: BorderRadius.circular(Radii.card),
+ child: ColoredBox(
+ color: context.colors.primaryContainer,
+ child: AvatarImageContent(
+ imageUrl: imageUrl,
+ fallback: Text(
+ '🐝',
+ textScaler: TextScaler.noScaling,
+ style: const TextStyle(fontSize: 18, height: 1),
+ ),
+ ),
+ ),
+ );
+ }
+
+ return SizedBox.square(
+ key: const Key('wide-navigation-community-icon'),
+ dimension: 32,
+ child: GestureDetector(
+ behavior: HitTestBehavior.translucent,
+ onVerticalDragStart: (_) => onVerticalDragStart(),
+ onVerticalDragUpdate: (details) =>
+ onVerticalDragUpdate(details.delta.dy),
+ onVerticalDragEnd: (details) =>
+ onVerticalDragEnd(details.velocity.pixelsPerSecond.dy),
+ onVerticalDragCancel: onVerticalDragCancel,
+ child: Stack(
+ clipBehavior: Clip.none,
+ fit: StackFit.expand,
+ children: [
+ Transform.translate(
+ key: const Key('wide-navigation-community-current-container'),
+ // Move the complete avatar card out of its resting position so
+ // the gesture reads as a community change, rather than an image
+ // shrinking within a fixed slot.
+ offset: Offset(0, outgoingProgress * 32),
+ child: Transform.scale(
+ scale: 1 - (outgoingProgress * 0.3),
+ child: Opacity(
+ key: const Key('wide-navigation-community-current-opacity'),
+ opacity: 1 - outgoingProgress,
+ child: iconImage(
+ iconUrl,
+ const Key('wide-navigation-community-current-icon'),
+ ),
+ ),
+ ),
+ ),
+ if (hasNextCommunity)
+ Transform.scale(
+ key: const Key('wide-navigation-community-incoming-container'),
+ scale: 0.1 + (incomingProgress * 0.9),
+ child: Opacity(
+ key: const Key('wide-navigation-community-incoming-opacity'),
+ opacity: incomingProgress,
+ child: iconImage(
+ nextIconUrl,
+ const Key('wide-navigation-community-incoming-icon'),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
+
+class _WideNavigationProfile extends ConsumerWidget {
+ final VoidCallback onTap;
+
+ const _WideNavigationProfile({required this.onTap});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ final palette = _wideSidebarPalette(context);
+ final profile = ref.watch(profileProvider).value;
+ final status = ref.watch(userStatusProvider).asData?.value;
+ final displayName = profile?.displayName?.trim();
+ final name = displayName != null && displayName.isNotEmpty
+ ? displayName
+ : 'You';
+ final statusEmoji = status?.emoji ?? '';
+ final statusText = status?.text.trim() ?? '';
+ final statusLabel = [
+ if (statusEmoji.isNotEmpty) statusEmoji,
+ if (statusText.isNotEmpty) statusText,
+ ].join(' ');
+
+ return SizedBox(
+ key: const Key('wide-navigation-profile'),
+ height: 56,
+ child: Row(
+ children: [
+ Expanded(
+ child: Semantics(
+ button: true,
+ label: 'Open settings for $name',
+ child: Material(
+ color: Colors.transparent,
+ borderRadius: BorderRadius.circular(Radii.md),
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ key: const Key('wide-navigation-profile-button'),
+ onTap: onTap,
+ child: Padding(
+ padding: const EdgeInsets.only(
+ left: HomePage._wideNavigationIdentityAvatarInset,
+ right: Grid.half,
+ ),
+ child: Row(
+ children: [
+ const ProfileAvatar(),
+ const SizedBox(
+ width: HomePage._wideNavigationIdentityLabelGap,
+ ),
+ Expanded(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ name,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: context.textTheme.bodyMedium?.copyWith(
+ color: palette.foreground,
+ fontWeight: FontWeight.w600,
+ ),
+ ),
+ if (statusLabel.isNotEmpty) ...[
+ const SizedBox(height: Grid.quarter),
+ Text(
+ statusLabel,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: context.textTheme.labelSmall?.copyWith(
+ color: palette.mutedForeground,
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ Tooltip(
+ message: 'Settings',
+ child: IconButton(
+ key: const Key('wide-navigation-settings-button'),
+ onPressed: onTap,
+ icon: Icon(
+ LucideIcons.settings,
+ color: palette.mutedForeground,
+ size: 20,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _WideChannelDestination extends StatelessWidget {
+ final Channel channel;
+ final String label;
+ final UserProfile? directMessageProfile;
+ final bool selected;
+ final VoidCallback onTap;
+
+ const _WideChannelDestination({
+ required this.channel,
+ required this.label,
+ this.directMessageProfile,
+ required this.selected,
+ required this.onTap,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final palette = _wideSidebarPalette(context);
+ final foregroundColor = selected
+ ? palette.activeForeground
+ : palette.foreground;
+ final icon = channel.isForum ? LucideIcons.fileText : LucideIcons.hash;
+
+ return Semantics(
+ key: ValueKey('wide-channel-${channel.id}'),
+ button: true,
+ selected: selected,
+ label: label,
+ child: Padding(
+ padding: const EdgeInsets.only(bottom: Grid.quarter),
+ child: SizedBox(
+ height: HomePage._wideNavigationChannelRowHeight,
+ child: Material(
+ color: selected ? palette.activeSurface : Colors.transparent,
+ borderRadius: BorderRadius.circular(Radii.sm),
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ onTap: onTap,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: Grid.sm),
+ child: Row(
+ children: [
+ SizedBox(
+ width: channel.isDm
+ ? HomePage._wideNavigationDmAvatarSize
+ : HomePage._tabIconSize,
+ child: Align(
+ alignment: Alignment.centerLeft,
+ child: channel.isDm
+ ? AvatarImage(
+ imageUrl: directMessageProfile?.avatarUrl,
+ radius:
+ HomePage._wideNavigationDmAvatarSize / 2,
+ backgroundColor:
+ context.colors.primaryContainer,
+ fallback: Text(
+ directMessageProfile?.initial ?? '?',
+ textScaler: TextScaler.noScaling,
+ style: context.textTheme.labelSmall?.copyWith(
+ color: context.colors.onPrimaryContainer,
+ ),
+ ),
+ )
+ : Icon(
+ icon,
+ color: foregroundColor,
+ size: HomePage._wideNavigationIconSize,
+ ),
+ ),
+ ),
+ SizedBox(
+ width: channel.isDm
+ ? HomePage._wideNavigationDmLabelGap
+ : HomePage._wideNavigationLabelGap,
+ ),
+ Expanded(
+ child: Text(
+ label,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: context.textTheme.bodyMedium?.copyWith(
+ color: foregroundColor,
+ fontWeight: selected
+ ? FontWeight.w600
+ : FontWeight.w500,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class _WideChannelContent extends StatelessWidget {
+ final Channel channel;
+
+ const _WideChannelContent({required this.channel});
+
+ @override
+ Widget build(BuildContext context) {
+ return Navigator(
+ key: ValueKey('wide-channel-detail-${channel.id}'),
+ onGenerateRoute: (_) => MaterialPageRoute(
+ builder: (_) => ChannelDetailPage(channel: channel),
+ ),
+ );
+ }
+}
+
+class _WideNavigationDestination extends StatelessWidget {
+ final _HomeDestination destination;
+ final bool selected;
+ final VoidCallback onTap;
+
+ const _WideNavigationDestination({
+ required this.destination,
+ required this.selected,
+ required this.onTap,
+ });
+
+ @override
+ Widget build(BuildContext context) {
+ final palette = _wideSidebarPalette(context);
+ final foregroundColor = selected
+ ? palette.activeForeground
+ : palette.foreground;
+ final label = destination.label == 'Activity' ? 'Inbox' : destination.label;
+
+ return Semantics(
+ key: ValueKey('wide-navigation-${label.toLowerCase()}'),
+ button: true,
+ selected: selected,
+ label: label,
+ child: Padding(
+ // Keep the primary navigation's row rhythm consistent with the
+ // channel list directly below it.
+ padding: const EdgeInsets.only(bottom: Grid.quarter),
+ child: SizedBox(
+ height: HomePage._wideNavigationPrimaryRowHeight,
+ child: Material(
+ color: selected ? palette.activeSurface : Colors.transparent,
+ borderRadius: BorderRadius.circular(Radii.md),
+ clipBehavior: Clip.antiAlias,
+ child: InkWell(
+ onTap: onTap,
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: Grid.sm),
+ child: Row(
+ children: [
+ SizedBox(
+ width: HomePage._tabIconSize,
+ child: Align(
+ alignment: Alignment.centerLeft,
+ child: Icon(
+ // The persistent tablet sidebar keeps a stable icon
+ // weight. Selection is expressed by the row's color,
+ // background, and label weight instead.
+ destination.selectedIcon,
+ color: foregroundColor,
+ size: HomePage._wideNavigationIconSize,
+ ),
+ ),
+ ),
+ const SizedBox(width: HomePage._wideNavigationLabelGap),
+ Expanded(
+ child: Text(
+ label,
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ style: context.textTheme.bodyMedium?.copyWith(
+ color: foregroundColor,
+ fontWeight: selected
+ ? FontWeight.w600
+ : FontWeight.w500,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+String _wideSidebarChannelLabel(
+ Channel channel, {
+ required String? currentPubkey,
+ required Map profiles,
+}) {
+ if (!channel.isDm) return channel.displayLabel();
+
+ final normalizedCurrent = currentPubkey?.toLowerCase();
+ final labels = [];
+ for (var index = 0; index < channel.participantPubkeys.length; index++) {
+ final pubkey = channel.participantPubkeys[index].toLowerCase();
+ if (pubkey == normalizedCurrent) continue;
+ final profile = profiles[pubkey];
+ final displayName = profile?.displayName?.trim();
+ if (displayName != null && displayName.isNotEmpty) {
+ labels.add(displayName);
+ } else if (index < channel.participants.length &&
+ channel.participants[index].trim().isNotEmpty) {
+ labels.add(channel.participants[index]);
+ }
+ }
+
+ if (labels.isNotEmpty) return formatDmParticipantDisplayName(labels);
+ return resolveDmChannelDisplayLabel(channel, currentPubkey: currentPubkey);
+}
+
+UserProfile? _wideSidebarDirectMessageProfile(
+ Channel channel, {
+ required String? currentPubkey,
+ required Map profiles,
+}) {
+ if (!channel.isDm) return null;
+
+ final normalizedCurrent = currentPubkey?.toLowerCase();
+ for (final pubkey in channel.participantPubkeys) {
+ final normalizedPubkey = pubkey.toLowerCase();
+ if (normalizedPubkey == normalizedCurrent) continue;
+ final profile = profiles[normalizedPubkey];
+ if (profile != null) return profile;
+ }
+ return null;
+}
diff --git a/mobile/lib/features/home/home_page/wide_navigation_skeletons.dart b/mobile/lib/features/home/home_page/wide_navigation_skeletons.dart
new file mode 100644
index 000000000..e4c7ca846
--- /dev/null
+++ b/mobile/lib/features/home/home_page/wide_navigation_skeletons.dart
@@ -0,0 +1,244 @@
+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.
+class _WideCommunityContentSkeleton extends StatelessWidget {
+ const _WideCommunityContentSkeleton({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ Widget inboxRow(double nameWidth, double previewWidth) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: Grid.gutter,
+ vertical: Grid.sm,
+ ),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ SkeletonBar(
+ width: 36,
+ height: 36,
+ borderRadius: BorderRadius.circular(Radii.full),
+ ),
+ const SizedBox(width: Grid.twelve),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ SkeletonBar(width: nameWidth, height: 14),
+ const SizedBox(height: Grid.half),
+ SkeletonBar(width: previewWidth, height: 12),
+ const SizedBox(height: Grid.half),
+ const SkeletonBar(width: 96, height: 12),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget messageRow(int index) {
+ const nameWidths = [112.0, 88.0, 128.0, 96.0];
+ const lineWidths = [196.0, 164.0, 212.0, 148.0];
+ return Padding(
+ padding: const EdgeInsets.only(bottom: Grid.lg),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ SkeletonBar(
+ width: 36,
+ height: 36,
+ borderRadius: BorderRadius.circular(Radii.full),
+ ),
+ const SizedBox(width: Grid.twelve),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ SkeletonBar(width: nameWidths[index], height: 15),
+ const SizedBox(width: Grid.xxs),
+ const SkeletonBar(width: 48, height: 12),
+ ],
+ ),
+ const SizedBox(height: Grid.half),
+ SkeletonBar(width: lineWidths[index], height: 15),
+ const SizedBox(height: Grid.half),
+ SkeletonBar(width: lineWidths[index] * 0.72, height: 15),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ return LayoutBuilder(
+ builder: (context, constraints) {
+ final inboxWidth = (constraints.maxWidth * 0.34)
+ .clamp(220.0, 340.0)
+ .toDouble();
+ return Row(
+ children: [
+ SizedBox(
+ width: inboxWidth,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ const Padding(
+ padding: EdgeInsets.fromLTRB(
+ Grid.gutter,
+ Grid.lg,
+ Grid.gutter,
+ Grid.md,
+ ),
+ child: SkeletonBar(width: 88, height: 24),
+ ),
+ Divider(height: 1, color: context.colors.outlineVariant),
+ Expanded(
+ child: ListView(
+ physics: const NeverScrollableScrollPhysics(),
+ padding: EdgeInsets.zero,
+ children: [
+ inboxRow(84, 112),
+ inboxRow(112, 120),
+ inboxRow(72, 124),
+ inboxRow(96, 108),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ VerticalDivider(width: 1, color: context.colors.outlineVariant),
+ Expanded(
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(
+ Grid.gutter,
+ Grid.lg,
+ Grid.gutter,
+ Grid.gutter,
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ const SkeletonBar(width: 176, height: 24),
+ const SizedBox(height: Grid.lg),
+ Expanded(
+ child: ListView(
+ physics: const NeverScrollableScrollPhysics(),
+ padding: EdgeInsets.zero,
+ children: [
+ messageRow(0),
+ messageRow(1),
+ messageRow(2),
+ messageRow(3),
+ ],
+ ),
+ ),
+ const SkeletonBar(width: double.infinity, height: 48),
+ ],
+ ),
+ ),
+ ),
+ ],
+ );
+ },
+ );
+ }
+}
diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart
index 85b781052..7af0f1bd2 100644
--- a/mobile/lib/features/pairing/pairing_page.dart
+++ b/mobile/lib/features/pairing/pairing_page.dart
@@ -194,106 +194,121 @@ class _SasVerificationView extends StatelessWidget {
@override
Widget build(BuildContext context) {
- return Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- const Spacer(flex: 2),
+ final isTablet = MediaQuery.sizeOf(context).shortestSide >= 600;
- Icon(LucideIcons.shieldCheck, size: 56, color: context.colors.primary),
- const SizedBox(height: Grid.sm),
+ return Center(
+ child: ConstrainedBox(
+ key: const Key('pairing-sas-verification-content'),
+ constraints: BoxConstraints(maxWidth: isTablet ? 520 : double.infinity),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Spacer(flex: 2),
- Text('Verify Security Code', style: context.textTheme.headlineSmall),
- const SizedBox(height: Grid.xs),
-
- Text(
- confirmed
- ? 'Waiting for desktop to confirm...'
- : 'Does your desktop app show this code?',
- textAlign: TextAlign.center,
- style: context.textTheme.bodyMedium?.copyWith(
- color: context.colors.onSurfaceVariant,
- ),
- ),
-
- const SizedBox(height: Grid.lg),
-
- // Large SAS code display
- Container(
- padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 20),
- decoration: BoxDecoration(
- color: context.colors.primaryContainer.withValues(alpha: 0.3),
- borderRadius: BorderRadius.circular(16),
- border: Border.all(
- color: context.colors.primary.withValues(alpha: 0.3),
- width: 2,
- ),
- ),
- child: Text(
- '${sasCode.substring(0, 3)} ${sasCode.substring(3)}',
- style: context.textTheme.displayMedium?.copyWith(
- fontFamily: 'GeistMono',
- fontWeight: FontWeight.w700,
- letterSpacing: 8,
+ Icon(
+ LucideIcons.shieldCheck,
+ size: 56,
color: context.colors.primary,
),
- ),
+ const SizedBox(height: Grid.sm),
+
+ Text(
+ 'Verify Security Code',
+ style: context.textTheme.headlineSmall,
+ ),
+ const SizedBox(height: Grid.xs),
+
+ Text(
+ confirmed
+ ? 'Waiting for desktop to confirm...'
+ : 'Does your desktop app show this code?',
+ textAlign: TextAlign.center,
+ style: context.textTheme.bodyMedium?.copyWith(
+ color: context.colors.onSurfaceVariant,
+ ),
+ ),
+
+ const SizedBox(height: Grid.lg),
+
+ // Large SAS code display
+ Container(
+ padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 20),
+ decoration: BoxDecoration(
+ color: context.colors.primaryContainer.withValues(alpha: 0.3),
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(
+ color: context.colors.primary.withValues(alpha: 0.3),
+ width: 2,
+ ),
+ ),
+ child: Text(
+ '${sasCode.substring(0, 3)} ${sasCode.substring(3)}',
+ style: context.textTheme.displayMedium?.copyWith(
+ fontFamily: 'GeistMono',
+ fontWeight: FontWeight.w700,
+ letterSpacing: 8,
+ color: context.colors.primary,
+ ),
+ ),
+ ),
+
+ const SizedBox(height: Grid.lg),
+
+ Text(
+ 'You are about to transfer your Buzz identity\nto this device. Only confirm if you initiated\nthis pairing from your desktop.',
+ textAlign: TextAlign.center,
+ style: context.textTheme.bodySmall?.copyWith(
+ color: context.colors.onSurfaceVariant,
+ ),
+ ),
+
+ const SizedBox(height: Grid.lg),
+
+ // Confirm / Deny buttons
+ if (confirmed)
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ BuzzLoadingIndicator(
+ size: 24,
+ color: context.colors.primary,
+ semanticLabel: 'Connecting',
+ ),
+ const SizedBox(width: Grid.twelve),
+ Text(
+ 'Confirmed — waiting for desktop',
+ style: context.textTheme.bodySmall?.copyWith(
+ color: context.colors.onSurfaceVariant,
+ ),
+ ),
+ ],
+ )
+ else
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Expanded(
+ child: OutlinedButton.icon(
+ onPressed: onDeny,
+ icon: const Icon(LucideIcons.x),
+ label: const Text('Cancel'),
+ ),
+ ),
+ const SizedBox(width: Grid.sm),
+ Expanded(
+ child: FilledButton.icon(
+ onPressed: onConfirm,
+ icon: const Icon(LucideIcons.check),
+ label: const Text('Codes Match'),
+ ),
+ ),
+ ],
+ ),
+
+ const Spacer(flex: 3),
+ ],
),
-
- const SizedBox(height: Grid.lg),
-
- Text(
- 'You are about to transfer your Buzz identity\nto this device. Only confirm if you initiated\nthis pairing from your desktop.',
- textAlign: TextAlign.center,
- style: context.textTheme.bodySmall?.copyWith(
- color: context.colors.onSurfaceVariant,
- ),
- ),
-
- const SizedBox(height: Grid.lg),
-
- // Confirm / Deny buttons
- if (confirmed)
- Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- BuzzLoadingIndicator(
- size: 24,
- color: context.colors.primary,
- semanticLabel: 'Connecting',
- ),
- const SizedBox(width: Grid.twelve),
- Text(
- 'Confirmed — waiting for desktop',
- style: context.textTheme.bodySmall?.copyWith(
- color: context.colors.onSurfaceVariant,
- ),
- ),
- ],
- )
- else
- Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Expanded(
- child: OutlinedButton.icon(
- onPressed: onDeny,
- icon: const Icon(LucideIcons.x),
- label: const Text('Cancel'),
- ),
- ),
- const SizedBox(width: Grid.sm),
- Expanded(
- child: FilledButton.icon(
- onPressed: onConfirm,
- icon: const Icon(LucideIcons.check),
- label: const Text('Codes Match'),
- ),
- ),
- ],
- ),
-
- const Spacer(flex: 3),
- ],
+ ),
);
}
}
diff --git a/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart b/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart
index 49fc18513..72fa513c8 100644
--- a/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart
+++ b/mobile/lib/features/pairing/pairing_page/pairing_welcome_view.dart
@@ -39,191 +39,209 @@ class _PairingWelcomeView extends StatelessWidget {
constraints: BoxConstraints(
minHeight: constraints.maxHeight - (Grid.sm * 2),
),
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Container(
- width: 136,
- height: 136,
- alignment: Alignment.center,
- decoration: const BoxDecoration(
- shape: BoxShape.circle,
- color: Color(0x4DFFFFFF),
- ),
- child: const TappableFlappingBee(
- width: 76,
- color: _onboardingInk,
- ),
- ),
- const SizedBox(height: Grid.sm),
- Text(
- 'Welcome to Buzz',
- textAlign: TextAlign.center,
- style: context.textTheme.headlineSmall?.copyWith(
- color: _onboardingInk,
- fontWeight: FontWeight.w600,
- letterSpacing: -0.4,
- ),
- ),
- const SizedBox(height: Grid.xxs),
- Text(
- 'Scan the QR code from your desktop app\nor paste a pairing code to connect.',
- textAlign: TextAlign.center,
- style: context.textTheme.bodyMedium?.copyWith(
- color: _onboardingMutedInk,
- ),
- ),
- const SizedBox(height: Grid.md),
- ConstrainedBox(
- constraints: const BoxConstraints(maxWidth: 440),
- child: SizedBox(
- width: double.infinity,
- child: Column(
- children: [
- FilledButton(
- style: _onboardingButtonStyle,
- onPressed: isBusy ? null : onScan,
- child: isBusy && !pairingCodeExpanded
- ? const SizedBox(
- width: 20,
- height: 20,
- child: BuzzLoadingIndicator(
- size: 20,
- color: _onboardingCtaLabel,
- semanticLabel: 'Opening scanner',
- ),
- )
- : const Text('Scan a QR code'),
- ),
- const SizedBox(height: Grid.xxs),
- TextButton(
- style: _onboardingSecondaryButtonStyle,
- onPressed: isBusy ? null : onTogglePairingCode,
- child: Text(
- pairingCodeExpanded
- ? 'Hide pairing code'
- : 'Use pairing code',
- ),
- ),
- AnimatedSwitcher(
- duration: revealDuration,
- switchInCurve: Curves.easeOutCubic,
- switchOutCurve: Curves.easeInCubic,
- transitionBuilder: (child, animation) {
- return SizeTransition(
- sizeFactor: animation,
- axisAlignment: -1,
- child: FadeTransition(
- opacity: animation,
- child: child,
+ child: Center(
+ child: ConstrainedBox(
+ key: const Key('pairing-welcome-content'),
+ constraints: const BoxConstraints(maxWidth: 440),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Container(
+ width: 136,
+ height: 136,
+ alignment: Alignment.center,
+ decoration: const BoxDecoration(
+ shape: BoxShape.circle,
+ color: Color(0x4DFFFFFF),
+ ),
+ child: const TappableFlappingBee(
+ width: 76,
+ color: _onboardingInk,
+ ),
+ ),
+ const SizedBox(height: Grid.sm),
+ Text(
+ 'Welcome to Buzz',
+ textAlign: TextAlign.center,
+ style: context.textTheme.headlineSmall?.copyWith(
+ color: _onboardingInk,
+ fontWeight: FontWeight.w600,
+ letterSpacing: -0.4,
+ ),
+ ),
+ const SizedBox(height: Grid.xxs),
+ Text(
+ 'Scan the QR code from your desktop app\nor paste a pairing code to connect.',
+ textAlign: TextAlign.center,
+ style: context.textTheme.bodyMedium?.copyWith(
+ color: _onboardingMutedInk,
+ ),
+ ),
+ const SizedBox(height: Grid.md),
+ ConstrainedBox(
+ constraints: const BoxConstraints(maxWidth: 440),
+ child: SizedBox(
+ width: double.infinity,
+ child: Column(
+ children: [
+ FilledButton(
+ style: _onboardingButtonStyle,
+ onPressed: isBusy ? null : onScan,
+ child: isBusy && !pairingCodeExpanded
+ ? const SizedBox(
+ width: 20,
+ height: 20,
+ child: BuzzLoadingIndicator(
+ size: 20,
+ color: _onboardingCtaLabel,
+ semanticLabel: 'Opening scanner',
+ ),
+ )
+ : const Text('Scan a QR code'),
+ ),
+ const SizedBox(height: Grid.xxs),
+ TextButton(
+ style: _onboardingSecondaryButtonStyle,
+ onPressed: isBusy ? null : onTogglePairingCode,
+ child: Text(
+ pairingCodeExpanded
+ ? 'Hide pairing code'
+ : 'Use pairing code',
),
- );
- },
- child: pairingCodeExpanded
- ? Column(
- key: const ValueKey('pairing-code-fields'),
- children: [
- const SizedBox(height: Grid.twelve),
- TextField(
- controller: codeController,
- style: context.textTheme.bodyMedium
- ?.copyWith(color: _onboardingInk),
- cursorColor: _onboardingInk,
- decoration: InputDecoration(
- filled: true,
- fillColor: Colors.white.withValues(
- alpha: 0.7,
- ),
- hintText:
- 'nostrpair://... or buzz://...',
- hintStyle: context.textTheme.bodyMedium
- ?.copyWith(
- color: _onboardingMutedInk,
+ ),
+ AnimatedSwitcher(
+ duration: revealDuration,
+ switchInCurve: Curves.easeOutCubic,
+ switchOutCurve: Curves.easeInCubic,
+ transitionBuilder: (child, animation) {
+ return SizeTransition(
+ sizeFactor: animation,
+ axisAlignment: -1,
+ child: FadeTransition(
+ opacity: animation,
+ child: child,
+ ),
+ );
+ },
+ child: pairingCodeExpanded
+ ? Column(
+ key: const ValueKey(
+ 'pairing-code-fields',
+ ),
+ children: [
+ const SizedBox(height: Grid.twelve),
+ TextField(
+ controller: codeController,
+ style: context.textTheme.bodyMedium
+ ?.copyWith(color: _onboardingInk),
+ cursorColor: _onboardingInk,
+ decoration: InputDecoration(
+ filled: true,
+ fillColor: Colors.white.withValues(
+ alpha: 0.7,
),
- prefixIcon: const Icon(
- LucideIcons.link,
- color: _onboardingInk,
+ hintText:
+ 'nostrpair://... or buzz://...',
+ hintStyle: context
+ .textTheme
+ .bodyMedium
+ ?.copyWith(
+ color: _onboardingMutedInk,
+ ),
+ prefixIcon: const Icon(
+ LucideIcons.link,
+ color: _onboardingInk,
+ ),
+ enabledBorder: _inputBorder,
+ disabledBorder: _inputBorder,
+ focusedBorder: _inputBorder
+ .copyWith(
+ borderSide: const BorderSide(
+ color: _onboardingInk,
+ ),
+ ),
+ isDense: true,
+ ),
+ autocorrect: false,
+ enableSuggestions: false,
+ enabled: !isBusy,
+ contextMenuBuilder:
+ (context, editableTextState) {
+ return AdaptiveTextSelectionToolbar.editableText(
+ editableTextState:
+ editableTextState,
+ );
+ },
),
- enabledBorder: _inputBorder,
- disabledBorder: _inputBorder,
- focusedBorder: _inputBorder.copyWith(
- borderSide: const BorderSide(
- color: _onboardingInk,
+ const SizedBox(height: Grid.twelve),
+ SizedBox(
+ width: double.infinity,
+ child: FilledButton(
+ style: _onboardingButtonStyle,
+ onPressed: isBusy
+ ? null
+ : onConnect,
+ child: isBusy
+ ? const SizedBox(
+ width: 20,
+ height: 20,
+ child: BuzzLoadingIndicator(
+ size: 20,
+ color:
+ _onboardingCtaLabel,
+ semanticLabel:
+ 'Connecting',
+ ),
+ )
+ : const Text('Connect'),
),
),
- isDense: true,
+ ],
+ )
+ : const SizedBox(
+ key: ValueKey(
+ 'pairing-code-fields-hidden',
),
- autocorrect: false,
- enableSuggestions: false,
- enabled: !isBusy,
- contextMenuBuilder:
- (context, editableTextState) {
- return AdaptiveTextSelectionToolbar.editableText(
- editableTextState:
- editableTextState,
- );
- },
),
- const SizedBox(height: Grid.twelve),
- SizedBox(
- width: double.infinity,
- child: FilledButton(
- style: _onboardingButtonStyle,
- onPressed: isBusy ? null : onConnect,
- child: isBusy
- ? const SizedBox(
- width: 20,
- height: 20,
- child: BuzzLoadingIndicator(
- size: 20,
- color: _onboardingCtaLabel,
- semanticLabel: 'Connecting',
- ),
- )
- : const Text('Connect'),
+ ),
+ if (errorMessage != null) ...[
+ const SizedBox(height: Grid.twelve),
+ Container(
+ padding: const EdgeInsets.all(Grid.twelve),
+ decoration: BoxDecoration(
+ color: context.colors.errorContainer,
+ borderRadius: BorderRadius.circular(Radii.md),
+ ),
+ child: Row(
+ children: [
+ Icon(
+ LucideIcons.triangleAlert,
+ size: 16,
+ color: context.colors.onErrorContainer,
+ ),
+ const SizedBox(width: Grid.xxs),
+ Expanded(
+ child: Text(
+ errorMessage!,
+ style: context.textTheme.bodySmall
+ ?.copyWith(
+ color: context
+ .colors
+ .onErrorContainer,
+ ),
),
),
],
- )
- : const SizedBox(
- key: ValueKey('pairing-code-fields-hidden'),
),
+ ),
+ ],
+ ],
),
- if (errorMessage != null) ...[
- const SizedBox(height: Grid.twelve),
- Container(
- padding: const EdgeInsets.all(Grid.twelve),
- decoration: BoxDecoration(
- color: context.colors.errorContainer,
- borderRadius: BorderRadius.circular(Radii.md),
- ),
- child: Row(
- children: [
- Icon(
- LucideIcons.triangleAlert,
- size: 16,
- color: context.colors.onErrorContainer,
- ),
- const SizedBox(width: Grid.xxs),
- Expanded(
- child: Text(
- errorMessage!,
- style: context.textTheme.bodySmall
- ?.copyWith(
- color:
- context.colors.onErrorContainer,
- ),
- ),
- ),
- ],
- ),
- ),
- ],
- ],
+ ),
),
- ),
+ ],
),
- ],
+ ),
),
),
);
diff --git a/mobile/lib/features/profile/profile_avatar.dart b/mobile/lib/features/profile/profile_avatar.dart
index fb60fae12..18e59c7b8 100644
--- a/mobile/lib/features/profile/profile_avatar.dart
+++ b/mobile/lib/features/profile/profile_avatar.dart
@@ -23,8 +23,14 @@ const _presenceDotRatio = 8 / 14;
class ProfileAvatar extends ConsumerWidget {
final VoidCallback? onTap;
final bool showPresence;
+ final double size;
- const ProfileAvatar({super.key, this.onTap, this.showPresence = true});
+ const ProfileAvatar({
+ super.key,
+ this.onTap,
+ this.showPresence = true,
+ this.size = _avatarSize,
+ });
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -45,7 +51,7 @@ class ProfileAvatar extends ConsumerWidget {
Widget _buildPlaceholder(BuildContext context) {
return CircleAvatar(
- radius: _avatarSize / 2,
+ radius: size / 2,
backgroundColor: context.colors.primaryContainer,
);
}
@@ -58,7 +64,7 @@ class ProfileAvatar extends ConsumerWidget {
return GestureDetector(
onTap: onTap,
child: MaskedAvatarBadge(
- size: _avatarSize,
+ size: size,
geometry: AvatarBadgeMaskGeometry.presenceDot,
avatar: ClipOval(
child: ColoredBox(
diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart
index f089f6391..a9aa3c444 100644
--- a/mobile/lib/features/settings/settings_page.dart
+++ b/mobile/lib/features/settings/settings_page.dart
@@ -28,6 +28,7 @@ class SettingsPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final packageInfoFuture = useMemoized(() => PackageInfo.fromPlatform());
final packageInfo = useFuture(packageInfoFuture);
+ final isTablet = MediaQuery.sizeOf(context).shortestSide >= 600;
return FrostedScaffold(
appBar: const FrostedAppBar(title: Text('Settings')),
@@ -40,10 +41,23 @@ class SettingsPage extends HookConsumerWidget {
bottom: Grid.xs,
),
children: [
- profileHeader,
- const _AppearanceSection(),
- const _ConnectionSection(),
- const _RemoveCommunitySection(),
+ Align(
+ alignment: Alignment.topCenter,
+ child: ConstrainedBox(
+ key: const Key('settings-content'),
+ constraints: BoxConstraints(
+ maxWidth: isTablet ? 640 : double.infinity,
+ ),
+ child: Column(
+ children: [
+ profileHeader,
+ const _AppearanceSection(),
+ const _ConnectionSection(),
+ const _RemoveCommunitySection(),
+ ],
+ ),
+ ),
+ ),
],
),
),
diff --git a/mobile/lib/shared/widgets/frosted_app_bar.dart b/mobile/lib/shared/widgets/frosted_app_bar.dart
index 6a0bd2729..9cef7ed51 100644
--- a/mobile/lib/shared/widgets/frosted_app_bar.dart
+++ b/mobile/lib/shared/widgets/frosted_app_bar.dart
@@ -9,6 +9,25 @@ import '../theme/theme.dart';
const _kBarContentMinHeight = Grid.xxs + 32 + Grid.xxs; // 48
const _kBottomBorderWidth = 1.0;
+/// Shares the scroll elevation state from [FrostedScaffold] with its header.
+/// Keeping this separate lets all of the existing page bodies continue to own
+/// their scroll views.
+class FrostedHeaderScrollState extends InheritedNotifier> {
+ const FrostedHeaderScrollState({
+ required super.notifier,
+ required super.child,
+ super.key,
+ });
+
+ static bool isScrolledOf(BuildContext context) {
+ return context
+ .dependOnInheritedWidgetOfExactType()
+ ?.notifier
+ ?.value ??
+ false;
+ }
+}
+
TextStyle _effectiveTitleStyle(BuildContext context, TextStyle? titleStyle) {
final baseStyle =
context.textTheme.titleMedium ??
@@ -120,6 +139,11 @@ class FrostedAppBar extends StatelessWidget {
titleStyle,
titleContentHeight,
);
+ final useIpadHeaderTreatment =
+ MediaQuery.sizeOf(context).shortestSide >= 600;
+ final isScrolled =
+ useIpadHeaderTreatment &&
+ FrostedHeaderScrollState.isScrolledOf(context);
final effectiveLeading =
leading ??
@@ -142,22 +166,46 @@ class FrostedAppBar extends StatelessWidget {
right: 0,
child: ClipRect(
child: BackdropFilter(
- filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
+ filter: ImageFilter.blur(
+ sigmaX: useIpadHeaderTreatment ? 0 : 20,
+ sigmaY: useIpadHeaderTreatment ? 0 : 20,
+ ),
child: Container(
+ key: Key(
+ useIpadHeaderTreatment && isScrolled
+ ? 'frosted-app-bar-shadow'
+ : 'frosted-app-bar-divider',
+ ),
padding: EdgeInsets.only(top: topPadding),
decoration: BoxDecoration(
- // A gradient and a color cannot both paint, so the gradient
- // replaces the frosted surface fill when one is supplied.
- color: gradient == null
+ // The expanded iPad layout keeps its title area on the page
+ // canvas. A resting divider becomes a small elevation shadow
+ // once scroll content passes underneath.
+ color: useIpadHeaderTreatment
+ ? context.colors.surface
+ : gradient == null
? context.colors.surface.withValues(alpha: 0.5)
: null,
- gradient: gradient,
- border: Border(
- bottom: BorderSide(
- color: context.colors.outlineVariant.withValues(alpha: 0.3),
- width: _kBottomBorderWidth,
- ),
- ),
+ gradient: useIpadHeaderTreatment ? null : gradient,
+ border: useIpadHeaderTreatment && isScrolled
+ ? null
+ : Border(
+ bottom: BorderSide(
+ color: context.colors.outlineVariant.withValues(
+ alpha: 0.3,
+ ),
+ width: _kBottomBorderWidth,
+ ),
+ ),
+ boxShadow: useIpadHeaderTreatment && isScrolled
+ ? [
+ BoxShadow(
+ color: context.colors.shadow.withValues(alpha: 0.14),
+ blurRadius: 8,
+ offset: const Offset(0, 2),
+ ),
+ ]
+ : null,
),
child: Column(
mainAxisSize: MainAxisSize.min,
diff --git a/mobile/lib/shared/widgets/frosted_scaffold.dart b/mobile/lib/shared/widgets/frosted_scaffold.dart
index fc0e2fe50..13256354a 100644
--- a/mobile/lib/shared/widgets/frosted_scaffold.dart
+++ b/mobile/lib/shared/widgets/frosted_scaffold.dart
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
import 'frosted_app_bar.dart';
@@ -7,7 +8,7 @@ import 'frosted_app_bar.dart';
/// The body is rendered full-bleed inside a [Stack] with the frosted app bar
/// floating above it. The body is responsible for adding its own top spacing
/// using [frostedAppBarHeight] so content starts below the bar.
-class FrostedScaffold extends StatelessWidget {
+class FrostedScaffold extends HookWidget {
/// The frosted app bar displayed at the top of the screen.
final FrostedAppBar appBar;
@@ -36,11 +37,34 @@ class FrostedScaffold extends StatelessWidget {
@override
Widget build(BuildContext context) {
- return Scaffold(
- backgroundColor: backgroundColor,
- resizeToAvoidBottomInset: resizeToAvoidBottomInset,
- floatingActionButton: floatingActionButton,
- body: Stack(children: [body, appBar]),
+ // This is deliberately screen-size based rather than platform based: a
+ // compact window on iPad keeps the mobile frosted chrome, while the roomy
+ // iPad layout gets a flush desktop-style header.
+ final useIpadHeaderTreatment =
+ MediaQuery.sizeOf(context).shortestSide >= 600;
+ final headerIsScrolled = useState(false);
+
+ return NotificationListener(
+ onNotification: (notification) {
+ if (!useIpadHeaderTreatment ||
+ notification.metrics.axis != Axis.vertical) {
+ return false;
+ }
+ final isScrolled = notification.metrics.pixels > 0;
+ if (headerIsScrolled.value != isScrolled) {
+ headerIsScrolled.value = isScrolled;
+ }
+ return false;
+ },
+ child: FrostedHeaderScrollState(
+ notifier: headerIsScrolled,
+ child: Scaffold(
+ backgroundColor: backgroundColor,
+ resizeToAvoidBottomInset: resizeToAvoidBottomInset,
+ floatingActionButton: floatingActionButton,
+ body: Stack(children: [body, appBar]),
+ ),
+ ),
);
}
}
diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart
index a0293455f..8c1f05f5a 100644
--- a/mobile/test/features/activity/activity_page_test.dart
+++ b/mobile/test/features/activity/activity_page_test.dart
@@ -182,6 +182,33 @@ void main() {
expect(find.byTooltip('Back'), findsNothing);
});
+ testWidgets('uses a persistent inbox list and detail pane on wide iPad', (
+ tester,
+ ) async {
+ tester.view.physicalSize = const Size(1180, 820);
+ tester.view.devicePixelRatio = 1;
+ addTearDown(() {
+ tester.view.resetPhysicalSize();
+ tester.view.resetDevicePixelRatio();
+ });
+
+ await tester.pumpWidget(await buildTestable());
+ await tester.pump();
+
+ final inboxList = find.byKey(const Key('wide-activity-inbox-list'));
+ expect(inboxList, findsOneWidget);
+ expect(tester.getSize(inboxList).width, closeTo(1180 / 3, 0.1));
+ expect(find.text('Inbox'), findsOneWidget);
+
+ await tester.tap(find.byKey(const ValueKey('inbox-row-m1')));
+ await tester.pump();
+
+ expect(
+ find.byKey(const ValueKey('wide-activity-detail-m1')),
+ findsOneWidget,
+ );
+ });
+
testWidgets('keeps bottom clearance for the floating tab bar', (
tester,
) async {
diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart
index 899394de2..e7a07fd8e 100644
--- a/mobile/test/features/channels/channel_detail_page_test.dart
+++ b/mobile/test/features/channels/channel_detail_page_test.dart
@@ -2085,6 +2085,45 @@ void main() {
});
group('Deep-link navigation', () {
+ testWidgets(
+ 'waits for thread hydration before opening an Inbox deep link',
+ (tester) async {
+ final root = _textMsg(
+ id: 'root',
+ pubkey: 'alice',
+ content: 'Thread root',
+ createdAt: 1000,
+ );
+ final reply = _textMsg(
+ id: 'reply',
+ pubkey: 'bob',
+ content: 'Deep-linked reply',
+ createdAt: 1100,
+ extraTags: const [
+ ['e', 'root', '', 'reply'],
+ ],
+ );
+ final completer = Completer>();
+
+ await tester.pumpWidget(
+ _buildTestable(
+ messages: [root, reply],
+ initialMessageId: 'reply',
+ initialThreadRootId: 'root',
+ pendingThreadReplies: {'root': completer.future},
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ expect(find.byType(ThreadDetailPage), findsNothing);
+
+ completer.complete([reply]);
+ await tester.pumpAndSettle();
+
+ expect(find.byType(ThreadDetailPage), findsOneWidget);
+ },
+ );
+
testWidgets('opens a nested reply in its direct-parent thread', (
tester,
) async {
@@ -2496,6 +2535,63 @@ void main() {
expect(find.byKey(const ValueKey('reaction-pill-👍')), findsOneWidget);
});
+ testWidgets(
+ 'a thread reaction keeps its channel for optimistic rendering',
+ (tester) async {
+ final rootEvent = _textMsg(
+ id: 'thread-root',
+ pubkey: 'alice',
+ content: 'Thread root',
+ createdAt: 1000,
+ );
+ String? reactionTargetId;
+ String? reactionEmoji;
+ String? reactionChannelId;
+
+ await tester.pumpWidget(
+ _buildTestable(
+ messages: [
+ rootEvent,
+ _reaction(id: 'reaction-1', targetId: 'thread-root'),
+ ],
+ createChannelActions: (ref) => _FakeChannelActions(
+ ref,
+ onAddReaction: (eventId, emoji, channelId) {
+ reactionTargetId = eventId;
+ reactionEmoji = emoji;
+ reactionChannelId = channelId;
+ },
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ final threadHead = formatTimeline([
+ rootEvent,
+ _reaction(id: 'reaction-1', targetId: 'thread-root'),
+ ]).single;
+ Navigator.of(tester.element(find.byType(ChannelDetailPage))).push(
+ MaterialPageRoute(
+ builder: (_) => ThreadDetailPage(
+ threadHead: threadHead,
+ allMessages: [threadHead],
+ channelId: _channelId,
+ currentPubkey: 'self',
+ isMember: true,
+ isArchived: false,
+ ),
+ ),
+ );
+ await tester.pumpAndSettle();
+
+ await tester.tap(find.byKey(const ValueKey('reaction-pill-👍')));
+
+ expect(reactionTargetId, 'thread-root');
+ expect(reactionEmoji, '👍');
+ expect(reactionChannelId, _channelId);
+ },
+ );
+
testWidgets('a live deletion does not restore the routed thread head', (
tester,
) async {
@@ -2771,8 +2867,10 @@ class _FakeChannelsNotifier extends ChannelsNotifier {
class _FakeChannelActions extends ChannelActions {
final Future Function(String channelId)? onJoinChannel;
+ final void Function(String eventId, String emoji, String? channelId)?
+ onAddReaction;
- _FakeChannelActions(Ref ref, {this.onJoinChannel})
+ _FakeChannelActions(Ref ref, {this.onJoinChannel, this.onAddReaction})
: super(
ref: ref,
session: ref.read(relaySessionProvider.notifier),
@@ -2792,6 +2890,15 @@ class _FakeChannelActions extends ChannelActions {
Future leaveChannel(String channelId) async {
return;
}
+
+ @override
+ Future addReaction(
+ String eventId,
+ String emoji, {
+ String? channelId,
+ }) async {
+ onAddReaction?.call(eventId, emoji, channelId);
+ }
}
class _TestNavigatorObserver extends NavigatorObserver {
diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart
index 919d4039e..6080aacc7 100644
--- a/mobile/test/features/channels/compose_bar_test.dart
+++ b/mobile/test/features/channels/compose_bar_test.dart
@@ -1402,6 +1402,42 @@ void main() {
}
});
+ testWidgets('uses a taller camera surface on tablet-width layouts', (
+ tester,
+ ) async {
+ final previousPlatform = debugDefaultTargetPlatformOverride;
+ debugDefaultTargetPlatformOverride = TargetPlatform.android;
+ tester.view.physicalSize = const Size(1180, 820);
+ tester.view.devicePixelRatio = 1;
+
+ try {
+ await tester.pumpWidget(
+ _buildComposeBar(
+ uploadService: _testUploadService(nostr.Keys.generate().nsec),
+ onSend:
+ (
+ content,
+ mentionPubkeys, {
+ mediaTags = const >[],
+ }) async {},
+ ),
+ );
+
+ await _openAttachmentMenu(tester);
+ await tester.tap(find.text('Camera'));
+ await tester.pump(const Duration(milliseconds: 320));
+
+ expect(
+ tester.getSize(find.byKey(const ValueKey('camera-preview'))).height,
+ 640,
+ );
+ } finally {
+ debugDefaultTargetPlatformOverride = previousPlatform;
+ tester.view.resetPhysicalSize();
+ tester.view.resetDevicePixelRatio();
+ }
+ });
+
testWidgets('photo picker errors keep the action visible at large text', (
tester,
) async {
diff --git a/mobile/test/features/home/home_page_test.dart b/mobile/test/features/home/home_page_test.dart
index cf688ce72..acf4e7737 100644
--- a/mobile/test/features/home/home_page_test.dart
+++ b/mobile/test/features/home/home_page_test.dart
@@ -1,20 +1,49 @@
+import 'dart:ui' show Tristate;
+
import 'package:buzz/features/home/home_page.dart';
+import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channels_page.dart';
+import 'package:buzz/features/channels/channels_provider.dart';
+import 'package:buzz/features/profile/profile_avatar.dart';
+import 'package:buzz/features/profile/user_status.dart';
+import 'package:buzz/features/profile/user_status_provider.dart';
+import 'package:buzz/shared/community/community.dart';
+import 'package:buzz/shared/community/community_provider.dart';
import 'package:buzz/shared/theme/theme.dart';
+import 'package:buzz/shared/widgets/skeleton.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
- Future buildHome() async {
+ Future buildHome({
+ ThemeData? theme,
+ UserStatus? status,
+ CommunityListNotifier? communityListNotifier,
+ Community? activeCommunity,
+ List? channels,
+ }) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
return ProviderScope(
- overrides: [savedPrefsProvider.overrideWithValue(prefs)],
+ overrides: [
+ savedPrefsProvider.overrideWithValue(prefs),
+ if (status != null)
+ userStatusProvider.overrideWith(
+ () => _FakeUserStatusNotifier(status),
+ ),
+ if (communityListNotifier != null)
+ communityListProvider.overrideWith(() => communityListNotifier),
+ if (activeCommunity != null)
+ activeCommunityProvider.overrideWith((ref) async => activeCommunity),
+ if (channels != null)
+ channelsProvider.overrideWith(() => _FakeChannelsNotifier(channels)),
+ ],
child: MaterialApp(
- theme: AppTheme.light(),
+ theme: theme ?? AppTheme.light(),
home: const HomePage(settingsPageBuilder: _buildSettingsPage),
),
);
@@ -58,6 +87,493 @@ void main() {
);
});
+ testWidgets('uses a persistent sidebar instead of tabs on wide displays', (
+ tester,
+ ) async {
+ tester.view.physicalSize = const Size(1180, 820);
+ tester.view.devicePixelRatio = 1;
+ addTearDown(() {
+ tester.view.resetPhysicalSize();
+ tester.view.resetDevicePixelRatio();
+ });
+
+ await tester.pumpWidget(await buildHome());
+ await tester.pump();
+
+ final sidebar = find.byKey(const Key('wide-navigation-sidebar'));
+ final contentSurface = find.byKey(
+ const Key('wide-navigation-content-surface'),
+ );
+ final inbox = find.byKey(const Key('wide-navigation-inbox'));
+ final search = find.byKey(const Key('wide-navigation-search'));
+ final communitySwitcher = find.byKey(
+ const Key('wide-navigation-community-switcher'),
+ );
+ final communityIcon = find.byKey(
+ const Key('wide-navigation-community-icon'),
+ );
+ final profile = find.byKey(const Key('wide-navigation-profile'));
+ final sidebarSkeleton = find.byKey(
+ const Key('wide-navigation-community-switch-skeleton'),
+ );
+
+ expect(sidebar, findsOneWidget);
+ expect(
+ tester
+ .widget(
+ find.ancestor(
+ of: sidebarSkeleton,
+ matching: find.byType(SkeletonShimmer),
+ ),
+ )
+ .enabled,
+ isFalse,
+ );
+ expect(tester.getSize(sidebar).width, 280);
+ expect(tester.getRect(contentSurface).left, 280);
+ expect(tester.getRect(contentSurface).top, Grid.half + Grid.quarter);
+ expect(
+ tester.getRect(contentSurface).right,
+ 1180 - Grid.half - Grid.quarter,
+ );
+ expect(
+ tester.getRect(contentSurface).bottom,
+ 820 - Grid.half - Grid.quarter,
+ );
+ final contentDecoration =
+ tester.widget(contentSurface).decoration as BoxDecoration;
+ expect(contentDecoration.color, AppTheme.light().colorScheme.surface);
+ expect(contentDecoration.borderRadius, BorderRadius.circular(24));
+ expect(contentDecoration.boxShadow, hasLength(2));
+ expect(contentDecoration.boxShadow!.first.offset, const Offset(-1, -1));
+ expect(contentDecoration.boxShadow!.first.blurRadius, 0);
+ expect(contentDecoration.boxShadow!.last.blurRadius, 4);
+ final sidebarScrollView = find.byKey(
+ const Key('wide-navigation-channel-list'),
+ );
+ expect(
+ tester.getRect(sidebarScrollView).bottom,
+ tester.getRect(profile).top - Grid.xxs,
+ );
+ expect(
+ tester.getRect(profile).bottom,
+ tester.getRect(sidebar).bottom - Grid.xs,
+ );
+ expect(communitySwitcher, findsOneWidget);
+ expect(communityIcon, findsOneWidget);
+ expect(tester.getSize(communityIcon), const Size.square(32));
+ expect(tester.getSize(communitySwitcher).height, 48);
+ expect(tester.getRect(communitySwitcher).top, 0);
+ expect(profile, findsOneWidget);
+ expect(
+ tester
+ .widget(
+ find.descendant(of: profile, matching: find.byType(ProfileAvatar)),
+ )
+ .size,
+ 32,
+ );
+ expect(
+ tester
+ .getRect(
+ find.descendant(
+ of: communitySwitcher,
+ matching: find.text('Community'),
+ ),
+ )
+ .left,
+ closeTo(
+ tester
+ .getRect(find.descendant(of: inbox, matching: find.text('Inbox')))
+ .left,
+ 0.01,
+ ),
+ );
+ final communityLabel = tester.widget(
+ find.descendant(of: communitySwitcher, matching: find.text('Community')),
+ );
+ final inboxLabel = tester.widget(
+ find.descendant(of: inbox, matching: find.text('Inbox')),
+ );
+ expect(communityLabel.style?.fontSize, 18);
+ expect(
+ communityLabel.style!.fontSize!,
+ greaterThan(inboxLabel.style!.fontSize!),
+ );
+ expect(tester.getSize(inbox).height, greaterThanOrEqualTo(48));
+ expect(tester.getSize(search).height, greaterThanOrEqualTo(48));
+ expect(tester.getRect(search).top, lessThan(tester.getRect(inbox).top));
+ final searchRow = find.descendant(
+ of: search,
+ matching: find.byType(Material),
+ );
+ final inboxRow = find.descendant(
+ of: inbox,
+ matching: find.byType(Material),
+ );
+ expect(
+ tester.getRect(inboxRow).top - tester.getRect(searchRow).bottom,
+ Grid.quarter,
+ );
+ expect(
+ tester
+ .widget(find.descendant(of: inbox, matching: find.byType(Icon)))
+ .size,
+ 20,
+ );
+ expect(
+ tester
+ .widget(find.descendant(of: inbox, matching: find.byType(Icon)))
+ .icon,
+ LucideIcons.inbox500,
+ );
+ expect(
+ tester
+ .widget(
+ find.descendant(of: search, matching: find.byType(Icon)),
+ )
+ .size,
+ 20,
+ );
+ expect(
+ tester
+ .widget(
+ find.descendant(of: search, matching: find.byType(Icon)),
+ )
+ .icon,
+ LucideIcons.search500,
+ );
+ expect(find.byKey(const Key('floating-tab-bar')), findsNothing);
+ expect(find.byKey(const Key('wide-navigation-home')), findsNothing);
+ expect(search, findsOneWidget);
+ expect(find.byTooltip('Create channel'), findsOneWidget);
+ expect(find.byTooltip('New direct message'), findsOneWidget);
+ expect(find.byTooltip('Create or start conversation'), findsNothing);
+ expect(
+ tester.getSemantics(inbox).flagsCollection.isSelected,
+ Tristate.isTrue,
+ );
+ expect(
+ tester
+ .widget(
+ find.descendant(
+ of: contentSurface,
+ matching: find.byType(IndexedStack),
+ ),
+ )
+ .index,
+ 1,
+ );
+
+ await tester.tap(inbox);
+ await tester.pump();
+
+ expect(
+ tester.getSemantics(inbox).flagsCollection.isSelected,
+ Tristate.isTrue,
+ );
+
+ await tester.tap(search);
+ await tester.pump();
+
+ expect(find.byKey(const Key('search-field')), findsOneWidget);
+
+ tester
+ .widget(
+ find.byKey(const Key('wide-navigation-profile-button')),
+ )
+ .onTap!();
+ await tester.pump(const Duration(milliseconds: 300));
+
+ expect(Navigator.of(tester.element(profile)).canPop(), isTrue);
+
+ Navigator.of(tester.element(profile)).pop();
+ await tester.pump();
+
+ await tester.tap(find.byKey(const Key('wide-navigation-settings-button')));
+ await tester.pump(const Duration(milliseconds: 300));
+
+ expect(Navigator.of(tester.element(profile)).canPop(), isTrue);
+
+ Navigator.of(tester.element(profile)).pop();
+ await tester.pump();
+
+ await tester.tap(communitySwitcher);
+ await tester.pump();
+ expect(find.byKey(const Key('community-switcher-sheet')), findsOneWidget);
+ });
+
+ testWidgets('extends the Buzz gradient through the iPad sidebar', (
+ tester,
+ ) async {
+ tester.view.physicalSize = const Size(1180, 820);
+ tester.view.devicePixelRatio = 1;
+ addTearDown(() {
+ tester.view.resetPhysicalSize();
+ tester.view.resetDevicePixelRatio();
+ });
+
+ final gradient = const LinearGradient(colors: [Colors.yellow, Colors.blue]);
+ await tester.pumpWidget(
+ await buildHome(theme: AppTheme.light(topSectionGradient: gradient)),
+ );
+ await tester.pump();
+
+ final sidebar = tester.widget(
+ find.byKey(const Key('wide-navigation-sidebar-background')),
+ );
+ final decoration = sidebar.decoration as BoxDecoration;
+ expect(decoration.gradient, same(gradient));
+ expect(decoration.color, isNull);
+ expect(decoration.border, isNull);
+
+ final foreground = Theme.of(
+ tester.element(find.byKey(const Key('wide-navigation-sidebar'))),
+ ).colorScheme.onSurface;
+ final inbox = find.byKey(const Key('wide-navigation-inbox'));
+ final inboxLabel = tester.widget(
+ find.descendant(of: inbox, matching: find.text('Inbox')),
+ );
+ final inboxMaterial = tester.widget(
+ find.descendant(of: inbox, matching: find.byType(Material)),
+ );
+ final channelsLabel = tester.widget(find.text('Channels'));
+
+ expect(inboxLabel.style?.color, foreground);
+ expect(inboxMaterial.color, foreground.withValues(alpha: 0.07));
+ expect(channelsLabel.style?.color, foreground.withValues(alpha: 0.4));
+ });
+
+ testWidgets('selecting Inbox or Search clears a selected tablet channel', (
+ tester,
+ ) async {
+ tester.view.physicalSize = const Size(1180, 820);
+ tester.view.devicePixelRatio = 1;
+ addTearDown(() {
+ tester.view.resetPhysicalSize();
+ tester.view.resetDevicePixelRatio();
+ });
+ final channel = Channel(
+ id: 'test-channel',
+ name: 'general',
+ channelType: 'stream',
+ visibility: 'open',
+ description: '',
+ createdBy: 'alice',
+ createdAt: DateTime(2025),
+ memberCount: 1,
+ );
+
+ await tester.pumpWidget(await buildHome(channels: [channel]));
+ await tester.pumpAndSettle();
+
+ final channelDestination = find.byKey(
+ const ValueKey('wide-channel-test-channel'),
+ );
+ final inbox = find.byKey(const Key('wide-navigation-inbox'));
+ final search = find.byKey(const Key('wide-navigation-search'));
+
+ await tester.tap(channelDestination);
+ await tester.pump();
+ expect(
+ tester.getSemantics(channelDestination).flagsCollection.isSelected,
+ Tristate.isTrue,
+ );
+
+ await tester.tap(inbox);
+ await tester.pump();
+ expect(
+ tester.getSemantics(inbox).flagsCollection.isSelected,
+ Tristate.isTrue,
+ );
+ expect(
+ tester.getSemantics(channelDestination).flagsCollection.isSelected,
+ Tristate.isFalse,
+ );
+
+ await tester.tap(channelDestination);
+ await tester.pump();
+ await tester.tap(search);
+ await tester.pump();
+ expect(
+ tester.getSemantics(search).flagsCollection.isSelected,
+ Tristate.isTrue,
+ );
+ expect(
+ tester.getSemantics(channelDestination).flagsCollection.isSelected,
+ Tristate.isFalse,
+ );
+ });
+
+ testWidgets('shows the current status in the wide sidebar profile row', (
+ tester,
+ ) async {
+ tester.view.physicalSize = const Size(1180, 820);
+ tester.view.devicePixelRatio = 1;
+ addTearDown(() {
+ tester.view.resetPhysicalSize();
+ tester.view.resetDevicePixelRatio();
+ });
+
+ await tester.pumpWidget(
+ await buildHome(
+ status: const UserStatus(text: 'Focusing', emoji: '🎯', updatedAt: 1),
+ ),
+ );
+ await tester.pump();
+ await tester.pump();
+
+ expect(
+ find.descendant(
+ of: find.byKey(const Key('wide-navigation-profile')),
+ matching: find.text('🎯 Focusing'),
+ ),
+ findsOneWidget,
+ );
+ });
+
+ testWidgets('swiping the community icon selects the next community', (
+ tester,
+ ) async {
+ tester.view.physicalSize = const Size(1180, 820);
+ tester.view.devicePixelRatio = 1;
+ addTearDown(() {
+ tester.view.resetPhysicalSize();
+ tester.view.resetDevicePixelRatio();
+ });
+
+ final communities = [
+ Community(
+ id: 'alpha',
+ name: 'Alpha',
+ relayUrl: 'wss://alpha.example.com',
+ addedAt: DateTime(2026),
+ ),
+ Community(
+ id: 'bravo',
+ name: 'Bravo',
+ relayUrl: 'wss://bravo.example.com',
+ addedAt: DateTime(2026),
+ ),
+ ];
+ final communityListNotifier = _FakeCommunityListNotifier(communities);
+
+ await tester.pumpWidget(
+ await buildHome(
+ communityListNotifier: communityListNotifier,
+ activeCommunity: communities.first,
+ ),
+ );
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 1));
+
+ expect(find.text('Alpha'), findsOneWidget);
+ expect(communityListNotifier.state.value, communities);
+ expect(
+ find.byKey(const Key('wide-navigation-community-current-icon')),
+ findsOneWidget,
+ );
+ expect(
+ find.byKey(const Key('wide-navigation-community-incoming-icon')),
+ findsOneWidget,
+ );
+ final iconGesture = tester.widget(
+ find.descendant(
+ of: find.byKey(const Key('wide-navigation-community-icon')),
+ matching: find.byType(GestureDetector),
+ ),
+ );
+ iconGesture.onVerticalDragStart!(
+ DragStartDetails(globalPosition: Offset.zero),
+ );
+ iconGesture.onVerticalDragUpdate!(
+ DragUpdateDetails(globalPosition: Offset(0, 40), delta: Offset(0, 40)),
+ );
+ await tester.pump();
+ final outgoingContainer = tester.widget(
+ find.byKey(const Key('wide-navigation-community-current-container')),
+ );
+ expect(outgoingContainer.transform.getTranslation().y, closeTo(32, 0.01));
+ expect(
+ tester
+ .widget(
+ find.byKey(const Key('wide-navigation-community-current-opacity')),
+ )
+ .opacity,
+ 0,
+ );
+ expect(
+ tester
+ .widget(
+ find.byKey(const Key('wide-navigation-community-incoming-opacity')),
+ )
+ .opacity,
+ 0,
+ );
+ expect(
+ find.byKey(const Key('wide-navigation-community-incoming-container')),
+ findsOneWidget,
+ );
+ iconGesture.onVerticalDragEnd!(DragEndDetails(velocity: Velocity.zero));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 120));
+ await tester.pump();
+
+ expect(
+ tester
+ .widget(
+ find.byKey(const Key('wide-navigation-community-incoming-opacity')),
+ )
+ .opacity,
+ 1,
+ );
+ expect(find.text('Bravo'), findsOneWidget);
+ expect(
+ tester
+ .widget(
+ find.ancestor(
+ of: find.byKey(
+ const Key('wide-navigation-community-switch-skeleton'),
+ ),
+ matching: find.byType(SkeletonShimmer),
+ ),
+ )
+ .enabled,
+ isFalse,
+ );
+
+ await tester.pump(const Duration(milliseconds: 120));
+ await tester.pump();
+
+ expect(communityListNotifier.switchedToId, 'bravo');
+ expect(
+ tester
+ .widget(
+ find.ancestor(
+ of: find.byKey(
+ const Key('wide-navigation-community-switch-skeleton'),
+ ),
+ matching: find.byType(SkeletonShimmer),
+ ),
+ )
+ .enabled,
+ isTrue,
+ );
+ expect(
+ tester
+ .widget(
+ find.ancestor(
+ of: find.byKey(
+ const Key('wide-community-switch-content-skeleton'),
+ ),
+ matching: find.byType(SkeletonShimmer),
+ ),
+ )
+ .enabled,
+ isTrue,
+ );
+ });
+
testWidgets('gives selection haptics only when the tab changes', (
tester,
) async {
@@ -138,3 +654,36 @@ void main() {
}
Widget _buildSettingsPage(BuildContext context) => const SizedBox.shrink();
+
+class _FakeUserStatusNotifier extends UserStatusNotifier {
+ _FakeUserStatusNotifier(this._status);
+
+ final UserStatus _status;
+
+ @override
+ Future build() async => _status;
+}
+
+class _FakeChannelsNotifier extends ChannelsNotifier {
+ final List _channels;
+
+ _FakeChannelsNotifier(this._channels);
+
+ @override
+ Future> build() async => _channels;
+}
+
+class _FakeCommunityListNotifier extends CommunityListNotifier {
+ _FakeCommunityListNotifier(this._communities);
+
+ final List _communities;
+ String? switchedToId;
+
+ @override
+ Future> build() async => _communities;
+
+ @override
+ Future switchCommunity(String id) async {
+ switchedToId = id;
+ }
+}
diff --git a/mobile/test/features/pairing/pairing_page_test.dart b/mobile/test/features/pairing/pairing_page_test.dart
index 678be9dfe..3fc872c9e 100644
--- a/mobile/test/features/pairing/pairing_page_test.dart
+++ b/mobile/test/features/pairing/pairing_page_test.dart
@@ -46,6 +46,48 @@ void main() {
expect(find.byType(OutlinedButton), findsNothing);
});
+ testWidgets('centers pairing content on wide displays', (tester) async {
+ await tester.binding.setSurfaceSize(const Size(1180, 820));
+ addTearDown(() => tester.binding.setSurfaceSize(null));
+
+ await tester.pumpWidget(
+ WidgetHelpers.testable(child: const PairingPage()),
+ );
+
+ final contentRect = tester.getRect(
+ find.byKey(const Key('pairing-welcome-content')),
+ );
+ final screenCenter = const Offset(590, 410);
+
+ expect(contentRect.center.dx, closeTo(screenCenter.dx, 1));
+ expect(contentRect.center.dy, closeTo(screenCenter.dy, 16));
+ });
+
+ testWidgets('keeps security verification compact on wide displays', (
+ tester,
+ ) async {
+ await tester.binding.setSurfaceSize(const Size(1180, 820));
+ addTearDown(() => tester.binding.setSurfaceSize(null));
+
+ await tester.pumpWidget(
+ ProviderScope(
+ overrides: [
+ pairingProvider.overrideWith(() => _ConfirmingSasPairingNotifier()),
+ ],
+ child: MaterialApp(
+ theme: AppTheme.light(),
+ home: const PairingPage(),
+ ),
+ ),
+ );
+
+ final content = tester.getRect(
+ find.byKey(const Key('pairing-sas-verification-content')),
+ );
+ expect(content.width, 520);
+ expect(content.center.dx, closeTo(590, 1));
+ });
+
testWidgets('uses dark status-bar icons on the onboarding surface', (
tester,
) async {
diff --git a/mobile/test/features/settings/theme_picker_page_test.dart b/mobile/test/features/settings/theme_picker_page_test.dart
index 6f8591f28..40bbfb312 100644
--- a/mobile/test/features/settings/theme_picker_page_test.dart
+++ b/mobile/test/features/settings/theme_picker_page_test.dart
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:buzz/features/settings/accent_picker_page.dart';
+import 'package:buzz/features/settings/settings_page.dart';
import 'package:buzz/features/settings/theme_picker_page.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -200,4 +201,24 @@ void main() {
);
});
});
+
+ testWidgets('centers settings content at a tablet-friendly width', (
+ tester,
+ ) async {
+ await tester.binding.setSurfaceSize(const Size(1180, 820));
+ addTearDown(() => tester.binding.setSurfaceSize(null));
+ final instance = await _prefs(const {});
+
+ await tester.pumpWidget(
+ WidgetHelpers.testable(
+ child: const SettingsPage(profileHeader: SizedBox.shrink()),
+ overrides: [savedPrefsProvider.overrideWithValue(instance)],
+ ),
+ );
+ await tester.pump();
+
+ final content = tester.getRect(find.byKey(const Key('settings-content')));
+ expect(content.width, 640);
+ expect(content.center.dx, closeTo(590, 1));
+ });
}
diff --git a/mobile/test/shared/theme/buzz_theme_test.dart b/mobile/test/shared/theme/buzz_theme_test.dart
index 613fec146..d8375e1fe 100644
--- a/mobile/test/shared/theme/buzz_theme_test.dart
+++ b/mobile/test/shared/theme/buzz_theme_test.dart
@@ -140,16 +140,19 @@ void main() {
return container.decoration! as BoxDecoration;
}
- Widget harness(ThemeData theme) => MaterialApp(
- theme: theme,
- home: Builder(
- builder: (context) => Stack(
- children: [
- FrostedAppBar(
- gradient: context.appColors.topSectionGradient,
- title: const Text('Home'),
- ),
- ],
+ Widget harness(ThemeData theme) => MediaQuery(
+ data: const MediaQueryData(size: Size(390, 844)),
+ child: MaterialApp(
+ theme: theme,
+ home: Builder(
+ builder: (context) => Stack(
+ children: [
+ FrostedAppBar(
+ gradient: context.appColors.topSectionGradient,
+ title: const Text('Home'),
+ ),
+ ],
+ ),
),
),
);
diff --git a/mobile/test/shared/widgets/frosted_app_bar_test.dart b/mobile/test/shared/widgets/frosted_app_bar_test.dart
index bc17f66ee..f4cfebecc 100644
--- a/mobile/test/shared/widgets/frosted_app_bar_test.dart
+++ b/mobile/test/shared/widgets/frosted_app_bar_test.dart
@@ -1,5 +1,6 @@
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
+import 'package:buzz/shared/widgets/frosted_scaffold.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -83,4 +84,38 @@ void main() {
expect(reportedHeight, closeTo(titleContentHeight + Grid.xs + 1, 0.01));
expect(tester.takeException(), isNull);
});
+
+ testWidgets('iPad headers replace the divider with a scroll shadow', (
+ tester,
+ ) async {
+ tester.view.physicalSize = const Size(1180, 820);
+ tester.view.devicePixelRatio = 1;
+ addTearDown(() {
+ tester.view.resetPhysicalSize();
+ tester.view.resetDevicePixelRatio();
+ });
+
+ await tester.pumpWidget(
+ MaterialApp(
+ theme: AppTheme.light(),
+ home: FrostedScaffold(
+ appBar: const FrostedAppBar(title: Text('Inbox')),
+ body: ListView.builder(
+ itemExtent: 56,
+ itemCount: 30,
+ itemBuilder: (_, index) => Text('Row $index'),
+ ),
+ ),
+ ),
+ );
+
+ expect(find.byKey(const Key('frosted-app-bar-divider')), findsOneWidget);
+ expect(find.byKey(const Key('frosted-app-bar-shadow')), findsNothing);
+
+ await tester.drag(find.byType(ListView), const Offset(0, -180));
+ await tester.pump();
+
+ expect(find.byKey(const Key('frosted-app-bar-divider')), findsNothing);
+ expect(find.byKey(const Key('frosted-app-bar-shadow')), findsOneWidget);
+ });
}