From 44b3c4823f0cf1f7715f85dbf5566cb64e848162 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 10 Nov 2026 00:52:02 +0000 Subject: [PATCH] Add swipeable photo stacks and native iOS gallery preview (#9259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Replace bubble photo carousels with swipeable, overlapping photo stacks, with distinct sent/received layouts and a stationary photo count. Opening and closing the gallery preserves the selected photo. Photo frames follow the active theme. Add an opt-in iOS 18+ native zoom gallery (`BUZZ_NATIVE_PHOTO_ZOOM=true`) with paging, continuous dot scrubbing, preview-backed loading, Reply/More actions, and Liquid Glass controls on iOS 26+. The existing gallery remains the fallback. UIKit’s late corner settling and brief return-transition input lockout remain known preview limitations. ### Related issue None found. ### Testing Validation: full mobile pre-push checks/tests passed on `2b5bac3075`; light/dark sent/received widget renders verified. Earlier native simulator checks covered paging, scrubbing, downward close, and Reply. Snapshots below are actual Flutter widget renders with sample photos (not device screenshots). | Before: carousel | After: sent | After: received | | --- | --- | --- | | ![Original photo carousel](https://github.com/user-attachments/assets/9fb13b0a-d2b9-4429-9bb1-396deeb126da) | ![Sent photo stack](https://github.com/user-attachments/assets/ab6a3094-5460-45fb-8020-68c454d6474f) | ![Received photo stack](https://github.com/user-attachments/assets/0ec2e803-a5b5-4812-8159-2e0585eea199) | | Dark mode: sent | Dark mode: received | | --- | --- | | ![Dark sent photo stack](https://github.com/user-attachments/assets/3d4f993a-4e59-4da8-a30b-c6a0c19ad7eb) | ![Dark received photo stack](https://github.com/user-attachments/assets/e552070a-e516-434c-ae76-0f8811184d2e) | --------- Signed-off-by: kenny lopez Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> --- .../NativePhotoZoomTests.swift | 61 ++ mobile/ios/Runner.xcodeproj/project.pbxproj | 4 + mobile/ios/Runner/AppDelegate.swift | 8 + .../Runner/NativePhotoZoomCoordinator.swift | 458 +++++++++++++ .../channel_detail_page/message_bubble.dart | 7 + .../features/channels/media_viewer_page.dart | 9 + .../features/channels/message_actions.dart | 119 +++- .../features/channels/message_content.dart | 55 +- .../channels/message_content/media_stack.dart | 538 ++++++++++++++++ .../message_content/media_stack_tuning.dart | 185 ++++++ .../features/channels/message_gallery.dart | 5 + .../channels/native_photo_zoom_preview.dart | 254 ++++++++ .../thread_detail_page/thread_message.dart | 7 + .../channels/channel_detail_page_test.dart | 75 +-- .../review_avatar_tests.dart | 76 +-- .../message_carousel_preview_cases.dart | 2 +- .../message_content_bubble_cases.dart | 2 +- .../channels/message_content_test.dart | 2 + .../channels/message_stack_cases.dart | 606 ++++++++++++++++++ .../native_photo_zoom_preview_test.dart | 403 ++++++++++++ scripts/test-mobile-photo-zoom.sh | 19 + 21 files changed, 2771 insertions(+), 124 deletions(-) create mode 100644 mobile/ios/NativeControlTests/NativePhotoZoomTests.swift create mode 100644 mobile/ios/Runner/NativePhotoZoomCoordinator.swift create mode 100644 mobile/lib/features/channels/message_content/media_stack.dart create mode 100644 mobile/lib/features/channels/message_content/media_stack_tuning.dart create mode 100644 mobile/lib/features/channels/native_photo_zoom_preview.dart create mode 100644 mobile/test/features/channels/message_stack_cases.dart create mode 100644 mobile/test/features/channels/native_photo_zoom_preview_test.dart create mode 100644 scripts/test-mobile-photo-zoom.sh diff --git a/mobile/ios/NativeControlTests/NativePhotoZoomTests.swift b/mobile/ios/NativeControlTests/NativePhotoZoomTests.swift new file mode 100644 index 0000000000..f5790063f5 --- /dev/null +++ b/mobile/ios/NativeControlTests/NativePhotoZoomTests.swift @@ -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) } + } +} diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 8b2b106522..f47764b7d2 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopover.swift; sourceTree = ""; }; 4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopoverCoordinator.swift; sourceTree = ""; }; + 7B82D0063F40300100B28E02 /* NativePhotoZoomCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativePhotoZoomCoordinator.swift; sourceTree = ""; }; 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcentricSheetSurface.swift; sourceTree = ""; }; 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToLatestGlassButton.swift; sourceTree = ""; }; 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickyDateGlassHeader.swift; sourceTree = ""; }; @@ -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 */, diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index e0d4fabb29..569bfc5ae3 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -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" ) diff --git a/mobile/ios/Runner/NativePhotoZoomCoordinator.swift b/mobile/ios/Runner/NativePhotoZoomCoordinator.swift new file mode 100644 index 0000000000..52d6ae5fc8 --- /dev/null +++ b/mobile/ios/Runner/NativePhotoZoomCoordinator.swift @@ -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) } +} diff --git a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart index 1e8126f92c..1fe305fdff 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_bubble.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_bubble.dart @@ -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, diff --git a/mobile/lib/features/channels/media_viewer_page.dart b/mobile/lib/features/channels/media_viewer_page.dart index 9928f0d9f5..2a03ce4baf 100644 --- a/mobile/lib/features/channels/media_viewer_page.dart +++ b/mobile/lib/features/channels/media_viewer_page.dart @@ -84,6 +84,7 @@ PageRoute buildImageViewerRoute({ double? aspectRatio, List? galleryItems, int initialIndex = 0, + ValueChanged? onSelectionChanged, VoidCallback? onReply, MediaViewerMoreAction? onMore, bool disableAnimations = false, @@ -114,6 +115,7 @@ PageRoute buildImageViewerRoute({ semanticLabel: semanticLabel, galleryItems: images, initialIndex: safeInitialIndex, + onSelectionChanged: onSelectionChanged, onReply: onReply, onMore: onMore, ), @@ -131,6 +133,7 @@ void openImageViewer( double? aspectRatio, List? galleryItems, int initialIndex = 0, + ValueChanged? 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? galleryItems; final int initialIndex; + + /// Reports the visible photo immediately, including before any exit path. + final ValueChanged? 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, ); diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index 2d463396b2..2ba3e1d740 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -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 _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 _saveImage( BuildContext context, WidgetRef ref, @@ -388,27 +413,7 @@ Future _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 _saveImage( } } +Future _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 _shareImage( BuildContext context, WidgetRef ref, @@ -429,15 +450,7 @@ Future _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 _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) diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 8bd49741df..4542e17d4d 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -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, + ), ), ), ], diff --git a/mobile/lib/features/channels/message_content/media_stack.dart b/mobile/lib/features/channels/message_content/media_stack.dart new file mode 100644 index 0000000000..643232821c --- /dev/null +++ b/mobile/lib/features/channels/message_content/media_stack.dart @@ -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 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 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 = {}; + 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, + ), + ), + ), + ), + ), + ), + ); +} diff --git a/mobile/lib/features/channels/message_content/media_stack_tuning.dart b/mobile/lib/features/channels/message_content/media_stack_tuning.dart new file mode 100644 index 0000000000..9e5da33ff0 --- /dev/null +++ b/mobile/lib/features/channels/message_content/media_stack_tuning.dart @@ -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.filled(3, const _StackAdjustment()), +); + +Future _openStackTuning( + BuildContext context, + List items, + bool mirrored, +) => showModalBottomSheet( + 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 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( + 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'), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/message_gallery.dart b/mobile/lib/features/channels/message_gallery.dart index d65e2f1e18..842a478fe3 100644 --- a/mobile/lib/features/channels/message_gallery.dart +++ b/mobile/lib/features/channels/message_gallery.dart @@ -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; diff --git a/mobile/lib/features/channels/native_photo_zoom_preview.dart b/mobile/lib/features/channels/native_photo_zoom_preview.dart new file mode 100644 index 0000000000..f613fcdb26 --- /dev/null +++ b/mobile/lib/features/channels/native_photo_zoom_preview.dart @@ -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 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 open({ + required BuildContext context, + required List sourceKeys, + required List> providers, + required List urls, + List? mimeTypes, + required List labels, + ImageProvider? previewProvider, + List>? previewProviders, + required int initialIndex, + required Future 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('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('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? _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 load( + int value, + ImageProvider 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 provider; + final ImageConfiguration configuration; + final frame = Completer(); + late final ImageStream stream; + late final ImageStreamListener listener; + late final Timer timer; + late final Future result; + bool disposed = false; + bool detached = false; + bool receivedFrame = false; + + Future 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(); + } +} diff --git a/mobile/lib/features/channels/thread_detail_page/thread_message.dart b/mobile/lib/features/channels/thread_detail_page/thread_message.dart index 7d33df5e89..cbebc7b5ad 100644 --- a/mobile/lib/features/channels/thread_detail_page/thread_message.dart +++ b/mobile/lib/features/channels/thread_detail_page/thread_message.dart @@ -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, diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 3d48ab4d18..51fc26e75b 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -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( + 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( - find.descendant( - of: carousel, - matching: find.byType(CustomScrollView), - ), + await tester.drag(carousel, const Offset(-140, 0)); + await tester.pumpAndSettle(); + expect( + tester + .widget( + 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( + find.byKey(const ValueKey('message-media-stack-semantics')), + ) + .properties + .value, + 'Photo 1 of 2', + ); + expect(avatarOpacity(), 1); final messageMaterial = find .ancestor( diff --git a/mobile/test/features/channels/channel_detail_page_test/review_avatar_tests.dart b/mobile/test/features/channels/channel_detail_page_test/review_avatar_tests.dart index 6dc920fe7a..30da59bd88 100644 --- a/mobile/test/features/channels/channel_detail_page_test/review_avatar_tests.dart +++ b/mobile/test/features/channels/channel_detail_page_test/review_avatar_tests.dart @@ -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( - 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( + 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( + 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( - find.descendant( - of: carousel, - matching: find.byType(CustomScrollView), - ), - ) - .controller! - as PageController; - expect(replacementController.page, 0); + expect( + tester + .widget( + find.byKey(const ValueKey('message-media-stack-semantics')), + ) + .properties + .value, + 'Photo 1 of 2', + ); final restored = tester.getSemantics( find.bySemanticsLabel('Open Alice profile'), ); diff --git a/mobile/test/features/channels/message_carousel_preview_cases.dart b/mobile/test/features/channels/message_carousel_preview_cases.dart index d7ff26a374..9cde5917db 100644 --- a/mobile/test/features/channels/message_carousel_preview_cases.dart +++ b/mobile/test/features/channels/message_carousel_preview_cases.dart @@ -209,7 +209,7 @@ void carouselPreviewTests() { child: MessageContent( key: ValueKey(own), ownMessage: own, - bubble: true, + bubble: false, mediaCarouselTrailingOverflow: Grid.gutter, content: '![one](https://example.com/mirror-one.png)\n![two](https://example.com/mirror-two.png)', diff --git a/mobile/test/features/channels/message_content_bubble_cases.dart b/mobile/test/features/channels/message_content_bubble_cases.dart index d4bac05b27..601b27207a 100644 --- a/mobile/test/features/channels/message_content_bubble_cases.dart +++ b/mobile/test/features/channels/message_content_bubble_cases.dart @@ -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); diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 755fcabd02..b29be5732b 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -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; diff --git a/mobile/test/features/channels/message_stack_cases.dart b/mobile/test/features/channels/message_stack_cases.dart new file mode 100644 index 0000000000..a4592a778a --- /dev/null +++ b/mobile/test/features/channels/message_stack_cases.dart @@ -0,0 +1,606 @@ +part of 'message_content_test.dart'; + +void stackJourneyTests() { + testWidgets( + 'sent and received stacks browse whole cards and open the selected gallery photo', + (tester) async { + _setSurfaceSize(tester, const Size(375, 800)); + tester.view.devicePixelRatio = 3; + tester.view.physicalSize = const Size(1125, 2400); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final semantics = tester.ensureSemantics(); + const first = 'https://example.com/stack-one.png'; + const second = 'https://example.com/stack-two.png'; + const third = 'https://example.com/stack-three.png'; + for (final own in [false, true]) { + await tester.pumpWidget( + _testable( + MessageContent( + key: ValueKey(own), + bubble: true, + ownMessage: own, + content: '![one]($first)\n![two]($second)\n![three]($third)', + ), + ), + ); + await tester.pumpAndSettle(); + final stack = find.byKey(const ValueKey('message-media-stack')); + final spoken = find.byKey( + const ValueKey('message-media-stack-semantics'), + ); + expect( + tester + .widget( + 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( + 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(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( + 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(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(find.byType(PageIndicator)).selected, + 1, + ); + final viewer = tester.widget( + find.byType(MediaImageViewerPage), + ); + final preview = viewer.galleryItems![1]; + final cardImage = tester.widget( + 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(find.byType(PageIndicator)).selected, + 2, + ); + await tester.tap( + find.byKey(const ValueKey('message-media-image-viewer-close')), + ); + await tester.pumpAndSettle(); + expect( + tester.widget(spoken).properties.value, + 'Photo 3 of 3', + ); + await tester.tap(stack); + await tester.pumpAndSettle(); + expect( + tester.widget(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(spoken).properties.value, + 'Photo 2 of 3', + ); + await tester.drag(stack, const Offset(100, 0)); + await tester.pumpAndSettle(); + expect( + tester + .widget( + 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( + find.byKey(const ValueKey('message-media-stack-semantics')), + ) + .properties + .value, + 'Photo 1 of 3', + ); + expect( + find.byKey(const ValueKey('message-media-image-viewer')), + findsNothing, + ); + } + // Larger collections keep their rear cards quiet rather than multiplying + // the fan angle with every additional photo. + for (final own in [false, true]) { + await tester.pumpWidget( + _testable( + MessageContent( + key: ValueKey(own), + ownMessage: own, + bubble: true, + content: List.filled(6, '![photo]($first)').join('\n'), + ), + ), + ); + await tester.pumpAndSettle(); + final largeStack = find.byKey(const ValueKey('message-media-stack')); + for (final element + in find + .descendant( + of: largeStack, + matching: find.byType(ClipRSuperellipse), + ) + .evaluate()) { + final box = element.renderObject! as RenderBox; + final edge = + box.localToGlobal(Offset(box.size.width, 0)) - + box.localToGlobal(Offset.zero); + expect((edge.dy / edge.dx).abs(), lessThan(own ? .12 : .2)); + } + Finder rearClip(int index) => find.descendant( + of: find.byKey(ValueKey('stack-card:$index')), + matching: find.byType(ClipRSuperellipse), + ); + final rearBefore = { + for (final i in [2, 3]) i: tester.getRect(rearClip(i)), + }; + final rearCenters = + rearBefore.values.map((rect) => rect.center.dx).toList()..sort(); + expect( + rearCenters.last - rearCenters.first, + lessThan(1), + reason: 'Large collections use compact backing tiers', + ); + List rearPaintOrder() => tester + .widget( + 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( + find.byKey(const ValueKey('message-media-card-deck')), + ); + final photoKeys = nearSettledDeck.children + .map((child) => child.key) + .where( + (key) => + key is ValueKey && + 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( + find.descendant( + of: largeStack, + matching: find.byType(DecoratedBox), + ), + ) + .map((box) => box.decoration) + .whereType() + .expand((decoration) => decoration.shadows ?? []) + .fold(0.0, (sum, shadow) => sum + shadow.color.a); + final shadowBefore = shadowStrength(); + expect(shadowBefore, closeTo(3 * 48 / 255, .001)); + final outgoingBox = tester.renderObject(rearClip(0)); + final coveringBox = tester.renderObject(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( + find.byKey(const ValueKey('message-media-stack-semantics')), + ) + .properties + .value!; + final selected = int.parse(value.split(' ')[1]) - 1; + final keys = tester + .widget( + find.byKey(const ValueKey('message-media-card-deck')), + ) + .children + .map((child) => child.key) + .where( + (key) => + key is ValueKey && + key.value.startsWith('stack-card:'), + ); + expect(keys.last, ValueKey('stack-card:$selected')); + expect(tester.takeException(), isNull); + } + } + await tester.pumpAndSettle(); + } + await tester.pumpWidget( + _testable( + const MessageContent( + bubble: true, + content: '![one]($first)\n![duplicate]($first)', + ), + disableAnimations: true, + ), + ); + await tester.pumpAndSettle(); + final spoken = find.byKey( + const ValueKey('message-media-stack-semantics'), + ); + final node = tester.getSemantics(spoken); + node.owner!.performAction(node.id, SemanticsAction.decrease); + await tester.pump(); + expect( + tester + .widget( + find.byKey(const ValueKey('message-media-stack-semantics')), + ) + .properties + .value, + 'Photo 2 of 2', + ); + expect(tester.binding.hasScheduledFrame, isFalse); + expect(tester.takeException(), isNull); + semantics.dispose(); + }, + ); + testWidgets('stack tuner adjusts slots independently and resets', ( + tester, + ) async { + String? copiedSettings; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') { + copiedSettings = (call.arguments as Map)['text'] as String; + } + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + _setSurfaceSize(tester, const Size(430, 932)); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + _testable( + MessageContent( + bubble: true, + ownMessage: true, + content: List.filled( + 6, + '![photo](https://example.com/tuning.png)', + ).join('\n'), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Tune stack')); + await tester.pumpAndSettle(); + final sheet = find.byKey(const ValueKey('stack-tuning-sheet')); + Finder clip(int i) => find.descendant( + of: find.descendant( + of: sheet, + matching: find.byKey(ValueKey('stack-card:$i')), + ), + matching: find.byType(ClipRSuperellipse), + ); + final first = tester.getRect(clip(0)); + final second = tester.getRect(clip(1)); + final third = tester.getRect(clip(2)); + await tester.tap(find.text('Card 2')); + await tester.pumpAndSettle(); + final sliders = find.descendant(of: sheet, matching: find.byType(Slider)); + await tester.drag(sliders.at(0), const Offset(45, 0)); + await tester.pumpAndSettle(); + expect( + tester.getRect(clip(1)).center.dx, + greaterThan(second.center.dx + 1), + ); + expect(tester.getRect(clip(0)), first); + expect(tester.getRect(clip(2)), third); + await tester.drag(sliders.at(1), const Offset(35, 0)); + await tester.pumpAndSettle(); + expect( + tester.getRect(clip(1)).center.dy, + greaterThan(second.center.dy + 1), + ); + await tester.drag(sliders.at(2), const Offset(35, 0)); + await tester.pumpAndSettle(); + expect(tester.widget(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(sliders.at(0)).value, 0); + expect(tester.widget(sliders.at(1)).value, 0); + expect(tester.widget(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')); +} diff --git a/mobile/test/features/channels/native_photo_zoom_preview_test.dart b/mobile/test/features/channels/native_photo_zoom_preview_test.dart new file mode 100644 index 0000000000..665cac0127 --- /dev/null +++ b/mobile/test/features/channels/native_photo_zoom_preview_test.dart @@ -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? 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()); + }, + 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 = []; + final actions = []; + var replied = false; + const codec = StandardMethodCodec(); + Future nativeCall( + String method, + Map args, + ) async { + final reply = Completer(); + 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 = { + 'session': args['session'] as int, + 'index': 1, + }; + expect(await nativeCall('select', payload), isA()); + expect(await nativeCall('image', payload), isA()); + expect( + await nativeCall('preview', {...payload, 'index': 0}), + isA(), + ); + 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 = >[]; + const codec = StandardMethodCodec(); + Future nativeCall(String method, int session, int index) async { + final reply = Completer(); + 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(); + 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; + }); + } +} diff --git a/scripts/test-mobile-photo-zoom.sh b/scripts/test-mobile-photo-zoom.sh new file mode 100644 index 0000000000..36a910cfcb --- /dev/null +++ b/scripts/test-mobile-photo-zoom.sh @@ -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 +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"