Add swipeable photo stacks and native iOS gallery preview (#9259)

## Summary

Replace bubble photo carousels with swipeable, overlapping photo stacks,
with distinct sent/received layouts and a stationary photo count.
Opening and closing the gallery preserves the selected photo. Photo
frames follow the active theme.

Add an opt-in iOS 18+ native zoom gallery
(`BUZZ_NATIVE_PHOTO_ZOOM=true`) with paging, continuous dot scrubbing,
preview-backed loading, Reply/More actions, and Liquid Glass controls on
iOS 26+. The existing gallery remains the fallback. UIKit’s late corner
settling and brief return-transition input lockout remain known preview
limitations.

### Related issue

None found.

### Testing

Validation: full mobile pre-push checks/tests passed on `2b5bac3075`;
light/dark sent/received widget renders verified. Earlier native
simulator checks covered paging, scrubbing, downward close, and Reply.

Snapshots below are actual Flutter widget renders with sample photos
(not device screenshots).

| Before: carousel | After: sent | After: received |
| --- | --- | --- |
| ![Original photo
carousel](https://github.com/user-attachments/assets/9fb13b0a-d2b9-4429-9bb1-396deeb126da)
| ![Sent photo
stack](https://github.com/user-attachments/assets/ab6a3094-5460-45fb-8020-68c454d6474f)
| ![Received photo
stack](https://github.com/user-attachments/assets/0ec2e803-a5b5-4812-8159-2e0585eea199)
|



| Dark mode: sent | Dark mode: received |
| --- | --- |
| ![Dark sent photo
stack](https://github.com/user-attachments/assets/3d4f993a-4e59-4da8-a30b-c6a0c19ad7eb)
| ![Dark received photo
stack](https://github.com/user-attachments/assets/e552070a-e516-434c-ae76-0f8811184d2e)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-11-10 00:52:02 +00:00
committed by GitHub
parent e8a4bc1591
commit 44b3c4823f
21 changed files with 2771 additions and 124 deletions

View File

@@ -0,0 +1,61 @@
// Concatenated with the production source by test-mobile-photo-zoom.sh so this
// fixture can exercise the private controller without widening its app API.
@main
struct NativePhotoZoomTests {
@MainActor static func main() {
if #available(iOS 18.0, *) {
let data = UIGraphicsImageRenderer(size: CGSize(width: 2, height: 2)).pngData { context in
UIColor.red.setFill(); context.fill(CGRect(x: 0, y: 0, width: 2, height: 2))
}
let viewer = NativePhotoZoomViewController(
image: UIImage(data: data)!, labels: (0..<100).map { "Photo \($0)" },
initialIndex: 0, canReply: false, canMore: false, canDelete: false
)
var requests: [(String, Int, FlutterResult)] = []
viewer.request = { method, index, _, completion in
if method == "select" { completion(nil) }
else { requests.append((method, index, completion)) }
}
viewer.loadViewIfNeeded()
viewer.view.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
viewer.view.layoutIfNeeded()
precondition(!requests.isEmpty && requests.allSatisfy { $0.1 <= 2 }, "Initial requests must stay adjacent")
let pager = viewer.children.compactMap { $0 as? UIPageViewController }.first!
let indicator = descendants(viewer.view).compactMap { $0 as? UIPageControl }.first!
let stale = requests
requests.removeAll()
indicator.currentPage = 50; sendValueChanged(indicator)
precondition(!requests.isEmpty && requests.allSatisfy { (48...52).contains($0.1) }, "Scrubbing must move the request window")
let page = pager.viewControllers!.first!
page.loadViewIfNeeded()
let photo = descendants(page.view).compactMap { $0 as? UIImageView }.first!
for request in stale { request.2(FlutterStandardTypedData(bytes: data)) }
precondition(photo.image == nil, "Late old-window responses cannot paint the selected photo")
let current = requests
requests.removeAll()
for request in current { request.2(FlutterStandardTypedData(bytes: data)) }
precondition(photo.image != nil, "Current-window responses must paint the actual page")
indicator.currentPage = 0; sendValueChanged(indicator)
precondition(requests.contains { $0.1 == 0 }, "Leaving the window evicts old decoded images")
let closing = requests
requests.removeAll()
var closed = false
viewer.onClose = { _ in closed = true }
viewer.viewDidDisappear(false)
for request in closing { request.2(FlutterStandardTypedData(bytes: data)) }
precondition(closed && requests.isEmpty, "Teardown must ignore late loads and stop requesting")
print("PASS: native photo window, stale responses, eviction and teardown")
}
}
@MainActor static func sendValueChanged(_ control: UIControl) {
// A command-line UIKit fixture has no UIApplication action dispatcher.
for target in control.allTargets {
for action in control.actions(forTarget: target, forControlEvent: .valueChanged) ?? [] {
_ = (target as AnyObject).perform(NSSelectorFromString(action), with: control)
}
}
}
@MainActor static func descendants(_ view: UIView) -> [UIView] {
[view] + view.subviews.flatMap { descendants($0) }
}
}

View File

@@ -30,6 +30,7 @@
4A71C0012F40100100A17E01 /* InlinePhotoPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */; };
4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */; };
4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */; };
7B82D0053F40300100B28E02 /* NativePhotoZoomCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B82D0063F40300100B28E02 /* NativePhotoZoomCoordinator.swift */; };
4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */; };
4A71C0092F40500100A17E01 /* JumpToLatestGlassButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */; };
4A71C00B2F40600100A17E01 /* StickyDateGlassHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */; };
@@ -130,6 +131,7 @@
4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlinePhotoPicker.swift; sourceTree = "<group>"; };
4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopover.swift; sourceTree = "<group>"; };
4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopoverCoordinator.swift; sourceTree = "<group>"; };
7B82D0063F40300100B28E02 /* NativePhotoZoomCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePhotoZoomCoordinator.swift; sourceTree = "<group>"; };
4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcentricSheetSurface.swift; sourceTree = "<group>"; };
4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToLatestGlassButton.swift; sourceTree = "<group>"; };
4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickyDateGlassHeader.swift; sourceTree = "<group>"; };
@@ -309,6 +311,7 @@
4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */,
4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */,
4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */,
7B82D0063F40300100B28E02 /* NativePhotoZoomCoordinator.swift */,
4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */,
4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */,
4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */,
@@ -657,6 +660,7 @@
4A71C0012F40100100A17E01 /* InlinePhotoPicker.swift in Sources */,
4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */,
4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */,
7B82D0053F40300100B28E02 /* NativePhotoZoomCoordinator.swift in Sources */,
4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */,
4A71C0092F40500100A17E01 /* JumpToLatestGlassButton.swift in Sources */,
4A71C00B2F40600100A17E01 /* StickyDateGlassHeader.swift in Sources */,

View File

@@ -43,6 +43,7 @@ import os.log
private var ageSignalRequestID: UUID?
private var ageSignalResult: FlutterResult?
private var concentricSheetSurfaceChannel: FlutterMethodChannel?
private var nativePhotoZoomCoordinator: NativePhotoZoomCoordinator?
private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator?
private var nativeEmojiPickerCoordinator: NativeEmojiPickerCoordinator?
private var nativeCreateChannelSheetCoordinator: NativeCreateChannelSheetCoordinator?
@@ -300,6 +301,13 @@ import os.log
)?.viewController
)
nativePhotoZoomCoordinator = NativePhotoZoomCoordinator(
messenger: messenger,
parentViewController: engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzNativePhotoZoom"
)?.viewController
)
let nativeAttachmentRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzNativeAttachmentPopover"
)

View File

