mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
## 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>
238 lines
7.1 KiB
Swift
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
|
|
}
|