mirror of
https://github.com/block/buzz.git
synced 2026-11-10 11:21:05 +00:00
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 | | --- | --- | --- | |  |  |  | | Dark mode: sent | Dark mode: received | | --- | --- | |  |  | --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
This commit is contained in:
61
mobile/ios/NativeControlTests/NativePhotoZoomTests.swift
Normal file
61
mobile/ios/NativeControlTests/NativePhotoZoomTests.swift
Normal 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) }
|
||||
}
|
||||
}
|
||||
@@ -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 */,
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
458
mobile/ios/Runner/NativePhotoZoomCoordinator.swift
Normal file
458
mobile/ios/Runner/NativePhotoZoomCoordinator.swift
Normal 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) }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
538
mobile/lib/features/channels/message_content/media_stack.dart
Normal file
538
mobile/lib/features/channels/message_content/media_stack.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
254
mobile/lib/features/channels/native_photo_zoom_preview.dart
Normal file
254
mobile/lib/features/channels/native_photo_zoom_preview.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
|
||||
@@ -209,7 +209,7 @@ void carouselPreviewTests() {
|
||||
child: MessageContent(
|
||||
key: ValueKey(own),
|
||||
ownMessage: own,
|
||||
bubble: true,
|
||||
bubble: false,
|
||||
mediaCarouselTrailingOverflow: Grid.gutter,
|
||||
content:
|
||||
'\n',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
606
mobile/test/features/channels/message_stack_cases.dart
Normal file
606
mobile/test/features/channels/message_stack_cases.dart
Normal 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: '\n\n',
|
||||
),
|
||||
),
|
||||
);
|
||||
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, '').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: '\n',
|
||||
),
|
||||
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,
|
||||
'',
|
||||
).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'));
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
19
scripts/test-mobile-photo-zoom.sh
Normal file
19
scripts/test-mobile-photo-zoom.sh
Normal 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"
|
||||
Reference in New Issue
Block a user