Files
buzz/mobile/ios/Runner/InlinePhotoPicker.swift
klopez4212andGitHub 857e63c4dd Polish mobile composer and messaging UI (#3918)
## Summary

- Refine the mobile composer with compact and expanded states, shared
footer fades, haptics, reliable keyboard dismissal, and full-width
camera and photo surfaces.
- Standardize popovers, filters, and section menus with consistent type,
strokes, radii, spacing, icons, and destructive styling.
- Align message presentation with desktop through consistent system
rows, typing and loading feedback, emoji placement, and predictable
photo viewing.

## Validation

- `just mobile-check`
- `just mobile-test` — 1,037 passed, 1 skipped
- Tested on Pixel 10 and a connected iPhone

## Snapshots

<table>
  <tr>
    <td align="center">Compact composer</td>
    <td align="center">Attachment menu</td>
    <td align="center">Recent photos</td>
  </tr>
  <tr>
<td><img
src="https://raw.githubusercontent.com/block/buzz/9732022cb13bb39ce797c4faaa714fe4c924955f/pr-3918--01-compact-composer.png"
width="260" /></td>
<td><img
src="https://raw.githubusercontent.com/block/buzz/9732022cb13bb39ce797c4faaa714fe4c924955f/pr-3918--02-attachment-menu.png"
width="260" /></td>
<td><img
src="https://raw.githubusercontent.com/block/buzz/9732022cb13bb39ce797c4faaa714fe4c924955f/pr-3918--03-photo-surface.png"
width="260" /></td>
  </tr>
</table>

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
2026-08-03 07:30:23 -07:00

238 lines
7.1 KiB
Swift

import Flutter
import PhotosUI
import UIKit
import UniformTypeIdentifiers
final class InlinePhotoPickerFactory: NSObject, FlutterPlatformViewFactory {
private let messenger: FlutterBinaryMessenger
private weak var parentViewController: UIViewController?
init(
messenger: FlutterBinaryMessenger,
parentViewController: UIViewController?
) {
self.messenger = messenger
self.parentViewController = parentViewController
super.init()
}
func create(
withFrame frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?
) -> FlutterPlatformView {
InlinePhotoPickerPlatformView(
frame: frame,
viewIdentifier: viewId,
messenger: messenger,
parentViewController: parentViewController
)
}
}
final class InlinePhotoPickerPlatformView: NSObject, FlutterPlatformView {
private let containerView: UIView
private let channel: FlutterMethodChannel
private weak var parentViewController: UIViewController?
private var pickerViewController: PHPickerViewController?
private var selectionGeneration = 0
private var selectionTask: Task<Void, Never>?
private var selectedTemporaryPaths: [String] = []
init(
frame: CGRect,
viewIdentifier viewId: Int64,
messenger: FlutterBinaryMessenger,
parentViewController: UIViewController?
) {
containerView = UIView(frame: frame)
channel = FlutterMethodChannel(
name: "buzz/inline_photo_picker/\(viewId)",
binaryMessenger: messenger
)
self.parentViewController = parentViewController
super.init()
channel.setMethodCallHandler { [weak self] call, result in
guard call.method == "claimSelection" else {
result(FlutterMethodNotImplemented)
return
}
let paths = call.arguments as? [String] ?? []
guard let self, paths == self.selectedTemporaryPaths else {
result(false)
return
}
self.selectedTemporaryPaths = []
result(true)
}
containerView.backgroundColor = .clear
containerView.clipsToBounds = true
if #available(iOS 17.0, *) {
installPicker()
}
}
deinit {
selectionTask?.cancel()
Self.removeTemporaryFiles(selectedTemporaryPaths)
channel.setMethodCallHandler(nil)
pickerViewController?.willMove(toParent: nil)
pickerViewController?.view.removeFromSuperview()
pickerViewController?.removeFromParent()
}
func view() -> UIView {
containerView
}
@available(iOS 17.0, *)
private func installPicker() {
var configuration = PHPickerConfiguration(photoLibrary: .shared())
configuration.filter = .images
configuration.selectionLimit = 0
configuration.selection = .continuousAndOrdered
configuration.preferredAssetRepresentationMode = .compatible
configuration.disabledCapabilities = [
.search,
.stagingArea,
.collectionNavigation,
.selectionActions,
]
configuration.edgesWithoutContentMargins = .all
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = self
picker.view.backgroundColor = .clear
picker.view.translatesAutoresizingMaskIntoConstraints = false
if let parentViewController {
parentViewController.addChild(picker)
}
containerView.addSubview(picker.view)
NSLayoutConstraint.activate([
picker.view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
picker.view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
picker.view.topAnchor.constraint(equalTo: containerView.topAnchor),
picker.view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
])
if parentViewController != nil {
picker.didMove(toParent: parentViewController)
}
pickerViewController = picker
containerView.layoutIfNeeded()
}
private func exportPickerResult(_ result: PHPickerResult) async throws -> String {
let provider = result.itemProvider
guard
let typeIdentifier = provider.registeredTypeIdentifiers.first(where: {
guard let type = UTType($0) else { return false }
return type.conforms(to: .image)
})
else {
throw InlinePhotoPickerError.unsupportedImage
}
return try await withCheckedThrowingContinuation { continuation in
provider.loadFileRepresentation(forTypeIdentifier: typeIdentifier) {
sourceURL,
error in
if let error {
continuation.resume(throwing: error)
return
}
guard let sourceURL else {
continuation.resume(throwing: InlinePhotoPickerError.missingFile)
return
}
do {
let fileExtension =
sourceURL.pathExtension.isEmpty
? (UTType(typeIdentifier)?.preferredFilenameExtension ?? "jpg")
: sourceURL.pathExtension
let destinationURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension(fileExtension)
try FileManager.default.copyItem(
at: sourceURL,
to: destinationURL
)
continuation.resume(returning: destinationURL.path)
} catch {
continuation.resume(throwing: error)
}
}
}
}
private static func removeTemporaryFiles(_ paths: [String]) {
for path in paths where !path.isEmpty {
try? FileManager.default.removeItem(atPath: path)
}
}
}
extension InlinePhotoPickerPlatformView: PHPickerViewControllerDelegate {
func picker(
_ picker: PHPickerViewController,
didFinishPicking results: [PHPickerResult]
) {
selectionGeneration += 1
let generation = selectionGeneration
selectionTask?.cancel()
selectionTask = nil
Self.removeTemporaryFiles(selectedTemporaryPaths)
selectedTemporaryPaths = []
channel.invokeMethod(
"selectionCountChanged",
arguments: results.count
)
guard !results.isEmpty else {
channel.invokeMethod("selectionDidChange", arguments: [String]())
return
}
selectionTask = Task { [weak self] in
guard let self else { return }
var paths: [String] = []
do {
for result in results {
try Task.checkCancellation()
paths.append(try await self.exportPickerResult(result))
}
try Task.checkCancellation()
await MainActor.run {
guard generation == self.selectionGeneration else {
Self.removeTemporaryFiles(paths)
return
}
self.selectedTemporaryPaths = paths
self.selectionTask = nil
self.channel.invokeMethod("selectionDidChange", arguments: paths)
}
} catch is CancellationError {
Self.removeTemporaryFiles(paths)
} catch {
Self.removeTemporaryFiles(paths)
await MainActor.run {
guard generation == self.selectionGeneration else { return }
self.selectionTask = nil
self.channel.invokeMethod(
"didFail",
arguments: "Unable to prepare the selected photos."
)
}
}
}
}
}
private enum InlinePhotoPickerError: Error {
case missingFile
case unsupportedImage
}