@@ -0,0 +1,458 @@
import Flutter
import UIKit
/// Preview-only presentation. Flutter owns authenticated image loading.
final class NativePhotoZoomCoordinator {
private let channel: FlutterMethodChannel
private weak var parent: UIViewController?
private var active: UIViewController?
init(messenger: FlutterBinaryMessenger, parentViewController: UIViewController?) {
channel = FlutterMethodChannel(name: "buzz/native_photo_zoom", binaryMessenger: messenger)
parent = parentViewController
channel.setMethodCallHandler { [weak self] call, result in
guard let self else { result("unavailable"); return }
if call.method == "isSupported" {
if #available(iOS 18.0, *) { result(true) } else { result(false) }
return
}
guard call.method == "present" else { result(FlutterMethodNotImplemented); return }
guard #available(iOS 18.0, *), self.active == nil,
let args = call.arguments as? [String: Any],
let bytes = args["bytes"] as? FlutterStandardTypedData,
let image = UIImage(data: bytes.data),
let x = args["x"] as? Double, let y = args["y"] as? Double,
let width = args["width"] as? Double, let height = args["height"] as? Double,
let angle = args["angle"] as? Double,
[x, y, width, height, angle].allSatisfy({ $0.isFinite }), width > 0, height > 0,
let presenter = self.presentationController(), presenter.presentedViewController == nil,
let window = presenter.view.window
else { result("unavailable"); return }
let anchor = UIImageView(image: image)
anchor.contentMode = .scaleAspectFill
anchor.clipsToBounds = true
anchor.layer.cornerRadius = 26
anchor.layer.cornerCurve = .continuous
anchor.bounds = CGRect(x: 0, y: 0, width: width, height: height)
anchor.center = presenter.view.convert(CGPoint(x: x, y: y), from: window)
anchor.transform = CGAffineTransform(rotationAngle: angle)
anchor.isUserInteractionEnabled = false
anchor.isAccessibilityElement = false
presenter.view.addSubview(anchor)
let labels = args["labels"] as? [String] ?? ["Photo"]
let initial = args["initialIndex"] as? Int ?? 0
guard !labels.isEmpty, labels.indices.contains(initial), let session = args["session"] as? Int else {
anchor.removeFromSuperview(); result("unavailable"); return
}
let controller = NativePhotoZoomViewController(
image: image, labels: labels, initialIndex: initial,
canReply: args["canReply"] as? Bool ?? false,
canMore: args["canMore"] as? Bool ?? false,
canDelete: args["canDelete"] as? Bool ?? false
)
controller.request = { [weak self] method, index, action, completion in
var payload: [String: Any] = ["session": session, "index": index]
if let action { payload["action"] = action }
self?.channel.invokeMethod(method, arguments: payload, result: completion)
}
controller.onSelection = { [weak anchor, weak presenter] image, geometry in
guard let anchor, let presenter else { return }
anchor.image = image
guard let geometry,
let x = geometry["x"] as? Double, let y = geometry["y"] as? Double,
let width = geometry["width"] as? Double, let height = geometry["height"] as? Double,
let angle = geometry["angle"] as? Double,
[x, y, width, height, angle].allSatisfy({ $0.isFinite }), width > 0, height > 0
else { anchor.isHidden = true; return }
anchor.isHidden = false
anchor.transform = .identity
anchor.bounds = CGRect(x: 0, y: 0, width: width, height: height)
anchor.center = presenter.view.convert(CGPoint(x: x, y: y), from: presenter.view.window)
anchor.transform = CGAffineTransform(rotationAngle: angle)
}
// Keep Flutter rendering underneath so page changes can update the return card.
controller.modalPresentationStyle = .overFullScreen
let animate = !(args["reduceMotion"] as? Bool ?? false) && !UIAccessibility.isReduceMotionEnabled
if animate {
let options = UIViewController.Transition.ZoomOptions()
options.interactiveDismissShouldBegin = { [weak controller] context in
controller?.shouldBeginDismissal(location: context.location, velocity: context.velocity) ?? false
}
options.alignmentRectProvider = { [weak controller] _ in controller?.photoAlignmentRect() }
controller.preferredTransition = .zoom(options: options) { [weak anchor] _ in
guard let anchor, !anchor.isHidden else { return nil }; return anchor
}
}
let previousAccessibilityHidden = presenter.view.accessibilityElementsHidden
presenter.view.accessibilityElementsHidden = true
var finished = false
controller.onClose = { [weak self, weak anchor, weak presenter] action in
guard !finished else { return }
finished = true
presenter?.view.accessibilityElementsHidden = previousAccessibilityHidden
anchor?.removeFromSuperview()
self?.active = nil
result(action)
}
self.active = controller
presenter.present(controller, animated: animate)
}
}
// Implicit-engine registration can happen before the Flutter view controller
// exists. Resolve the active scene at presentation time, as the other native
// presentation bridges do, instead of retaining that initial nil value.
private func presentationController() -> UIViewController? {
var controller = parent?.viewIfLoaded?.window != nil ? parent :
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.filter { $0.activationState == .foregroundActive }
.flatMap(\.windows)
.first(where: \.isKeyWindow)?.rootViewController
while let current = controller {
if let navigation = current as? UINavigationController {
controller = navigation.visibleViewController
} else if let tab = current as? UITabBarController {
controller = tab.selectedViewController
} else {
return current
}
}
return nil
}
}
@available(iOS 18.0, *)
private final class NativePhotoZoomViewController: UIViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, UIGestureRecognizerDelegate {
private let pager = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal)
private let indicator = UIPageControl()
private let close = UIButton(type: .system)
private let navigationSizingBar = UINavigationBar()
private var closeCenterConstraint: NSLayoutConstraint?
private let reply = UIButton(type: .system)
private let more = UIButton(type: .system)
private let labels: [String]
private var previews: [Int: UIImage] = [:]
private var images: [Int: UIImage] = [:]
private var pending: [String: UUID] = [:]
private let canReply: Bool, canMore: Bool, canDelete: Bool
private var index: Int
private var action = "closed"
private var finishing = false
private lazy var downwardDismiss = UIPanGestureRecognizer(target: self, action: #selector(dragToDismiss(_:)))
private var paging = false
private var acting = false
var onClose: ((String) -> Void)?
var request: ((String, Int, String?, @escaping FlutterResult) -> Void)?
var onSelection: ((UIImage?, [String: Any]?) -> Void)?
init(image: UIImage, labels: [String], initialIndex: Int, canReply: Bool, canMore: Bool, canDelete: Bool) {
self.previews[initialIndex] = image
self.labels = labels; index = initialIndex
self.canReply = canReply; self.canMore = canMore; self.canDelete = canDelete
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { nil }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
overrideUserInterfaceStyle = .dark
view.accessibilityViewIsModal = true
downwardDismiss.delegate = self
downwardDismiss.maximumNumberOfTouches = 1
view.addGestureRecognizer(downwardDismiss)
pager.dataSource = self; pager.delegate = self
addChild(pager); view.addSubview(pager.view); pager.didMove(toParent: self)
pager.setViewControllers([page(index)], direction: .forward, animated: false)
loadWindow()
configure(close, symbol: "xmark", label: "Close photo", selector: #selector(closePhoto))
configure(reply, symbol: "arrowshape.turn.up.left", label: "Reply in thread", selector: #selector(replyToMessage))
configure(more, symbol: "ellipsis", label: "More image actions", selector: nil)
reply.isEnabled = canReply; more.isEnabled = canMore
more.showsMenuAsPrimaryAction = true
var menu = [UIAction(title: "Save image", image: UIImage(systemName: "square.and.arrow.down")) { [weak self] _ in self?.perform("save") },
UIAction(title: "Share image", image: UIImage(systemName: "square.and.arrow.up")) { [weak self] _ in self?.perform("share") },
UIAction(title: "Copy image link", image: UIImage(systemName: "link")) { [weak self] _ in self?.perform("copy") }]
if canDelete {
menu.append(UIAction(title: "Delete message", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in self?.confirmDelete() })
}
more.menu = UIMenu(children: menu)
indicator.allowsContinuousInteraction = true
indicator.numberOfPages = labels.count; indicator.currentPage = index
indicator.hidesForSinglePage = true
indicator.currentPageIndicatorTintColor = .white
indicator.pageIndicatorTintColor = .gray
indicator.accessibilityLabel = "Photo"
indicator.addTarget(self, action: #selector(selectPage), for: .valueChanged)
let pageContainer = UIView()
let pageSurface: UIVisualEffectView
if #available(iOS 26.0, *) {
let glass = UIGlassEffect(style: .regular)
glass.isInteractive = true
pageSurface = UIVisualEffectView(effect: glass)
} else {
pageSurface = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterialDark))
}
pageSurface.isHidden = labels.count < 2
pageSurface.clipsToBounds = true
pageSurface.layer.cornerRadius = 22
pageSurface.layer.cornerCurve = .continuous
pageContainer.addSubview(pageSurface)
pageSurface.contentView.addSubview(indicator)
pageSurface.translatesAutoresizingMaskIntoConstraints = false
indicator.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
pageContainer.heightAnchor.constraint(equalToConstant: 44),
pageSurface.centerXAnchor.constraint(equalTo: pageContainer.centerXAnchor),
pageSurface.topAnchor.constraint(equalTo: pageContainer.topAnchor),
pageSurface.bottomAnchor.constraint(equalTo: pageContainer.bottomAnchor),
pageSurface.leadingAnchor.constraint(greaterThanOrEqualTo: pageContainer.leadingAnchor),
pageSurface.trailingAnchor.constraint(lessThanOrEqualTo: pageContainer.trailingAnchor),
indicator.leadingAnchor.constraint(equalTo: pageSurface.contentView.leadingAnchor, constant: 8),
indicator.trailingAnchor.constraint(equalTo: pageSurface.contentView.trailingAnchor, constant: -8),
indicator.topAnchor.constraint(equalTo: pageSurface.contentView.topAnchor),
indicator.bottomAnchor.constraint(equalTo: pageSurface.contentView.bottomAnchor),
])
let controls = UIStackView(arrangedSubviews: [reply, pageContainer, more])
controls.axis = .horizontal; controls.alignment = .center; controls.spacing = 8
view.addSubview(close); view.addSubview(controls)
pager.view.translatesAutoresizingMaskIntoConstraints = false
pager.view.clipsToBounds = true
close.translatesAutoresizingMaskIntoConstraints = false
controls.translatesAutoresizingMaskIntoConstraints = false
let closeCenter = close.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
closeCenterConstraint = closeCenter
NSLayoutConstraint.activate([
// Match the existing gallery: fit photos between the toolbar bands.
pager.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 56),
pager.view.bottomAnchor.constraint(equalTo: controls.topAnchor, constant: -16),
pager.view.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
pager.view.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor),
closeCenter,
close.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
close.widthAnchor.constraint(equalToConstant: 44), close.heightAnchor.constraint(equalToConstant: 44),
reply.widthAnchor.constraint(equalToConstant: 44), reply.heightAnchor.constraint(equalToConstant: 44),
more.widthAnchor.constraint(equalToConstant: 44), more.heightAnchor.constraint(equalToConstant: 44),
indicator.heightAnchor.constraint(greaterThanOrEqualToConstant: 44),
controls.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16),
controls.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -16),
controls.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -12),
])
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// Ask UIKit for its compact navigation height, including the current OS metrics.
closeCenterConstraint?.constant = navigationSizingBar.sizeThatFits(view.bounds.size).height / 2
}
func shouldBeginDismissal(location: CGPoint, velocity: CGVector) -> Bool {
!paging && !finishing && pager.view.frame.contains(location)
&& velocity.dy > abs(velocity.dx) * 1.5
&& ((pager.viewControllers?.first as? NativePhotoPage)?.isAtRestingZoom ?? false)
}
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard gestureRecognizer === downwardDismiss else { return true }
let velocity = downwardDismiss.velocity(in: view)
return shouldBeginDismissal(location: downwardDismiss.location(in: view), velocity: CGVector(dx: velocity.x, dy: velocity.y))
}
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
gestureRecognizer === downwardDismiss || otherGestureRecognizer === downwardDismiss
}
@objc private func dragToDismiss(_ gesture: UIPanGestureRecognizer) {
// Nested paging/zoom scroll views can consume UIKit's content-dismiss drag.
// Once an intentional downward drag is established, use the same native
// zoom dismissal as Close. Horizontal drags never begin this recognizer.
guard !isBeingDismissed, !finishing,
gesture.state == .changed || gesture.state == .ended else { return }
let translation = gesture.translation(in: view)
if translation.y > 60 && translation.y > abs(translation.x) * 1.5 { closePhoto() }
}
func photoAlignmentRect() -> CGRect? {
view.layoutIfNeeded()
guard let page = pager.viewControllers?.first as? NativePhotoPage else { return nil }
page.view.layoutIfNeeded()
return page.photoFrame(in: view)
}
private func configure(_ button: UIButton, symbol: String, label: String, selector: Selector?) {
var config: UIButton.Configuration
if #available(iOS 26.0, *) {
config = .glass()
} else {
config = .filled()
config.baseBackgroundColor = .darkGray
}
config.image = UIImage(systemName: symbol)
config.baseForegroundColor = .white; config.cornerStyle = .capsule
button.configuration = config; button.accessibilityLabel = label
if let selector { button.addTarget(self, action: selector, for: .touchUpInside) }
}
private func isNearby(_ value: Int) -> Bool { abs(value - index) <= 2 }
private func trimWindow() {
previews = previews.filter { isNearby($0.key) }
images = images.filter { isNearby($0.key) }
pending = pending.filter { key, _ in
key.split(separator: ":").last.flatMap { Int($0) }.map(isNearby) ?? false
}
for page in pager.children.compactMap({ $0 as? NativePhotoPage }) where !isNearby(page.index) {
page.setImage(nil)
}
}
private func loadWindow() {
trimWindow()
for value in max(0, index - 2)...min(labels.count - 1, index + 2) {
load("preview", value)
}
for page in pager.children.compactMap({ $0 as? NativePhotoPage }) where isNearby(page.index) {
load("image", page.index)
}
}
private func load(_ method: String, _ value: Int) {
guard !finishing, isNearby(value),
(method == "preview" ? previews[value] : images[value]) == nil else { return }
let key = "\(method):\(value)"
guard pending[key] == nil else { return }
let token = UUID()
pending[key] = token
request?(method, value, nil) { [weak self] result in
guard let self, !self.finishing, self.isNearby(value), self.pending[key] == token else { return }
self.pending.removeValue(forKey: key)
let image = (result as? FlutterStandardTypedData).flatMap { UIImage(data: $0.data) }
if let image {
if method == "preview" { self.previews[value] = image }
else { self.images[value] = image }
}
for page in self.pager.children.compactMap({ $0 as? NativePhotoPage }) where page.index == value {
if let image = self.images[value] ?? self.previews[value] { page.setImage(image) }
else if method == "image" { page.setImage(nil) }
}
if self.index == value, method == "image" { self.syncSelection() }
}
}
private func page(_ value: Int) -> NativePhotoPage {
let page = NativePhotoPage(index: value, label: labels[value], image: images[value] ?? previews[value])
load("image", value)
return page
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let page = viewController as? NativePhotoPage, page.index > 0 else { return nil }; return self.page(page.index - 1)
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let page = viewController as? NativePhotoPage, page.index + 1 < labels.count else { return nil }; return self.page(page.index + 1)
}
func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) { paging = true; indicator.isEnabled = false }
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
paging = false; indicator.isEnabled = true
if completed, let page = pager.viewControllers?.first as? NativePhotoPage { selected(page.index) }
}
private func selected(_ value: Int) {
index = value; indicator.currentPage = value
trimWindow()
// Move Dart's request window before asking for newly adjacent images.
syncSelection { [weak self] in self?.loadWindow() }
}
@objc private func selectPage() {
guard !paging, !finishing else { return }
let target = indicator.currentPage
guard target != index else { return }
// Scrubbing must not disable its own control or wait for a page animation.
let direction: UIPageViewController.NavigationDirection = target >= index ? .forward : .reverse
selected(target)
pager.setViewControllers([page(target)], direction: direction, animated: false)
}
private func syncSelection(completion: (() -> Void)? = nil) {
let selected = index
request?("select", selected, nil) { [weak self] result in
guard let self else { return }
if self.index == selected {
self.onSelection?((self.pager.viewControllers?.first as? NativePhotoPage)?.image, result as? [String: Any])
}
completion?()
}
}
@objc private func closePhoto() { finish("closed") }
@objc private func replyToMessage() { if canReply { finish("reply") } }
private func finish(_ result: String) {
guard !finishing else { return }; finishing = true; action = result
syncSelection { [weak self] in self?.dismiss(animated: !UIAccessibility.isReduceMotionEnabled) }
}
private func confirmDelete() {
guard canDelete, !acting else { return }
let alert = UIAlertController(title: "Delete message", message: "This cannot be undone.", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
alert.addAction(UIAlertAction(title: "Delete", style: .destructive) { [weak self, weak alert] _ in
alert?.dismiss(animated: true) { self?.perform("delete") }
})
present(alert, animated: true)
}
private func perform(_ action: String) {
guard canMore, !acting, !finishing else { return }; acting = true; more.isEnabled = false
request?("action", index, action) { [weak self] result in
guard let self else { return }; self.acting = false; self.more.isEnabled = self.canMore
if let error = result as? FlutterError { self.showStatus(error.message ?? "Could not complete action"); return }
if let message = result as? String { self.showStatus(message) }
else if action == "delete" { self.finish("closed") }
}
}
private func showStatus(_ message: String) {
guard !finishing, presentedViewController == nil else { return }
let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .default)); present(alert, animated: true)
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if isBeingDismissed || presentingViewController == nil {
finishing = true
pending.removeAll(); previews.removeAll(); images.removeAll()
request = nil
onClose?(action); onClose = nil
}
}
override func accessibilityPerformEscape() -> Bool { closePhoto(); return true }
}
@available(iOS 18.0, *)
private final class NativePhotoPage: UIViewController, UIScrollViewDelegate {
let index: Int
private let imageView = UIImageView()
private let scroll = UIScrollView()
private let status = UILabel()
private var previousSize = CGSize.zero
var image: UIImage? { imageView.image }
var isAtRestingZoom: Bool { scroll.zoomScale <= scroll.minimumZoomScale + 0.01 }
func photoFrame(in target: UIView) -> CGRect? {
guard let image, image.size.width > 0, image.size.height > 0 else { return nil }
let bounds = imageView.bounds
let scale = min(bounds.width / image.size.width, bounds.height / image.size.height)
let size = CGSize(width: image.size.width * scale, height: image.size.height * scale)
return imageView.convert(CGRect(x: bounds.midX - size.width / 2, y: bounds.midY - size.height / 2, width: size.width, height: size.height), to: target)
}
init(index: Int, label: String, image: UIImage?) {
self.index = index; super.init(nibName: nil, bundle: nil)
imageView.image = image; imageView.accessibilityLabel = label; imageView.isAccessibilityElement = true
status.text = image == nil ? "Loading photo…" : nil
}
required init?(coder: NSCoder) { nil }
override func viewDidLoad() {
super.viewDidLoad(); view.backgroundColor = .black
scroll.delegate = self; scroll.minimumZoomScale = 1; scroll.maximumZoomScale = 5
scroll.contentInsetAdjustmentBehavior = .never
scroll.showsHorizontalScrollIndicator = false; scroll.showsVerticalScrollIndicator = false
imageView.contentMode = .scaleAspectFit
scroll.addSubview(imageView); view.addSubview(scroll)
status.textColor = .white; status.textAlignment = .center; status.numberOfLines = 0
status.font = .preferredFont(forTextStyle: .body); status.adjustsFontForContentSizeCategory = true
view.addSubview(status)
let tap = UITapGestureRecognizer(target: self, action: #selector(toggleZoom)); tap.numberOfTapsRequired = 2; scroll.addGestureRecognizer(tap)
}
func setImage(_ image: UIImage?) { imageView.image = image; status.text = image == nil ? "Image unavailable" : nil }
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews(); scroll.frame = view.bounds; status.frame = view.bounds.insetBy(dx: 24, dy: 100)
guard previousSize != scroll.bounds.size else { return }; previousSize = scroll.bounds.size
scroll.zoomScale = 1; imageView.frame = scroll.bounds; scroll.contentSize = scroll.bounds.size
}
func viewForZooming(in scrollView: UIScrollView) -> UIView? { imageView }
@objc private func toggleZoom() { scroll.setZoomScale(scroll.zoomScale > 1 ? 1 : 2.5, animated: !UIAccessibility.isReduceMotionEnabled) }
}

