mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Polish mobile message layout and navigation controls (#8427)
Align outgoing messages and galleries to the right, reclaim the outgoing avatar gutter, and match desktop calendar dividers with balanced spacing. Standardize navigation and sheet icons at 24 points, restore native Edit/Done styling, and align section menus with trailing channel indicators. Validation: clean analysis; 3,513 mobile tests and push-configuration checks passed; iPhone Release build installed and tested by the owner. Includes regression coverage for message placement, date spacing, gallery direction, and section alignment. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: fin <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -606,7 +606,7 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
|
||||
private func makeAction(_ data: [String: Any]) -> UIAction {
|
||||
let id = data["id"] as? String ?? ""
|
||||
let enabled = data["enabled"] as? Bool == true
|
||||
let symbol = (data["symbol"] as? String).flatMap { NativeTabler.image($0) }
|
||||
let symbol = (data["symbol"] as? String).flatMap { NativeTabler.image($0, size: 24) }
|
||||
return UIAction(title: data["label"] as? String ?? "", image: symbol,
|
||||
attributes: enabled ? [] : [.disabled],
|
||||
state: data["selected"] as? Bool == true ? .on : .off) { [weak self] _ in
|
||||
@@ -615,8 +615,13 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
|
||||
}
|
||||
|
||||
private func makeControlGroup(_ actions: [[String: Any]]) -> UIBarButtonItem {
|
||||
if actions.count == 1, actions[0]["plain"] as? Bool == true {
|
||||
return makeItem(actions[0])
|
||||
if actions.count == 1 {
|
||||
let item = makeItem(actions[0])
|
||||
// Let UIKit retain its navigation typography and sizing for text actions
|
||||
// such as Edit/Done, rather than inheriting UIButton's default styling.
|
||||
if actions[0]["plain"] as? Bool == true || (item.image == nil && item.customView == nil) {
|
||||
return item
|
||||
}
|
||||
}
|
||||
let surface = UIVisualEffectView()
|
||||
if #available(iOS 26.0, *) {
|
||||
|
||||
@@ -452,7 +452,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView {
|
||||
)
|
||||
let pointSize: CGFloat = icon == "shutter"
|
||||
? controlSize * Self.shutterIconRatio
|
||||
: 17
|
||||
: 24
|
||||
buttonImage = NativeTabler.image(buttonIconName, size: pointSize)
|
||||
}
|
||||
updateDisplayedContent()
|
||||
|
||||
@@ -72,7 +72,7 @@ final class NativeReactionDetailsViewController: UIViewController, UITableViewDa
|
||||
configuration.baseBackgroundColor = .secondarySystemBackground
|
||||
}
|
||||
configuration.cornerStyle = .capsule
|
||||
configuration.image = NativeTabler.image("xmark", size: 17)
|
||||
configuration.image = NativeTabler.image("xmark", size: 24)
|
||||
configuration.baseForegroundColor = foregroundColor
|
||||
close.configuration = configuration
|
||||
close.tintColor = foregroundColor
|
||||
|
||||
@@ -16,10 +16,6 @@ enum NativeTabler {
|
||||
}()
|
||||
|
||||
static func image(_ symbol: String, size: CGFloat = 20) -> UIImage? {
|
||||
// Back and Close deliberately retain native navigation artwork.
|
||||
if ["chevron.backward", "chevron.left", "xmark", "xmark.circle.fill"].contains(symbol) {
|
||||
return UIImage(systemName: symbol, withConfiguration: UIImage.SymbolConfiguration(pointSize: size, weight: .semibold))
|
||||
}
|
||||
guard let code = glyphs[symbol], let scalar = UnicodeScalar(code),
|
||||
let name = fontName, let font = UIFont(name: name, size: size) else {
|
||||
assertionFailure("Missing Tabler icon: \(symbol)")
|
||||
|
||||
@@ -5,6 +5,8 @@ class _MessageBubble extends HookConsumerWidget {
|
||||
final bool showAuthor;
|
||||
final bool typingHandoff;
|
||||
final bool showAvatar;
|
||||
final bool startsDay;
|
||||
final bool endsDay;
|
||||
final bool hasReplies;
|
||||
final Widget? replyFooter;
|
||||
final Map<String, String> channelNames;
|
||||
@@ -21,6 +23,8 @@ class _MessageBubble extends HookConsumerWidget {
|
||||
required this.showAuthor,
|
||||
this.typingHandoff = false,
|
||||
this.showAvatar = true,
|
||||
this.startsDay = false,
|
||||
this.endsDay = false,
|
||||
required this.hasReplies,
|
||||
this.replyFooter,
|
||||
required this.channelNames,
|
||||
@@ -52,6 +56,7 @@ class _MessageBubble extends HookConsumerWidget {
|
||||
);
|
||||
// Watch only this user's profile to avoid rebuilding on unrelated cache changes.
|
||||
final pk = message.pubkey.toLowerCase();
|
||||
final ownMessage = currentPubkey?.toLowerCase() == pk;
|
||||
final profile =
|
||||
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
|
||||
ref.read(userCacheProvider.notifier).get(pk);
|
||||
@@ -133,7 +138,7 @@ class _MessageBubble extends HookConsumerWidget {
|
||||
return true;
|
||||
},
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
|
||||
padding: EdgeInsets.only(top: showAuthor && !startsDay ? Grid.xs : 0),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
@@ -165,46 +170,60 @@ class _MessageBubble extends HookConsumerWidget {
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: showAvatar ? Grid.quarter : Grid.quarter / 2,
|
||||
bottom: endsDay
|
||||
? 0
|
||||
: (showAvatar ? Grid.quarter : Grid.quarter / 2),
|
||||
),
|
||||
child: MessageFooterLayout(
|
||||
animateSize: true,
|
||||
overlap: message.reactions.isNotEmpty ? Grid.xs : 0,
|
||||
footer: message.reactions.isNotEmpty || replyFooter != null
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left:
|
||||
messageAvatarSize +
|
||||
messageAvatarContentGap +
|
||||
Grid.xxs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (message.reactions.isNotEmpty)
|
||||
ReactionRow(
|
||||
combined: true,
|
||||
messageId: message.id,
|
||||
channelId: currentChannelId,
|
||||
reactions: message.reactions,
|
||||
onToggle: (emoji) =>
|
||||
toggleReaction(ref, message, emoji),
|
||||
showAddButton: isMember && !isArchived,
|
||||
onAddReaction: () => showAddReactionPicker(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
? Align(
|
||||
alignment: ownMessage
|
||||
? Alignment.centerRight
|
||||
: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: ownMessage ? Grid.xxs : 0,
|
||||
left: ownMessage
|
||||
? 0
|
||||
: messageAvatarSize +
|
||||
messageAvatarContentGap +
|
||||
Grid.xxs,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: ownMessage
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (message.reactions.isNotEmpty)
|
||||
ReactionRow(
|
||||
combined: true,
|
||||
messageId: message.id,
|
||||
channelId: currentChannelId,
|
||||
reactions: message.reactions,
|
||||
onToggle: (emoji) =>
|
||||
toggleReaction(ref, message, emoji),
|
||||
showAddButton: isMember && !isArchived,
|
||||
onAddReaction: () => showAddReactionPicker(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (replyFooter != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Grid.xxs,
|
||||
if (replyFooter != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: Grid.xxs,
|
||||
),
|
||||
child: Directionality(
|
||||
textDirection: Directionality.of(context),
|
||||
child: replyFooter!,
|
||||
),
|
||||
),
|
||||
child: replyFooter,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
@@ -214,29 +233,41 @@ class _MessageBubble extends HookConsumerWidget {
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Row(
|
||||
textDirection: ownMessage
|
||||
? TextDirection.rtl
|
||||
: TextDirection.ltr,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
const SizedBox(width: messageAvatarSize),
|
||||
const SizedBox(width: messageAvatarContentGap),
|
||||
if (!ownMessage)
|
||||
const SizedBox(width: messageAvatarSize),
|
||||
if (!ownMessage)
|
||||
const SizedBox(width: messageAvatarContentGap),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: showAuthor ? Grid.half : 0,
|
||||
top: showAuthor && !startsDay ? Grid.half : 0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: ownMessage
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: Grid.quarter,
|
||||
left: Grid.xs,
|
||||
left: ownMessage ? 0 : Grid.xs,
|
||||
right: ownMessage ? Grid.xs : 0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MessageAuthorMeta(
|
||||
showTimestamp: true,
|
||||
reversed: false,
|
||||
alignment: ownMessage
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
nameStyle:
|
||||
messageUsernameTextStyle
|
||||
.copyWith(
|
||||
@@ -318,8 +349,8 @@ class _MessageBubble extends HookConsumerWidget {
|
||||
replyFooter != null,
|
||||
bubbleEnd: showAvatar,
|
||||
bubbleStart: showAuthor,
|
||||
ownMessage:
|
||||
currentPubkey?.toLowerCase() == pk,
|
||||
ownMessage: ownMessage,
|
||||
trimTrailingSpacing: endsDay,
|
||||
mentionNames: resolvedMentionNames,
|
||||
mentionLabels: mentionLabels,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
@@ -392,9 +423,10 @@ class _MessageBubble extends HookConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showAvatar)
|
||||
if (showAvatar && !ownMessage)
|
||||
Positioned(
|
||||
left: 0,
|
||||
left: ownMessage ? null : 0,
|
||||
right: ownMessage ? 0 : null,
|
||||
bottom: 0,
|
||||
child: MessageMediaAvatar(
|
||||
offset: mediaOffset,
|
||||
|
||||
@@ -963,6 +963,13 @@ class _MessageList extends HookConsumerWidget {
|
||||
if (message.isSystem)
|
||||
_SystemMessageRow(
|
||||
message: message,
|
||||
startsDay: showDayDivider,
|
||||
endsDay:
|
||||
nextMessage != null &&
|
||||
!isSameDay(
|
||||
message.createdAt,
|
||||
nextMessage.createdAt,
|
||||
),
|
||||
groupedMessages: entryGroup.length > 1
|
||||
? entryGroup
|
||||
.map((entry) => entry.message)
|
||||
@@ -977,6 +984,13 @@ class _MessageList extends HookConsumerWidget {
|
||||
else ...[
|
||||
_MessageBubble(
|
||||
message: message,
|
||||
startsDay: showDayDivider,
|
||||
endsDay:
|
||||
nextMessage != null &&
|
||||
!isSameDay(
|
||||
message.createdAt,
|
||||
nextMessage.createdAt,
|
||||
),
|
||||
showAuthor:
|
||||
showAuthor ||
|
||||
isRecentAgentReplyHandoff(
|
||||
|
||||
@@ -8,6 +8,8 @@ class _SystemMessageRow extends HookConsumerWidget {
|
||||
final List<TimelineMessage>? allMessages;
|
||||
final bool isMember;
|
||||
final bool isArchived;
|
||||
final bool startsDay;
|
||||
final bool endsDay;
|
||||
|
||||
const _SystemMessageRow({
|
||||
required this.message,
|
||||
@@ -17,6 +19,8 @@ class _SystemMessageRow extends HookConsumerWidget {
|
||||
this.allMessages,
|
||||
this.isMember = false,
|
||||
this.isArchived = false,
|
||||
this.startsDay = false,
|
||||
this.endsDay = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -136,37 +140,43 @@ class _SystemMessageRow extends HookConsumerWidget {
|
||||
systemEvent.type == SystemEventType.huddleStarted &&
|
||||
systemEvent.ephemeralChannelId != null;
|
||||
final actor = (systemEvent.actorPubkey ?? message.pubkey).toLowerCase();
|
||||
final ownActor = currentPubkey?.toLowerCase() == actor;
|
||||
final ownHuddle =
|
||||
ownActor &&
|
||||
(isHuddle || systemEvent.type == SystemEventType.huddleEnded);
|
||||
Widget withActor(Widget content) {
|
||||
if (!isHuddle) return content;
|
||||
return Row(
|
||||
key: ValueKey('huddle-activity-${message.id}'),
|
||||
textDirection: ownHuddle ? TextDirection.rtl : TextDirection.ltr,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Semantics(
|
||||
container: true,
|
||||
label: 'Open ${resolveLabel(actor)} profile',
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
onTap: () => showUserProfileSheet(
|
||||
context,
|
||||
actor,
|
||||
names: channelIdentityNamesProvider(channelId),
|
||||
),
|
||||
child: GestureDetector(
|
||||
excludeFromSemantics: true,
|
||||
if (!ownHuddle)
|
||||
Semantics(
|
||||
container: true,
|
||||
label: 'Open ${resolveLabel(actor)} profile',
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
onTap: () => showUserProfileSheet(
|
||||
context,
|
||||
actor,
|
||||
names: channelIdentityNamesProvider(channelId),
|
||||
),
|
||||
child: _UserAvatar(
|
||||
profile: userCache[actor],
|
||||
pubkey: actor,
|
||||
isAgent: userCache[actor]?.ownerPubkey != null,
|
||||
child: GestureDetector(
|
||||
excludeFromSemantics: true,
|
||||
onTap: () => showUserProfileSheet(
|
||||
context,
|
||||
actor,
|
||||
names: channelIdentityNamesProvider(channelId),
|
||||
),
|
||||
child: _UserAvatar(
|
||||
profile: userCache[actor],
|
||||
pubkey: actor,
|
||||
isAgent: userCache[actor]?.ownerPubkey != null,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: messageAvatarContentGap),
|
||||
if (!ownHuddle) const SizedBox(width: messageAvatarContentGap),
|
||||
Expanded(child: content),
|
||||
],
|
||||
);
|
||||
@@ -183,9 +193,14 @@ class _SystemMessageRow extends HookConsumerWidget {
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
highlightColor: context.colors.primary.withValues(alpha: 0.1),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
||||
padding: EdgeInsets.only(
|
||||
top: startsDay ? 0 : Grid.xxs,
|
||||
bottom: endsDay ? 0 : Grid.xxs,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: isHuddle
|
||||
crossAxisAlignment: ownHuddle
|
||||
? CrossAxisAlignment.end
|
||||
: isHuddle
|
||||
? CrossAxisAlignment.start
|
||||
: CrossAxisAlignment.center,
|
||||
children: [
|
||||
@@ -193,7 +208,9 @@ class _SystemMessageRow extends HookConsumerWidget {
|
||||
RepaintBoundary(
|
||||
key: spotlightKey,
|
||||
child: Align(
|
||||
alignment: isHuddle
|
||||
alignment: ownHuddle
|
||||
? Alignment.centerRight
|
||||
: isHuddle
|
||||
? Alignment.centerLeft
|
||||
: Alignment.center,
|
||||
child: Padding(
|
||||
@@ -244,25 +261,32 @@ class _SystemMessageRow extends HookConsumerWidget {
|
||||
overlap: reactions.isNotEmpty ? Grid.xs : 0,
|
||||
footer: reactions.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left:
|
||||
messageAvatarSize +
|
||||
messageAvatarContentGap +
|
||||
Grid.xxs,
|
||||
),
|
||||
child: ReactionRow(
|
||||
combined: true,
|
||||
messageId: message.id,
|
||||
channelId: channelId,
|
||||
reactions: reactions,
|
||||
onToggle: (emoji) =>
|
||||
toggleReaction(ref, message, emoji),
|
||||
showAddButton: isMember && !isArchived,
|
||||
onAddReaction: () => showAddReactionPicker(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
: Align(
|
||||
alignment: ownHuddle
|
||||
? Alignment.centerRight
|
||||
: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: ownHuddle
|
||||
? 0
|
||||
: messageAvatarSize +
|
||||
messageAvatarContentGap +
|
||||
Grid.xxs,
|
||||
right: ownHuddle ? Grid.xxs : 0,
|
||||
),
|
||||
child: ReactionRow(
|
||||
combined: true,
|
||||
messageId: message.id,
|
||||
channelId: channelId,
|
||||
reactions: reactions,
|
||||
onToggle: (emoji) =>
|
||||
toggleReaction(ref, message, emoji),
|
||||
showAddButton: isMember && !isArchived,
|
||||
onAddReaction: () => showAddReactionPicker(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -609,6 +633,7 @@ class _ThreadSummaryRow extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ReplySummaryLink(
|
||||
key: ValueKey('thread-summary-${message.id}'),
|
||||
mirrored: currentPubkey?.toLowerCase() == message.pubkey.toLowerCase(),
|
||||
summary: summary,
|
||||
hasReactions: message.reactions.isNotEmpty,
|
||||
onTap: () {
|
||||
|
||||
@@ -12,11 +12,16 @@ class _EphemeralBadge extends StatelessWidget {
|
||||
|
||||
return Tooltip(
|
||||
message: display.tooltipLabel,
|
||||
child: Icon(
|
||||
BuzzIcons.clockFading,
|
||||
key: Key('channel-ephemeral-${channel.id}'),
|
||||
size: 16,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
child: SizedBox(
|
||||
width: _kChannelIconSize,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
BuzzIcons.clockFading,
|
||||
key: Key('channel-ephemeral-${channel.id}'),
|
||||
size: 16,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -86,10 +86,15 @@ class _ChannelTile extends ConsumerWidget {
|
||||
],
|
||||
if (isMuted) ...[
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Icon(
|
||||
BuzzIcons.bellOff,
|
||||
size: 12,
|
||||
color: context.colors.onSurface.withValues(alpha: 0.4),
|
||||
SizedBox(
|
||||
width: _kChannelIconSize,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
BuzzIcons.bellOff,
|
||||
size: 12,
|
||||
color: context.colors.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (!channel.isMember && !channel.isDm)
|
||||
|
||||
@@ -138,10 +138,9 @@ class _CustomSectionHeader extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Builder(
|
||||
builder: (buttonContext) => IconButton(
|
||||
builder: (buttonContext) => _SectionOptionsButton(
|
||||
key: ValueKey('section-menu-${section.id}'),
|
||||
tooltip: '${section.name} options',
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: Icon(
|
||||
BuzzIcons.ellipsisVertical,
|
||||
size: _kChannelIconSize,
|
||||
@@ -209,7 +208,6 @@ class _CustomSectionHeader extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.quarter),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -495,10 +493,9 @@ class _SectionHeader extends StatelessWidget {
|
||||
),
|
||||
if (sortMode case final mode?) ...[
|
||||
Builder(
|
||||
builder: (buttonContext) => IconButton(
|
||||
builder: (buttonContext) => _SectionOptionsButton(
|
||||
key: ValueKey('sort-menu-$label'),
|
||||
tooltip: '$label options',
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: Icon(
|
||||
BuzzIcons.ellipsisVertical,
|
||||
size: _kChannelIconSize,
|
||||
@@ -509,15 +506,6 @@ class _SectionHeader extends StatelessWidget {
|
||||
context: buttonContext,
|
||||
width: 216,
|
||||
alignment: AnchoredPopoverAlignment.end,
|
||||
color: context.colors.surface,
|
||||
elevation: 4,
|
||||
shadowColor: context.colors.shadow.withValues(
|
||||
alpha: 0.18,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
side: BorderSide(color: context.colors.outline),
|
||||
),
|
||||
surfaceKey: ValueKey('sort-popover-$label'),
|
||||
items: _sortMenuItems(mode, showDivider: false),
|
||||
);
|
||||
@@ -529,7 +517,6 @@ class _SectionHeader extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.quarter),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -587,3 +574,35 @@ class _SectionChevron extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionOptionsButton extends StatelessWidget {
|
||||
const _SectionOptionsButton({
|
||||
super.key,
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
final String tooltip;
|
||||
final Widget icon;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Semantics(
|
||||
button: true,
|
||||
label: tooltip,
|
||||
onTap: onPressed,
|
||||
excludeSemantics: true,
|
||||
child: Tooltip(
|
||||
message: tooltip,
|
||||
child: SizedBox.square(
|
||||
dimension: 40,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
borderRadius: BorderRadius.circular(Radii.full),
|
||||
child: Align(alignment: Alignment.centerRight, child: icon),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -309,7 +309,7 @@ class _CameraCloseButton extends StatelessWidget {
|
||||
),
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
icon: Icon(BuzzIcons.arrowLeft, size: emphasized ? 24 : 18),
|
||||
icon: Icon(BuzzIcons.arrowLeft, size: 24),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ class _RecentPhotoGalleryPicker extends HookConsumerWidget {
|
||||
: () => _runComposerAction(onBack),
|
||||
tooltip: 'Back to attachment options',
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(BuzzIcons.arrowLeft, size: 20),
|
||||
icon: const Icon(BuzzIcons.arrowLeft, size: 24),
|
||||
),
|
||||
const SizedBox(width: Grid.quarter),
|
||||
Expanded(
|
||||
|
||||
@@ -28,11 +28,49 @@ class DayDivider extends StatelessWidget {
|
||||
: const Duration(milliseconds: 120),
|
||||
curve: Curves.easeOutCubic,
|
||||
opacity: isSticky ? 0 : 1,
|
||||
child: Text(
|
||||
label,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant.withValues(alpha: 0.72),
|
||||
fontWeight: FontWeight.w500,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Divider(
|
||||
color: context.colors.outlineVariant,
|
||||
thickness: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xs),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: (constraints.maxWidth - Grid.xs * 2) * 0.6,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (dayTimestamp != null) ...[
|
||||
_CalendarDateTile(timestamp: dayTimestamp!),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
],
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant.withValues(
|
||||
alpha: 0.72,
|
||||
),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xs),
|
||||
Expanded(
|
||||
child: Divider(
|
||||
color: context.colors.outlineVariant,
|
||||
thickness: 0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -44,7 +82,7 @@ class DayDivider extends StatelessWidget {
|
||||
final activeTimestamp = stickyDayTimestamp;
|
||||
final timestamp = dayTimestamp;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.xxs + Grid.quarter),
|
||||
padding: const EdgeInsets.symmetric(vertical: Grid.gutter),
|
||||
child: Center(
|
||||
child: activeTimestamp == null || timestamp == null
|
||||
? _buildOpacity(context, isSticky: false)
|
||||
@@ -59,3 +97,50 @@ class DayDivider extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decorative desktop calendar artwork; the adjacent label owns semantics.
|
||||
class _CalendarDateTile extends StatelessWidget {
|
||||
const _CalendarDateTile({required this.timestamp});
|
||||
|
||||
final int timestamp;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final day = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000).day;
|
||||
return ExcludeSemantics(
|
||||
child: Container(
|
||||
key: ValueKey('day-calendar-$timestamp'),
|
||||
width: Grid.gutter,
|
||||
height: Grid.gutter,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(Radii.sm),
|
||||
border: Border.all(color: context.colors.outlineVariant, width: 0.5),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: Grid.gutter * 7 / 24,
|
||||
width: double.infinity,
|
||||
child: ColoredBox(color: Color(0xFFFF0000)),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$day',
|
||||
textScaler: TextScaler.noScaling,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF111111),
|
||||
fontSize: Grid.gutter / 2,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ class _MediaViewerCircleButton extends StatelessWidget {
|
||||
child: IconButton(
|
||||
onPressed: onPressed,
|
||||
tooltip: tooltip,
|
||||
icon: Icon(icon, color: Colors.white, size: 20),
|
||||
icon: Icon(icon, color: Colors.white, size: 24),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -101,6 +101,9 @@ class MessageContent extends HookConsumerWidget {
|
||||
/// Distinguishes messages authored by the signed-in identity.
|
||||
final bool ownMessage;
|
||||
|
||||
/// Lets an adjacent day divider own spacing after the final text segment.
|
||||
final bool trimTrailingSpacing;
|
||||
|
||||
/// Marks the final message in a sender run for rounded corners and dots.
|
||||
final bool bubbleEnd;
|
||||
|
||||
@@ -171,6 +174,7 @@ class MessageContent extends HookConsumerWidget {
|
||||
required this.content,
|
||||
this.bubble = false,
|
||||
this.ownMessage = false,
|
||||
this.trimTrailingSpacing = false,
|
||||
this.bubbleEnd = true,
|
||||
this.bubbleStart = true,
|
||||
this.hasReactions = false,
|
||||
@@ -395,7 +399,9 @@ class MessageContent extends HookConsumerWidget {
|
||||
: const <({String text, bool attachment})>[];
|
||||
final markdown = bubble
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: ownMessage
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final (index, segment) in segments.indexed)
|
||||
@@ -413,7 +419,14 @@ class MessageContent extends HookConsumerWidget {
|
||||
else
|
||||
Padding(
|
||||
key: ValueKey('message-text-bubble-$index'),
|
||||
padding: const EdgeInsets.only(bottom: Grid.quarter),
|
||||
padding: EdgeInsets.only(
|
||||
bottom:
|
||||
trimTrailingSpacing &&
|
||||
trailingGallery == null &&
|
||||
index == segments.length - 1
|
||||
? 0
|
||||
: Grid.quarter,
|
||||
),
|
||||
child: LocalSendBubbleSurface(
|
||||
color: ownMessage
|
||||
? chatColors.outgoing
|
||||
@@ -479,7 +492,9 @@ class MessageContent extends HookConsumerWidget {
|
||||
if (trailingGallery == null) return markdown;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: ownMessage
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (trailingGallery.content.trim().isNotEmpty) markdown,
|
||||
@@ -493,6 +508,7 @@ class MessageContent extends HookConsumerWidget {
|
||||
trailingGallery.items.map((item) => item.url).join('\u0000'),
|
||||
),
|
||||
items: trailingGallery.items,
|
||||
mirrored: ownMessage,
|
||||
leadingOverflow: mediaCarouselLeadingOverflow,
|
||||
trailingOverflow: mediaCarouselTrailingOverflow,
|
||||
onReply: onMediaReply,
|
||||
|
||||
@@ -11,21 +11,24 @@ class _MessageGalleryPrecache extends HookWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final prefetch = useMemoized(_GalleryPrecache.new, const []);
|
||||
useEffect(() => prefetch.dispose, [prefetch]);
|
||||
final providerSignature = Object.hashAll(providers);
|
||||
useEffect(() {
|
||||
var cancelled = false;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (cancelled || !context.mounted) {
|
||||
return;
|
||||
}
|
||||
for (var index = focusedIndex - 2; index <= focusedIndex + 2; index++) {
|
||||
if (index < 0 || index >= providers.length) {
|
||||
continue;
|
||||
}
|
||||
unawaited(
|
||||
precacheImage(providers[index], context, onError: (_, _) {}),
|
||||
);
|
||||
}
|
||||
if (cancelled || !context.mounted) return;
|
||||
// Prepare a five-photo strip immediately, then keep four ahead and two
|
||||
// behind the current page. Two workers bound speculative network work.
|
||||
final indices = [
|
||||
for (var i = focusedIndex; i <= focusedIndex + 4; i++) i,
|
||||
focusedIndex - 1,
|
||||
focusedIndex - 2,
|
||||
];
|
||||
prefetch.update(context, [
|
||||
for (final i in indices)
|
||||
if (i >= 0 && i < providers.length) providers[i],
|
||||
]);
|
||||
});
|
||||
return () => cancelled = true;
|
||||
}, [focusedIndex, providerSignature]);
|
||||
@@ -33,8 +36,50 @@ class _MessageGalleryPrecache extends HookWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _GalleryPrecache {
|
||||
BuildContext? _context;
|
||||
List<ImageProvider<Object>> _window = [];
|
||||
final _ready = <ImageProvider<Object>>{};
|
||||
final _loading = <ImageProvider<Object>>{};
|
||||
|
||||
void update(BuildContext context, List<ImageProvider<Object>> window) {
|
||||
_context = context;
|
||||
_window = window;
|
||||
_ready.removeWhere((provider) => !window.contains(provider));
|
||||
_drain();
|
||||
}
|
||||
|
||||
void _drain() {
|
||||
final context = _context;
|
||||
if (context == null || !context.mounted) return;
|
||||
for (final provider in _window) {
|
||||
if (_loading.length >= 2) break;
|
||||
if (_ready.contains(provider) || _loading.contains(provider)) continue;
|
||||
_loading.add(provider);
|
||||
unawaited(_load(context, provider));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _load(
|
||||
BuildContext context,
|
||||
ImageProvider<Object> provider,
|
||||
) async {
|
||||
await precacheImage(provider, context, onError: (_, _) {});
|
||||
_loading.remove(provider);
|
||||
if (_window.contains(provider)) _ready.add(provider);
|
||||
_drain();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_context = null;
|
||||
_window = [];
|
||||
_ready.clear();
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageImageCarousel extends HookConsumerWidget {
|
||||
final List<MessageGalleryItem> items;
|
||||
final bool mirrored;
|
||||
final double leadingOverflow;
|
||||
final double trailingOverflow;
|
||||
final VoidCallback? onReply;
|
||||
@@ -43,6 +88,7 @@ class _MessageImageCarousel extends HookConsumerWidget {
|
||||
const _MessageImageCarousel({
|
||||
super.key,
|
||||
required this.items,
|
||||
required this.mirrored,
|
||||
required this.leadingOverflow,
|
||||
required this.trailingOverflow,
|
||||
required this.onReply,
|
||||
@@ -51,6 +97,7 @@ class _MessageImageCarousel extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final viewerContext = context;
|
||||
final itemSignature = items.map((item) => item.url).join('\u0000');
|
||||
final heroTags = useMemoized(
|
||||
() => [for (var index = 0; index < items.length; index++) Object()],
|
||||
@@ -62,211 +109,223 @@ class _MessageImageCarousel extends HookConsumerWidget {
|
||||
|
||||
return MessageGalleryFrame(
|
||||
count: items.length,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final contentWidth = constraints.hasBoundedWidth
|
||||
? constraints.maxWidth
|
||||
: messageMediaMaxWidth(context);
|
||||
final carouselWidth =
|
||||
contentWidth + leadingOverflow + trailingOverflow;
|
||||
final leadingExtent = leadingOverflow;
|
||||
final isLeftToRight = Directionality.of(context) == TextDirection.ltr;
|
||||
final itemTrailingPaddings = [
|
||||
for (var index = 0; index < items.length; index++)
|
||||
index == items.length - 1 ? Grid.gutter : Grid.half,
|
||||
];
|
||||
final previewDecodeWidths = [
|
||||
for (var index = 0; index < items.length; index++)
|
||||
math.max(1.0, carouselWidth * 0.9 - itemTrailingPaddings[index]),
|
||||
];
|
||||
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
|
||||
final previewProviders = [
|
||||
for (var index = 0; index < items.length; index++)
|
||||
ResizeImage.resizeIfNeeded(
|
||||
(previewDecodeWidths[index] * devicePixelRatio).ceil(),
|
||||
null,
|
||||
MediaImageProvider(
|
||||
url: items[index].url,
|
||||
auth: mediaAuth,
|
||||
client: mediaClient,
|
||||
mirrored: mirrored,
|
||||
child: Directionality(
|
||||
textDirection: mirrored ? TextDirection.rtl : TextDirection.ltr,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final contentWidth = constraints.hasBoundedWidth
|
||||
? constraints.maxWidth
|
||||
: messageMediaMaxWidth(context);
|
||||
final carouselWidth =
|
||||
contentWidth + leadingOverflow + trailingOverflow;
|
||||
// Directionality mirrors both the starting edge and trailing overflow.
|
||||
final leadingExtent = leadingOverflow;
|
||||
final isLeftToRight =
|
||||
Directionality.of(context) == TextDirection.ltr;
|
||||
final itemTrailingPaddings = [
|
||||
for (var index = 0; index < items.length; index++)
|
||||
index == items.length - 1 ? Grid.gutter : Grid.half,
|
||||
];
|
||||
final previewDecodeWidths = [
|
||||
for (var index = 0; index < items.length; index++)
|
||||
math.max(
|
||||
1.0,
|
||||
carouselWidth * 0.9 - itemTrailingPaddings[index],
|
||||
),
|
||||
),
|
||||
];
|
||||
final viewerItems = [
|
||||
for (var index = 0; index < items.length; index++)
|
||||
MediaViewerImage(
|
||||
url: items[index].url,
|
||||
heroTag: heroTags[index],
|
||||
semanticLabel: items[index].semanticLabel,
|
||||
previewDecodeWidth: previewDecodeWidths[index],
|
||||
aspectRatio: items[index].aspectRatio,
|
||||
preloadProvider: previewProviders[index],
|
||||
),
|
||||
];
|
||||
final nativeGutter = math.max(
|
||||
0.0,
|
||||
MediaQuery.sizeOf(context).width - carouselWidth,
|
||||
);
|
||||
final carousel = SizedBox(
|
||||
key: const ValueKey('message-media-carousel'),
|
||||
width: carouselWidth,
|
||||
height: messageMediaCarouselHeight,
|
||||
child: !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS
|
||||
? OverflowBox(
|
||||
minWidth: carouselWidth + 2 * nativeGutter,
|
||||
maxWidth: carouselWidth + 2 * nativeGutter,
|
||||
child: IosMediaCarousel(
|
||||
key: ValueKey((itemSignature, mediaAuth)),
|
||||
providers: previewProviders,
|
||||
labels: [for (final item in items) item.semanticLabel],
|
||||
];
|
||||
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
|
||||
final previewProviders = [
|
||||
for (var index = 0; index < items.length; index++)
|
||||
ResizeImage.resizeIfNeeded(
|
||||
(previewDecodeWidths[index] * devicePixelRatio).ceil(),
|
||||
null,
|
||||
MediaImageProvider(
|
||||
url: items[index].url,
|
||||
auth: mediaAuth,
|
||||
client: mediaClient,
|
||||
),
|
||||
),
|
||||
];
|
||||
final viewerItems = [
|
||||
for (var index = 0; index < items.length; index++)
|
||||
MediaViewerImage(
|
||||
url: items[index].url,
|
||||
heroTag: heroTags[index],
|
||||
semanticLabel: items[index].semanticLabel,
|
||||
previewDecodeWidth: previewDecodeWidths[index],
|
||||
aspectRatio: items[index].aspectRatio,
|
||||
preloadProvider: previewProviders[index],
|
||||
),
|
||||
];
|
||||
final nativeGutter = math.max(
|
||||
0.0,
|
||||
MediaQuery.sizeOf(context).width - carouselWidth,
|
||||
);
|
||||
final carousel = SizedBox(
|
||||
key: const ValueKey('message-media-carousel'),
|
||||
width: carouselWidth,
|
||||
height: messageMediaCarouselHeight,
|
||||
child: !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS
|
||||
? OverflowBox(
|
||||
minWidth: carouselWidth + 2 * nativeGutter,
|
||||
maxWidth: carouselWidth + 2 * nativeGutter,
|
||||
child: IosMediaCarousel(
|
||||
key: ValueKey((itemSignature, mediaAuth)),
|
||||
providers: previewProviders,
|
||||
labels: [for (final item in items) item.semanticLabel],
|
||||
width: carouselWidth,
|
||||
gutter: nativeGutter,
|
||||
gap: Grid.half,
|
||||
trailingPadding: Grid.gutter,
|
||||
radius:
|
||||
(MessageAttachmentCorners.of(context) ??
|
||||
BorderRadius.circular(Radii.md))
|
||||
.topLeft
|
||||
.x,
|
||||
onScroll: (offset) => MessageGalleryScrollNotification(
|
||||
offset,
|
||||
).dispatch(context),
|
||||
onPageChanged: (index) {
|
||||
if (index != currentIndex.value) {
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
}
|
||||
currentIndex.value = index;
|
||||
},
|
||||
onOpen: (index) => openImageViewer(
|
||||
viewerContext,
|
||||
imageUrl: items[index].url,
|
||||
heroTag: heroTags[index],
|
||||
semanticLabel: items[index].semanticLabel,
|
||||
previewDecodeWidth: previewDecodeWidths[index],
|
||||
aspectRatio: items[index].aspectRatio,
|
||||
galleryItems: viewerItems,
|
||||
initialIndex: index,
|
||||
onReply: onReply,
|
||||
onMore: onMore,
|
||||
),
|
||||
),
|
||||
)
|
||||
: _MessageCarouselViewport(
|
||||
width: carouselWidth,
|
||||
gutter: nativeGutter,
|
||||
gap: Grid.half,
|
||||
trailingPadding: Grid.gutter,
|
||||
radius:
|
||||
(MessageAttachmentCorners.of(context) ??
|
||||
BorderRadius.circular(Radii.md))
|
||||
.topLeft
|
||||
.x,
|
||||
onScroll: (offset) => MessageGalleryScrollNotification(
|
||||
offset,
|
||||
).dispatch(context),
|
||||
currentIndex: currentIndex.value,
|
||||
itemCount: items.length,
|
||||
onPageChanged: (index) {
|
||||
if (index != currentIndex.value) {
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
}
|
||||
currentIndex.value = index;
|
||||
},
|
||||
onOpen: (index) => openImageViewer(
|
||||
context,
|
||||
imageUrl: items[index].url,
|
||||
heroTag: heroTags[index],
|
||||
semanticLabel: items[index].semanticLabel,
|
||||
previewDecodeWidth: previewDecodeWidths[index],
|
||||
aspectRatio: items[index].aspectRatio,
|
||||
galleryItems: viewerItems,
|
||||
initialIndex: index,
|
||||
onReply: onReply,
|
||||
onMore: onMore,
|
||||
),
|
||||
),
|
||||
)
|
||||
: _MessageCarouselViewport(
|
||||
width: carouselWidth,
|
||||
currentIndex: currentIndex.value,
|
||||
itemCount: items.length,
|
||||
onPageChanged: (index) {
|
||||
if (index != currentIndex.value) {
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
}
|
||||
currentIndex.value = index;
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return Padding(
|
||||
key: ValueKey(
|
||||
'message-media-carousel-page:${item.url}',
|
||||
),
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
end: itemTrailingPaddings[index],
|
||||
),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
label: 'Open ${item.semanticLabel}',
|
||||
child: GestureDetector(
|
||||
key: ValueKey(
|
||||
'message-media-carousel-item:${item.url}',
|
||||
),
|
||||
onTap: () => openImageViewer(
|
||||
context,
|
||||
imageUrl: item.url,
|
||||
heroTag: heroTags[index],
|
||||
semanticLabel: item.semanticLabel,
|
||||
previewDecodeWidth: previewDecodeWidths[index],
|
||||
aspectRatio: item.aspectRatio,
|
||||
galleryItems: viewerItems,
|
||||
initialIndex: index,
|
||||
onReply: onReply,
|
||||
onMore: onMore,
|
||||
),
|
||||
child: Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius:
|
||||
MessageAttachmentCorners.of(context) ??
|
||||
BorderRadius.circular(Radii.md),
|
||||
border: Border.all(
|
||||
color: context.colors.outlineVariant,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final item = items[index];
|
||||
return Padding(
|
||||
key: ValueKey(
|
||||
'message-media-carousel-page:${item.url}',
|
||||
),
|
||||
padding: EdgeInsetsDirectional.only(
|
||||
end: itemTrailingPaddings[index],
|
||||
),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
excludeSemantics: true,
|
||||
label: 'Open ${item.semanticLabel}',
|
||||
child: GestureDetector(
|
||||
key: ValueKey(
|
||||
'message-media-carousel-item:${item.url}',
|
||||
),
|
||||
child: MediaViewerHero(
|
||||
tag: heroTags[index],
|
||||
child: ClipRRect(
|
||||
onTap: () => openImageViewer(
|
||||
viewerContext,
|
||||
imageUrl: item.url,
|
||||
heroTag: heroTags[index],
|
||||
semanticLabel: item.semanticLabel,
|
||||
previewDecodeWidth: previewDecodeWidths[index],
|
||||
aspectRatio: item.aspectRatio,
|
||||
galleryItems: viewerItems,
|
||||
initialIndex: index,
|
||||
onReply: onReply,
|
||||
onMore: onMore,
|
||||
),
|
||||
child: Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
borderRadius:
|
||||
MessageAttachmentCorners.of(context) ??
|
||||
BorderRadius.circular(Radii.md),
|
||||
child: MediaImage(
|
||||
url: item.url,
|
||||
decodeWidth: previewDecodeWidths[index],
|
||||
fit: BoxFit.cover,
|
||||
semanticLabel: item.semanticLabel,
|
||||
frameBuilder:
|
||||
(context, child, frame, synchronous) =>
|
||||
frame != null || synchronous
|
||||
? child
|
||||
: const MediaLoadingPlaceholder(
|
||||
label: 'Loading image',
|
||||
),
|
||||
errorBuilder: (_, _, _) =>
|
||||
const _MediaPreviewFallback(
|
||||
icon: BuzzIcons.imageOff,
|
||||
label: 'Image unavailable',
|
||||
),
|
||||
border: Border.all(
|
||||
color: context.colors.outlineVariant,
|
||||
),
|
||||
),
|
||||
child: MediaViewerHero(
|
||||
tag: heroTags[index],
|
||||
child: ClipRRect(
|
||||
borderRadius:
|
||||
MessageAttachmentCorners.of(context) ??
|
||||
BorderRadius.circular(Radii.md),
|
||||
child: MediaImage(
|
||||
url: item.url,
|
||||
decodeWidth: previewDecodeWidths[index],
|
||||
fit: BoxFit.cover,
|
||||
semanticLabel: item.semanticLabel,
|
||||
frameBuilder:
|
||||
(
|
||||
context,
|
||||
child,
|
||||
frame,
|
||||
synchronous,
|
||||
) => frame != null || synchronous
|
||||
? child
|
||||
: const MediaLoadingPlaceholder(
|
||||
label: 'Loading image',
|
||||
),
|
||||
errorBuilder: (_, _, _) =>
|
||||
const _MediaPreviewFallback(
|
||||
icon: BuzzIcons.imageOff,
|
||||
label: 'Image unavailable',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final carouselSurface = Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
carousel,
|
||||
if (kIsWeb || defaultTargetPlatform != TargetPlatform.iOS)
|
||||
final carouselSurface = Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
carousel,
|
||||
_MessageGalleryPrecache(
|
||||
providers: previewProviders,
|
||||
focusedIndex: currentIndex.value,
|
||||
),
|
||||
],
|
||||
);
|
||||
],
|
||||
);
|
||||
|
||||
if (leadingExtent <= 0 && trailingOverflow <= 0) {
|
||||
return carouselSurface;
|
||||
}
|
||||
return SizedBox(
|
||||
width: contentWidth,
|
||||
height: messageMediaCarouselHeight,
|
||||
child: OverflowBox(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
minWidth: carouselWidth,
|
||||
maxWidth: carouselWidth,
|
||||
child: Transform.translate(
|
||||
offset: Offset(
|
||||
isLeftToRight ? -leadingExtent : leadingExtent,
|
||||
0,
|
||||
if (leadingExtent <= 0 && trailingOverflow <= 0) {
|
||||
return carouselSurface;
|
||||
}
|
||||
return SizedBox(
|
||||
width: contentWidth,
|
||||
height: messageMediaCarouselHeight,
|
||||
child: OverflowBox(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
minWidth: carouselWidth,
|
||||
maxWidth: carouselWidth,
|
||||
child: Transform.translate(
|
||||
offset: Offset(
|
||||
isLeftToRight ? -leadingExtent : leadingExtent,
|
||||
0,
|
||||
),
|
||||
child: carouselSurface,
|
||||
),
|
||||
child: carouselSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,11 +11,13 @@ class MessageGalleryFrame extends StatelessWidget {
|
||||
final int count;
|
||||
final Widget child;
|
||||
final bool loading;
|
||||
final bool mirrored;
|
||||
|
||||
const MessageGalleryFrame({
|
||||
required this.count,
|
||||
required this.child,
|
||||
this.loading = false,
|
||||
this.mirrored = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -36,11 +38,16 @@ class MessageGalleryFrame extends StatelessWidget {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: Grid.half),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: mirrored
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: Grid.xs),
|
||||
padding: EdgeInsets.only(
|
||||
left: mirrored ? 0 : Grid.xs,
|
||||
right: mirrored ? Grid.xs : 0,
|
||||
),
|
||||
child: loading
|
||||
? ExcludeSemantics(
|
||||
child: Stack(
|
||||
|
||||
@@ -13,12 +13,16 @@ class ReplySummaryLink extends ConsumerWidget {
|
||||
required this.summary,
|
||||
required this.onTap,
|
||||
this.hasReactions = false,
|
||||
this.mirrored = false,
|
||||
});
|
||||
|
||||
final ThreadSummary summary;
|
||||
final VoidCallback onTap;
|
||||
final bool hasReactions;
|
||||
|
||||
/// Places the connector on the outgoing edge without reversing its contents.
|
||||
final bool mirrored;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final participants = summary.participantPubkeys.take(3).toList();
|
||||
@@ -28,7 +32,10 @@ class ReplySummaryLink extends ConsumerWidget {
|
||||
: '${summary.replyCount}${summary.isLowerBound ? '+' : ''} ${summary.replyCount == 1 ? 'reply' : 'replies'}';
|
||||
final connectorTop = hasReactions ? -Grid.half : Grid.half;
|
||||
return Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: Grid.xxs),
|
||||
padding: EdgeInsets.only(
|
||||
left: mirrored ? 0 : Grid.xxs,
|
||||
right: mirrored ? Grid.xxs : 0,
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
@@ -38,14 +45,17 @@ class ReplySummaryLink extends ConsumerWidget {
|
||||
child: CustomPaint(
|
||||
painter: _ReplyConnector(
|
||||
context.colors.outlineVariant,
|
||||
Directionality.of(context),
|
||||
mirrored ? TextDirection.rtl : TextDirection.ltr,
|
||||
connectorTop,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(start: Grid.twelve),
|
||||
padding: EdgeInsets.only(
|
||||
left: mirrored ? 0 : Grid.twelve,
|
||||
right: mirrored ? Grid.twelve : 0,
|
||||
),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: 'View thread: $label',
|
||||
|
||||
@@ -97,6 +97,7 @@ class _ThreadMessageList extends HookWidget {
|
||||
),
|
||||
_ThreadMessage(
|
||||
message: head,
|
||||
startsDay: true,
|
||||
channelNames: channelNames,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
@@ -300,6 +301,13 @@ class _ThreadMessageList extends HookWidget {
|
||||
),
|
||||
_ThreadMessage(
|
||||
message: reply,
|
||||
startsDay: showDayDivider,
|
||||
endsDay:
|
||||
nextReply != null &&
|
||||
!isSameDay(
|
||||
reply.createdAt,
|
||||
nextReply.createdAt,
|
||||
),
|
||||
channelNames: channelNames,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
|
||||
@@ -23,29 +23,41 @@ class _NestedThreadSummaryRow extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: messageAvatarSize + messageAvatarContentGap + Grid.xxs,
|
||||
bottom: Grid.xxs,
|
||||
),
|
||||
child: ReplySummaryLink(
|
||||
key: ValueKey('nested-thread-summary-${replyMessage.id}'),
|
||||
summary: summary,
|
||||
hasReactions: replyMessage.reactions.isNotEmpty,
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: replyMessage,
|
||||
allMessages: allMessages,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
final ownMessage =
|
||||
currentPubkey?.toLowerCase() == replyMessage.pubkey.toLowerCase();
|
||||
return Align(
|
||||
alignment: ownMessage ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Directionality(
|
||||
textDirection: Directionality.of(context),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: ownMessage
|
||||
? 0
|
||||
: messageAvatarSize + messageAvatarContentGap + Grid.xxs,
|
||||
right: ownMessage ? Grid.xxs : 0,
|
||||
bottom: Grid.xxs,
|
||||
),
|
||||
child: ReplySummaryLink(
|
||||
key: ValueKey('nested-thread-summary-${replyMessage.id}'),
|
||||
summary: summary,
|
||||
mirrored: ownMessage,
|
||||
hasReactions: replyMessage.reactions.isNotEmpty,
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ThreadDetailPage(
|
||||
threadHead: replyMessage,
|
||||
allMessages: allMessages,
|
||||
channelId: channelId,
|
||||
currentPubkey: currentPubkey,
|
||||
isMember: isMember,
|
||||
isArchived: isArchived,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ class _ThreadMessage extends HookConsumerWidget {
|
||||
final String? currentPubkey;
|
||||
final bool showAuthor;
|
||||
final bool showAvatar;
|
||||
final bool startsDay;
|
||||
final bool endsDay;
|
||||
final bool isHighlighted;
|
||||
final List<TimelineMessage>? allMessages;
|
||||
final bool isMember;
|
||||
@@ -25,6 +27,8 @@ class _ThreadMessage extends HookConsumerWidget {
|
||||
required this.currentPubkey,
|
||||
required this.showAuthor,
|
||||
this.showAvatar = true,
|
||||
this.startsDay = false,
|
||||
this.endsDay = false,
|
||||
this.isHighlighted = false,
|
||||
this.allMessages,
|
||||
this.isMember = false,
|
||||
@@ -39,6 +43,7 @@ class _ThreadMessage extends HookConsumerWidget {
|
||||
final messageSnapshotKey = useMemoized(GlobalKey.new, const []);
|
||||
final mediaOffset = useMessageMediaOffset(message.content, message.tags);
|
||||
final pk = message.pubkey.toLowerCase();
|
||||
final ownMessage = currentPubkey?.toLowerCase() == pk;
|
||||
final profile =
|
||||
ref.watch(userCacheProvider.select((cache) => cache[pk])) ??
|
||||
ref.read(userCacheProvider.notifier).get(pk);
|
||||
@@ -134,7 +139,7 @@ class _ThreadMessage extends HookConsumerWidget {
|
||||
return true;
|
||||
},
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: showAuthor ? Grid.xs : 0),
|
||||
padding: EdgeInsets.only(top: showAuthor && !startsDay ? Grid.xs : 0),
|
||||
child: DecoratedBox(
|
||||
key: ValueKey('thread-message-${message.id}'),
|
||||
decoration: BoxDecoration(
|
||||
@@ -156,31 +161,40 @@ class _ThreadMessage extends HookConsumerWidget {
|
||||
snapshotKey: messageSnapshotKey,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: showAvatar ? Grid.quarter : Grid.quarter / 2,
|
||||
bottom: endsDay
|
||||
? 0
|
||||
: (showAvatar ? Grid.quarter : Grid.quarter / 2),
|
||||
),
|
||||
child: MessageFooterLayout(
|
||||
animateSize: true,
|
||||
overlap: message.reactions.isNotEmpty ? Grid.xs : 0,
|
||||
footer: (isThreadHead || message.reactions.isNotEmpty)
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left:
|
||||
messageAvatarSize +
|
||||
messageAvatarContentGap +
|
||||
Grid.xxs,
|
||||
),
|
||||
child: ReactionRow(
|
||||
combined: true,
|
||||
messageId: message.id,
|
||||
channelId: channelId,
|
||||
reactions: message.reactions,
|
||||
onToggle: (emoji) =>
|
||||
toggleReaction(ref, message, emoji),
|
||||
showAddButton: isMember && !isArchived,
|
||||
onAddReaction: () => showAddReactionPicker(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
? Align(
|
||||
alignment: ownMessage
|
||||
? Alignment.centerRight
|
||||
: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: ownMessage ? Grid.xxs : 0,
|
||||
left: ownMessage
|
||||
? 0
|
||||
: messageAvatarSize +
|
||||
messageAvatarContentGap +
|
||||
Grid.xxs,
|
||||
),
|
||||
child: ReactionRow(
|
||||
combined: true,
|
||||
messageId: message.id,
|
||||
channelId: channelId,
|
||||
reactions: message.reactions,
|
||||
onToggle: (emoji) =>
|
||||
toggleReaction(ref, message, emoji),
|
||||
showAddButton: isMember && !isArchived,
|
||||
onAddReaction: () => showAddReactionPicker(
|
||||
context: context,
|
||||
ref: ref,
|
||||
message: message,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -191,29 +205,41 @@ class _ThreadMessage extends HookConsumerWidget {
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Row(
|
||||
textDirection: ownMessage
|
||||
? TextDirection.rtl
|
||||
: TextDirection.ltr,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
const SizedBox(width: messageAvatarSize),
|
||||
const SizedBox(width: messageAvatarContentGap),
|
||||
if (!ownMessage)
|
||||
const SizedBox(width: messageAvatarSize),
|
||||
if (!ownMessage)
|
||||
const SizedBox(width: messageAvatarContentGap),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: showAuthor ? Grid.half : 0,
|
||||
top: showAuthor && !startsDay ? Grid.half : 0,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: ownMessage
|
||||
? CrossAxisAlignment.end
|
||||
: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showAuthor)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: Grid.quarter,
|
||||
left: Grid.xs,
|
||||
left: ownMessage ? 0 : Grid.xs,
|
||||
right: ownMessage ? Grid.xs : 0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: MessageAuthorMeta(
|
||||
showTimestamp: true,
|
||||
reversed: false,
|
||||
alignment: ownMessage
|
||||
? MainAxisAlignment.end
|
||||
: MainAxisAlignment.start,
|
||||
nameStyle:
|
||||
messageUsernameTextStyle
|
||||
.copyWith(
|
||||
@@ -290,8 +316,8 @@ class _ThreadMessage extends HookConsumerWidget {
|
||||
message.reactions.isNotEmpty,
|
||||
bubbleEnd: showAvatar,
|
||||
bubbleStart: showAuthor,
|
||||
ownMessage:
|
||||
currentPubkey?.toLowerCase() == pk,
|
||||
ownMessage: ownMessage,
|
||||
trimTrailingSpacing: endsDay,
|
||||
mentionNames: resolvedMentionNames,
|
||||
mentionLabels: mentionLabels,
|
||||
agentMentionPubkeys: agentMentionPubkeys,
|
||||
@@ -362,9 +388,10 @@ class _ThreadMessage extends HookConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (showAvatar)
|
||||
if (showAvatar && !ownMessage)
|
||||
Positioned(
|
||||
left: 0,
|
||||
left: ownMessage ? null : 0,
|
||||
right: ownMessage ? 0 : null,
|
||||
bottom: 0,
|
||||
child: MessageMediaAvatar(
|
||||
offset: mediaOffset,
|
||||
|
||||
@@ -96,7 +96,7 @@ class ProfileTextEditSheet extends HookWidget {
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
),
|
||||
),
|
||||
icon: const Icon(BuzzIcons.x, size: 22),
|
||||
icon: const Icon(BuzzIcons.x, size: 24),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -305,7 +305,7 @@ class _ThemePreviewCloseButton extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
),
|
||||
),
|
||||
icon: const Icon(BuzzIcons.x, size: 22),
|
||||
icon: const Icon(BuzzIcons.x, size: 24),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ class _SheetCloseButton extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(Radii.dialog),
|
||||
),
|
||||
),
|
||||
icon: const Icon(BuzzIcons.x, size: 22),
|
||||
icon: const Icon(BuzzIcons.x, size: 24),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -258,7 +258,7 @@ class IosGlassNavigationButton extends HookWidget {
|
||||
},
|
||||
size: icon == IosGlassNavigationIcon.shutter
|
||||
? controlSize * 0.72
|
||||
: 22,
|
||||
: 24,
|
||||
color: icon == IosGlassNavigationIcon.colorSwatch
|
||||
? swatchColor
|
||||
: effectiveForeground,
|
||||
|
||||
@@ -43,6 +43,12 @@ class MessageAuthorMeta extends StatelessWidget {
|
||||
/// Base text style for [timestamp], with [metadataColor] applied.
|
||||
final TextStyle timestampStyle;
|
||||
|
||||
/// Mirrors metadata order without changing text reading direction.
|
||||
final bool reversed;
|
||||
|
||||
/// Positions the metadata within the available message width.
|
||||
final MainAxisAlignment alignment;
|
||||
|
||||
/// Creates an inline author row with optional username and tap handling.
|
||||
const MessageAuthorMeta({
|
||||
super.key,
|
||||
@@ -51,6 +57,8 @@ class MessageAuthorMeta extends StatelessWidget {
|
||||
required this.nameColor,
|
||||
required this.metadataColor,
|
||||
this.username,
|
||||
this.alignment = MainAxisAlignment.start,
|
||||
this.reversed = false,
|
||||
this.showTimestamp = true,
|
||||
this.onAuthorTap,
|
||||
this.displayNameKey,
|
||||
@@ -91,37 +99,39 @@ class MessageAuthorMeta extends StatelessWidget {
|
||||
? constraints.maxWidth / (showUsername ? 3 : 2)
|
||||
: double.infinity;
|
||||
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Flexible(child: authorName),
|
||||
if (showUsername) ...[
|
||||
const SizedBox(width: Grid.half),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: metadataMaxWidth),
|
||||
child: Text(
|
||||
normalizedUsername,
|
||||
key: usernameKey,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: resolvedMetadataStyle,
|
||||
),
|
||||
final children = <Widget>[
|
||||
Flexible(child: authorName),
|
||||
if (showUsername) ...[
|
||||
const SizedBox(width: Grid.half),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: metadataMaxWidth),
|
||||
child: Text(
|
||||
normalizedUsername,
|
||||
key: usernameKey,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: resolvedMetadataStyle,
|
||||
),
|
||||
],
|
||||
if (showTimestamp) ...[
|
||||
const SizedBox(width: Grid.xxs),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: metadataMaxWidth),
|
||||
child: Text(
|
||||
timestamp,
|
||||
key: timestampKey,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: resolvedTimestampStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (showTimestamp) ...[
|
||||
const SizedBox(width: Grid.xxs),
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: metadataMaxWidth),
|
||||
child: Text(
|
||||
timestamp,
|
||||
key: timestampKey,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: resolvedTimestampStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
];
|
||||
return Row(
|
||||
mainAxisAlignment: alignment,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: reversed ? children.reversed.toList() : children,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -5679,7 +5679,7 @@ void main() {
|
||||
for (final platform in [TargetPlatform.iOS, TargetPlatform.android]) {
|
||||
for (final multiline in [false, true]) {
|
||||
testWidgets(
|
||||
'group avatar stays fixed throughout a local send: $platform, multiline=$multiline',
|
||||
'outgoing group stays avatar-free throughout a local send: $platform, multiline=$multiline',
|
||||
(tester) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = platform;
|
||||
@@ -5710,7 +5710,7 @@ void main() {
|
||||
of: find.byKey(ValueKey('message-row-$id')),
|
||||
matching: find.byType(AvatarImage),
|
||||
);
|
||||
final before = tester.getRect(avatar('first'));
|
||||
expect(avatar('first'), findsNothing);
|
||||
final container = ProviderScope.containerOf(
|
||||
tester.element(find.byType(ChannelDetailPage)),
|
||||
);
|
||||
@@ -5730,19 +5730,19 @@ void main() {
|
||||
]);
|
||||
for (var frame = 0; frame < 32; frame++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
final visibleAvatar = avatar('second').evaluate().isNotEmpty
|
||||
? avatar('second')
|
||||
: avatar('first');
|
||||
expect(visibleAvatar, findsOneWidget);
|
||||
final current = tester.getRect(visibleAvatar);
|
||||
expect(
|
||||
current.top,
|
||||
closeTo(before.top, 1),
|
||||
reason: 'avatar frame $frame',
|
||||
avatar('first'),
|
||||
findsNothing,
|
||||
reason: 'first avatar frame $frame',
|
||||
);
|
||||
expect(
|
||||
avatar('second'),
|
||||
findsNothing,
|
||||
reason: 'second avatar frame $frame',
|
||||
);
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
expect(avatar('second'), findsOneWidget);
|
||||
expect(avatar('second'), findsNothing);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
@@ -6508,6 +6508,51 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('own ended huddle reclaims avatar space and aligns reactions', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
signingPubkey: 'alice',
|
||||
messages: [
|
||||
_huddleMsg(id: 'own-huddle', kind: EventKind.huddleStarted),
|
||||
_huddleMsg(
|
||||
id: 'own-ending',
|
||||
kind: EventKind.huddleEnded,
|
||||
createdAt: 1500,
|
||||
),
|
||||
_reaction(id: 'own-huddle-reaction', targetId: 'own-huddle'),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final card = tester.getRect(
|
||||
find.byKey(const ValueKey('huddle-card-own-huddle')),
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const ValueKey('huddle-activity-own-huddle')),
|
||||
matching: find.byType(AvatarImage),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
expect(find.text('Huddle ended'), findsOneWidget);
|
||||
expect(
|
||||
card.right,
|
||||
closeTo(
|
||||
tester
|
||||
.getRect(find.byKey(const ValueKey('huddle-activity-own-huddle')))
|
||||
.right,
|
||||
0.01,
|
||||
),
|
||||
);
|
||||
final tray = tester.getRect(
|
||||
find.byKey(const ValueKey('combined-reaction-tray')),
|
||||
);
|
||||
expect(tray.right, lessThan(card.right));
|
||||
expect(tray.right, greaterThan(card.center.dx));
|
||||
});
|
||||
|
||||
testWidgets('renders a compact huddle activity row', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
|
||||
@@ -1,6 +1,222 @@
|
||||
part of '../channel_detail_page_test.dart';
|
||||
|
||||
void bubbleLayoutTests() {
|
||||
for (final author in ['self', 'alice']) {
|
||||
testWidgets('date divider owns equal space around $author messages', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(390, 1100);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
signingPubkey: 'self',
|
||||
messages: [
|
||||
_textMsg(
|
||||
id: 'before-day',
|
||||
pubkey: author,
|
||||
content: 'Before',
|
||||
createdAt: 1000,
|
||||
),
|
||||
_textMsg(
|
||||
id: 'after-day',
|
||||
pubkey: author,
|
||||
content: 'After',
|
||||
createdAt: 87400,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final before = tester.getRect(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byKey(const ValueKey('message-row-before-day')),
|
||||
matching: find.byType(LocalSendBubbleSurface),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
final divider = tester.getRect(
|
||||
find.byKey(const ValueKey('channel-day-divider-opacity-87400')),
|
||||
);
|
||||
final after = tester.getRect(
|
||||
find.byKey(const ValueKey('message-author-after-day')),
|
||||
);
|
||||
expect(
|
||||
divider.top - before.bottom,
|
||||
closeTo(after.top - divider.bottom, 1),
|
||||
);
|
||||
expect(divider.top - before.bottom, closeTo(Grid.gutter, 1));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
for (final personalChat in [false, true]) {
|
||||
for (final scale in [1.0, 2.0]) {
|
||||
testWidgets(
|
||||
'viewer bubbles align right: personal=$personalChat scale=$scale',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(390, 1100);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
signingPubkey: 'self',
|
||||
personalChat: personalChat,
|
||||
textScaler: TextScaler.linear(scale),
|
||||
users: const {
|
||||
'self': UserProfile(pubkey: 'self', displayName: 'Me'),
|
||||
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
|
||||
},
|
||||
messages: [
|
||||
_textMsg(
|
||||
id: 'incoming',
|
||||
pubkey: 'alice',
|
||||
content: 'Hello',
|
||||
createdAt: 1000,
|
||||
),
|
||||
_textMsg(
|
||||
id: 'outgoing',
|
||||
pubkey: 'self',
|
||||
content: 'Hi',
|
||||
createdAt: 1001,
|
||||
),
|
||||
_reaction(
|
||||
id: 'reaction-in',
|
||||
targetId: 'incoming',
|
||||
pubkey: 'self',
|
||||
),
|
||||
_reaction(
|
||||
id: 'reaction-out',
|
||||
targetId: 'outgoing',
|
||||
pubkey: 'alice',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
Rect bounds(String id, Finder child) => tester.getRect(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byKey(ValueKey('message-row-$id')),
|
||||
matching: child,
|
||||
)
|
||||
.first,
|
||||
);
|
||||
final incoming = bounds(
|
||||
'incoming',
|
||||
find.byType(LocalSendBubbleSurface),
|
||||
);
|
||||
final outgoing = bounds(
|
||||
'outgoing',
|
||||
find.byType(LocalSendBubbleSurface),
|
||||
);
|
||||
expect(outgoing.center.dx, greaterThan(incoming.center.dx));
|
||||
expect(
|
||||
bounds(
|
||||
'outgoing',
|
||||
find.byKey(const ValueKey('message-author-outgoing')),
|
||||
).right,
|
||||
lessThan(
|
||||
bounds(
|
||||
'outgoing',
|
||||
find.byKey(const ValueKey('message-timestamp-outgoing')),
|
||||
).left,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
bounds(
|
||||
'incoming',
|
||||
find.byKey(const ValueKey('message-author-incoming')),
|
||||
).right,
|
||||
lessThan(
|
||||
bounds(
|
||||
'incoming',
|
||||
find.byKey(const ValueKey('message-timestamp-incoming')),
|
||||
).left,
|
||||
),
|
||||
);
|
||||
|
||||
expect(outgoing.right, greaterThan(300));
|
||||
expect(incoming.left, lessThan(90));
|
||||
final incomingAvatar = bounds(
|
||||
'incoming',
|
||||
find.byType(MessageMediaAvatar),
|
||||
);
|
||||
expect(incomingAvatar.right, lessThan(incoming.left));
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const ValueKey('message-row-outgoing')),
|
||||
matching: find.byType(MessageMediaAvatar),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
outgoing.right,
|
||||
closeTo(
|
||||
tester
|
||||
.getRect(find.byKey(const ValueKey('message-row-outgoing')))
|
||||
.right,
|
||||
0.01,
|
||||
),
|
||||
);
|
||||
final incomingReaction = bounds(
|
||||
'incoming',
|
||||
find.byKey(const ValueKey('combined-reaction-tray')),
|
||||
);
|
||||
final outgoingReaction = bounds(
|
||||
'outgoing',
|
||||
find.byKey(const ValueKey('combined-reaction-tray')),
|
||||
);
|
||||
expect(
|
||||
outgoingReaction.center.dx,
|
||||
greaterThan(incomingReaction.center.dx),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets('thread replies reclaim the outgoing avatar gutter', (
|
||||
tester,
|
||||
) async {
|
||||
final root = _textMsg(id: threadIdRoot, pubkey: 'alice', content: 'Root');
|
||||
final reply = _textMsg(
|
||||
id: threadIdR1,
|
||||
pubkey: 'self',
|
||||
content: 'My reply',
|
||||
createdAt: 1100,
|
||||
extraTags: const [
|
||||
['e', threadIdRoot, '', 'reply'],
|
||||
],
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
_buildTestable(
|
||||
signingPubkey: 'self',
|
||||
messages: [root, reply],
|
||||
threadReplies: {
|
||||
threadIdRoot: [reply],
|
||||
},
|
||||
initialThreadRootId: threadIdRoot,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final row = find.byKey(const ValueKey('thread-message-row-$threadIdR1'));
|
||||
final bubble = tester.getRect(
|
||||
find
|
||||
.descendant(of: row, matching: find.byType(LocalSendBubbleSurface))
|
||||
.first,
|
||||
);
|
||||
expect(
|
||||
find.descendant(of: row, matching: find.byType(MessageMediaAvatar)),
|
||||
findsNothing,
|
||||
);
|
||||
expect(bubble.right, closeTo(tester.getRect(row).right, 0.01));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('large activity members build and fetch only visible rows', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -248,6 +248,98 @@ void main() {
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
});
|
||||
|
||||
for (final label in ['Channels options', 'Design options']) {
|
||||
testWidgets('$label exposes a labeled button and tap action', (
|
||||
tester,
|
||||
) async {
|
||||
final semantics = tester.ensureSemantics();
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
buildTestable(
|
||||
overrides: [
|
||||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||||
channelSectionsProvider.overrideWith(
|
||||
() => _FakeChannelSectionsNotifier(
|
||||
const ChannelSectionStore(
|
||||
sections: [
|
||||
ChannelSection(
|
||||
id: 'section-1',
|
||||
name: 'Design',
|
||||
icon: '🎨',
|
||||
order: 0,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final control = find.bySemanticsLabel(label);
|
||||
expect(control, findsOneWidget);
|
||||
final node = tester.getSemantics(control);
|
||||
expect(node.flagsCollection.isButton, isTrue);
|
||||
expect(
|
||||
node.getSemanticsData().hasAction(ui.SemanticsAction.tap),
|
||||
isTrue,
|
||||
);
|
||||
node.owner!.performAction(node.id, ui.SemanticsAction.tap);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(
|
||||
ValueKey(
|
||||
label == 'Channels options'
|
||||
? 'sort-popover-Channels'
|
||||
: 'section-popover-section-1',
|
||||
),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
} finally {
|
||||
semantics.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('section ellipsis aligns with temporary channel icon', (
|
||||
tester,
|
||||
) async {
|
||||
final temporary = Channel(
|
||||
id: 'temporary',
|
||||
name: 'temporary',
|
||||
channelType: 'stream',
|
||||
visibility: 'open',
|
||||
description: '',
|
||||
createdBy: 'abc',
|
||||
createdAt: DateTime(2025),
|
||||
memberCount: 1,
|
||||
isMember: true,
|
||||
ttlSeconds: 3600,
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
buildTestable(
|
||||
overrides: [
|
||||
channelsProvider.overrideWith(() => _FakeNotifier([temporary])),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
final ellipsis = find.descendant(
|
||||
of: find.byTooltip('Channels options'),
|
||||
matching: find.byIcon(BuzzIcons.ellipsisVertical),
|
||||
);
|
||||
expect(
|
||||
tester.getCenter(ellipsis).dx,
|
||||
closeTo(
|
||||
tester
|
||||
.getCenter(find.byKey(const Key('channel-ephemeral-temporary')))
|
||||
.dx,
|
||||
0.01,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('shows grouped channel list when data loads', (tester) async {
|
||||
// Valid fixture keys whose npub encodings were verified against the
|
||||
// NIP-19 codec independently of the code under test.
|
||||
@@ -325,6 +417,17 @@ void main() {
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
expect(
|
||||
tester
|
||||
.getTopRight(
|
||||
find.descendant(
|
||||
of: find.byTooltip('Channels options'),
|
||||
matching: find.byIcon(BuzzIcons.ellipsisVertical),
|
||||
),
|
||||
)
|
||||
.dx,
|
||||
closeTo(tester.getTopRight(find.byTooltip('Channels options')).dx, 0.01),
|
||||
);
|
||||
await tester.tap(find.byTooltip('Channels options'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Sort: Recent'), findsOneWidget);
|
||||
@@ -986,6 +1089,20 @@ void main() {
|
||||
AppTheme.light().colorScheme.onSurface.withValues(alpha: 0.07),
|
||||
);
|
||||
}
|
||||
expect(
|
||||
tester
|
||||
.getTopRight(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('section-menu-section-1')),
|
||||
matching: find.byIcon(BuzzIcons.ellipsisVertical),
|
||||
),
|
||||
)
|
||||
.dx,
|
||||
closeTo(
|
||||
tester.getTopRight(find.byKey(const Key('section-menu-section-1'))).dx,
|
||||
0.01,
|
||||
),
|
||||
);
|
||||
await tester.tap(find.byKey(const Key('section-menu-section-1')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
|
||||
@@ -4,6 +4,75 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
for (final dark in [false, true]) {
|
||||
testWidgets('inline calendar shows local day before label, dark=$dark', (
|
||||
tester,
|
||||
) async {
|
||||
final date = DateTime(2026, 10, 18, 12);
|
||||
final timestamp = date.millisecondsSinceEpoch ~/ 1000;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: dark ? AppTheme.dark() : AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: MediaQuery(
|
||||
data: const MediaQueryData(textScaler: TextScaler.linear(2)),
|
||||
child: SizedBox(
|
||||
width: 320,
|
||||
child: DayDivider(
|
||||
label: 'October 18, 2026',
|
||||
dayTimestamp: timestamp,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final tile = find.byKey(ValueKey('day-calendar-$timestamp'));
|
||||
expect(
|
||||
find.descendant(of: tile, matching: find.text('18')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
tester.getRect(tile).right,
|
||||
lessThan(tester.getRect(find.text('October 18, 2026')).left),
|
||||
);
|
||||
expect(find.byType(Divider), findsNWidgets(2));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
for (final label in ['Today', 'October 18, 2026']) {
|
||||
testWidgets('date group centered with full-width rules: $label', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 390,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
||||
child: DayDivider(label: label, dayTimestamp: 1000),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final left = tester.getRect(find.byType(Divider).first);
|
||||
final right = tester.getRect(find.byType(Divider).last);
|
||||
final icon = tester.getRect(
|
||||
find.byKey(const ValueKey('day-calendar-1000')),
|
||||
);
|
||||
final text = tester.getRect(find.text(label));
|
||||
expect(left.left, closeTo(Grid.gutter, 0.01));
|
||||
expect(right.right, closeTo(390 - Grid.gutter, 0.01));
|
||||
expect(left.width, closeTo(right.width, 0.01));
|
||||
expect((icon.left + text.right) / 2, closeTo(195, 0.01));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('fades the in-flow date while that day is sticky', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
part of 'message_content_test.dart';
|
||||
|
||||
void carouselPreviewTests() {
|
||||
testWidgets(
|
||||
'iOS prepares five thumbnails before scrolling with two preload requests',
|
||||
(tester) async {
|
||||
final started = <String>[];
|
||||
|
||||
final responses = <String, Completer<http.Response>>{};
|
||||
final urls = List.generate(
|
||||
10,
|
||||
(i) => 'https://example.com/preload-$i.png',
|
||||
);
|
||||
final messenger = tester.binding.defaultBinaryMessenger;
|
||||
const connectivity = MethodChannel(
|
||||
'dev.fluttercommunity.plus/connectivity_status',
|
||||
);
|
||||
messenger.setMockMethodCallHandler(connectivity, (_) async => null);
|
||||
addTearDown(() => messenger.setMockMethodCallHandler(connectivity, null));
|
||||
final channels = <MethodChannel>[];
|
||||
messenger.setMockMethodCallHandler(SystemChannels.platform_views, (
|
||||
call,
|
||||
) async {
|
||||
if (call.method == 'create') {
|
||||
final id = (call.arguments as Map)['id'];
|
||||
final channel = MethodChannel('buzz/ios_media_carousel/$id');
|
||||
channels.add(channel);
|
||||
messenger.setMockMethodCallHandler(channel, (_) async => null);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
final client = MockClient((request) {
|
||||
final url = request.url.toString();
|
||||
started.add(url);
|
||||
final response = responses[url] = Completer<http.Response>();
|
||||
|
||||
return response.future;
|
||||
});
|
||||
Future<void> waitForCount(int count) async {
|
||||
for (
|
||||
var attempt = 0;
|
||||
attempt < 100 && started.length < count;
|
||||
attempt++
|
||||
) {
|
||||
await tester.pump();
|
||||
await tester.runAsync(
|
||||
() => Future<void>.delayed(const Duration(milliseconds: 10)),
|
||||
);
|
||||
}
|
||||
expect(started.length, count);
|
||||
}
|
||||
|
||||
final png = base64Decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL1sAAAAASUVORK5CYII=',
|
||||
);
|
||||
void release(String url) {
|
||||
final response = responses[url]!;
|
||||
if (!response.isCompleted) {
|
||||
response.complete(http.Response.bytes(png, 200));
|
||||
}
|
||||
}
|
||||
|
||||
addTearDown(() async {
|
||||
for (final response in responses.values) {
|
||||
if (!response.isCompleted) response.complete(http.Response('', 404));
|
||||
}
|
||||
messenger.setMockMethodCallHandler(SystemChannels.platform_views, null);
|
||||
for (final channel in channels) {
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
}
|
||||
|
||||
client.close();
|
||||
});
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
MessageContent(
|
||||
content: urls.map((url) => '').join('\n'),
|
||||
),
|
||||
overrides: [
|
||||
mediaGetAuthServiceProvider.overrideWithValue(
|
||||
MediaGetAuthService(baseUrl: 'https://example.com', nsec: null),
|
||||
),
|
||||
mediaHttpClientProvider.overrideWithValue(client),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
{
|
||||
await waitForCount(2);
|
||||
expect(started, hasLength(2));
|
||||
for (var i = 0; i < 3; i++) {
|
||||
release(started[i]);
|
||||
await waitForCount(i + 3);
|
||||
expect(started, hasLength(i + 3));
|
||||
}
|
||||
release(started[3]);
|
||||
release(started[4]);
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
expect(started.toSet(), urls.take(5).toSet());
|
||||
// No native subscribeImage or page callback was sent by this harness.
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'outgoing gallery starts at the right and mirrors its spacing',
|
||||
(tester) async {
|
||||
final messenger = tester.binding.defaultBinaryMessenger;
|
||||
final channels = <MethodChannel>[];
|
||||
final configs = <Map<Object?, Object?>>[];
|
||||
messenger.setMockMethodCallHandler(SystemChannels.platform_views, (
|
||||
call,
|
||||
) async {
|
||||
if (call.method == 'create') {
|
||||
final id = (call.arguments as Map)['id'];
|
||||
final channel = MethodChannel('buzz/ios_media_carousel/$id');
|
||||
channels.add(channel);
|
||||
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||
if (call.method == 'configure') {
|
||||
configs.add(Map<Object?, Object?>.from(call.arguments as Map));
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
addTearDown(() {
|
||||
messenger.setMockMethodCallHandler(SystemChannels.platform_views, null);
|
||||
for (final channel in channels) {
|
||||
messenger.setMockMethodCallHandler(channel, null);
|
||||
}
|
||||
});
|
||||
final counts = <Rect>[];
|
||||
final widths = <double>[];
|
||||
for (final own in [false, true]) {
|
||||
await tester.pumpWidget(
|
||||
_testable(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 50),
|
||||
child: MessageContent(
|
||||
key: ValueKey(own),
|
||||
ownMessage: own,
|
||||
bubble: true,
|
||||
mediaCarouselTrailingOverflow: Grid.gutter,
|
||||
content:
|
||||
'\n',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
counts.add(
|
||||
tester.getRect(
|
||||
find.byKey(const ValueKey('message-media-carousel-count')),
|
||||
),
|
||||
);
|
||||
widths.add(
|
||||
tester
|
||||
.getSize(find.byKey(const ValueKey('message-media-carousel')))
|
||||
.width,
|
||||
);
|
||||
expect(configs.last['rtl'], own);
|
||||
}
|
||||
expect(widths[0], widths[1]);
|
||||
expect(counts[0].left, closeTo(800 - counts[1].right, 0.1));
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
variant: TargetPlatformVariant.only(TargetPlatform.iOS),
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/buzz_loading_indicator.dart';
|
||||
|
||||
part 'message_content_bubble_cases.dart';
|
||||
part 'message_carousel_preview_cases.dart';
|
||||
|
||||
Widget _testable(
|
||||
Widget child, {
|
||||
@@ -379,6 +380,7 @@ class _TestChannelsNotifier extends ChannelsNotifier {
|
||||
}
|
||||
|
||||
void main() {
|
||||
carouselPreviewTests();
|
||||
bubbleContentTests();
|
||||
test('wide voice-note waveforms distribute bars across their full width', () {
|
||||
const width = 320.0;
|
||||
|
||||
Reference in New Issue
Block a user