Add tablet workspace layout

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-02 08:37:17 +01:00
parent ac4fa13b8e
commit a307669962
35 changed files with 3161 additions and 528 deletions
+3 -3
View File
@@ -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;
@@ -15,7 +15,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BuildableName = "Buzz.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
@@ -32,7 +32,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BuildableName = "Buzz.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
@@ -68,7 +68,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BuildableName = "Buzz.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
@@ -85,7 +85,7 @@
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
BuildableName = "Runner.app"
BuildableName = "Buzz.app"
BlueprintName = "Runner"
ReferencedContainer = "container:Runner.xcodeproj">
</BuildableReference>
+1 -3
View File
@@ -1,6 +1,4 @@
import Flutter
import UIKit
class SceneDelegate: FlutterSceneDelegate {
}
class SceneDelegate: FlutterSceneDelegate {}
+155 -4
View File
@@ -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<String?>(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<InboxItem?>().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<void>(
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<void>(
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,
),
),
],
),
),
);
}
}
@@ -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,
),
),
],
),
),
),
],
],
),
),
),
);
@@ -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';
@@ -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,
),
),
],
@@ -40,6 +40,31 @@ class _MessageList extends HookConsumerWidget {
final previousLatestEntryId = useRef<String?>(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<void>(
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<void>(
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);
@@ -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,
),
),
@@ -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<void> addReaction(String eventId, String emoji) async {
Future<void> 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<void> removeReaction(String reactionEventId, String emoji) async {
await _signedEventRelay.submit(
kind: EventKind.deletion,
content: '',
tags: [
['e', reactionEventId],
],
);
Future<void> 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<void> editMessage({
@@ -315,6 +315,42 @@ class ChannelMessagesNotifier extends Notifier<AsyncValue<List<NostrEvent>>> {
_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 <NostrEvent>[];
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.
@@ -237,11 +237,7 @@ class ChannelsPage extends HookConsumerWidget {
leading: _CommunityIndicator(
onTap: () {
ref.invalidate(communityIconProvider);
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (_) => const _CommunitySwitcherSheet(),
);
unawaited(showCommunitySwitcherSheet(context));
},
),
title: const SizedBox.shrink(),
@@ -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<void> showCommunitySwitcherSheet(BuildContext context) {
return showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (_) => const _CommunitySwitcherSheet(),
);
}
class _CommunitySwitcherSheet extends HookConsumerWidget {
const _CommunitySwitcherSheet();
@@ -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<Channel?> showCreateChannelSheet(BuildContext context) {
return showModalBottomSheet<Channel>(
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<Channel?> showNewDirectMessageSheet(
BuildContext context, {
required String? currentPubkey,
}) {
return showModalBottomSheet<Channel>(
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<Channel>(
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<Channel>(
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);
@@ -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);
@@ -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(
+16 -4
View File
@@ -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);
},
);
}
@@ -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,
),
),
],
+249 -34
View File
@@ -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<Channel?>(null);
final pendingCommunityId = useState<String?>(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<void>(
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,
@@ -0,0 +1,881 @@
part of '../home_page.dart';
class _WideNavigationSidebar extends HookConsumerWidget {
final int? selectedIndex;
final ValueChanged<int> onDestinationSelected;
final ValueChanged<Channel> onChannelSelected;
final String? selectedChannelId;
final VoidCallback onProfileSelected;
final bool isCommunitySwitching;
final ValueChanged<String> 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 <Channel>[])
.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<void> createChannel() async {
final channel = await showCreateChannelSheet(context);
if (channel != null) onChannelSelected(channel);
}
Future<void> 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<String> 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 <Community>[];
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<Community?>(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<void> 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<void>.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<double> onVerticalDragUpdate;
final ValueChanged<double> 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<void>(
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<String, UserProfile> profiles,
}) {
if (!channel.isDm) return channel.displayLabel();
final normalizedCurrent = currentPubkey?.toLowerCase();
final labels = <String>[];
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<String, UserProfile> 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;
}
@@ -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),
],
),
),
),
],
);
},
);
}
}
+110 -95
View File
@@ -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),
],
),
);
}
}
@@ -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,
),
),
),
],
),
),
],
],
),
),
),
],
),
],
),
),
),
);
@@ -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(
@@ -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(),
],
),
),
),
],
),
),
+59 -11
View File
@@ -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<ValueNotifier<bool>> {
const FrostedHeaderScrollState({
required super.notifier,
required super.child,
super.key,
});
static bool isScrolledOf(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<FrostedHeaderScrollState>()
?.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,
@@ -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<ScrollNotification>(
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]),
),
),
);
}
}
@@ -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 {
@@ -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<List<NostrEvent>>();
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<void>(
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<void> 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<void> leaveChannel(String channelId) async {
return;
}
@override
Future<void> addReaction(
String eventId,
String emoji, {
String? channelId,
}) async {
onAddReaction?.call(eventId, emoji, channelId);
}
}
class _TestNavigatorObserver extends NavigatorObserver {
@@ -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 <List<String>>[],
}) 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 {
+552 -3
View File
@@ -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<Widget> buildHome() async {
Future<Widget> buildHome({
ThemeData? theme,
UserStatus? status,
CommunityListNotifier? communityListNotifier,
Community? activeCommunity,
List<Channel>? 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<SkeletonShimmer>(
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<DecoratedBox>(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<ProfileAvatar>(
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<Text>(
find.descendant(of: communitySwitcher, matching: find.text('Community')),
);
final inboxLabel = tester.widget<Text>(
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<Icon>(find.descendant(of: inbox, matching: find.byType(Icon)))
.size,
20,
);
expect(
tester
.widget<Icon>(find.descendant(of: inbox, matching: find.byType(Icon)))
.icon,
LucideIcons.inbox500,
);
expect(
tester
.widget<Icon>(
find.descendant(of: search, matching: find.byType(Icon)),
)
.size,
20,
);
expect(
tester
.widget<Icon>(
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<IndexedStack>(
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<InkWell>(
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<DecoratedBox>(
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<Text>(
find.descendant(of: inbox, matching: find.text('Inbox')),
);
final inboxMaterial = tester.widget<Material>(
find.descendant(of: inbox, matching: find.byType(Material)),
);
final channelsLabel = tester.widget<Text>(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<GestureDetector>(
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<Transform>(
find.byKey(const Key('wide-navigation-community-current-container')),
);
expect(outgoingContainer.transform.getTranslation().y, closeTo(32, 0.01));
expect(
tester
.widget<Opacity>(
find.byKey(const Key('wide-navigation-community-current-opacity')),
)
.opacity,
0,
);
expect(
tester
.widget<Opacity>(
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<Opacity>(
find.byKey(const Key('wide-navigation-community-incoming-opacity')),
)
.opacity,
1,
);
expect(find.text('Bravo'), findsOneWidget);
expect(
tester
.widget<SkeletonShimmer>(
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<SkeletonShimmer>(
find.ancestor(
of: find.byKey(
const Key('wide-navigation-community-switch-skeleton'),
),
matching: find.byType(SkeletonShimmer),
),
)
.enabled,
isTrue,
);
expect(
tester
.widget<SkeletonShimmer>(
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<UserStatus?> build() async => _status;
}
class _FakeChannelsNotifier extends ChannelsNotifier {
final List<Channel> _channels;
_FakeChannelsNotifier(this._channels);
@override
Future<List<Channel>> build() async => _channels;
}
class _FakeCommunityListNotifier extends CommunityListNotifier {
_FakeCommunityListNotifier(this._communities);
final List<Community> _communities;
String? switchedToId;
@override
Future<List<Community>> build() async => _communities;
@override
Future<void> switchCommunity(String id) async {
switchedToId = id;
}
}
@@ -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 {
@@ -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 <String, Object>{});
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));
});
}
+13 -10
View File
@@ -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'),
),
],
),
),
),
);
@@ -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);
});
}