mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add tablet workspace layout
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -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,6 +1,4 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
class SceneDelegate: FlutterSceneDelegate {
|
||||
|
||||
}
|
||||
class SceneDelegate: FlutterSceneDelegate {}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user