Fix mobile attachment and gallery polish (#3370)

## Summary

- align mobile message metadata and enlarge attachment-menu content
- smooth keyboard-to-camera/photo transitions and initialize the iOS
photo grid at the intended scale
- fix horizontal gallery loading, edge overflow, and end spacing

## Why

The attachment surfaces were reacting to keyboard and compact-menu
geometry during presentation, while gallery clipping and image lifecycle
behavior caused misalignment and occasional blank previews.

## Testing

- `just mobile-check`
- `flutter test` (881 passed, 1 skipped)
- native `RunnerTests` (17 passed)
- verified standalone Release build on a physical iPhone

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-07-29 07:09:08 +01:00
committed by GitHub
parent 6300a6b1d0
commit 485d03a358
15 changed files with 1054 additions and 101 deletions
+13
View File
@@ -3,6 +3,14 @@ import PhotosUI
import UIKit
import UniformTypeIdentifiers
enum EmbeddedPhotoPickerLayout {
static func applyPreferredScale(_ zoomIn: () -> Void) {
UIView.performWithoutAnimation {
zoomIn()
}
}
}
final class InlinePhotoPickerFactory: NSObject, FlutterPlatformViewFactory {
private let messenger: FlutterBinaryMessenger
private weak var parentViewController: UIViewController?
@@ -68,6 +76,7 @@ final class InlinePhotoPickerPlatformView: NSObject, FlutterPlatformView {
}
containerView.backgroundColor = .clear
containerView.clipsToBounds = true
if #available(iOS 17.0, *) {
installPicker()
}
@@ -120,6 +129,10 @@ final class InlinePhotoPickerPlatformView: NSObject, FlutterPlatformView {
picker.didMove(toParent: parentViewController)
}
pickerViewController = picker
containerView.layoutIfNeeded()
EmbeddedPhotoPickerLayout.applyPreferredScale {
picker.zoomIn()
}
}
private func exportPickerResult(_ result: PHPickerResult) async throws -> String {
+165 -45
View File
@@ -17,12 +17,12 @@ final class NativeAttachmentPopoverViewController:
case camera
}
private typealias ContentPreparation = (@escaping () -> Void) -> Void
private let channel: FlutterMethodChannel
private let expandedWidth: CGFloat
private let menuSize = CGSize(width: 176, height: 208)
private let expandedHeight: CGFloat = 372
private let expandedHorizontalOffset: CGFloat = 10
private let expandedVerticalOffset: CGFloat = 40
private let maximumMenuHeight: CGFloat
private let expandedHeight = NativeAttachmentMenuLayout.maximumHeight
private let contentHost = UIView()
private let cameraSession = AVCaptureSession()
private let cameraOutput = AVCapturePhotoOutput()
@@ -50,14 +50,31 @@ final class NativeAttachmentPopoverViewController:
private var activeCameraCaptureID: Int64?
private var isFinishing = false
private var didNotifyDismissal = false
private var keyboardDismissalOffset: CGFloat = 0
private var menuStackHeightConstraint: NSLayoutConstraint?
var onDismiss: (() -> Void)?
init(channel: FlutterMethodChannel, expandedWidth: CGFloat) {
private var menuSize: CGSize {
NativeAttachmentMenuLayout.size(
compatibleWith: traitCollection,
maximumHeight: maximumMenuHeight
)
}
init(
channel: FlutterMethodChannel,
expandedWidth: CGFloat,
maximumMenuHeight: CGFloat = NativeAttachmentMenuLayout.maximumHeight
) {
self.channel = channel
self.expandedWidth = expandedWidth
self.maximumMenuHeight = maximumMenuHeight
super.init(nibName: nil, bundle: nil)
preferredContentSize = menuSize
preferredContentSize = NativeAttachmentMenuLayout.size(
compatibleWith: .current,
maximumHeight: maximumMenuHeight
)
}
@available(*, unavailable)
@@ -106,6 +123,20 @@ final class NativeAttachmentPopoverViewController:
stopCamera()
}
override func traitCollectionDidChange(
_ previousTraitCollection: UITraitCollection?
) {
super.traitCollectionDidChange(previousTraitCollection)
guard
previousTraitCollection?.preferredContentSizeCategory
!= traitCollection.preferredContentSizeCategory
else {
return
}
updateMenuLayout()
}
func adaptivePresentationStyle(
for controller: UIPresentationController
) -> UIModalPresentationStyle {
@@ -122,16 +153,45 @@ final class NativeAttachmentPopoverViewController:
let container = UIView()
container.translatesAutoresizingMaskIntoConstraints = false
let scrollView = UIScrollView()
scrollView.alwaysBounceVertical = false
scrollView.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(scrollView)
let stack = UIStackView()
stack.axis = .vertical
stack.distribution = .fillEqually
stack.spacing = NativeAttachmentMenuLayout.itemSpacing
stack.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(stack)
scrollView.addSubview(stack)
let stackHeightConstraint = stack.heightAnchor.constraint(
equalToConstant: NativeAttachmentMenuLayout.itemsHeight(
compatibleWith: traitCollection
)
)
menuStackHeightConstraint = stackHeightConstraint
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: container.trailingAnchor),
stack.topAnchor.constraint(equalTo: container.topAnchor, constant: 8),
stack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -8),
scrollView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
scrollView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
scrollView.topAnchor.constraint(equalTo: container.topAnchor),
scrollView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
stack.leadingAnchor.constraint(
equalTo: scrollView.frameLayoutGuide.leadingAnchor,
constant: NativeAttachmentMenuLayout.contentPadding
),
stack.trailingAnchor.constraint(
equalTo: scrollView.frameLayoutGuide.trailingAnchor,
constant: -NativeAttachmentMenuLayout.contentPadding
),
stack.topAnchor.constraint(
equalTo: scrollView.contentLayoutGuide.topAnchor,
constant: NativeAttachmentMenuLayout.contentPadding
),
stack.bottomAnchor.constraint(
equalTo: scrollView.contentLayoutGuide.bottomAnchor,
constant: -NativeAttachmentMenuLayout.contentPadding
),
stackHeightConstraint,
])
stack.addArrangedSubview(
@@ -169,8 +229,19 @@ final class NativeAttachmentPopoverViewController:
return container
}
private func updateMenuLayout() {
menuStackHeightConstraint?.constant =
NativeAttachmentMenuLayout.itemsHeight(
compatibleWith: traitCollection
)
if surface == .menu {
preferredContentSize = menuSize
}
}
private func showPhotos() {
guard surface != .photos else { return }
prepareForExpandedSurface()
stopCamera()
var configuration = PHPickerConfiguration(photoLibrary: .shared())
@@ -192,16 +263,14 @@ final class NativeAttachmentPopoverViewController:
let container = UIView()
container.backgroundColor = .clear
container.clipsToBounds = true
addChild(picker)
picker.view.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(picker.view)
NSLayoutConstraint.activate([
picker.view.leadingAnchor.constraint(equalTo: container.leadingAnchor),
picker.view.trailingAnchor.constraint(equalTo: container.trailingAnchor),
picker.view.topAnchor.constraint(
equalTo: container.topAnchor,
constant: -8
),
picker.view.topAnchor.constraint(equalTo: container.topAnchor),
picker.view.bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
picker.didMove(toParent: self)
@@ -227,11 +296,32 @@ final class NativeAttachmentPopoverViewController:
trailing: actionButton
)
transition(to: .photos, content: container)
transition(
to: .photos,
content: container,
preparation: { [weak picker] reveal in
guard let picker else {
reveal()
return
}
// PHPicker ignores scale changes while its remote grid is still
// adapting to the compact menu bounds. Give it one main-loop turn at
// the final popover size, apply the scale offscreen, then reveal it.
DispatchQueue.main.async {
picker.view.layoutIfNeeded()
EmbeddedPhotoPickerLayout.applyPreferredScale {
picker.zoomIn()
picker.view.layoutIfNeeded()
}
DispatchQueue.main.async(execute: reveal)
}
}
)
}
private func showCamera() {
guard surface != .camera else { return }
prepareForExpandedSurface()
removePhotoPicker()
let container = UIView()
@@ -288,9 +378,26 @@ final class NativeAttachmentPopoverViewController:
stopCamera()
let menu = makeMenuView()
transition(to: .menu, content: menu) { [weak self] in
self?.removePhotoPicker()
transition(
to: .menu,
content: menu,
completion: { [weak self] in
self?.removePhotoPicker()
}
)
}
private func prepareForExpandedSurface() {
if let sourceHost = popoverPresentationController?.sourceView?.superview {
keyboardDismissalOffset = max(
keyboardDismissalOffset,
NativeAttachmentExpandedSurfaceBehavior.keyboardOverlap(
containerBounds: sourceHost.bounds,
keyboardLayoutFrame: sourceHost.keyboardLayoutGuide.layoutFrame
)
)
}
NativeAttachmentExpandedSurfaceBehavior.dismissKeyboard(in: view.window)
}
private func installContent(_ content: UIView) {
@@ -308,6 +415,7 @@ final class NativeAttachmentPopoverViewController:
private func transition(
to nextSurface: Surface,
content nextView: UIView,
preparation: ContentPreparation? = nil,
completion: (() -> Void)? = nil
) {
let previousView = visibleContentView
@@ -327,50 +435,61 @@ final class NativeAttachmentPopoverViewController:
])
contentHost.layoutIfNeeded()
let direction: CGFloat = isExpanding ? 34 : -34
nextView.alpha = UIAccessibility.isReduceMotionEnabled ? 0 : 0.01
nextView.transform =
UIAccessibility.isReduceMotionEnabled
? .identity
: CGAffineTransform(translationX: direction, y: 0).scaledBy(
x: 0.97,
y: 0.97
)
let shouldAnimate = !UIAccessibility.isReduceMotionEnabled
// Do not expose embedded surfaces while the popover changes size. In
// particular, PHPicker visibly reflows its grid from the compact menu
// width to the expanded width if it is allowed to paint during this step.
nextView.alpha = 0
visibleContentView = nextView
surface = nextSurface
let duration = UIAccessibility.isReduceMotionEnabled ? 0.16 : 0.36
let duration = shouldAnimate ? (isExpanding ? 0.24 : 0.2) : 0
UIView.animate(
withDuration: duration,
delay: 0,
usingSpringWithDamping: 0.86,
initialSpringVelocity: 0.18,
options: [.beginFromCurrentState, .allowUserInteraction]
options: [
.beginFromCurrentState,
.allowUserInteraction,
.curveEaseInOut,
]
) {
self.preferredContentSize = targetSize
if let popover = self.popoverPresentationController,
let sourceView = popover.sourceView
{
popover.sourceRect = sourceView.bounds.offsetBy(
dx: isExpanding ? self.expandedHorizontalOffset : 0,
dy: isExpanding ? self.expandedVerticalOffset : 0
popover.sourceRect = NativeAttachmentPopoverAnchorLayout.sourceRect(
anchorBounds: sourceView.bounds,
keyboardDismissalOffset: self.keyboardDismissalOffset,
isExpanded: isExpanding
)
}
previousView?.alpha = 0
previousView?.transform =
UIAccessibility.isReduceMotionEnabled
? .identity
: CGAffineTransform(translationX: -direction, y: 0).scaledBy(
x: 0.97,
y: 0.97
)
nextView.alpha = 1
nextView.transform = .identity
self.view.layoutIfNeeded()
} completion: { _ in
previousView?.removeFromSuperview()
previousView?.transform = .identity
completion?()
self.view.layoutIfNeeded()
let reveal = {
UIView.animate(
withDuration: shouldAnimate ? 0.14 : 0,
delay: 0,
options: [
.beginFromCurrentState,
.allowUserInteraction,
.curveEaseOut,
]
) {
nextView.alpha = 1
} completion: { _ in
completion?()
}
}
if let preparation {
preparation(reveal)
} else {
reveal()
}
}
}
@@ -917,6 +1036,7 @@ final class NativeAttachmentPopoverViewController:
Self.removeTemporaryFiles(temporaryPaths)
return
}
NativeAttachmentExpandedSurfaceBehavior.dismissKeyboard(in: view.window)
isFinishing = true
view.isUserInteractionEnabled = false
selectionGeneration += 1
@@ -1,6 +1,71 @@
import Flutter
import UIKit
enum NativeAttachmentExpandedSurfaceBehavior {
@MainActor
static func dismissKeyboard(in window: UIWindow?) {
window?.endEditing(true)
}
static func keyboardOverlap(
containerBounds: CGRect,
keyboardLayoutFrame: CGRect
) -> CGFloat {
guard
!keyboardLayoutFrame.isNull,
!keyboardLayoutFrame.isInfinite,
keyboardLayoutFrame.minY < containerBounds.maxY
else {
return 0
}
return max(0, containerBounds.maxY - keyboardLayoutFrame.minY)
}
}
enum NativeAttachmentPopoverAnchorLayout {
static let expandedVerticalOffset: CGFloat = 40
static func sourceRect(
anchorBounds: CGRect,
keyboardDismissalOffset: CGFloat,
isExpanded: Bool
) -> CGRect {
anchorBounds.offsetBy(
dx: 0,
dy: keyboardDismissalOffset
+ (isExpanded ? expandedVerticalOffset : 0)
)
}
}
enum NativeAttachmentPopoverPresentationLayout {
static func keyboardDismissalOffset(
sourceRect: CGRect,
containerBounds: CGRect,
safeAreaInsets: UIEdgeInsets,
keyboardLayoutFrame: CGRect,
menuHeight: CGFloat
) -> CGFloat {
let keyboardOverlap =
NativeAttachmentExpandedSurfaceBehavior.keyboardOverlap(
containerBounds: containerBounds,
keyboardLayoutFrame: keyboardLayoutFrame
)
guard keyboardOverlap > 0 else { return 0 }
let availableHeight =
sourceRect.minY - (containerBounds.minY + safeAreaInsets.top)
return availableHeight >= menuHeight ? 0 : keyboardOverlap
}
static func sourceRect(
_ sourceRect: CGRect,
keyboardDismissalOffset: CGFloat
) -> CGRect {
sourceRect.offsetBy(dx: 0, dy: keyboardDismissalOffset)
}
}
final class NativeAttachmentPopoverCoordinator: NSObject {
private let channel: FlutterMethodChannel
private weak var parentViewController: UIViewController?
@@ -90,13 +155,36 @@ final class NativeAttachmentPopoverCoordinator: NSObject {
}
let sourceView = presenter.view
let convertedRect: CGRect
var convertedRect: CGRect
if let window = sourceView?.window {
convertedRect = sourceView?.convert(sourceRect, from: window) ?? sourceRect
} else {
convertedRect = sourceRect
}
if let sourceView {
let keyboardDismissalOffset =
NativeAttachmentPopoverPresentationLayout.keyboardDismissalOffset(
sourceRect: convertedRect,
containerBounds: sourceView.bounds,
safeAreaInsets: sourceView.safeAreaInsets,
keyboardLayoutFrame: sourceView.keyboardLayoutGuide.layoutFrame,
menuHeight: NativeAttachmentMenuLayout.size(
compatibleWith: sourceView.traitCollection
).height
)
if keyboardDismissalOffset > 0 {
NativeAttachmentExpandedSurfaceBehavior.dismissKeyboard(
in: sourceView.window
)
convertedRect =
NativeAttachmentPopoverPresentationLayout.sourceRect(
convertedRect,
keyboardDismissalOffset: keyboardDismissalOffset
)
}
}
let anchorView = makeSourceAnchor(frame: convertedRect)
sourceView?.addSubview(anchorView)
sourceAnchorView = anchorView
@@ -175,6 +263,56 @@ final class NativeAttachmentPopoverCoordinator: NSObject {
}
}
enum NativeAttachmentMenuLayout {
static let itemCount: CGFloat = 4
static let contentPadding: CGFloat = 16
static let minimumItemHeight: CGFloat = 52
static let itemSpacing: CGFloat = 8
static let itemVerticalPadding: CGFloat = 8
static let maximumHeight: CGFloat = 372
static let width: CGFloat = 216
static let labelTextStyle: UIFont.TextStyle = .title3
static func itemHeight(
compatibleWith traitCollection: UITraitCollection
) -> CGFloat {
let labelHeight = UIFont.preferredFont(
forTextStyle: labelTextStyle,
compatibleWith: traitCollection
).lineHeight
return max(
minimumItemHeight,
ceil(labelHeight + (itemVerticalPadding * 2))
)
}
static func itemsHeight(
compatibleWith traitCollection: UITraitCollection
) -> CGFloat {
(itemHeight(compatibleWith: traitCollection) * itemCount)
+ (itemSpacing * (itemCount - 1))
}
static func contentHeight(
compatibleWith traitCollection: UITraitCollection
) -> CGFloat {
(contentPadding * 2) + itemsHeight(compatibleWith: traitCollection)
}
static func size(
compatibleWith traitCollection: UITraitCollection,
maximumHeight: CGFloat = NativeAttachmentMenuLayout.maximumHeight
) -> CGSize {
CGSize(
width: width,
height: min(
contentHeight(compatibleWith: traitCollection),
maximumHeight
)
)
}
}
func makeNativeAttachmentMenuButton(
title: String,
symbol: String,
@@ -200,7 +338,9 @@ func makeNativeAttachmentMenuButton(
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.textColor = .label
titleLabel.font = .preferredFont(forTextStyle: .body)
titleLabel.font = .preferredFont(
forTextStyle: NativeAttachmentMenuLayout.labelTextStyle
)
titleLabel.adjustsFontForContentSizeCategory = true
titleLabel.textAlignment = .left
titleLabel.translatesAutoresizingMaskIntoConstraints = false
@@ -208,7 +348,10 @@ func makeNativeAttachmentMenuButton(
button.addSubview(iconView)
button.addSubview(titleLabel)
NSLayoutConstraint.activate([
iconView.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 14),
iconView.leadingAnchor.constraint(
equalTo: button.leadingAnchor,
constant: 8
),
iconView.centerYAnchor.constraint(equalTo: button.centerYAnchor),
iconView.widthAnchor.constraint(equalToConstant: 26),
titleLabel.leadingAnchor.constraint(
@@ -217,7 +360,7 @@ func makeNativeAttachmentMenuButton(
),
titleLabel.trailingAnchor.constraint(
equalTo: button.trailingAnchor,
constant: -14
constant: -8
),
titleLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor),
])
+182
View File
@@ -6,6 +6,179 @@ import XCTest
class RunnerTests: XCTestCase {
@MainActor
func testExpandedAttachmentSurfaceDismissesKeyboard() {
let window = KeyboardDismissalSpyWindow()
NativeAttachmentExpandedSurfaceBehavior.dismissKeyboard(in: window)
XCTAssertTrue(window.didForceEndEditing)
}
func testExpandedAttachmentSurfaceMeasuresKeyboardOverlap() {
XCTAssertEqual(
NativeAttachmentExpandedSurfaceBehavior.keyboardOverlap(
containerBounds: CGRect(x: 0, y: 0, width: 390, height: 844),
keyboardLayoutFrame: CGRect(
x: 0,
y: 544,
width: 390,
height: 300
)
),
300
)
XCTAssertEqual(
NativeAttachmentExpandedSurfaceBehavior.keyboardOverlap(
containerBounds: CGRect(x: 0, y: 0, width: 390, height: 844),
keyboardLayoutFrame: CGRect(x: 0, y: 844, width: 390, height: 0)
),
0
)
}
func testAttachmentMenuReturnsToKeyboardDismissedAnchor() {
let anchorBounds = CGRect(x: 0, y: 0, width: 44, height: 44)
XCTAssertEqual(
NativeAttachmentPopoverAnchorLayout.sourceRect(
anchorBounds: anchorBounds,
keyboardDismissalOffset: 300,
isExpanded: true
),
anchorBounds.offsetBy(dx: 0, dy: 340)
)
XCTAssertEqual(
NativeAttachmentPopoverAnchorLayout.sourceRect(
anchorBounds: anchorBounds,
keyboardDismissalOffset: 300,
isExpanded: false
),
anchorBounds.offsetBy(dx: 0, dy: 300)
)
}
func testAttachmentMenuKeepsKeyboardWhenMenuFitsAboveTrigger() {
XCTAssertEqual(
NativeAttachmentPopoverPresentationLayout.keyboardDismissalOffset(
sourceRect: CGRect(x: 320, y: 480, width: 44, height: 44),
containerBounds: CGRect(x: 0, y: 0, width: 390, height: 844),
safeAreaInsets: UIEdgeInsets(top: 59, left: 0, bottom: 34, right: 0),
keyboardLayoutFrame: CGRect(
x: 0,
y: 544,
width: 390,
height: 300
),
menuHeight: NativeAttachmentMenuLayout.size(
compatibleWith: UITraitCollection(
preferredContentSizeCategory: .large
)
).height
),
0
)
}
func testAttachmentMenuDismissesKeyboardAndRepositionsInCompactHeight() {
let sourceRect = CGRect(x: 760, y: 168, width: 44, height: 44)
let keyboardDismissalOffset =
NativeAttachmentPopoverPresentationLayout.keyboardDismissalOffset(
sourceRect: sourceRect,
containerBounds: CGRect(x: 0, y: 0, width: 844, height: 390),
safeAreaInsets: UIEdgeInsets(top: 0, left: 59, bottom: 21, right: 59),
keyboardLayoutFrame: CGRect(
x: 0,
y: 228,
width: 844,
height: 162
),
menuHeight: NativeAttachmentMenuLayout.size(
compatibleWith: UITraitCollection(
preferredContentSizeCategory: .large
)
).height
)
XCTAssertEqual(keyboardDismissalOffset, 162)
XCTAssertEqual(
NativeAttachmentPopoverPresentationLayout.sourceRect(
sourceRect,
keyboardDismissalOffset: keyboardDismissalOffset
),
sourceRect.offsetBy(dx: 0, dy: 162)
)
}
func testAttachmentMenuDoesNotMoveWithoutSoftwareKeyboard() {
XCTAssertEqual(
NativeAttachmentPopoverPresentationLayout.keyboardDismissalOffset(
sourceRect: CGRect(x: 760, y: 168, width: 44, height: 44),
containerBounds: CGRect(x: 0, y: 0, width: 844, height: 390),
safeAreaInsets: UIEdgeInsets(top: 0, left: 59, bottom: 21, right: 59),
keyboardLayoutFrame: CGRect(x: 0, y: 390, width: 844, height: 0),
menuHeight: NativeAttachmentMenuLayout.size(
compatibleWith: UITraitCollection(
preferredContentSizeCategory: .large
)
).height
),
0
)
}
func testEmbeddedPhotoPickerAppliesOneZoomInStepWithoutAnimation() {
var zoomInCalls = 0
var animationsWereEnabled = true
EmbeddedPhotoPickerLayout.applyPreferredScale {
zoomInCalls += 1
animationsWereEnabled = UIView.areAnimationsEnabled
}
XCTAssertEqual(zoomInCalls, 1)
XCTAssertFalse(animationsWereEnabled)
}
func testNativeAttachmentMenuUsesRoomyRowsAndInsets() {
let traits = UITraitCollection(preferredContentSizeCategory: .large)
let size = NativeAttachmentMenuLayout.size(compatibleWith: traits)
XCTAssertEqual(size.width, 216)
XCTAssertEqual(size.height, 264)
XCTAssertEqual(NativeAttachmentMenuLayout.contentPadding, 16)
XCTAssertEqual(
NativeAttachmentMenuLayout.itemHeight(compatibleWith: traits),
52
)
XCTAssertEqual(NativeAttachmentMenuLayout.itemSpacing, 8)
XCTAssertEqual(NativeAttachmentMenuLayout.labelTextStyle, .title3)
}
func testNativeAttachmentMenuGrowsAndScrollsForAccessibilityText() {
let traits = UITraitCollection(
preferredContentSizeCategory: .accessibilityExtraExtraExtraLarge
)
let itemHeight = NativeAttachmentMenuLayout.itemHeight(
compatibleWith: traits
)
let contentHeight = NativeAttachmentMenuLayout.contentHeight(
compatibleWith: traits
)
let size = NativeAttachmentMenuLayout.size(compatibleWith: traits)
XCTAssertGreaterThan(itemHeight, 52)
XCTAssertGreaterThan(contentHeight, 264)
XCTAssertEqual(
size.height,
min(contentHeight, NativeAttachmentMenuLayout.maximumHeight)
)
XCTAssertLessThanOrEqual(
size.height,
NativeAttachmentMenuLayout.maximumHeight
)
}
func testDynamicIslandQrScannerRecognizesTallSafeAreas() {
for safeAreaTopInset in [51, 59, 62] {
XCTAssertTrue(
@@ -228,6 +401,15 @@ class RunnerTests: XCTestCase {
}
}
private final class KeyboardDismissalSpyWindow: UIWindow {
private(set) var didForceEndEditing = false
override func endEditing(_ force: Bool) -> Bool {
didForceEndEditing = force
return true
}
}
private enum RelayImagePolicyError: Error {
case invalidPng
case invalidJpeg
@@ -55,7 +55,10 @@ class _MessageBubble extends ConsumerWidget {
return Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(Radii.md),
clipBehavior: Clip.antiAlias,
// The media carousel intentionally continues through the list's trailing
// gutter. InkWell still clips its ink to [borderRadius], while leaving
// overflowing message content visible.
clipBehavior: Clip.none,
child: InkWell(
key: ValueKey('message-row-${message.id}'),
borderRadius: BorderRadius.circular(Radii.md),
@@ -736,7 +736,6 @@ class ComposeBar extends HookConsumerWidget {
return;
}
focusNode.unfocus();
unawaited(
iosAttachmentPopover
.present(
@@ -767,7 +766,10 @@ class ComposeBar extends HookConsumerWidget {
}),
)
.then((didPresent) {
if (!didPresent && context.mounted) toggleAttachments();
if (!didPresent && context.mounted) {
focusNode.unfocus();
toggleAttachments();
}
}),
);
}
@@ -2,10 +2,53 @@ part of '../compose_bar.dart';
enum _AttachmentSurface { closed, menu, camera, photos }
const _attachmentMenuWidth = 176.0;
const _attachmentMenuHeight = 208.0;
const _attachmentMenuWidth = 216.0;
const _attachmentMenuPadding = Grid.xs;
const _attachmentMenuItemHeight = 52.0;
const _attachmentMenuItemSpacing = Grid.xxs;
const _attachmentMenuIconSize = 24.0;
const _attachmentMenuIconSlotWidth = 28.0;
const _attachmentExpandedHeight = 372.0;
@immutable
class _AttachmentMenuLayout {
final double itemHeight;
final double contentHeight;
final double height;
const _AttachmentMenuLayout({
required this.itemHeight,
required this.contentHeight,
required this.height,
});
factory _AttachmentMenuLayout.from(BuildContext context) {
final textPainter = TextPainter(
text: TextSpan(text: 'Camera', style: context.textTheme.titleMedium),
textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context),
maxLines: 1,
)..layout();
final itemHeight = math.max(
_attachmentMenuItemHeight,
textPainter.height + (Grid.xxs * 2),
);
textPainter.dispose();
final contentHeight =
(_attachmentMenuPadding * 2) +
(itemHeight * 4) +
(_attachmentMenuItemSpacing * 3);
return _AttachmentMenuLayout(
itemHeight: itemHeight,
contentHeight: contentHeight,
height: math.min(contentHeight, _attachmentExpandedHeight),
);
}
bool get isScrollable => contentHeight > height;
}
class _AttachmentSurfacePanel extends HookWidget {
final _AttachmentSurface surface;
final Widget suggestionPanel;
@@ -36,6 +79,7 @@ class _AttachmentSurfacePanel extends HookWidget {
Widget build(BuildContext context) {
if (surface == _AttachmentSurface.closed) return suggestionPanel;
final menuLayout = _AttachmentMenuLayout.from(context);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final isExpanded =
surface == _AttachmentSurface.camera ||
@@ -81,10 +125,24 @@ class _AttachmentSurfacePanel extends HookWidget {
final visibleExpandedSurface =
renderedExpandedSurface.value ?? (isExpanded ? surface : null);
final cameraInitializationReady =
reducedMotion ||
(surface == _AttachmentSurface.camera && rawProgress >= 1);
final expandedContent = switch (visibleExpandedSurface) {
_AttachmentSurface.camera => KeyedSubtree(
key: const ValueKey('camera-preview'),
child: _InlineCameraPreview(onClose: onBack, onCapture: onCapture),
child: KeyedSubtree(
key: ValueKey(
cameraInitializationReady
? 'camera-initialization-ready'
: 'camera-initialization-deferred',
),
child: _InlineCameraPreview(
initializeCamera: cameraInitializationReady,
onClose: onBack,
onCapture: onCapture,
),
),
),
_AttachmentSurface.photos => KeyedSubtree(
key: const ValueKey('photo-gallery'),
@@ -117,8 +175,8 @@ class _AttachmentSurfacePanel extends HookWidget {
_attachmentMenuWidth +
((expandedWidth - _attachmentMenuWidth) * sizeProgress);
final height =
_attachmentMenuHeight +
((expandedHeight - _attachmentMenuHeight) * sizeProgress);
menuLayout.height +
((expandedHeight - menuLayout.height) * sizeProgress);
final baseColor = context.colors.surfaceContainerHighest;
final expandedColor =
visibleExpandedSurface == _AttachmentSurface.camera
@@ -151,12 +209,13 @@ class _AttachmentSurfacePanel extends HookWidget {
left: 0,
top: 0,
width: _attachmentMenuWidth,
height: _attachmentMenuHeight,
height: menuLayout.height,
child: IgnorePointer(
ignoring: surface != _AttachmentSurface.menu,
child: Opacity(
opacity: menuOpacity,
child: _AttachmentMenu(
layout: menuLayout,
onCamera: onCamera,
onPhotos: onPhotos,
onVideo: onVideo,
@@ -289,12 +348,14 @@ class _AttachmentTrigger extends StatelessWidget {
}
class _AttachmentMenu extends StatelessWidget {
final _AttachmentMenuLayout layout;
final VoidCallback onCamera;
final VoidCallback onPhotos;
final VoidCallback onVideo;
final VoidCallback onFiles;
const _AttachmentMenu({
required this.layout,
required this.onCamera,
required this.onPhotos,
required this.onVideo,
@@ -304,46 +365,45 @@ class _AttachmentMenu extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox(
key: const ValueKey('attachment-menu'),
width: _attachmentMenuWidth,
height: _attachmentMenuHeight,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_AttachmentMenuItem(
icon: LucideIcons.camera,
label: 'Camera',
onTap: onCamera,
),
_AttachmentMenuItem(
icon: LucideIcons.images,
label: 'Photos',
onTap: onPhotos,
),
_AttachmentMenuItem(
icon: LucideIcons.video,
label: 'Video',
onTap: onVideo,
),
_AttachmentMenuItem(
icon: LucideIcons.file,
label: 'Files',
onTap: onFiles,
),
],
),
height: layout.height,
child: ListView.separated(
key: const ValueKey('attachment-menu-scroll'),
padding: const EdgeInsets.all(_attachmentMenuPadding),
physics: layout.isScrollable
? null
: const NeverScrollableScrollPhysics(),
itemCount: 4,
separatorBuilder: (_, _) =>
const SizedBox(height: _attachmentMenuItemSpacing),
itemBuilder: (context, index) {
final (icon, label, onTap) = switch (index) {
0 => (LucideIcons.camera, 'Camera', onCamera),
1 => (LucideIcons.images, 'Photos', onPhotos),
2 => (LucideIcons.video, 'Video', onVideo),
_ => (LucideIcons.file, 'Files', onFiles),
};
return _AttachmentMenuItem(
height: layout.itemHeight,
icon: icon,
label: label,
onTap: onTap,
);
},
),
);
}
}
class _AttachmentMenuItem extends StatelessWidget {
final double height;
final IconData icon;
final String label;
final VoidCallback onTap;
const _AttachmentMenuItem({
required this.height,
required this.icon,
required this.label,
required this.onTap,
@@ -352,21 +412,40 @@ class _AttachmentMenuItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox(
height: 48,
key: ValueKey('attachment-menu-item-${label.toLowerCase()}'),
height: height,
child: Tooltip(
message: label,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.twelve),
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
child: Row(
children: [
Icon(icon, size: 20, color: context.colors.onSurfaceVariant),
SizedBox(
key: ValueKey('attachment-menu-icon-${label.toLowerCase()}'),
width: _attachmentMenuIconSlotWidth,
child: Center(
child: Icon(
icon,
size: _attachmentMenuIconSize,
color: context.colors.onSurfaceVariant,
),
),
),
const SizedBox(width: Grid.xxs),
Text(
label,
style: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurface,
Expanded(
child: Text(
label,
key: ValueKey(
'attachment-menu-label-${label.toLowerCase()}',
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleMedium?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w400,
),
),
),
],
@@ -1,10 +1,15 @@
part of '../compose_bar.dart';
class _InlineCameraPreview extends HookConsumerWidget {
final bool initializeCamera;
final Future<void> Function(XFile image) onCapture;
final VoidCallback onClose;
const _InlineCameraPreview({required this.onCapture, required this.onClose});
const _InlineCameraPreview({
required this.initializeCamera,
required this.onCapture,
required this.onClose,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -15,6 +20,8 @@ class _InlineCameraPreview extends HookConsumerWidget {
final error = useState<String?>(null);
useEffect(() {
if (!initializeCamera) return null;
var disposed = false;
var generation = 0;
@@ -76,7 +83,9 @@ class _InlineCameraPreview extends HookConsumerWidget {
onInactive: () => unawaited(disposeCurrent()),
onResume: () => unawaited(initialize()),
);
unawaited(initialize());
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!disposed) unawaited(initialize());
});
return () {
disposed = true;
@@ -86,7 +95,7 @@ class _InlineCameraPreview extends HookConsumerWidget {
controllerRef.value = null;
unawaited(current?.dispose() ?? Future<void>.value());
};
}, const []);
}, [initializeCamera]);
Future<void> capture() async {
final activeController = controller.value;
@@ -141,7 +141,7 @@ class _MessageImageCarousel extends HookConsumerWidget {
fontWeight: FontWeight.w400,
),
),
const SizedBox(height: Grid.half),
const SizedBox(height: Grid.half + Grid.quarter),
LayoutBuilder(
builder: (context, constraints) {
final contentWidth = constraints.hasBoundedWidth
@@ -152,12 +152,16 @@ class _MessageImageCarousel extends HookConsumerWidget {
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 * controller.viewportFraction -
(index == items.length - 1 ? 0 : Grid.half),
itemTrailingPaddings[index],
),
];
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
@@ -190,6 +194,10 @@ class _MessageImageCarousel extends HookConsumerWidget {
height: _messageMediaCarouselHeight,
child: PageView.builder(
controller: controller,
allowImplicitScrolling: true,
// Keep the first image aligned with the message body, but
// allow later pages to paint through the avatar gutter as
// the row scrolls.
clipBehavior: Clip.none,
padEnds: false,
itemCount: items.length,
@@ -197,8 +205,9 @@ class _MessageImageCarousel extends HookConsumerWidget {
itemBuilder: (context, index) {
final item = items[index];
return Padding(
key: ValueKey('message-media-carousel-page:${item.url}'),
padding: EdgeInsetsDirectional.only(
end: index == items.length - 1 ? 0 : Grid.half,
end: itemTrailingPaddings[index],
),
child: Semantics(
button: true,
@@ -504,7 +504,10 @@ class _ThreadMessage extends ConsumerWidget {
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(Radii.md),
clipBehavior: Clip.antiAlias,
// The media carousel intentionally continues through the list's
// trailing gutter. InkWell still clips its ink to [borderRadius],
// while leaving overflowing message content visible.
clipBehavior: Clip.none,
child: InkWell(
key: ValueKey('thread-message-row-${message.id}'),
borderRadius: BorderRadius.circular(Radii.md),
@@ -83,7 +83,7 @@ class MessageAuthorMeta extends StatelessWidget {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(child: authorName),
Flexible(child: authorName),
if (showUsername) ...[
const SizedBox(width: Grid.half),
ConstrainedBox(
@@ -749,6 +749,63 @@ void main() {
);
});
testWidgets(
'keeps image galleries body-aligned and flush with the trailing edge',
(tester) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
const firstImage = 'https://example.com/media/first.png';
const secondImage = 'https://example.com/media/second.png';
await tester.pumpWidget(
_buildTestable(
messages: [
_textMsg(
id: 'gallery',
pubkey: 'alice',
content:
'Gallery\n'
'![First]($firstImage)\n'
'![Second]($secondImage)',
extraTags: const [
['imeta', 'url $firstImage', 'm image/png'],
['imeta', 'url $secondImage', 'm image/png'],
],
),
],
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
final carousel = find.byKey(const ValueKey('message-media-carousel'));
final imageCount = find.byKey(
const ValueKey('message-media-carousel-count'),
);
final carouselRect = tester.getRect(carousel);
final imageCountRect = tester.getRect(imageCount);
expect(carouselRect.left, imageCountRect.left);
expect(carouselRect.right, tester.view.physicalSize.width);
expect(carouselRect.top - imageCountRect.bottom, Grid.half + 2);
final messageMaterial = find
.ancestor(
of: find.byKey(const ValueKey('message-row-gallery')),
matching: find.byType(Material),
)
.first;
expect(
tester.widget<Material>(messageMaterial).clipBehavior,
Clip.none,
);
},
);
testWidgets('uses larger participant avatars in reply summaries', (
tester,
) async {
@@ -544,6 +544,104 @@ void main() {
}
});
testWidgets('opening the native attachment popover keeps composer focus', (
tester,
) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
var presentCalls = 0;
_setMockNativeAttachmentPopoverHandler((call) async {
switch (call.method) {
case 'isSupported':
return true;
case 'present':
presentCalls += 1;
return true;
case 'dismiss':
return null;
}
return null;
});
try {
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(nostr.Keys.generate().nsec),
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
await _expandComposer(tester);
await tester.enterText(find.byType(TextField), 'Hello');
await tester.pumpAndSettle();
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.focusNode?.hasFocus, isTrue);
await tester.tap(find.byTooltip('Add attachment').hitTestable());
await tester.pumpAndSettle();
expect(presentCalls, 1);
expect(textField.focusNode?.hasFocus, isTrue);
} finally {
await _sendNativeAttachmentPopoverCall(tester, 'dismissed');
await tester.pumpWidget(const SizedBox.shrink());
_setMockNativeAttachmentPopoverHandler(null);
debugDefaultTargetPlatformOverride = previousPlatform;
}
});
testWidgets(
'unsupported iOS attachment popover unfocuses before fallback menu',
(tester) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
_setMockNativeAttachmentPopoverHandler((call) async {
return switch (call.method) {
'isSupported' => false,
'dismiss' => null,
_ => null,
};
});
try {
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(nostr.Keys.generate().nsec),
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
await _expandComposer(tester);
await tester.enterText(find.byType(TextField), 'Hello');
await tester.pumpAndSettle();
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.focusNode?.hasFocus, isTrue);
await tester.tap(find.byTooltip('Add attachment').hitTestable());
await tester.pumpAndSettle();
expect(textField.focusNode?.hasFocus, isFalse);
expect(find.byKey(const ValueKey('attachment-menu')), findsOneWidget);
} finally {
await tester.pumpWidget(const SizedBox.shrink());
_setMockNativeAttachmentPopoverHandler(null);
debugDefaultTargetPlatformOverride = previousPlatform;
}
},
);
testWidgets('disposing a non-owner keeps native popover callbacks active', (
tester,
) async {
@@ -1150,6 +1248,161 @@ void main() {
expect(find.text('Photos'), findsOneWidget);
});
testWidgets('attachment menu uses roomy rows and surrounding padding', (
tester,
) async {
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(nostr.Keys.generate().nsec),
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
await _openAttachmentMenu(tester);
final menu = find.byKey(const ValueKey('attachment-menu'));
final rows = [
for (final label in ['camera', 'photos', 'video', 'files'])
find.byKey(ValueKey('attachment-menu-item-$label')),
];
final menuRect = tester.getRect(menu);
expect(menuRect.size, const Size(216, 264));
for (final row in rows) {
expect(tester.getSize(row).height, 52);
expect(tester.getRect(row).left - menuRect.left, Grid.xs);
expect(menuRect.right - tester.getRect(row).right, Grid.xs);
}
for (final label in ['Camera', 'Photos', 'Video', 'Files']) {
final text = tester.widget<Text>(find.text(label));
expect(text.style?.fontSize, 20);
}
final icons = [
for (final label in ['camera', 'photos', 'video', 'files'])
find.byKey(ValueKey('attachment-menu-icon-$label')),
];
final labels = [
for (final label in ['camera', 'photos', 'video', 'files'])
find.byKey(ValueKey('attachment-menu-label-$label')),
];
for (final icon in icons) {
expect(tester.getSize(icon).width, 28);
expect(
tester
.widget<Icon>(
find.descendant(of: icon, matching: find.byType(Icon)),
)
.size,
24,
);
}
final labelLeft = tester.getRect(labels.first).left;
for (var index = 0; index < labels.length; index += 1) {
expect(tester.getRect(labels[index]).left, labelLeft);
expect(
tester.getRect(labels[index]).center.dy,
tester.getRect(rows[index]).center.dy,
);
}
expect(tester.getRect(rows.first).top - menuRect.top, Grid.xs);
expect(menuRect.bottom - tester.getRect(rows.last).bottom, Grid.xs);
for (var index = 1; index < rows.length; index += 1) {
expect(
tester.getRect(rows[index]).top -
tester.getRect(rows[index - 1]).bottom,
Grid.xxs,
);
}
});
testWidgets(
'attachment menu grows rows and scrolls for accessibility text',
(tester) async {
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(nostr.Keys.generate().nsec),
textScaler: const TextScaler.linear(4),
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
await _openAttachmentMenu(tester);
final menu = find.byKey(const ValueKey('attachment-menu'));
final rows = [
for (final label in ['camera', 'photos', 'video', 'files'])
find.byKey(ValueKey('attachment-menu-item-$label')),
];
final scrollView = tester.widget<ListView>(
find.byKey(const ValueKey('attachment-menu-scroll')),
);
expect(tester.getSize(menu), const Size(216, 372));
expect(tester.getSize(rows.first).height, greaterThan(52));
expect(scrollView.physics, isA<AlwaysScrollableScrollPhysics>());
await tester.drag(
find.byKey(const ValueKey('attachment-menu-scroll')),
const Offset(0, -300),
);
await tester.pump();
expect(tester.getSize(rows.last).height, greaterThan(52));
expect(tester.takeException(), isNull);
},
);
testWidgets('defers camera startup until the surface morph finishes', (
tester,
) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.android;
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();
expect(
find.byKey(const ValueKey('camera-initialization-deferred')),
findsOneWidget,
);
await tester.pump(const Duration(milliseconds: 300));
expect(
find.byKey(const ValueKey('camera-initialization-deferred')),
findsOneWidget,
);
await tester.pump(const Duration(milliseconds: 20));
expect(
find.byKey(const ValueKey('camera-initialization-ready')),
findsOneWidget,
);
} finally {
debugDefaultTargetPlatformOverride = previousPlatform;
}
});
testWidgets('photo picker errors keep the action visible at large text', (
tester,
) async {
@@ -539,6 +539,51 @@ Photos
},
);
testWidgets(
'keeps adjacent carousel images active and ends with a gutter',
(tester) async {
const first = 'https://example.com/media/gutter-one.png';
const second = 'https://example.com/media/gutter-two.png';
await tester.pumpWidget(
_testable(
const MessageContent(
content:
'''
![image]($first)
![image]($second)
''',
tags: [
['imeta', 'url $first', 'm image/png'],
['imeta', 'url $second', 'm image/png'],
],
),
),
);
await tester.pumpAndSettle();
final carousel = find.byKey(const ValueKey('message-media-carousel'));
final pageViewFinder = find.descendant(
of: carousel,
matching: find.byType(PageView),
);
final pageView = tester.widget<PageView>(pageViewFinder);
expect(pageView.allowImplicitScrolling, isTrue);
expect(pageView.clipBehavior, Clip.none);
pageView.controller!.jumpToPage(1);
await tester.pumpAndSettle();
final lastCard = find.byKey(
const ValueKey('message-media-carousel-item:$second'),
);
expect(
tester.getRect(carousel).right - tester.getRect(lastCard).right,
Grid.gutter,
);
},
);
testWidgets(
'jumps to a selected gallery thumbnail when motion is disabled',
(tester) async {
@@ -4,6 +4,41 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('keeps the separator and timestamp next to a short name', (
tester,
) async {
const displayNameKey = Key('author-display-name');
const timestampKey = Key('author-timestamp');
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: const Scaffold(
body: SizedBox(
width: 300,
child: MessageAuthorMeta(
displayName: 'Alice',
timestamp: '2m',
displayNameKey: displayNameKey,
timestampKey: timestampKey,
nameColor: Colors.black,
metadataColor: Colors.grey,
),
),
),
),
);
await tester.pumpAndSettle();
final displayNameRect = tester.getRect(find.byKey(displayNameKey));
final separatorRect = tester.getRect(find.text('·'));
final timestampRect = tester.getRect(find.byKey(timestampKey));
expect(separatorRect.left - displayNameRect.right, Grid.half);
expect(timestampRect.left - separatorRect.right, Grid.half);
expect(tester.takeException(), isNull);
});
testWidgets('reallocates unused metadata width to the display name', (
tester,
) async {