View File

@@ -404,6 +404,13 @@ class _MessageBubble extends HookConsumerWidget {
),
);
},
nativePhotoActions: nativeImageActions(
context: context,
ref: ref,
message: message,
channelId: currentChannelId,
canManageMessage: canManageMessage,
),
onMediaMore: (viewerContext, imageUrl) =>
showImageActions(
context: viewerContext,

View File

@@ -84,6 +84,7 @@ PageRoute<void> buildImageViewerRoute({
double? aspectRatio,
List<MediaViewerImage>? galleryItems,
int initialIndex = 0,
ValueChanged<int>? onSelectionChanged,
VoidCallback? onReply,
MediaViewerMoreAction? onMore,
bool disableAnimations = false,
@@ -114,6 +115,7 @@ PageRoute<void> buildImageViewerRoute({
semanticLabel: semanticLabel,
galleryItems: images,
initialIndex: safeInitialIndex,
onSelectionChanged: onSelectionChanged,
onReply: onReply,
onMore: onMore,
),
@@ -131,6 +133,7 @@ void openImageViewer(
double? aspectRatio,
List<MediaViewerImage>? galleryItems,
int initialIndex = 0,
ValueChanged<int>? onSelectionChanged,
VoidCallback? onReply,
MediaViewerMoreAction? onMore,
}) {
@@ -143,6 +146,7 @@ void openImageViewer(
aspectRatio: aspectRatio,
galleryItems: galleryItems,
initialIndex: initialIndex,
onSelectionChanged: onSelectionChanged,
onReply: onReply,
onMore: onMore,
disableAnimations: MediaQuery.disableAnimationsOf(context),
@@ -182,6 +186,9 @@ class MediaImageViewerPage extends HookConsumerWidget {
final String? semanticLabel;
final List<MediaViewerImage>? galleryItems;
final int initialIndex;
/// Reports the visible photo immediately, including before any exit path.
final ValueChanged<int>? onSelectionChanged;
final VoidCallback? onReply;
final MediaViewerMoreAction? onMore;
@@ -192,6 +199,7 @@ class MediaImageViewerPage extends HookConsumerWidget {
this.semanticLabel,
this.galleryItems,
this.initialIndex = 0,
this.onSelectionChanged,
this.onReply,
this.onMore,
});
@@ -295,6 +303,7 @@ class MediaImageViewerPage extends HookConsumerWidget {
}
_precacheViewerImages(context, images, index);
currentIndex.value = index;
onSelectionChanged?.call(index);
isTransformed.value = _hasImageTransform(
transformationControllers[index].value,
);

View File

@@ -36,6 +36,7 @@ import '../../shared/read_state/read_state_provider.dart';
import 'thread_detail_page.dart';
import 'thread_follows/thread_follows_provider.dart';
import 'timeline_message.dart';
import 'native_photo_zoom_preview.dart';
import 'unread_badge/is_high_priority_event.dart';
part 'message_actions/quick_reaction_row.dart';
@@ -381,6 +382,30 @@ String downloadedImageFilename(String imageUrl, String? contentType) {
return 'buzz-${DateTime.now().millisecondsSinceEpoch}$extension';
}
Future<void> _saveImageToPhotos(WidgetRef ref, String imageUrl) async {
final needsPhotoLibraryPermission =
defaultTargetPlatform == TargetPlatform.iOS ||
await requiresLegacyMediaStoragePermission();
if (needsPhotoLibraryPermission) {
final permission = await PhotoManager.requestPermissionExtend(
requestOption: const PermissionRequestOption(
iosAccessLevel: IosAccessLevel.addOnly,
androidPermission: AndroidPermission(
type: RequestType.image,
mediaLocation: false,
),
),
);
if (!permission.isAuth) {
throw const FileSystemException(
'Photo library permission was not granted.',
);
}
}
final image = await _downloadImage(ref, imageUrl);
await PhotoManager.editor.saveImage(image.bytes, filename: image.filename);
}
Future<void> _saveImage(
BuildContext context,
WidgetRef ref,
@@ -388,27 +413,7 @@ Future<void> _saveImage(
) async {
final messenger = ScaffoldMessenger.maybeOf(context);
try {
final needsPhotoLibraryPermission =
defaultTargetPlatform == TargetPlatform.iOS ||
await requiresLegacyMediaStoragePermission();
if (needsPhotoLibraryPermission) {
final permission = await PhotoManager.requestPermissionExtend(
requestOption: const PermissionRequestOption(
iosAccessLevel: IosAccessLevel.addOnly,
androidPermission: AndroidPermission(
type: RequestType.image,
mediaLocation: false,
),
),
);
if (!permission.isAuth) {
throw const FileSystemException(
'Photo library permission was not granted.',
);
}
}
final image = await _downloadImage(ref, imageUrl);
await PhotoManager.editor.saveImage(image.bytes, filename: image.filename);
await _saveImageToPhotos(ref, imageUrl);
messenger?.showFeedbackSnackBar(
const SnackBar(content: Text('Saved to Photos')),
kind: ToastKind.success,
@@ -421,6 +426,22 @@ Future<void> _saveImage(
}
}
Future<void> _shareImageFile(
WidgetRef ref,
String imageUrl, {
Rect? shareOrigin,
}) async {
final image = await _downloadImage(ref, imageUrl);
final directory = await getTemporaryDirectory();
final file = File(
'${directory.path}${Platform.pathSeparator}${image.filename}',
);
await file.writeAsBytes(image.bytes, flush: true);
await SharePlus.instance.share(
ShareParams(files: [XFile(file.path)], sharePositionOrigin: shareOrigin),
);
}
Future<void> _shareImage(
BuildContext context,
WidgetRef ref,
@@ -429,15 +450,7 @@ Future<void> _shareImage(
}) async {
final messenger = ScaffoldMessenger.maybeOf(context);
try {
final image = await _downloadImage(ref, imageUrl);
final directory = await getTemporaryDirectory();
final file = File(
'${directory.path}${Platform.pathSeparator}${image.filename}',
);
await file.writeAsBytes(image.bytes, flush: true);
await SharePlus.instance.share(
ShareParams(files: [XFile(file.path)], sharePositionOrigin: shareOrigin),
);
await _shareImageFile(ref, imageUrl, shareOrigin: shareOrigin);
} catch (_) {
messenger?.showFeedbackSnackBar(
const SnackBar(content: Text('Could not share image')),
@@ -446,6 +459,52 @@ Future<void> _shareImage(
}
}
/// Reuses the gallery's downloads, permissions and message authority for UIKit.
NativePhotoActions nativeImageActions({
required BuildContext context,
required WidgetRef ref,
required TimelineMessage message,
required String channelId,
required bool canManageMessage,
}) => NativePhotoActions(
canDelete: canManageMessage,
perform: (action, url) async {
if (!context.mounted) return 'This message is no longer available';
try {
switch (action) {
case 'save':
await _saveImageToPhotos(ref, url);
return 'Saved to Photos';
case 'share':
final size = MediaQuery.sizeOf(context);
await _shareImageFile(
ref,
url,
shareOrigin: Rect.fromLTWH(
size.width - 60,
size.height - 100,
44,
44,
),
);
return null;
case 'copy':
await Clipboard.setData(ClipboardData(text: url));
return 'Image link copied';
case 'delete':
if (!canManageMessage) return 'You cannot delete this message';
await ref
.read(channelActionsProvider)
.deleteMessage(channelId: channelId, eventId: message.id);
return null;
}
} catch (_) {
return 'Could not $action image';
}
return null;
},
);
/// Whether the actions menu should offer **Mark read** for `message`.
///
/// This is the one decision every menu presentation (sheet, popover, native)

View File

@@ -1,6 +1,7 @@
import 'message_reply_drag_target.dart';
import 'package:buzz/shared/widgets/feedback_snackbar.dart';
import 'dart:async';
import 'dart:convert';
import '../../shared/widgets/ios_media_carousel.dart';
import '../../shared/widgets/message_attachment_corners.dart';
import 'dart:io';
@@ -10,6 +11,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:gpt_markdown/gpt_markdown.dart';
@@ -38,6 +40,7 @@ import '../../shared/emoji/emoji_only.dart';
import 'channel_ids_by_name_provider.dart';
import 'local_send_bubble_surface.dart';
import 'media_viewer_page.dart';
import 'native_photo_zoom_preview.dart';
import 'message_content/link_normalizer.dart';
import 'message_content/markdown_escapes.dart';
import 'message_content/preview_text.dart';
@@ -54,6 +57,8 @@ import 'message_media_geometry.dart';
import 'voice_note_attachment.dart';
part 'message_content/media_carousel.dart';
part 'message_content/media_stack.dart';
part 'message_content/media_stack_tuning.dart';
part 'message_content/message_preview.dart';
part 'message_content/bubble_shape.dart';
part 'message_content/inline_components.dart';
@@ -158,6 +163,7 @@ class MessageContent extends HookConsumerWidget {
/// Opens message-specific actions for an image in the full-screen viewer.
final MediaViewerMoreAction? onMediaMore;
final NativePhotoActions? nativePhotoActions;
final TextStyle? baseStyle;
@@ -199,6 +205,7 @@ class MessageContent extends HookConsumerWidget {
this.onMentionTap,
this.onMediaReply,
this.onMediaMore,
this.nativePhotoActions,
this.baseStyle,
this.textAlign,
this.maxLines,
@@ -537,6 +544,17 @@ class MessageContent extends HookConsumerWidget {
: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (bubble)
Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
child: Text(
'${trailingGallery.items.length} photos',
key: const ValueKey('message-media-stack-count'),
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
if (trailingGallery.content.trim().isNotEmpty) markdown,
MessageReplyDragTarget(
child: MessageAttachmentCorners(
@@ -544,17 +562,32 @@ class MessageContent extends HookConsumerWidget {
start: bubbleStart && trailingGallery.content.trim().isEmpty,
end: bubbleEnd,
),
child: _MessageImageCarousel(
key: ValueKey(
trailingGallery.items.map((item) => item.url).join('\u0000'),
),
items: trailingGallery.items,
mirrored: ownMessage,
leadingOverflow: mediaCarouselLeadingOverflow,
trailingOverflow: mediaCarouselTrailingOverflow,
onReply: onMediaReply,
onMore: onMediaMore,
),
child: bubble
? _MessageImageStack(
key: ValueKey(
trailingGallery.items
.map((item) => item.url)
.join('\u0000'),
),
items: trailingGallery.items,
mirrored: ownMessage,
nativeActions: nativePhotoActions,
onReply: onMediaReply,
onMore: onMediaMore,
)
: _MessageImageCarousel(
key: ValueKey(
trailingGallery.items
.map((item) => item.url)
.join('\u0000'),
),
items: trailingGallery.items,
mirrored: ownMessage,
leadingOverflow: mediaCarouselLeadingOverflow,
trailingOverflow: mediaCarouselTrailingOverflow,
onReply: onMediaReply,
onMore: onMediaMore,
),
),
),
],

View File

@@ -0,0 +1,538 @@
part of '../message_content.dart';
/// A compact rounded photo fan with continuous perspective and depth overlap.
class _MessageImageStack extends HookConsumerWidget {
const _MessageImageStack({
super.key,
required this.items,
required this.mirrored,
this.onReply,
this.onMore,
this.nativeActions,
this.showTuningControl = true,
});
final List<MessageGalleryItem> items;
final bool mirrored;
final bool showTuningControl;
final VoidCallback? onReply;
final MediaViewerMoreAction? onMore;
final NativePhotoActions? nativeActions;
@override
Widget build(BuildContext context, WidgetRef ref) {
final signature = items.map((item) => item.url).join('\u0000');
final tags = useMemoized(() => [for (final _ in items) Object()], [
signature,
]);
final sourceKeys = useMemoized(() => [for (final _ in items) GlobalKey()], [
signature,
]);
final opening = useRef(false);
final dragging = useRef(false);
final position = useAnimationController(
lowerBound: double.negativeInfinity,
upperBound: double.infinity,
keys: [signature],
);
final value = useAnimation(position);
final tuning = useValueListenable(_stackTuning);
final start = useRef(0.0);
final distance = useRef(0.0);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final auth = ref.watch(mediaGetAuthServiceProvider);
final index = value.round() % items.length;
void settle(double target, {bool instant = false}) {
if (reducedMotion || instant) {
position.value = target;
} else {
unawaited(
position.animateWith(
ScrollSpringSimulation(
const SpringDescription(mass: 1, stiffness: 300, damping: 32),
position.value,
target,
0,
),
),
);
}
}
return Padding(
padding: EdgeInsets.zero,
child: LayoutBuilder(
builder: (context, constraints) {
// Reserve the full swept silhouette, including shadows, inside the row.
final width = math.min(constraints.maxWidth, 340.0);
final cardWidth = math.min(210.0, width * .62);
final cardHeight = cardWidth;
final height = cardHeight + Grid.xs * 2 + (mirrored ? 16 : 24);
final decodeWidth = cardWidth;
final physicalDecodeWidth =
(cardWidth * MediaQuery.devicePixelRatioOf(context))
.ceilToDouble();
final providers = [
for (final item in items)
ResizeImage.resizeIfNeeded(
physicalDecodeWidth.toInt(),
null,
MediaImageProvider(
url: item.url,
auth: auth,
client: ref.watch(mediaHttpClientProvider),
),
),
];
final viewerItems = [
for (var i = 0; i < items.length; i++)
MediaViewerImage(
url: items[i].url,
heroTag: tags[i],
semanticLabel: items[i].semanticLabel,
previewDecodeWidth: decodeWidth,
aspectRatio: items[i].aspectRatio,
preloadProvider: providers[i],
),
];
Future<void> open() async {
if (opening.value) return;
opening.value = true;
dragging.value = false;
try {
final selected = position.value.round() % items.length;
settle(position.value.roundToDouble(), instant: true);
if (NativePhotoZoomPreview.enabled) {
await WidgetsBinding.instance.endOfFrame;
if (!context.mounted) return;
final gallery = await NativePhotoZoomPreview.open(
context: context,
sourceKeys: sourceKeys,
previewProvider: providers[selected],
previewProviders: providers,
providers: [
for (final item in items)
ResizeImage.resizeIfNeeded(
2048,
null,
MediaImageProvider(
url: item.url,
auth: auth,
client: ref.read(mediaHttpClientProvider),
),
),
],
urls: [for (final item in items) item.url],
mimeTypes: [for (final item in items) item.mimeType],
labels: [for (final item in items) item.semanticLabel],
initialIndex: selected,
onSelect: (index) async {
if (!context.mounted) return;
dragging.value = false;
start.value = index.toDouble();
distance.value = 0;
settle(index.toDouble(), instant: true);
await WidgetsBinding.instance.endOfFrame;
},
onReply: onReply,
actions: nativeActions,
);
if (!gallery || !context.mounted) return;
}
openImageViewer(
context,
imageUrl: items[selected].url,
heroTag: tags[selected],
semanticLabel: items[selected].semanticLabel,
previewDecodeWidth: decodeWidth,
aspectRatio: items[selected].aspectRatio,
galleryItems: viewerItems,
initialIndex: selected,
onSelectionChanged: (index) {
if (!context.mounted) return;
dragging.value = false;
start.value = index.toDouble();
distance.value = 0;
settle(index.toDouble(), instant: true);
},
onReply: onReply,
onMore: onMore,
);
} finally {
opening.value = false;
}
}
// Keep the next hidden card underneath the third slot throughout
// the handoff. It emerges as that slot advances, without a mid-swipe swap.
final offsets = <int, double>{};
for (var n = 0; n <= 3; n++) {
final absolute = value.floor() + n;
final i = absolute % items.length;
final delta = absolute - value;
if (!offsets.containsKey(i) || delta.abs() < offsets[i]!.abs()) {
offsets[i] = delta;
}
}
final departing = value.floor() % items.length;
final arriving = (value.floor() + 1) % items.length;
final fraction = value - value.floor();
offsets[departing] = -fraction;
if (fraction > 0) offsets[arriving] = 1 - fraction;
// Use integer slots, not animated distances: equal-depth cards must
// not trade layers as floating-point values cross during a drag.
final order = offsets.keys.toList()
..sort((a, b) {
final aSlot = (offsets[a]! + fraction).round();
final bSlot = (offsets[b]! + fraction).round();
final depth = bSlot.abs().compareTo(aSlot.abs());
return depth != 0 ? depth : bSlot.compareTo(aSlot);
});
final radius = BorderRadius.circular(Radii.container + Grid.xxs);
final poses = {
for (final i in order)
i: _StackCardPose(
offsets[i]!,
items.length,
cardWidth,
mirrored,
fraction,
adjustments: _stackTuningEnabled ? tuning : null,
),
};
// Keep the front pair ordered even arbitrarily close to an integer.
// A transition epsilon briefly put the old photo back on top during
// the spring's last frames, flashing it before the new index settled.
order.remove(departing);
order.remove(arriving);
if (fraction >= .5) {
order.insert(0, departing);
order.add(arriving);
} else {
order.addAll(
fraction < .5 ? [arriving, departing] : [departing, arriving],
);
}
Widget card(int i) {
final photo = MediaImage(
url: items[i].url,
decodeWidth: decodeWidth,
fit: BoxFit.cover,
semanticLabel: items[i].semanticLabel,
frameBuilder: (context, child, frame, synchronous) =>
frame != null || synchronous
? child
: const MediaLoadingPlaceholder(label: 'Loading image'),
errorBuilder: (_, _, _) => const _MediaPreviewFallback(
icon: BuzzIcons.imageOff,
label: 'Image unavailable',
),
);
return _StackPhotoCard(
key: ValueKey('stack-card:$i'),
pose: poses[i]!,
mirrored: mirrored,
width: cardWidth,
height: cardHeight,
radius: radius,
shadow: items.length <= 3
? 1.0
: offsets[i]! < 0
? (1 + offsets[i]!).clamp(0.0, 1.0)
: (3 - offsets[i]!).clamp(0.0, 1.0),
child: SizedBox(
key: sourceKeys[i],
child: MediaViewerHero(tag: tags[i], child: photo),
),
);
}
return Align(
alignment: mirrored ? Alignment.centerRight : Alignment.centerLeft,
child: Semantics(
key: const ValueKey('message-media-stack-semantics'),
button: true,
label: items[index].semanticLabel,
hint: 'Open photo gallery',
customSemanticsActions: _stackTuningEnabled && showTuningControl
? {
CustomSemanticsAction(label: 'Tune stack'): () =>
_openStackTuning(context, items, mirrored),
}
: null,
value: 'Photo ${index + 1} of ${items.length}',
increasedValue:
'Photo ${(index + 1) % items.length + 1} of ${items.length}',
decreasedValue:
'Photo ${(index - 1) % items.length + 1} of ${items.length}',
onTap: open,
onIncrease: () => settle(position.value.roundToDouble() + 1),
onDecrease: () => settle(position.value.roundToDouble() - 1),
excludeSemantics: true,
child: Focus(
onKeyEvent: (_, event) {
if (event is! KeyDownEvent) {
return KeyEventResult.ignored;
}
if (event.logicalKey == LogicalKeyboardKey.arrowRight ||
event.logicalKey == LogicalKeyboardKey.arrowLeft) {
settle(
position.value.roundToDouble() +
(event.logicalKey == LogicalKeyboardKey.arrowRight
? 1
: -1),
instant: true,
);
return KeyEventResult.handled;
}
if (event.logicalKey == LogicalKeyboardKey.enter ||
event.logicalKey == LogicalKeyboardKey.space) {
open();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: GestureDetector(
key: const ValueKey('message-media-stack'),
behavior: HitTestBehavior.opaque,
onTap: open,
onHorizontalDragStart: (_) {
if (opening.value) return;
dragging.value = true;
position.stop();
start.value = position.value;
distance.value = 0;
},
onHorizontalDragUpdate: (details) {
if (!dragging.value || opening.value) return;
distance.value += details.delta.dx;
if (!reducedMotion) {
position.value = start.value - distance.value / 140;
}
},
onHorizontalDragEnd: (details) {
if (!dragging.value || opening.value) return;
dragging.value = false;
final dx = distance.value;
final speed = details.primaryVelocity ?? 0;
final step = dx.abs() > 48 || speed.abs() > 500
? (dx == 0 ? -speed.sign : -dx.sign) *
math.max(1, (dx.abs() / 140).round())
: 0;
settle(start.value.roundToDouble() + step);
},
onHorizontalDragCancel: () {
if (!dragging.value || opening.value) return;
dragging.value = false;
settle(start.value.roundToDouble());
},
child: SizedBox(
width: width,
height: height,
child: Stack(
key: const ValueKey('message-media-card-deck'),
clipBehavior: Clip.none,
alignment: mirrored
? Alignment.centerRight
: Alignment.centerLeft,
children: [
for (final i in order) card(i),
if (_stackTuningEnabled && showTuningControl)
Positioned(
left: 0,
top: 0,
child: TextButton(
onPressed: () =>
_openStackTuning(context, items, mirrored),
child: const Text('Tune stack'),
),
),
_MessageGalleryPrecache(
providers: providers,
focusedIndex: index,
),
],
),
),
),
),
),
);
},
),
);
}
}
/// Keep both photos solid. Their projected edges meet at the layer exchange,
/// then the arriving card's rounded edge moves across the departing photo.
class _StackCardPose {
_StackCardPose(
double delta,
int count,
double width,
bool mirrored,
double phase, {
List<_StackAdjustment>? adjustments,
}) {
var transform = _transform(
delta,
count,
width,
mirrored,
phase: phase,
adjustments: adjustments,
);
if (delta < 0 && delta > -1) {
final bounds = Rect.fromCenter(
center: Offset.zero,
width: width,
height: width,
);
final outgoing = MatrixUtils.transformRect(
_transform(-.5, count, width, mirrored, adjustments: adjustments),
bounds,
);
final incoming = MatrixUtils.transformRect(
_transform(.5, count, width, mirrored, adjustments: adjustments),
bounds,
);
final overlap = mirrored
? outgoing.right - incoming.left
: incoming.right - outgoing.left;
// Zero velocity at the exchange keeps the seam from jumping. Only the
// outgoing card receives the extra travel; the next card advances in place.
final arc = math.pow(math.sin(-delta * math.pi), 2).toDouble();
transform = Matrix4.translationValues(
(mirrored ? -1 : 1) * math.max(0, overlap) * arc,
0,
0,
)..multiply(transform);
}
if (delta < -.75 && delta > -1) {
// Finish underneath the persistent rear card before recycling this image.
// Matching its entire pose prevents an exposed edge from disappearing.
final rear = _transform(
math.min(count, 3) + delta,
count,
width,
mirrored,
phase: phase,
adjustments: adjustments,
);
final t = Curves.easeInOut.transform(
((-delta - .75) / .2).clamp(0.0, 1.0),
);
for (var i = 0; i < 16; i++) {
transform.storage[i] += (rear.storage[i] - transform.storage[i]) * t;
}
}
matrix = transform;
}
static Matrix4 _transform(
double delta,
int count,
double width,
bool mirrored, {
double phase = .5,
List<_StackAdjustment>? adjustments,
}) {
final depth = (delta < 0 ? -delta * (math.min(count, 3) - 1) : delta.abs())
.clamp(0.0, 2.0);
final adjustment = _StackAdjustment.at(
adjustments ??
(mirrored ? _sentStackAdjustments : _receivedStackAdjustments),
depth,
);
final active = delta.abs() < 1;
// Rear cards travel with the incoming deck, rather than remaining flat
// while only the two leading photos rotate. The shared phase also keeps
// their movement continuous when a card crosses a rear depth slot.
final excursion = math.sin((active ? delta.abs() : phase) * math.pi);
final departing = active && delta < 0;
final direction = mirrored ? 1.0 : -1.0;
// Two backing slots lean in opposite directions. Deeper photos share
// the third card's exact pose, fully covered until that card advances.
final fan = depth <= 1 ? .08 * depth : .08 - .16 * (depth - 1);
final backOffset = depth <= 1 ? -8 * depth : -8 + 16 * (depth - 1);
return Matrix4.identity()
..setEntry(3, 2, .0008)
..translateByDouble(
direction *
(width * (departing ? -.36 : .11) * excursion + backOffset) +
adjustment.x,
depth * 2 + adjustment.y,
0,
1,
)
..rotateY(direction * (departing ? -.95 : .75) * excursion)
..rotateZ(direction * fan + adjustment.degrees * math.pi / 180)
..scaleByDouble(1 - .025 * depth, 1 - .025 * depth, 1, 1);
}
late final Matrix4 matrix;
}
class _StackPhotoCard extends StatelessWidget {
const _StackPhotoCard({
super.key,
required this.pose,
required this.mirrored,
required this.width,
required this.height,
required this.radius,
required this.shadow,
required this.child,
});
final _StackCardPose pose;
final bool mirrored;
final double shadow;
final double width, height;
final BorderRadius radius;
final Widget child;
@override
Widget build(BuildContext context) => Transform.translate(
offset: Offset(
mirrored ? -(Grid.xs + Grid.twelve) : Grid.xs + Grid.twelve,
-Grid.half,
),
child: Transform(
alignment: Alignment.center,
transform: pose.matrix,
child: SizedBox(
width: width,
height: height,
child: DecoratedBox(
decoration: ShapeDecoration(
color: context.colors.surface,
shape: RoundedSuperellipseBorder(borderRadius: radius),
shadows: shadow > 0
? [
BoxShadow(
color: const Color(
0x30000000,
).withValues(alpha: 48 / 255 * shadow),
blurRadius: 12,
offset: Offset(0, 6),
),
]
: null,
),
child: RepaintBoundary(
child: Padding(
padding: const EdgeInsets.all(Grid.half),
child: ClipRSuperellipse(
borderRadius: radius - BorderRadius.circular(Grid.half),
child: child,
),
),
),
),
),
),
);
}

View File

@@ -0,0 +1,185 @@
part of '../message_content.dart';
const _stackTuningEnabled = bool.fromEnvironment('BUZZ_PHOTO_STACK_TUNING');
// Preview-only session settings, shared by message stacks and the tuning sheet.
final _stackTuning = ValueNotifier<List<_StackAdjustment>>(
List.filled(3, const _StackAdjustment()),
);
Future<void> _openStackTuning(
BuildContext context,
List<MessageGalleryItem> items,
bool mirrored,
) => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (_) => FractionallySizedBox(
heightFactor: .9,
child: _StackTuningSheet(items: items, mirrored: mirrored),
),
);
// Offsets selected in the web tuner, relative to the existing card poses.
const _sentStackAdjustments = [
_StackAdjustment(),
// Match the middle card visible in the approved sent-stack reference.
_StackAdjustment(x: 20, y: -7, degrees: 1),
_StackAdjustment(x: -18, degrees: -2),
];
const _receivedStackAdjustments = [
_StackAdjustment(),
_StackAdjustment(x: 3, y: -7, degrees: 10),
_StackAdjustment(x: -3, degrees: -15),
];
class _StackAdjustment {
const _StackAdjustment({this.x = 0, this.y = 0, this.degrees = 0});
final double x, y, degrees;
static _StackAdjustment at(List<_StackAdjustment> slots, double depth) {
final low = depth.floor().clamp(0, 2);
final high = depth.ceil().clamp(0, 2);
final t = depth - low;
final a = slots[low];
final b = slots[high];
return _StackAdjustment(
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
degrees: a.degrees + (b.degrees - a.degrees) * t,
);
}
}
class _StackTuningSheet extends HookConsumerWidget {
const _StackTuningSheet({required this.items, required this.mirrored});
final List<MessageGalleryItem> items;
final bool mirrored;
@override
Widget build(BuildContext context, WidgetRef ref) {
final selected = useState(0);
final settings = useValueListenable(_stackTuning);
final copied = useState(false);
final slot = settings[selected.value];
Widget slider(
String label,
double value,
double max,
_StackAdjustment Function(double) change,
) {
return Row(
children: [
SizedBox(width: 90, child: Text(label)),
Expanded(
child: Slider(
label: value.toStringAsFixed(0),
value: value,
min: -max,
max: max,
divisions: (max * 2).round(),
semanticFormatterCallback: (v) => '$label ${v.round()}',
onChanged: (v) {
final next = [...settings];
next[selected.value] = change(v);
_stackTuning.value = next;
copied.value = false;
},
),
),
SizedBox(width: 36, child: Text(value.toStringAsFixed(0))),
],
);
}
return SafeArea(
key: const ValueKey('stack-tuning-sheet'),
child: SingleChildScrollView(
padding: const EdgeInsets.all(Grid.xs),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Tune photo stack', style: context.textTheme.titleLarge),
const Text('Adjust each visible slot. Swipe to try the motion.'),
Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xs),
child: _MessageImageStack(
items: items,
mirrored: mirrored,
showTuningControl: false,
),
),
SegmentedButton<int>(
segments: [
for (var i = 0; i < 3; i++)
ButtonSegment(value: i, label: Text('Card ${i + 1}')),
],
selected: {selected.value},
onSelectionChanged: (values) => selected.value = values.single,
),
slider(
'Horizontal',
slot.x,
24,
(v) => _StackAdjustment(x: v, y: slot.y, degrees: slot.degrees),
),
slider(
'Vertical',
slot.y,
24,
(v) => _StackAdjustment(x: slot.x, y: v, degrees: slot.degrees),
),
slider(
'Rotation °',
slot.degrees,
15,
(v) => _StackAdjustment(x: slot.x, y: slot.y, degrees: v),
),
const Text(
'Offsets from the current design. Kept for this app session.',
),
Wrap(
alignment: WrapAlignment.center,
children: [
TextButton(
onPressed: () {
_stackTuning.value = List.filled(
3,
const _StackAdjustment(),
);
copied.value = false;
},
child: const Text('Reset all'),
),
TextButton(
onPressed: () async {
await Clipboard.setData(
ClipboardData(
text: jsonEncode([
for (var i = 0; i < settings.length; i++)
{
'card': i + 1,
'x': settings[i].x,
'y': settings[i].y,
'rotation': settings[i].degrees,
},
]),
),
);
if (context.mounted) copied.value = true;
},
child: Text(copied.value ? 'Copied' : 'Copy settings'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Done'),
),
],
),
],
),
),
);
}
}

View File

@@ -11,10 +11,14 @@ class MessageGalleryItem {
final String semanticLabel;
final double? aspectRatio;
/// Declared media type, used to keep potentially animated images in Flutter.
final String? mimeType;
const MessageGalleryItem({
required this.url,
required this.semanticLabel,
required this.aspectRatio,
this.mimeType,
});
}
@@ -60,6 +64,7 @@ TrailingImageGallery? extractTrailingImageGallery(
imeta?.alt ??
(markdownLabel.isNotEmpty ? markdownLabel : 'Message image'),
aspectRatio: imeta?.aspectRatio,
mimeType: imeta?.mimeType,
),
);
cursor -= 1;

View File

@@ -0,0 +1,254 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../shared/relay/media_image.dart';
/// Message-scoped actions shared with the Flutter gallery.
class NativePhotoActions {
const NativePhotoActions({required this.canDelete, required this.perform});
final bool canDelete;
final Future<String?> Function(String action, String url) perform;
}
/// UIKit gallery presentation, enabled in the photo-stack preview.
class NativePhotoZoomPreview {
static const enabled = bool.fromEnvironment('BUZZ_NATIVE_PHOTO_ZOOM');
static const channel = MethodChannel('buzz/native_photo_zoom');
static bool _presenting = false;
static int _session = 0;
/// Returns true only when the existing Flutter gallery is needed as fallback.
///
/// Only JPEG galleries use the static PNG bridge. Animation-capable formats
/// (including ordinary PNGs) and unknown types keep the Flutter renderer.
/// [mimeTypes], when provided, contains one declared type per [urls] entry.
static Future<bool> open({
required BuildContext context,
required List<GlobalKey> sourceKeys,
required List<ImageProvider<Object>> providers,
required List<String> urls,
List<String?>? mimeTypes,
required List<String> labels,
ImageProvider<Object>? previewProvider,
List<ImageProvider<Object>>? previewProviders,
required int initialIndex,
required Future<void> Function(int) onSelect,
VoidCallback? onReply,
NativePhotoActions? actions,
}) async {
if (!enabled || defaultTargetPlatform != TargetPlatform.iOS) return true;
// PNG/APNG, WebP, GIF and unknown formats may animate. The PNG bridge
// cannot preserve their frames, so keep the whole gallery in Flutter.
// Metadata wins over the extension (e.g. a GIF served at a .jpg URL).
for (var i = 0; i < urls.length; i++) {
final mime = mimeTypes?[i]?.split(';').first.trim().toLowerCase();
final path = Uri.tryParse(urls[i])?.path.toLowerCase() ?? '';
if (mime != null
? mime != 'image/jpeg'
: !(path.endsWith('.jpg') || path.endsWith('.jpeg'))) {
return true;
}
}
if (_presenting) return false;
_presenting = true;
final session = ++_session;
_PhotoWindow? window;
try {
if (await channel.invokeMethod<bool>('isSupported') != true) return true;
if (!context.mounted) return false;
final configuration = createLocalImageConfiguration(context);
final reduceMotion = MediaQuery.disableAnimationsOf(context);
final loads = _PhotoWindow(configuration, initialIndex);
window = loads;
final bytes = await loads.load(
initialIndex,
previewProvider ??
previewProviders?[initialIndex] ??
providers[initialIndex],
preview: true,
);
if (bytes == null) return true;
if (!context.mounted) return false;
final geometry = _geometry(sourceKeys[initialIndex]);
if (geometry == null) return true;
channel.setMethodCallHandler((call) async {
final args = call.arguments;
if (args is! Map || args['session'] != session || !context.mounted) {
return null;
}
final index = args['index'];
if (index is! int || index < 0 || index >= providers.length) {
return null;
}
switch (call.method) {
case 'preview':
final provider = previewProviders?[index];
return provider == null
? null
: loads.load(index, provider, preview: true);
case 'image':
return loads.load(index, providers[index]);
case 'select':
loads.select(index);
await onSelect(index).timeout(const Duration(seconds: 2));
if (!context.mounted) return null;
return _geometry(sourceKeys[index]);
case 'action':
final action = args['action'];
if (actions == null ||
!['save', 'share', 'copy', 'delete'].contains(action)) {
return null;
}
if (action == 'delete' && !actions.canDelete) return null;
return actions.perform(action as String, urls[index]);
}
return null;
});
final result = await channel.invokeMethod<String>('present', {
...geometry,
'bytes': bytes,
'session': session,
'labels': labels,
'initialIndex': initialIndex,
'canReply': onReply != null,
'canMore': actions != null,
'canDelete': actions?.canDelete ?? false,
'reduceMotion': reduceMotion,
});
if (result == 'reply' && context.mounted) onReply?.call();
return result == null || result == 'unavailable';
} catch (_) {
// Preserve the established gallery and its error handling on bridge failure.
return context.mounted;
} finally {
window?.dispose();
channel.setMethodCallHandler(null);
_presenting = false;
}
}
static Map<String, double>? _geometry(GlobalKey key) {
final box = key.currentContext?.findRenderObject();
if (box is! RenderBox ||
!box.attached ||
!box.hasSize ||
box.size.isEmpty) {
return null;
}
final center = box.localToGlobal(box.size.center(Offset.zero));
final direction =
box.localToGlobal(Offset(box.size.width, 0)) -
box.localToGlobal(Offset.zero);
return {
'x': center.dx,
'y': center.dy,
'width': direction.distance,
'height': box.size.height * direction.distance / box.size.width,
'angle': direction.direction,
};
}
}
// At most five nearby photos, at preview and full resolution. Moving the window
// abandons stale downloads; closing also releases all listeners and PNG bytes.
class _PhotoWindow {
_PhotoWindow(this.configuration, this.index);
final ImageConfiguration configuration;
int index;
bool disposed = false;
final loads = <(int, bool), _PhotoLoad>{};
Future<Uint8List?> load(
int value,
ImageProvider<Object> provider, {
bool preview = false,
}) {
if (disposed || (value - index).abs() > 2) return Future.value(null);
return (loads[(value, preview)] ??= _PhotoLoad(
provider,
configuration,
)).result;
}
void select(int value) {
index = value;
for (final key in loads.keys.toList()) {
if ((key.$1 - value).abs() > 2) loads.remove(key)?.dispose();
}
}
void dispose() {
disposed = true;
for (final load in loads.values) {
load.dispose();
}
loads.clear();
}
}
class _PhotoLoad {
_PhotoLoad(this.provider, this.configuration) {
stream = provider.resolve(configuration);
listener = ImageStreamListener(
(info, _) {
receivedFrame = true;
if (!frame.isCompleted) {
frame.complete(info);
} else {
info.dispose();
}
},
onError: (Object _, StackTrace? _) {
if (!frame.isCompleted) frame.complete(null);
},
);
stream.addListener(listener);
timer = Timer(const Duration(seconds: 10), dispose);
result = encode();
}
final ImageProvider<Object> provider;
final ImageConfiguration configuration;
final frame = Completer<ImageInfo?>();
late final ImageStream stream;
late final ImageStreamListener listener;
late final Timer timer;
late final Future<Uint8List?> result;
bool disposed = false;
bool detached = false;
bool receivedFrame = false;
Future<Uint8List?> encode() async {
final info = await frame.future;
detach();
try {
if (info == null || disposed) return null;
final data = await info.image.toByteData(format: ui.ImageByteFormat.png);
if (disposed || data == null) return null;
return data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes);
} catch (_) {
return null;
} finally {
info?.dispose();
timer.cancel();
}
}
void detach() {
if (detached) return;
detached = true;
stream.removeListener(listener);
if (!receivedFrame) abandonPendingImage(stream, provider, configuration);
}
void dispose() {
disposed = true;
timer.cancel();
if (!frame.isCompleted) frame.complete(null);
detach();
}
}

View File

@@ -366,6 +366,13 @@ class _ThreadMessage extends HookConsumerWidget {
),
);
},
nativePhotoActions: nativeImageActions(
context: context,
ref: ref,
message: message,
channelId: channelId,
canManageMessage: canManageMessage,
),
onMediaMore: (viewerContext, imageUrl) =>
showImageActions(
context: viewerContext,

View File

@@ -5666,9 +5666,7 @@ void main() {
);
await tester.pumpAndSettle();
await tester.longPress(
find.byKey(const ValueKey('message-media-carousel')),
);
await tester.longPress(find.byKey(const ValueKey('message-media-stack')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('quick-reaction-more')), findsOneWidget);
@@ -5685,7 +5683,7 @@ void main() {
});
testWidgets(
'keeps image galleries body-aligned and flush with the trailing edge',
'keeps photo stacks body-aligned and avatars visible while cycling',
(tester) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
@@ -5717,22 +5715,31 @@ void main() {
);
await tester.pumpAndSettle();
final carousel = find.byKey(const ValueKey('message-media-carousel'));
final carousel = find.byKey(const ValueKey('message-media-stack'));
final imageCount = find.byKey(
const ValueKey('message-media-carousel-count'),
);
final carouselRect = tester.getRect(carousel);
final imageCountRect = tester.getRect(imageCount);
expect(imageCountRect.left, carouselRect.left + Grid.xs);
expect(imageCount, findsNothing);
expect(
imageCountRect.left,
tester
.widget<Semantics>(
find.byKey(const ValueKey('message-media-stack-semantics')),
)
.properties
.value,
'Photo 1 of 2',
);
expect(
carouselRect.left + Grid.xs,
tester
.getRect(find.byKey(const ValueKey('message-author-gallery')))
.left,
);
expect(carouselRect.right, tester.view.physicalSize.width);
expect(carouselRect.top - imageCountRect.bottom, Grid.half + 2);
expect(
carouselRect.right,
lessThanOrEqualTo(tester.view.physicalSize.width),
);
final galleryAvatar = find.descendant(
of: find.byKey(const ValueKey('message-row-gallery')),
matching: find.byType(MessageMediaAvatar),
@@ -5745,34 +5752,30 @@ void main() {
)
.opacity;
expect(avatarOpacity(), 1);
final scroll = tester.widget<CustomScrollView>(
find.descendant(
of: carousel,
matching: find.byType(CustomScrollView),
),
await tester.drag(carousel, const Offset(-140, 0));
await tester.pumpAndSettle();
expect(
tester
.widget<Semantics>(
find.byKey(const ValueKey('message-media-stack-semantics')),
)
.properties
.value,
'Photo 2 of 2',
);
final controller = scroll.controller! as PageController;
final gesture = await tester.startGesture(tester.getCenter(carousel));
await gesture.moveBy(const Offset(-45, 0));
await tester.pump();
await gesture.moveBy(const Offset(-20, 0));
await tester.pump();
expect(avatarOpacity(), lessThan(1));
await gesture.moveBy(const Offset(-100, 0));
await tester.pump();
expect(avatarOpacity(), 0);
await gesture.up();
await tester.pumpAndSettle();
controller.jumpToPage(1);
await tester.pumpAndSettle();
expect(avatarOpacity(), 0);
controller.jumpToPage(0);
await tester.pumpAndSettle();
expect(avatarOpacity(), 1);
await tester.drag(carousel, const Offset(100, 0));
await tester.pump();
expect(controller.offset, greaterThanOrEqualTo(0));
await tester.drag(carousel, const Offset(140, 0));
await tester.pumpAndSettle();
expect(
tester
.widget<Semantics>(
find.byKey(const ValueKey('message-media-stack-semantics')),
)
.properties
.value,
'Photo 1 of 2',
);
expect(avatarOpacity(), 1);
final messageMaterial = find
.ancestor(

View File

@@ -207,10 +207,7 @@ void reviewAvatarTests() {
),
);
await tester.pumpAndSettle();
final gate = find.byType(MessageMediaAvatar).first;
final avatar = find
.descendant(of: gate, matching: find.byType(AvatarImage))
.first;
final profile = tester.getSemantics(
find.bySemanticsLabel('Open Alice profile'),
);
@@ -244,55 +241,44 @@ void reviewAvatarTests() {
return found;
}
final carousel = find.byKey(const ValueKey('message-media-carousel'));
final controller =
tester
.widget<CustomScrollView>(
find.descendant(
of: carousel,
matching: find.byType(CustomScrollView),
),
)
.controller!
as PageController;
final stack = find.byKey(const ValueKey('message-media-stack'));
expect(profileIsAccessible(), isTrue);
controller.jumpTo(-10);
await tester.pump();
expect(profileIsAccessible(), isTrue);
controller.jumpToPage(1);
await tester.drag(stack, const Offset(-140, 0));
await tester.pumpAndSettle();
expect(profileIsAccessible(), isFalse);
final pageBeforeEdit = controller.page;
expect(pageBeforeEdit, greaterThan(0.5));
notifier.setMessages([message('Edited caption')]);
await tester.pumpAndSettle();
expect(controller.page, pageBeforeEdit);
expect(profileIsAccessible(), isFalse);
controller.jumpToPage(0);
await tester.pumpAndSettle();
// The semantics node may be recreated after exclusion.
expect(
tester
.getSemantics(avatar)
.getSemanticsData()
.hasAction(SemanticsAction.tap),
isTrue,
.widget<Semantics>(
find.byKey(const ValueKey('message-media-stack-semantics')),
)
.properties
.value,
'Photo 2 of 2',
);
controller.jumpToPage(1);
// Stacks stay inside their row; cycling never covers the profile.
expect(profileIsAccessible(), isTrue);
notifier.setMessages([message('Edited caption')]);
await tester.pumpAndSettle();
expect(
tester
.widget<Semantics>(
find.byKey(const ValueKey('message-media-stack-semantics')),
)
.properties
.value,
'Photo 2 of 2',
);
expect(profileIsAccessible(), isTrue);
notifier.setMessages([message('New media', 'replacement')]);
await tester.pumpAndSettle();
final replacementController =
tester
.widget<CustomScrollView>(
find.descendant(
of: carousel,
matching: find.byType(CustomScrollView),
),
)
.controller!
as PageController;
expect(replacementController.page, 0);
expect(
tester
.widget<Semantics>(
find.byKey(const ValueKey('message-media-stack-semantics')),
)
.properties
.value,
'Photo 1 of 2',
);
final restored = tester.getSemantics(
find.bySemanticsLabel('Open Alice profile'),
);

View File

@@ -209,7 +209,7 @@ void carouselPreviewTests() {
child: MessageContent(
key: ValueKey(own),
ownMessage: own,
bubble: true,
bubble: false,
mediaCarouselTrailingOverflow: Grid.gutter,
content:
'![one](https://example.com/mirror-one.png)\n![two](https://example.com/mirror-two.png)',

View File

@@ -197,7 +197,7 @@ void bubbleContentTests() {
),
);
await tester.pumpAndSettle();
final gallery = find.byKey(const ValueKey('message-media-carousel'));
final gallery = find.byKey(const ValueKey('message-media-stack'));
expect(gallery, findsOneWidget);
expect(bubbles, findsOneWidget);
expect(find.descendant(of: bubbles, matching: gallery), findsNothing);

View File

@@ -41,6 +41,7 @@ import 'package:buzz/shared/widgets/buzz_loading_indicator.dart';
part 'message_content_bubble_cases.dart';
part 'message_carousel_preview_cases.dart';
part 'message_stack_cases.dart';
Widget _testable(
Widget child, {
@@ -460,6 +461,7 @@ class _TestChannelsNotifier extends ChannelsNotifier {
void main() {
carouselPreviewTests();
stackJourneyTests();
bubbleContentTests();
test('wide voice-note waveforms distribute bars across their full width', () {
const width = 320.0;

View File

@@ -0,0 +1,606 @@
part of 'message_content_test.dart';
void stackJourneyTests() {
testWidgets(
'sent and received stacks browse whole cards and open the selected gallery photo',
(tester) async {
_setSurfaceSize(tester, const Size(375, 800));
tester.view.devicePixelRatio = 3;
tester.view.physicalSize = const Size(1125, 2400);
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final semantics = tester.ensureSemantics();
const first = 'https://example.com/stack-one.png';
const second = 'https://example.com/stack-two.png';
const third = 'https://example.com/stack-three.png';
for (final own in [false, true]) {
await tester.pumpWidget(
_testable(
MessageContent(
key: ValueKey(own),
bubble: true,
ownMessage: own,
content: '![one]($first)\n![two]($second)\n![three]($third)',
),
),
);
await tester.pumpAndSettle();
final stack = find.byKey(const ValueKey('message-media-stack'));
final spoken = find.byKey(
const ValueKey('message-media-stack-semantics'),
);
expect(
tester
.widget<Semantics>(
find.byKey(const ValueKey('message-media-stack-semantics')),
)
.properties
.value,
'Photo 1 of 3',
);
expect(
tester
.getSemantics(spoken)
.getSemanticsData()
.hasAction(SemanticsAction.increase),
isTrue,
);
Finder cardClip(int index) => find.descendant(
of: find.byKey(ValueKey('stack-card:$index')),
matching: find.byType(ClipRSuperellipse),
);
{
Matrix4 pose(int index) => tester
.widgetList<Transform>(
find.descendant(
of: find.byKey(ValueKey('stack-card:$index')),
matching: find.byType(Transform),
),
)
.last
.transform;
// Three-photo stacks retain each reference's distinct middle and
// back slots, rather than giving both rear cards one pose.
expect(pose(1).storage[12], closeTo(own ? 12 : 11, .001));
expect(pose(1).storage[13], closeTo(-5, .001));
expect(pose(1).storage[1], greaterThan(0));
expect(pose(2).storage[12], closeTo(own ? -10 : -11, .001));
expect(pose(2).storage[13], closeTo(4, .001));
expect(pose(2).storage[1], lessThan(0));
}
final resting = tester.getRect(cardClip(0));
final viewport = tester.getRect(stack);
final countLabel = find.byKey(
const ValueKey('message-media-stack-count'),
);
final countBefore = tester.getRect(countLabel);
expect(find.text('3 photos'), findsOneWidget);
expect(
find.byKey(const ValueKey('message-media-stack-counter')),
findsNothing,
);
expect(
own ? viewport.right - resting.right : resting.left - viewport.left,
closeTo(Grid.xs + Grid.half + Grid.twelve, 1),
);
// Sample the complete path, including either side of the layer swap.
final sweep = await tester.startGesture(tester.getCenter(stack));
await sweep.moveBy(const Offset(-20, 0));
for (var step = 1; step < 100; step++) {
await sweep.moveBy(const Offset(-1.4, 0));
await tester.pump();
for (final element
in find
.descendant(
of: stack,
matching: find.byType(ClipRSuperellipse),
)
.evaluate()) {
final box = element.renderObject! as RenderBox;
for (final corner in [
Offset.zero,
Offset(box.size.width, 0),
Offset(0, box.size.height),
Offset(box.size.width, box.size.height),
]) {
expect(
viewport.contains(box.localToGlobal(corner)),
isTrue,
reason:
'phase ${step / 100}: ${box.localToGlobal(corner)} outside $viewport',
);
}
}
}
await sweep.moveBy(const Offset(138.6, 0));
await sweep.up();
await tester.pumpAndSettle();
final drag = await tester.startGesture(tester.getCenter(stack));
await drag.moveBy(const Offset(-20, 0));
await drag.moveBy(const Offset(-42, 0));
await tester.pump();
expect(
tester.getRect(countLabel),
countBefore,
reason: 'Photo count remains stationary during the swipe',
);
expect(find.byKey(const ValueKey('stack-layer-blend')), findsNothing);
await drag.moveBy(const Offset(-28, 0));
await tester.pump();
final arriving = tester.getRect(cardClip(1));
// The solid cards meet at their edges when their paint order changes.
expect((arriving.center.dx - resting.center.dx).abs(), greaterThan(12));
final departing = tester.getRect(cardClip(0));
// The tuned rotation slightly narrows the projected outgoing card.
expect(departing.width, greaterThan(resting.width * .45));
expect(departing.width, lessThan(resting.width * .85));
expect(arriving.width, greaterThan(resting.width * .6));
expect(departing.intersect(arriving).width, lessThan(2));
expect(find.byKey(const ValueKey('stack-layer-blend')), findsNothing);
expect(
find.descendant(of: stack, matching: find.byType(ClipPath)),
findsNothing,
);
// Every photo retains its own clip while the two front cards exchange.
final clips = find.descendant(
of: stack,
matching: find.byType(ClipRSuperellipse),
);
expect(clips, findsNWidgets(3));
for (final clip in tester.widgetList<ClipRSuperellipse>(clips)) {
expect(
clip.borderRadius,
BorderRadius.circular(Radii.container + Grid.xxs - Grid.half),
);
}
final stage = tester.getRect(stack);
for (final element in clips.evaluate()) {
final box = element.renderObject! as RenderBox;
for (final corner in [
Offset.zero,
Offset(box.size.width, 0),
Offset(0, box.size.height),
Offset(box.size.width, box.size.height),
]) {
expect(
stage.contains(box.localToGlobal(corner)),
isTrue,
reason: '${box.localToGlobal(corner)} outside $stage',
);
}
}
await drag.moveBy(const Offset(-28, 0));
await tester.pump();
expect(find.byKey(const ValueKey('stack-layer-blend')), findsNothing);
await drag.up();
await tester.pumpAndSettle();
expect(
tester
.widget<Semantics>(
find.byKey(const ValueKey('message-media-stack-semantics')),
)
.properties
.value,
'Photo 2 of 3',
);
// A canceled touch that never became a drag must not restore an old
// drag origin after a gallery changes the selected photo.
final canceledTouch = await tester.startGesture(
tester.getCenter(stack),
);
await canceledTouch.cancel();
await tester.pumpAndSettle();
expect(
tester.widget<Semantics>(spoken).properties.value,
'Photo 2 of 3',
);
await tester.tap(stack);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('message-media-image-viewer')),
findsOneWidget,
);
expect(
tester.widget<PageIndicator>(find.byType(PageIndicator)).selected,
1,
);
final viewer = tester.widget<MediaImageViewerPage>(
find.byType(MediaImageViewerPage),
);
final preview = viewer.galleryItems![1];
final cardImage = tester.widget<MediaImage>(
find
.descendant(
skipOffstage: false,
of: find.byKey(
const ValueKey('stack-card:1'),
skipOffstage: false,
),
matching: find.byType(MediaImage, skipOffstage: false),
)
.first,
);
expect(cardImage.decodeWidth, lessThanOrEqualTo(210));
expect(preview.previewDecodeWidth, cardImage.decodeWidth);
final resized = preview.preloadProvider! as ResizeImage;
expect(resized.width, (cardImage.decodeWidth! * 3).ceil());
// Page in the full-screen gallery, close, then reopen the same photo.
await tester.drag(find.byType(PageView), const Offset(-350, 0));
await tester.pumpAndSettle();
expect(
tester.widget<PageIndicator>(find.byType(PageIndicator)).selected,
2,
);
await tester.tap(
find.byKey(const ValueKey('message-media-image-viewer-close')),
);
await tester.pumpAndSettle();
expect(
tester.widget<Semantics>(spoken).properties.value,
'Photo 3 of 3',
);
await tester.tap(stack);
await tester.pumpAndSettle();
expect(
tester.widget<PageIndicator>(find.byType(PageIndicator)).selected,
2,
);
await tester.drag(find.byType(PageView), const Offset(350, 0));
await tester.pumpAndSettle();
// System back must preserve selection as well as the Close control.
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(
tester.widget<Semantics>(spoken).properties.value,
'Photo 2 of 3',
);
await tester.drag(stack, const Offset(100, 0));
await tester.pumpAndSettle();
expect(
tester
.widget<Semantics>(
find.byKey(const ValueKey('message-media-stack-semantics')),
)
.properties
.value,
'Photo 1 of 3',
);
final reverse = await tester.startGesture(tester.getCenter(stack));
await reverse.moveBy(const Offset(-20, 0));
await reverse.moveBy(const Offset(-65, 0));
await tester.pump();
await reverse.moveBy(const Offset(65, 0));
await reverse.up();
await tester.pumpAndSettle();
expect(
tester
.widget<Semantics>(
find.byKey(const ValueKey('message-media-stack-semantics')),
)
.properties
.value,
'Photo 1 of 3',
);
expect(
find.byKey(const ValueKey('message-media-image-viewer')),
findsNothing,
);
}
// Larger collections keep their rear cards quiet rather than multiplying
// the fan angle with every additional photo.
for (final own in [false, true]) {
await tester.pumpWidget(
_testable(
MessageContent(
key: ValueKey(own),
ownMessage: own,
bubble: true,
content: List.filled(6, '![photo]($first)').join('\n'),
),
),
);
await tester.pumpAndSettle();
final largeStack = find.byKey(const ValueKey('message-media-stack'));
for (final element
in find
.descendant(
of: largeStack,
matching: find.byType(ClipRSuperellipse),
)
.evaluate()) {
final box = element.renderObject! as RenderBox;
final edge =
box.localToGlobal(Offset(box.size.width, 0)) -
box.localToGlobal(Offset.zero);
expect((edge.dy / edge.dx).abs(), lessThan(own ? .12 : .2));
}
Finder rearClip(int index) => find.descendant(
of: find.byKey(ValueKey('stack-card:$index')),
matching: find.byType(ClipRSuperellipse),
);
final rearBefore = {
for (final i in [2, 3]) i: tester.getRect(rearClip(i)),
};
final rearCenters =
rearBefore.values.map((rect) => rect.center.dx).toList()..sort();
expect(
rearCenters.last - rearCenters.first,
lessThan(1),
reason: 'Large collections use compact backing tiers',
);
List<Key?> rearPaintOrder() => tester
.widget<Stack>(
find.byKey(const ValueKey('message-media-card-deck')),
)
.children
.map((child) => child.key)
.where((key) => [2, 3].any((i) => key == ValueKey('stack-card:$i')))
.toList();
final stableRearOrder = rearPaintOrder();
final deckDrag = await tester.startGesture(
tester.getCenter(largeStack),
);
await deckDrag.moveBy(const Offset(-20, 0));
void checkDeckBounds() {
final stage = tester.getRect(largeStack);
for (final element
in find
.descendant(
of: largeStack,
matching: find.byType(ClipRSuperellipse),
)
.evaluate()) {
final box = element.renderObject! as RenderBox;
for (final corner in [
Offset.zero,
Offset(box.size.width, 0),
Offset(0, box.size.height),
Offset(box.size.width, box.size.height),
]) {
expect(stage.contains(box.localToGlobal(corner)), isTrue);
}
}
}
for (var step = 0; step < 50; step++) {
await deckDrag.moveBy(const Offset(-1.4, 0));
await tester.pump();
checkDeckBounds();
expect(
rearPaintOrder(),
stableRearOrder,
reason: 'Rear cards must not swap layers during a handoff',
);
}
expect(
find.descendant(
of: largeStack,
matching: find.byType(ClipRSuperellipse),
),
findsNWidgets(4),
);
for (final entry in rearBefore.entries) {
final moved = tester.getRect(rearClip(entry.key));
expect(
moved.width,
lessThan(entry.value.width * .9),
reason: 'Rear card ${entry.key} must turn with the deck',
);
expect(
(moved.center.dx - entry.value.center.dx).abs(),
greaterThan(8),
reason: 'Rear card ${entry.key} must travel with the deck',
);
}
for (var step = 0; step < 49; step++) {
await deckDrag.moveBy(const Offset(-1.4, 0));
await tester.pump();
checkDeckBounds();
expect(
rearPaintOrder(),
stableRearOrder,
reason: 'Rear cards must not swap layers during a handoff',
);
}
// The spring can render arbitrarily close to the next integer. The
// arriving photo must remain in front until and after that boundary.
await deckDrag.moveBy(const Offset(-1.393, 0));
await tester.pump();
final nearSettledDeck = tester.widget<Stack>(
find.byKey(const ValueKey('message-media-card-deck')),
);
final photoKeys = nearSettledDeck.children
.map((child) => child.key)
.where(
(key) =>
key is ValueKey<String> &&
key.value.startsWith('stack-card:'),
);
expect(
photoKeys.last,
const ValueKey('stack-card:1'),
reason: 'Do not flash the departing image just before settling',
);
double shadowStrength() => tester
.widgetList<DecoratedBox>(
find.descendant(
of: largeStack,
matching: find.byType(DecoratedBox),
),
)
.map((box) => box.decoration)
.whereType<ShapeDecoration>()
.expand((decoration) => decoration.shadows ?? <BoxShadow>[])
.fold(0.0, (sum, shadow) => sum + shadow.color.a);
final shadowBefore = shadowStrength();
expect(shadowBefore, closeTo(3 * 48 / 255, .001));
final outgoingBox = tester.renderObject<RenderBox>(rearClip(0));
final coveringBox = tester.renderObject<RenderBox>(rearClip(3));
for (final corner in [
Offset.zero,
Offset(outgoingBox.size.width, outgoingBox.size.height),
]) {
expect(
(outgoingBox.localToGlobal(corner) -
coveringBox.localToGlobal(corner))
.distance,
lessThan(.01),
reason: 'Outgoing image is fully covered before removal',
);
}
await deckDrag.up();
await tester.pumpAndSettle();
expect(
shadowStrength(),
closeTo(shadowBefore, .001),
reason: 'The rear shadow must survive the integer handoff',
);
expect(find.byKey(const ValueKey('stack-card:0')), findsNothing);
expect(find.byKey(const ValueKey('stack-card:3')), findsOneWidget);
// Start each next swipe while the previous spring is still running.
for (final dx in [-90.0, -90.0, 90.0, -90.0, 90.0, 90.0]) {
await tester.fling(largeStack, Offset(dx, 0), 2000);
for (var frame = 0; frame < 3; frame++) {
await tester.pump(const Duration(milliseconds: 16));
final value = tester
.widget<Semantics>(
find.byKey(const ValueKey('message-media-stack-semantics')),
)
.properties
.value!;
final selected = int.parse(value.split(' ')[1]) - 1;
final keys = tester
.widget<Stack>(
find.byKey(const ValueKey('message-media-card-deck')),
)
.children
.map((child) => child.key)
.where(
(key) =>
key is ValueKey<String> &&
key.value.startsWith('stack-card:'),
);
expect(keys.last, ValueKey('stack-card:$selected'));
expect(tester.takeException(), isNull);
}
}
await tester.pumpAndSettle();
}
await tester.pumpWidget(
_testable(
const MessageContent(
bubble: true,
content: '![one]($first)\n![duplicate]($first)',
),
disableAnimations: true,
),
);
await tester.pumpAndSettle();
final spoken = find.byKey(
const ValueKey('message-media-stack-semantics'),
);
final node = tester.getSemantics(spoken);
node.owner!.performAction(node.id, SemanticsAction.decrease);
await tester.pump();
expect(
tester
.widget<Semantics>(
find.byKey(const ValueKey('message-media-stack-semantics')),
)
.properties
.value,
'Photo 2 of 2',
);
expect(tester.binding.hasScheduledFrame, isFalse);
expect(tester.takeException(), isNull);
semantics.dispose();
},
);
testWidgets('stack tuner adjusts slots independently and resets', (
tester,
) async {
String? copiedSettings;
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'Clipboard.setData') {
copiedSettings = (call.arguments as Map)['text'] as String;
}
return null;
},
);
addTearDown(
() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
SystemChannels.platform,
null,
),
);
_setSurfaceSize(tester, const Size(430, 932));
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
_testable(
MessageContent(
bubble: true,
ownMessage: true,
content: List.filled(
6,
'![photo](https://example.com/tuning.png)',
).join('\n'),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Tune stack'));
await tester.pumpAndSettle();
final sheet = find.byKey(const ValueKey('stack-tuning-sheet'));
Finder clip(int i) => find.descendant(
of: find.descendant(
of: sheet,
matching: find.byKey(ValueKey('stack-card:$i')),
),
matching: find.byType(ClipRSuperellipse),
);
final first = tester.getRect(clip(0));
final second = tester.getRect(clip(1));
final third = tester.getRect(clip(2));
await tester.tap(find.text('Card 2'));
await tester.pumpAndSettle();
final sliders = find.descendant(of: sheet, matching: find.byType(Slider));
await tester.drag(sliders.at(0), const Offset(45, 0));
await tester.pumpAndSettle();
expect(
tester.getRect(clip(1)).center.dx,
greaterThan(second.center.dx + 1),
);
expect(tester.getRect(clip(0)), first);
expect(tester.getRect(clip(2)), third);
await tester.drag(sliders.at(1), const Offset(35, 0));
await tester.pumpAndSettle();
expect(
tester.getRect(clip(1)).center.dy,
greaterThan(second.center.dy + 1),
);
await tester.drag(sliders.at(2), const Offset(35, 0));
await tester.pumpAndSettle();
expect(tester.widget<Slider>(sliders.at(2)).value, greaterThan(0));
await tester.tap(find.text('Copy settings'));
await tester.pumpAndSettle();
expect(copiedSettings, isNotNull);
final exported = jsonDecode(copiedSettings!) as List;
expect(exported[0]['x'], 0);
expect(exported[1]['x'], greaterThan(0));
expect(exported[2]['x'], 0);
await tester.tap(find.text('Reset all'));
await tester.pumpAndSettle();
expect(tester.getRect(clip(1)), second);
expect(tester.widget<Slider>(sliders.at(0)).value, 0);
expect(tester.widget<Slider>(sliders.at(1)).value, 0);
expect(tester.widget<Slider>(sliders.at(2)).value, 0);
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
expect(sheet, findsNothing);
expect(tester.takeException(), isNull);
}, skip: !const bool.fromEnvironment('BUZZ_PHOTO_STACK_TUNING'));
}

View File

@@ -0,0 +1,403 @@
import 'dart:convert';
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/services.dart';
import 'package:buzz/features/channels/native_photo_zoom_preview.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
final provider = MemoryImage(
base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAEElEQVR4nGNgaPj/H4xhDABS0gn5PEa22gAAAABJRU5ErkJggg==',
),
);
tearDown(() {
debugDefaultTargetPlatformOverride = null;
messenger.setMockMethodCallHandler(NativePhotoZoomPreview.channel, null);
});
for (final outcome in ['closed', 'reply', 'unavailable']) {
testWidgets(
'native photo preview handles $outcome and selected source bounds',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
final key = GlobalKey();
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (value) {
context = value;
return Center(
child: SizedBox(key: key, width: 200, height: 200),
);
},
),
),
);
Map<dynamic, dynamic>? presented;
messenger.setMockMethodCallHandler(NativePhotoZoomPreview.channel, (
call,
) async {
if (call.method == 'isSupported') return true;
presented = call.arguments as Map;
return outcome;
});
final result = await tester.runAsync(
() => NativePhotoZoomPreview.open(
context: context,
sourceKeys: [key],
providers: [provider],
previewProviders: [provider],
urls: ['https://example.org/photo.jpg'],
initialIndex: 0,
onSelect: (_) async {},
labels: ['Selected photo'],
),
);
debugDefaultTargetPlatformOverride = null;
expect(result, outcome == 'unavailable');
expect(presented?['labels'], ['Selected photo']);
expect(presented?.containsKey('previews'), isFalse);
expect(presented?['width'], 200);
expect(presented?['height'], 200);
expect(presented?['x'], 400);
expect(presented?['y'], 300);
expect(presented?['angle'], 0);
expect(presented?['bytes'], isA<Uint8List>());
},
skip: !NativePhotoZoomPreview.enabled,
);
}
testWidgets(
'native gallery selects the right URL and enforces action authority',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
final keys = [GlobalKey(), GlobalKey()];
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (value) {
context = value;
return Row(
children: [
for (final key in keys)
SizedBox(key: key, width: 100, height: 100),
],
);
},
),
),
);
final pendingPreview = _PendingPreview();
final selected = <int>[];
final actions = <String>[];
var replied = false;
const codec = StandardMethodCodec();
Future<Object?> nativeCall(
String method,
Map<String, Object> args,
) async {
final reply = Completer<Object?>();
await messenger.handlePlatformMessage(
NativePhotoZoomPreview.channel.name,
codec.encodeMethodCall(MethodCall(method, args)),
(data) {
reply.complete(data == null ? null : codec.decodeEnvelope(data));
},
);
return reply.future;
}
messenger.setMockMethodCallHandler(NativePhotoZoomPreview.channel, (
call,
) async {
if (call.method == 'isSupported') return true;
final args = call.arguments as Map;
expect(args['initialIndex'], 1);
expect(pendingPreview.frame.isCompleted, isFalse);
expect(args.containsKey('previews'), isFalse);
final codec = await ui.instantiateImageCodec(provider.bytes);
final frame = await codec.getNextFrame();
pendingPreview.frame.complete(ImageInfo(image: frame.image));
codec.dispose();
expect(args['canReply'], isTrue);
expect(args['canDelete'], isFalse);
final payload = <String, Object>{
'session': args['session'] as int,
'index': 1,
};
expect(await nativeCall('select', payload), isA<Map>());
expect(await nativeCall('image', payload), isA<Uint8List>());
expect(
await nativeCall('preview', {...payload, 'index': 0}),
isA<Uint8List>(),
);
await nativeCall('action', {...payload, 'action': 'copy'});
await nativeCall('action', {...payload, 'action': 'delete'});
await nativeCall('action', {...payload, 'index': 99, 'action': 'copy'});
await nativeCall('action', {
...payload,
'session': -1,
'action': 'copy',
});
return 'reply';
});
final fallback = await tester.runAsync(
() => NativePhotoZoomPreview.open(
context: context,
sourceKeys: keys,
providers: [provider, provider],
previewProviders: [pendingPreview, provider],
urls: [
'https://example.org/first.jpg',
'https://example.org/second.jpg',
],
labels: ['First', 'Second'],
initialIndex: 1,
onSelect: (index) async {
selected.add(index);
},
onReply: () {
replied = true;
},
actions: NativePhotoActions(
canDelete: false,
perform: (action, url) async {
actions.add('$action:$url');
return null;
},
),
),
);
debugDefaultTargetPlatformOverride = null;
expect(fallback, isFalse);
expect(replied, isTrue);
expect(selected, [1]);
expect(actions, ['copy:https://example.org/second.jpg']);
},
skip: !NativePhotoZoomPreview.enabled,
);
testWidgets(
'large native gallery loads only nearby requests and abandons work on move and close',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
final key = GlobalKey();
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (value) {
context = value;
return SizedBox(key: key, width: 100, height: 100);
},
),
),
);
final pending = List.generate(50, (_) => _PendingPreview());
final requests = <Future<Object?>>[];
const codec = StandardMethodCodec();
Future<Object?> nativeCall(String method, int session, int index) async {
final reply = Completer<Object?>();
await messenger.handlePlatformMessage(
NativePhotoZoomPreview.channel.name,
codec.encodeMethodCall(
MethodCall(method, {'session': session, 'index': index}),
),
(data) =>
reply.complete(data == null ? null : codec.decodeEnvelope(data)),
);
return reply.future;
}
messenger.setMockMethodCallHandler(NativePhotoZoomPreview.channel, (
call,
) async {
if (call.method == 'isSupported') return true;
final session = (call.arguments as Map)['session'] as int;
expect(pending.where((p) => p.started), isEmpty);
requests.add(nativeCall('preview', session, 1));
requests.add(nativeCall('image', session, 2));
expect(await nativeCall('preview', session, 49), isNull);
await nativeCall('select', session, 25);
expect(await Future.wait(requests), everyElement(isNull));
expect(pending[1].abandoned, isTrue);
expect(pending[2].abandoned, isTrue);
requests.add(nativeCall('preview', session, 24));
requests.add(nativeCall('image', session, 26));
// Drain delivery, without resolving either image, then dismiss.
await nativeCall('preview', session, 49);
expect(pending.where((p) => p.started).length, 4);
return 'closed';
});
await tester.runAsync(() async {
expect(
await NativePhotoZoomPreview.open(
context: context,
sourceKeys: List.filled(50, key),
providers: pending,
previewProvider: provider,
previewProviders: pending,
urls: List.generate(50, (i) => 'https://example.org/$i.jpg'),
labels: List.generate(50, (i) => 'Photo $i'),
initialIndex: 0,
onSelect: (_) async {},
),
isFalse,
);
expect(pending[24].abandoned, isTrue);
expect(pending[26].abandoned, isTrue);
expect(await Future.wait(requests), everyElement(isNull));
// Late provider completion cannot resurrect a closed native session.
for (final load in pending.where((p) => p.started)) {
final imageCodec = await ui.instantiateImageCodec(provider.bytes);
final frame = await imageCodec.getNextFrame();
load.frame.complete(ImageInfo(image: frame.image));
imageCodec.dispose();
}
});
await tester.pump();
expect(tester.takeException(), isNull);
debugDefaultTargetPlatformOverride = null;
},
skip: !NativePhotoZoomPreview.enabled,
);
testWidgets('animation-capable galleries retain all frames in Flutter', (
tester,
) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
// Two one-pixel frames, red then blue, with a 100ms frame delay.
final animated = base64Decode(
'R0lGODlhAQABAIAAAP8AAAAA/yH5BAAKAAAALAAAAAABAAEAAAICRAEAIfkEAAoAAAAsAAAAAAEAAQAAAgJMAQA7',
);
final image = MemoryImage(animated);
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (value) {
context = value;
return const SizedBox();
},
),
),
);
var bridgeCalls = 0;
messenger.setMockMethodCallHandler(NativePhotoZoomPreview.channel, (
_,
) async {
bridgeCalls++;
return false;
});
for (final source in [
('https://example.org/photo.png', null),
('https://example.org/photo.apng', null),
('https://example.org/photo.webp', null),
('https://example.org/photo.jpg', 'image/gif'),
('https://example.org/photo', 'image/webp'),
('https://example.org/photo', null),
]) {
expect(
await NativePhotoZoomPreview.open(
context: context,
sourceKeys: [GlobalKey(), GlobalKey()],
providers: [provider, image],
urls: ['https://example.org/still.jpg', source.$1],
mimeTypes: ['image/jpeg', source.$2],
labels: ['Still', 'Animated'],
initialIndex: 0,
onSelect: (_) async {},
),
isTrue,
);
}
expect(
bridgeCalls,
0,
reason: 'Potential animations must bypass the PNG bridge',
);
await tester.runAsync(() async {
final codec = await ui.instantiateImageCodec(animated);
expect(codec.frameCount, 2);
final first = await codec.getNextFrame();
final second = await codec.getNextFrame();
final firstPixels = await first.image.toByteData();
final secondPixels = await second.image.toByteData();
expect(
firstPixels!.buffer.asUint8List(),
isNot(secondPixels!.buffer.asUint8List()),
);
first.image.dispose();
second.image.dispose();
codec.dispose();
});
debugDefaultTargetPlatformOverride = null;
});
testWidgets(
'unsupported platform preserves existing gallery without decoding',
(tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
late BuildContext context;
await tester.pumpWidget(
MaterialApp(
home: Builder(
builder: (value) {
context = value;
return const SizedBox();
},
),
),
);
messenger.setMockMethodCallHandler(NativePhotoZoomPreview.channel, (
call,
) async {
expect(call.method, 'isSupported');
return false;
});
expect(
await NativePhotoZoomPreview.open(
context: context,
sourceKeys: [GlobalKey()],
providers: [provider],
urls: ['https://example.org/photo.jpg'],
initialIndex: 0,
onSelect: (_) async {},
labels: ['Photo'],
),
isTrue,
);
debugDefaultTargetPlatformOverride = null;
},
);
}
class _PendingPreview extends ImageProvider<_PendingPreview> {
late final frame = Completer<ImageInfo>();
bool started = false;
bool abandoned = false;
@override
Future<_PendingPreview> obtainKey(ImageConfiguration configuration) =>
SynchronousFuture(this);
@override
ImageStreamCompleter loadImage(
_PendingPreview key,
ImageDecoderCallback decode,
) {
started = true;
return OneFrameImageStreamCompleter(frame.future)
..addOnLastListenerRemovedCallback(() {
abandoned = true;
});
}
}

View File

@@ -0,0 +1,19 @@
#!/bin/bash
# Exercise the production native gallery on a booted iOS simulator.
# Usage: /bin/bash scripts/test-mobile-photo-zoom.sh <simulator-udid>
set -euo pipefail
repo_root=$(cd "$(dirname "$0")/.." && pwd)
device=${1:?Pass a booted iOS simulator UDID}
flutter_root=$(sed -n 's/^FLUTTER_ROOT=//p' "$repo_root/mobile/ios/Flutter/Generated.xcconfig")
frameworks="$flutter_root/bin/cache/artifacts/engine/ios/Flutter.xcframework/ios-arm64_x86_64-simulator"
fixture=$(mktemp -d)
trap 'rm -r "$fixture"' EXIT
cat "$repo_root/mobile/ios/Runner/NativePhotoZoomCoordinator.swift" \
"$repo_root/mobile/ios/NativeControlTests/NativePhotoZoomTests.swift" > "$fixture/test.swift"
xcrun --sdk iphonesimulator swiftc -parse-as-library \
-sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)" \
-target "$(uname -m)-apple-ios18.0-simulator" \
-F "$frameworks" -framework Flutter \
-Xlinker -rpath -Xlinker "$frameworks" \
"$fixture/test.swift" -o "$fixture/photo-zoom-tests"
xcrun simctl spawn "$device" "$fixture/photo-zoom-tests"