Refine mobile attachment picking (#3313)

## What
- morph the composer plus button into the attachment menu, camera, and
photo surfaces
- add ordered multi-select with inline recent photos and system picker
fallback
- add native iOS attachment/photo popovers and align the Android camera
treatment

## Stack
- follows #3312

## Validation
- `just mobile-check`
- `flutter test test/features/channels/compose_bar_test.dart`
- full mobile pre-push suite

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
klopez4212
2026-07-28 18:05:43 +01:00
committed by GitHub
parent 6da45ac5cf
commit a3b097745a
21 changed files with 3930 additions and 462 deletions

View File

@@ -5,11 +5,14 @@
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="28"
android:maxSdkVersion="32"
tools:replace="android:maxSdkVersion" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
<application
android:label="@string/app_name"
android:name="${applicationName}"

View File

@@ -10,6 +10,9 @@
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C809A294A618700263BE5 /* MediaSanitizer.swift */; };
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 */; };
331C809D294A63AB00263BE5 /* UIKitEncoded.png in Resources */ = {isa = PBXBuildFile; fileRef = 331C809C294A618700263BE5 /* UIKitEncoded.png */; };
331C809F294A63AB00263BE5 /* UIKitEncoded.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 331C809E294A618700263BE5 /* UIKitEncoded.jpg */; };
33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; };
@@ -51,6 +54,9 @@
30CE81D3D1E0B195EF2A6390 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C809A294A618700263BE5 /* MediaSanitizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaSanitizer.swift; sourceTree = "<group>"; };
4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlinePhotoPicker.swift; sourceTree = "<group>"; };
4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopover.swift; sourceTree = "<group>"; };
4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopoverCoordinator.swift; sourceTree = "<group>"; };
331C809C294A618700263BE5 /* UIKitEncoded.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = UIKitEncoded.png; sourceTree = "<group>"; };
331C809E294A618700263BE5 /* UIKitEncoded.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = UIKitEncoded.jpg; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -170,6 +176,9 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
331C809A294A618700263BE5 /* MediaSanitizer.swift */,
4A71C0022F40100100A17E01 /* InlinePhotoPicker.swift */,
4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */,
4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
@@ -401,6 +410,9 @@
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */,
4A71C0012F40100100A17E01 /* InlinePhotoPicker.swift in Sources */,
4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */,
4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);

View File

@@ -7,6 +7,8 @@ import UserNotifications
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private var mediaUploadChannel: FlutterMethodChannel?
private var qrScannerChannel: FlutterMethodChannel?
private var inlinePhotoPickerSupportChannel: FlutterMethodChannel?
private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator?
override func application(
_ application: UIApplication,
@@ -18,20 +20,56 @@ import UserNotifications
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
let messenger = engineBridge.applicationRegistrar.messenger()
mediaUploadChannel = FlutterMethodChannel(
name: "buzz/media_upload",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
binaryMessenger: messenger
)
mediaUploadChannel?.setMethodCallHandler { [weak self] call, result in
self?.handleMediaUploadMethodCall(call, result: result)
}
qrScannerChannel = FlutterMethodChannel(
name: "buzz/qr_scanner",
binaryMessenger: engineBridge.applicationRegistrar.messenger()
binaryMessenger: messenger
)
qrScannerChannel?.setMethodCallHandler { call, result in
Self.handleQrScannerMethodCall(call, result: result)
}
inlinePhotoPickerSupportChannel = FlutterMethodChannel(
name: "buzz/inline_photo_picker",
binaryMessenger: messenger
)
inlinePhotoPickerSupportChannel?.setMethodCallHandler { call, result in
guard call.method == "isSupported" else {
result(FlutterMethodNotImplemented)
return
}
if #available(iOS 17.0, *) {
result(true)
} else {
result(false)
}
}
if let inlinePhotoPickerRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzInlinePhotoPicker"
) {
inlinePhotoPickerRegistrar.register(
InlinePhotoPickerFactory(
messenger: messenger,
parentViewController: inlinePhotoPickerRegistrar.viewController
),
withId: "buzz/inline_photo_picker"
)
}
let nativeAttachmentRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzNativeAttachmentPopover"
)
nativeAttachmentPopoverCoordinator = NativeAttachmentPopoverCoordinator(
messenger: messenger,
parentViewController: nativeAttachmentRegistrar?.viewController
)
}
private static func handleQrScannerMethodCall(
@@ -231,10 +269,12 @@ import UserNotifications
let sourceURL = URL(fileURLWithPath: sourcePath)
let asset = AVURLAsset(url: sourceURL)
guard let exportSession = AVAssetExportSession(
asset: asset,
presetName: AVAssetExportPresetPassthrough
) else {
guard
let exportSession = AVAssetExportSession(
asset: asset,
presetName: AVAssetExportPresetPassthrough
)
else {
result(
FlutterError(
code: "transcode_failed",
@@ -259,7 +299,8 @@ import UserNotifications
case .completed:
result(outputURL.path)
default:
let errorMessage = exportSession.error?.localizedDescription
let errorMessage =
exportSession.error?.localizedDescription
?? "Video transcoding failed with status \(exportSession.status.rawValue)."
result(
FlutterError(

View File

@@ -49,6 +49,8 @@
<string>Buzz needs photo library access so you can attach images to messages.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Buzz needs permission to save images to your photo library.</string>
<key>PHPhotoLibraryPreventAutomaticLimitedAccessAlert</key>
<true/>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>

View File

@@ -0,0 +1,235 @@
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
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
}
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
}

View File

@@ -0,0 +1,969 @@
import AVFoundation
import Flutter
import PhotosUI
import UIKit
import UniformTypeIdentifiers
@available(iOS 26.0, *)
final class NativeAttachmentPopoverViewController:
UIViewController,
PHPickerViewControllerDelegate,
UIPopoverPresentationControllerDelegate,
AVCapturePhotoCaptureDelegate
{
private enum Surface {
case menu
case photos
case camera
}
private let channel: FlutterMethodChannel
private let expandedWidth: CGFloat
private let menuSize = CGSize(width: 176, height: 208)
private let expandedHeight: CGFloat = 372
private let expandedHorizontalOffset: CGFloat = 10
private let expandedVerticalOffset: CGFloat = 40
private let contentHost = UIView()
private let cameraSession = AVCaptureSession()
private let cameraOutput = AVCapturePhotoOutput()
private let cameraQueue = DispatchQueue(
label: "buzz.native-attachment-camera"
)
private var surface = Surface.menu
private var visibleContentView: UIView?
private var photoPickerViewController: PHPickerViewController?
private var cameraPreviewLayer: AVCaptureVideoPreviewLayer?
private weak var cameraPreviewView: UIView?
private weak var photoActionButton: UIButton?
private weak var cameraCaptureButton: UIButton?
private var cameraDevice: AVCaptureDevice?
private var cameraRotationCoordinator: AVCaptureDevice.RotationCoordinator?
private var cameraRotationObservation: NSKeyValueObservation?
private var selectionGeneration = 0
private var selectionTask: Task<Void, Never>?
private var selectedPhotoPaths: [String] = []
private var cameraConfigured = false
private var cameraIsStarting = false
private var cameraStartupGeneration = 0
private var cameraIsCapturing = false
private var activeCameraCaptureID: Int64?
private var isFinishing = false
private var didNotifyDismissal = false
var onDismiss: (() -> Void)?
init(channel: FlutterMethodChannel, expandedWidth: CGFloat) {
self.channel = channel
self.expandedWidth = expandedWidth
super.init(nibName: nil, bundle: nil)
preferredContentSize = menuSize
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .clear
view.layer.cornerRadius = 22
view.layer.cornerCurve = .continuous
view.clipsToBounds = true
let glassEffect = UIGlassEffect(style: .regular)
glassEffect.isInteractive = true
let glassView = UIVisualEffectView(effect: glassEffect)
glassView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(glassView)
contentHost.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(contentHost)
NSLayoutConstraint.activate([
glassView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
glassView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
glassView.topAnchor.constraint(equalTo: view.topAnchor),
glassView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
contentHost.leadingAnchor.constraint(equalTo: view.leadingAnchor),
contentHost.trailingAnchor.constraint(equalTo: view.trailingAnchor),
contentHost.topAnchor.constraint(equalTo: view.topAnchor),
contentHost.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
let menu = makeMenuView()
installContent(menu)
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
cameraPreviewLayer?.frame = cameraPreviewView?.bounds ?? .zero
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
invalidateCameraCapture()
stopCamera()
}
func adaptivePresentationStyle(
for controller: UIPresentationController
) -> UIModalPresentationStyle {
.none
}
func presentationControllerDidDismiss(
_ presentationController: UIPresentationController
) {
notifyDismissalIfNeeded()
}
private func makeMenuView() -> UIView {
let container = UIView()
container.translatesAutoresizingMaskIntoConstraints = false
let stack = UIStackView()
stack.axis = .vertical
stack.distribution = .fillEqually
stack.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: container.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: container.trailingAnchor),
stack.topAnchor.constraint(equalTo: container.topAnchor, constant: 8),
stack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -8),
])
stack.addArrangedSubview(
makeNativeAttachmentMenuButton(
title: "Camera",
symbol: "camera",
action: UIAction { [weak self] _ in self?.showCamera() }
)
)
stack.addArrangedSubview(
makeNativeAttachmentMenuButton(
title: "Photos",
symbol: "photo.on.rectangle.angled",
action: UIAction { [weak self] _ in self?.showPhotos() }
)
)
stack.addArrangedSubview(
makeNativeAttachmentMenuButton(
title: "Video",
symbol: "video",
action: UIAction { [weak self] _ in
self?.finish(method: "pickVideo")
}
)
)
stack.addArrangedSubview(
makeNativeAttachmentMenuButton(
title: "Files",
symbol: "doc",
action: UIAction { [weak self] _ in
self?.finish(method: "pickFiles")
}
)
)
return container
}
private func showPhotos() {
guard surface != .photos else { return }
stopCamera()
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
let container = UIView()
container.backgroundColor = .clear
addChild(picker)
picker.view.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(picker.view)
NSLayoutConstraint.activate([
picker.view.leadingAnchor.constraint(equalTo: container.leadingAnchor),
picker.view.trailingAnchor.constraint(equalTo: container.trailingAnchor),
picker.view.topAnchor.constraint(
equalTo: container.topAnchor,
constant: -8
),
picker.view.bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
picker.didMove(toParent: self)
photoPickerViewController = picker
let backButton = makeGlassControl(
title: nil,
symbol: "chevron.left",
accessibilityLabel: "Back to attachment options",
action: UIAction { [weak self] _ in self?.showMenu() }
)
let actionButton = makeGlassControl(
title: "All Photos",
symbol: nil,
accessibilityLabel: "All Photos",
prominent: true,
action: UIAction { [weak self] _ in self?.performPhotoAction() }
)
photoActionButton = actionButton
addBottomControls(
to: container,
leading: backButton,
trailing: actionButton
)
transition(to: .photos, content: container)
}
private func showCamera() {
guard surface != .camera else { return }
removePhotoPicker()
let container = UIView()
container.backgroundColor = .black
let preview = UIView()
preview.backgroundColor = .black
preview.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(preview)
NSLayoutConstraint.activate([
preview.leadingAnchor.constraint(equalTo: container.leadingAnchor),
preview.trailingAnchor.constraint(equalTo: container.trailingAnchor),
preview.topAnchor.constraint(equalTo: container.topAnchor),
preview.bottomAnchor.constraint(equalTo: container.bottomAnchor),
])
cameraPreviewView = preview
let placeholder = UIActivityIndicatorView(style: .large)
placeholder.color = .white
placeholder.startAnimating()
placeholder.translatesAutoresizingMaskIntoConstraints = false
preview.addSubview(placeholder)
NSLayoutConstraint.activate([
placeholder.centerXAnchor.constraint(equalTo: preview.centerXAnchor),
placeholder.centerYAnchor.constraint(equalTo: preview.centerYAnchor),
])
placeholder.tag = 7001
let backButton = makeGlassControl(
title: nil,
symbol: "chevron.left",
accessibilityLabel: "Back to attachment options",
action: UIAction { [weak self] _ in self?.showMenu() }
)
let captureButton = makeCameraCaptureButton()
cameraCaptureButton = captureButton
addBottomControls(
to: container,
leading: backButton,
center: captureButton
)
transition(to: .camera, content: container)
startCamera()
}
private func showMenu() {
guard surface != .menu else { return }
invalidateCameraCapture()
selectionGeneration += 1
selectionTask?.cancel()
selectionTask = nil
Self.removeTemporaryFiles(selectedPhotoPaths)
selectedPhotoPaths = []
stopCamera()
let menu = makeMenuView()
transition(to: .menu, content: menu) { [weak self] in
self?.removePhotoPicker()
}
}
private func installContent(_ content: UIView) {
content.translatesAutoresizingMaskIntoConstraints = false
contentHost.addSubview(content)
NSLayoutConstraint.activate([
content.leadingAnchor.constraint(equalTo: contentHost.leadingAnchor),
content.trailingAnchor.constraint(equalTo: contentHost.trailingAnchor),
content.topAnchor.constraint(equalTo: contentHost.topAnchor),
content.bottomAnchor.constraint(equalTo: contentHost.bottomAnchor),
])
visibleContentView = content
}
private func transition(
to nextSurface: Surface,
content nextView: UIView,
completion: (() -> Void)? = nil
) {
let previousView = visibleContentView
let isExpanding = nextSurface != .menu
let targetSize =
isExpanding
? CGSize(width: expandedWidth, height: expandedHeight)
: menuSize
nextView.translatesAutoresizingMaskIntoConstraints = false
contentHost.addSubview(nextView)
NSLayoutConstraint.activate([
nextView.leadingAnchor.constraint(equalTo: contentHost.leadingAnchor),
nextView.trailingAnchor.constraint(equalTo: contentHost.trailingAnchor),
nextView.topAnchor.constraint(equalTo: contentHost.topAnchor),
nextView.bottomAnchor.constraint(equalTo: contentHost.bottomAnchor),
])
contentHost.layoutIfNeeded()
let direction: CGFloat = isExpanding ? 34 : -34
nextView.alpha = UIAccessibility.isReduceMotionEnabled ? 0 : 0.01
nextView.transform =
UIAccessibility.isReduceMotionEnabled
? .identity
: CGAffineTransform(translationX: direction, y: 0).scaledBy(
x: 0.97,
y: 0.97
)
visibleContentView = nextView
surface = nextSurface
let duration = UIAccessibility.isReduceMotionEnabled ? 0.16 : 0.36
UIView.animate(
withDuration: duration,
delay: 0,
usingSpringWithDamping: 0.86,
initialSpringVelocity: 0.18,
options: [.beginFromCurrentState, .allowUserInteraction]
) {
self.preferredContentSize = targetSize
if let popover = self.popoverPresentationController,
let sourceView = popover.sourceView
{
popover.sourceRect = sourceView.bounds.offsetBy(
dx: isExpanding ? self.expandedHorizontalOffset : 0,
dy: isExpanding ? self.expandedVerticalOffset : 0
)
}
previousView?.alpha = 0
previousView?.transform =
UIAccessibility.isReduceMotionEnabled
? .identity
: CGAffineTransform(translationX: -direction, y: 0).scaledBy(
x: 0.97,
y: 0.97
)
nextView.alpha = 1
nextView.transform = .identity
self.view.layoutIfNeeded()
} completion: { _ in
previousView?.removeFromSuperview()
previousView?.transform = .identity
completion?()
}
}
private func makeGlassControl(
title: String?,
symbol: String?,
accessibilityLabel: String,
prominent: Bool = false,
action: UIAction
) -> UIButton {
var configuration =
prominent
? UIButton.Configuration.prominentGlass()
: UIButton.Configuration.glass()
configuration.title = title
if prominent {
configuration.baseBackgroundColor = .black
}
if let symbol {
configuration.image = UIImage(systemName: symbol)
}
configuration.imagePadding = 8
configuration.baseForegroundColor = .white
configuration.contentInsets = NSDirectionalEdgeInsets(
top: 11,
leading: 15,
bottom: 11,
trailing: 15
)
let button = UIButton(configuration: configuration, primaryAction: action)
button.accessibilityLabel = accessibilityLabel
return button
}
private func makeCameraCaptureButton() -> UIButton {
let button = UIButton(
primaryAction: UIAction { [weak self] _ in self?.capturePhoto() }
)
button.accessibilityLabel = "Take photo"
button.translatesAutoresizingMaskIntoConstraints = false
button.backgroundColor = UIColor.white.withAlphaComponent(0.22)
button.layer.cornerRadius = 34
button.layer.borderColor = UIColor.white.cgColor
button.layer.borderWidth = 3
let inner = UIView()
inner.isUserInteractionEnabled = false
inner.translatesAutoresizingMaskIntoConstraints = false
inner.backgroundColor = .white
inner.layer.cornerRadius = 25
button.addSubview(inner)
NSLayoutConstraint.activate([
button.widthAnchor.constraint(equalToConstant: 68),
button.heightAnchor.constraint(equalToConstant: 68),
inner.widthAnchor.constraint(equalToConstant: 50),
inner.heightAnchor.constraint(equalToConstant: 50),
inner.centerXAnchor.constraint(equalTo: button.centerXAnchor),
inner.centerYAnchor.constraint(equalTo: button.centerYAnchor),
])
return button
}
private func addBottomControls(
to container: UIView,
leading: UIButton,
center: UIButton? = nil,
trailing: UIButton? = nil
) {
leading.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(leading)
var constraints = [
leading.leadingAnchor.constraint(
equalTo: container.leadingAnchor,
constant: 12
),
leading.bottomAnchor.constraint(
equalTo: container.bottomAnchor,
constant: -12
),
leading.heightAnchor.constraint(greaterThanOrEqualToConstant: 44),
]
if let center {
center.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(center)
constraints.append(
center.centerXAnchor.constraint(equalTo: container.centerXAnchor)
)
constraints.append(
center.bottomAnchor.constraint(
equalTo: container.bottomAnchor,
constant: -12
)
)
}
if let trailing {
trailing.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(trailing)
constraints.append(
trailing.trailingAnchor.constraint(
equalTo: container.trailingAnchor,
constant: -12
)
)
constraints.append(
trailing.bottomAnchor.constraint(
equalTo: container.bottomAnchor,
constant: -12
)
)
constraints.append(
trailing.heightAnchor.constraint(greaterThanOrEqualToConstant: 44)
)
}
NSLayoutConstraint.activate(constraints)
}
func picker(
_ picker: PHPickerViewController,
didFinishPicking results: [PHPickerResult]
) {
selectionGeneration += 1
let generation = selectionGeneration
selectionTask?.cancel()
selectionTask = nil
Self.removeTemporaryFiles(selectedPhotoPaths)
selectedPhotoPaths = []
updatePhotoAction(count: results.count, preparing: !results.isEmpty)
guard !results.isEmpty else { 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.selectedPhotoPaths = paths
self.selectionTask = nil
self.updatePhotoAction(count: paths.count, preparing: false)
}
} catch is CancellationError {
Self.removeTemporaryFiles(paths)
} catch {
Self.removeTemporaryFiles(paths)
await MainActor.run {
guard generation == self.selectionGeneration else { return }
self.selectionTask = nil
self.selectedPhotoPaths = []
self.updatePhotoAction(count: 0, preparing: false)
self.showError("Unable to prepare the selected photos.")
}
}
}
}
private func updatePhotoAction(count: Int, preparing: Bool) {
let title: String
if preparing {
title = "Preparing…"
} else if count == 0 {
title = "All Photos"
} else {
title = "Add \(count) \(count == 1 ? "photo" : "photos")"
}
guard let button = photoActionButton else { return }
let canInteract = !preparing
button.configuration?.title = title
button.accessibilityLabel = title
button.isUserInteractionEnabled = canInteract
if canInteract {
button.accessibilityTraits.remove(.notEnabled)
} else {
button.accessibilityTraits.insert(.notEnabled)
}
}
private func performPhotoAction() {
if selectedPhotoPaths.isEmpty {
finish(method: "pickAllPhotos")
} else {
let paths = selectedPhotoPaths
selectedPhotoPaths = []
finish(
method: "photosSelected",
arguments: paths,
temporaryPaths: paths
)
}
}
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 NativeAttachmentPopoverError.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: NativeAttachmentPopoverError.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)
}
}
private func startCamera() {
guard !cameraIsStarting else { return }
cameraIsStarting = true
cameraStartupGeneration += 1
let startupGeneration = cameraStartupGeneration
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
configureAndStartCamera(startupGeneration: startupGeneration)
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
DispatchQueue.main.async {
guard
let self,
self.cameraStartupIsCurrent(startupGeneration)
else { return }
if granted {
self.configureAndStartCamera(
startupGeneration: startupGeneration
)
} else {
self.cameraIsStarting = false
self.showCameraUnavailable(
"Camera access is needed to take a photo."
)
}
}
}
default:
cameraIsStarting = false
showCameraUnavailable("Camera access is needed to take a photo.")
}
}
private func configureAndStartCamera(startupGeneration: Int) {
cameraQueue.async { [weak self] in
guard let self else { return }
do {
if !self.cameraConfigured {
self.cameraSession.beginConfiguration()
defer { self.cameraSession.commitConfiguration() }
self.cameraSession.sessionPreset = .photo
guard
let device = AVCaptureDevice.default(
.builtInWideAngleCamera,
for: .video,
position: .back
)
else {
throw NativeAttachmentPopoverError.cameraUnavailable
}
let input = try AVCaptureDeviceInput(device: device)
guard self.cameraSession.canAddInput(input) else {
throw NativeAttachmentPopoverError.cameraUnavailable
}
self.cameraSession.addInput(input)
self.cameraDevice = device
guard self.cameraSession.canAddOutput(self.cameraOutput) else {
throw NativeAttachmentPopoverError.cameraUnavailable
}
self.cameraSession.addOutput(self.cameraOutput)
self.cameraConfigured = true
}
if !self.cameraSession.isRunning {
self.cameraSession.startRunning()
}
DispatchQueue.main.async { [weak self] in
guard
let self,
self.cameraStartupIsCurrent(startupGeneration)
else { return }
self.cameraIsStarting = false
self.installCameraPreview()
}
} catch {
if self.cameraSession.isRunning {
self.cameraSession.stopRunning()
}
DispatchQueue.main.async { [weak self] in
guard
let self,
self.cameraStartupIsCurrent(startupGeneration)
else { return }
self.cameraIsStarting = false
self.showCameraUnavailable("Camera isn’t available here.")
}
}
}
}
private func cameraStartupIsCurrent(_ generation: Int) -> Bool {
generation == cameraStartupGeneration && surface == .camera && !isFinishing
}
private func installCameraPreview() {
guard surface == .camera, let previewView = cameraPreviewView else { return }
previewView.viewWithTag(7001)?.removeFromSuperview()
cameraPreviewLayer?.removeFromSuperlayer()
let layer = AVCaptureVideoPreviewLayer(session: cameraSession)
layer.videoGravity = .resizeAspectFill
layer.frame = previewView.bounds
previewView.layer.insertSublayer(layer, at: 0)
cameraPreviewLayer = layer
if let cameraDevice {
let coordinator = AVCaptureDevice.RotationCoordinator(
device: cameraDevice,
previewLayer: layer
)
cameraRotationCoordinator = coordinator
cameraRotationObservation = coordinator.observe(
\.videoRotationAngleForHorizonLevelPreview,
options: [.initial, .new]
) { [weak layer] coordinator, _ in
guard let connection = layer?.connection else { return }
let angle = coordinator.videoRotationAngleForHorizonLevelPreview
guard connection.isVideoRotationAngleSupported(angle) else { return }
connection.videoRotationAngle = angle
}
}
}
private func stopCamera() {
cameraStartupGeneration += 1
cameraIsStarting = false
cameraRotationObservation?.invalidate()
cameraRotationObservation = nil
cameraRotationCoordinator = nil
cameraPreviewLayer?.removeFromSuperlayer()
cameraPreviewLayer = nil
cameraQueue.async { [weak self] in
guard let self, self.cameraSession.isRunning else { return }
self.cameraSession.stopRunning()
}
}
private func capturePhoto() {
guard !cameraIsCapturing, cameraSession.isRunning else { return }
cameraIsCapturing = true
cameraCaptureButton?.isEnabled = false
cameraCaptureButton?.transform = CGAffineTransform(
scaleX: 0.92,
y: 0.92
)
UIView.animate(
withDuration: 0.12,
delay: 0,
options: [.beginFromCurrentState, .allowUserInteraction]
) {
self.cameraCaptureButton?.transform = .identity
}
if let connection = cameraOutput.connection(with: .video),
let cameraRotationCoordinator
{
let angle =
cameraRotationCoordinator.videoRotationAngleForHorizonLevelCapture
if connection.isVideoRotationAngleSupported(angle) {
connection.videoRotationAngle = angle
}
}
let settings = AVCapturePhotoSettings()
activeCameraCaptureID = settings.uniqueID
cameraOutput.capturePhoto(with: settings, delegate: self)
}
func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto,
error: Error?
) {
guard error == nil, let data = photo.fileDataRepresentation() else {
DispatchQueue.main.async { [weak self] in
self?.completeCameraCapture(
captureID: photo.resolvedSettings.uniqueID,
path: nil,
errorMessage: "Unable to capture the photo."
)
}
return
}
DispatchQueue.global(qos: .userInitiated).async {
do {
let destinationURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("jpg")
try data.write(to: destinationURL, options: .atomic)
DispatchQueue.main.async { [weak self] in
guard let self else {
Self.removeTemporaryFiles([destinationURL.path])
return
}
self.completeCameraCapture(
captureID: photo.resolvedSettings.uniqueID,
path: destinationURL.path,
errorMessage: nil
)
}
} catch {
DispatchQueue.main.async { [weak self] in
self?.completeCameraCapture(
captureID: photo.resolvedSettings.uniqueID,
path: nil,
errorMessage: "Unable to prepare the captured photo."
)
}
}
}
}
@MainActor
private func completeCameraCapture(
captureID: Int64,
path: String?,
errorMessage: String?
) {
guard
captureID == activeCameraCaptureID, surface == .camera, !isFinishing
else {
if let path { Self.removeTemporaryFiles([path]) }
return
}
activeCameraCaptureID = nil
cameraIsCapturing = false
cameraCaptureButton?.isEnabled = true
if let path {
finish(
method: "cameraCaptured",
arguments: path,
temporaryPaths: [path]
)
} else if !isFinishing, let errorMessage {
showError(errorMessage)
}
}
private func invalidateCameraCapture() {
activeCameraCaptureID = nil
cameraIsCapturing = false
cameraCaptureButton?.isEnabled = true
}
private func showCameraUnavailable(_ message: String) {
guard let previewView = cameraPreviewView else { return }
previewView.viewWithTag(7001)?.removeFromSuperview()
let label = UILabel()
label.text = message
label.textColor = .white
label.textAlignment = .center
label.numberOfLines = 0
label.translatesAutoresizingMaskIntoConstraints = false
previewView.addSubview(label)
NSLayoutConstraint.activate([
label.centerXAnchor.constraint(equalTo: previewView.centerXAnchor),
label.centerYAnchor.constraint(equalTo: previewView.centerYAnchor),
label.leadingAnchor.constraint(
greaterThanOrEqualTo: previewView.leadingAnchor,
constant: 28
),
label.trailingAnchor.constraint(
lessThanOrEqualTo: previewView.trailingAnchor,
constant: -28
),
])
}
private func showError(_ message: String) {
let alert = UIAlertController(
title: nil,
message: message,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .default))
present(alert, animated: true)
}
private func removePhotoPicker() {
guard let picker = photoPickerViewController else { return }
picker.willMove(toParent: nil)
picker.view.removeFromSuperview()
picker.removeFromParent()
photoPickerViewController = nil
}
private func finish(
method: String,
arguments: Any? = nil,
temporaryPaths: [String] = []
) {
guard !isFinishing else {
Self.removeTemporaryFiles(temporaryPaths)
return
}
isFinishing = true
view.isUserInteractionEnabled = false
selectionGeneration += 1
selectionTask?.cancel()
selectionTask = nil
stopCamera()
dismiss(animated: true) { [weak self] in
guard let self else {
Self.removeTemporaryFiles(temporaryPaths)
return
}
self.channel.invokeMethod(method, arguments: arguments) { _ in
Self.removeTemporaryFiles(temporaryPaths)
}
self.notifyDismissalIfNeeded()
}
}
func dismissAndNotify() {
guard !isFinishing else { return }
isFinishing = true
view.isUserInteractionEnabled = false
selectionGeneration += 1
selectionTask?.cancel()
selectionTask = nil
Self.removeTemporaryFiles(selectedPhotoPaths)
selectedPhotoPaths = []
stopCamera()
dismiss(animated: true) { [weak self] in
self?.notifyDismissalIfNeeded()
}
}
private func notifyDismissalIfNeeded() {
selectionGeneration += 1
selectionTask?.cancel()
selectionTask = nil
Self.removeTemporaryFiles(selectedPhotoPaths)
selectedPhotoPaths = []
guard !didNotifyDismissal else { return }
didNotifyDismissal = true
onDismiss?()
}
}
private enum NativeAttachmentPopoverError: Error {
case cameraUnavailable
case missingFile
case unsupportedImage
}

View File

@@ -0,0 +1,228 @@
import Flutter
import UIKit
final class NativeAttachmentPopoverCoordinator: NSObject {
private let channel: FlutterMethodChannel
private weak var parentViewController: UIViewController?
private weak var presentedController: UIViewController?
private weak var sourceAnchorView: UIView?
init(
messenger: FlutterBinaryMessenger,
parentViewController: UIViewController?
) {
channel = FlutterMethodChannel(
name: "buzz/native_attachment_popover",
binaryMessenger: messenger
)
self.parentViewController = parentViewController
super.init()
channel.setMethodCallHandler { [weak self] call, result in
self?.handle(call, result: result)
}
}
private func handle(
_ call: FlutterMethodCall,
result: @escaping FlutterResult
) {
switch call.method {
case "isSupported":
if #available(iOS 26.0, *) {
result(true)
} else {
result(false)
}
case "present":
guard
let arguments = call.arguments as? [String: Any],
let x = arguments["x"] as? NSNumber,
let y = arguments["y"] as? NSNumber,
let width = arguments["width"] as? NSNumber,
let height = arguments["height"] as? NSNumber
else {
result(
FlutterError(
code: "invalid_arguments",
message: "Expected the attachment trigger bounds.",
details: nil
)
)
return
}
let sourceRect = CGRect(
x: CGFloat(truncating: x),
y: CGFloat(truncating: y),
width: CGFloat(truncating: width),
height: CGFloat(truncating: height)
)
DispatchQueue.main.async { [weak self] in
result(self?.presentPopover(sourceRect: sourceRect) ?? false)
}
case "dismiss":
DispatchQueue.main.async { [weak self] in
if #available(iOS 26.0, *),
let controller =
self?.presentedController
as? NativeAttachmentPopoverViewController
{
controller.dismissAndNotify()
} else {
self?.presentedController?.dismiss(animated: true)
}
result(nil)
}
default:
result(FlutterMethodNotImplemented)
}
}
@MainActor
private func presentPopover(sourceRect: CGRect) -> Bool {
guard #available(iOS 26.0, *) else { return false }
guard presentedController == nil else { return true }
let rootViewController =
parentViewController ?? activeWindowRootViewController()
guard let presenter = topViewController(from: rootViewController) else {
return false
}
let sourceView = presenter.view
let convertedRect: CGRect
if let window = sourceView?.window {
convertedRect = sourceView?.convert(sourceRect, from: window) ?? sourceRect
} else {
convertedRect = sourceRect
}
let anchorView = makeSourceAnchor(frame: convertedRect)
sourceView?.addSubview(anchorView)
sourceAnchorView = anchorView
let availableWidth = max(
320,
min(
(sourceView?.bounds.width ?? UIScreen.main.bounds.width) - 24,
430
)
)
let controller = NativeAttachmentPopoverViewController(
channel: channel,
expandedWidth: availableWidth
)
controller.modalPresentationStyle = .popover
controller.preferredTransition = .zoom { [weak anchorView] _ in
anchorView
}
controller.onDismiss = { [weak self] in
self?.presentedController = nil
self?.sourceAnchorView?.removeFromSuperview()
self?.channel.invokeMethod("dismissed", arguments: nil)
}
guard let popover = controller.popoverPresentationController else {
anchorView.removeFromSuperview()
return false
}
popover.sourceView = anchorView
popover.sourceRect = anchorView.bounds
popover.permittedArrowDirections = [.down]
popover.backgroundColor = .clear
popover.delegate = controller
presentedController = controller
presenter.present(controller, animated: true)
return true
}
@MainActor
private func makeSourceAnchor(frame: CGRect) -> UIView {
let anchor = UIView(frame: frame)
anchor.isUserInteractionEnabled = false
anchor.accessibilityElementsHidden = true
anchor.backgroundColor = .clear
anchor.layer.cornerRadius = min(frame.width, frame.height) / 2
anchor.layer.cornerCurve = .continuous
return anchor
}
@MainActor
private func activeWindowRootViewController() -> UIViewController? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.filter { $0.activationState == .foregroundActive }
.flatMap(\.windows)
.first(where: \.isKeyWindow)?
.rootViewController
}
@MainActor
private func topViewController(
from viewController: UIViewController?
) -> UIViewController? {
if let presented = viewController?.presentedViewController {
return topViewController(from: presented)
}
if let navigation = viewController as? UINavigationController {
return topViewController(from: navigation.visibleViewController)
}
if let tab = viewController as? UITabBarController {
return topViewController(from: tab.selectedViewController)
}
return viewController
}
}
func makeNativeAttachmentMenuButton(
title: String,
symbol: String,
action: UIAction
) -> UIButton {
let button = UIButton(primaryAction: action)
button.accessibilityLabel = title
let symbolConfiguration = UIImage.SymbolConfiguration(
pointSize: 18,
weight: .regular
)
let iconView = UIImageView(
image: UIImage(
systemName: symbol,
withConfiguration: symbolConfiguration
)
)
iconView.tintColor = .label
iconView.contentMode = .center
iconView.translatesAutoresizingMaskIntoConstraints = false
let titleLabel = UILabel()
titleLabel.text = title
titleLabel.textColor = .label
titleLabel.font = .preferredFont(forTextStyle: .body)
titleLabel.adjustsFontForContentSizeCategory = true
titleLabel.textAlignment = .left
titleLabel.translatesAutoresizingMaskIntoConstraints = false
button.addSubview(iconView)
button.addSubview(titleLabel)
NSLayoutConstraint.activate([
iconView.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 14),
iconView.centerYAnchor.constraint(equalTo: button.centerYAnchor),
iconView.widthAnchor.constraint(equalToConstant: 26),
titleLabel.leadingAnchor.constraint(
equalTo: iconView.trailingAnchor,
constant: 12
),
titleLabel.trailingAnchor.constraint(
equalTo: button.trailingAnchor,
constant: -14
),
titleLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor),
])
button.configurationUpdateHandler = { button in
button.alpha = button.isHighlighted ? 0.62 : 1
}
return button
}

View File

@@ -5,16 +5,26 @@ import 'package:image_picker/image_picker.dart';
Future<void> processCapturedImage(
XFile image,
Future<void> Function(XFile image) onCapture,
) async {
await processTemporaryImages([image], (images) => onCapture(images.single));
}
/// Processes native-owned [images], then removes their temporary files.
Future<void> processTemporaryImages(
List<XFile> images,
Future<void> Function(List<XFile> images) process,
) async {
try {
await onCapture(image);
await process(images);
} finally {
final path = image.path;
if (path.isNotEmpty) {
try {
await File(path).delete();
} on FileSystemException {
// The camera plugin may already have removed its temporary file.
for (final image in images) {
final path = image.path;
if (path.isNotEmpty) {
try {
await File(path).delete();
} on FileSystemException {
// The native picker may already have removed its temporary file.
}
}
}
}

View File

@@ -1,10 +1,12 @@
import 'dart:async';
import 'dart:collection';
import 'dart:math' as math;
import 'package:camera/camera.dart' as camera;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
@@ -28,21 +30,21 @@ import 'emoji_picker.dart';
import 'mentions/mention_candidates.dart';
import 'mentions/mention_candidates_provider.dart';
import 'mentions/mention_ranking.dart';
import 'photo_library.dart';
part 'compose_bar/helpers.dart';
part 'compose_bar/markdown_editing_controller.dart';
part 'compose_bar/suggestions.dart';
part 'compose_bar/formatting_toolbar.dart';
part 'compose_bar/attachments.dart';
part 'compose_bar/photo_gallery_picker.dart';
part 'compose_bar/ios_photo_picker.dart';
part 'compose_bar/ios_attachment_popover.dart';
part 'compose_bar/camera_preview.dart';
part 'compose_bar/send_button.dart';
part 'compose_bar/layout.dart';
const _pastedImageMimeTypes = <String>[
'image/jpeg',
'image/jpg',
'image/png',
'image/webp',
];
const _maxConcurrentImageUploads = 3;
/// Rich compose bar with @mention autocomplete and a markdown formatting
/// toolbar. Used in both channel and thread views — the caller provides an
@@ -120,8 +122,15 @@ class ComposeBar extends HookConsumerWidget {
}, [controller, draftKey, draftIdentity]);
final focusNode = useFocusNode();
final isComposerExpanded = useState(false);
final showAttachments = useState(false);
final showCamera = useState(false);
final attachmentSurface = useState(_AttachmentSurface.closed);
final iosAttachmentPopover = useMemoized(
_IOSAttachmentPopoverController.new,
);
useEffect(
() =>
() => unawaited(iosAttachmentPopover.dispose()),
[iosAttachmentPopover],
);
final isSending = useState(false);
final showFormatting = useState(false);
final attachments = useState<List<BlobDescriptor>>([]);
@@ -389,8 +398,7 @@ class ComposeBar extends HookConsumerWidget {
mentionMap.value.clear();
mentionQuery.value = null;
channelQuery.value = null;
showAttachments.value = false;
showCamera.value = false;
attachmentSurface.value = _AttachmentSurface.closed;
showFormatting.value = false;
uploadError.value = null;
focusNode.requestFocus();
@@ -533,6 +541,80 @@ class ComposeBar extends HookConsumerWidget {
}
}
Future<void> pickThenUpload({
required Future<XFile?> Function() pick,
required Future<BlobDescriptor> Function(XFile file) upload,
}) async {
uploadError.value = null;
try {
final picked = await pick();
if (picked == null || !context.mounted) return;
await pickAndUpload(() => upload(picked));
} catch (error) {
if (context.mounted) {
uploadError.value = _formatUploadError(error);
}
}
}
Future<void> uploadImages(List<XFile> images) async {
if (images.isEmpty) return;
uploadError.value = null;
uploadingCount.value += images.length;
try {
Future<({BlobDescriptor? uploaded, Object? error})> uploadImage(
XFile image,
) async {
try {
final uploaded = await ref
.read(mediaUploadServiceProvider)
.uploadImage(image);
return (uploaded: uploaded, error: null);
} catch (error) {
return (uploaded: null, error: error);
}
}
final results = <({BlobDescriptor? uploaded, Object? error})>[];
for (
var start = 0;
start < images.length;
start += _maxConcurrentImageUploads
) {
final end = math.min(
start + _maxConcurrentImageUploads,
images.length,
);
results.addAll(
await Future.wait([
for (final image in images.sublist(start, end))
uploadImage(image),
]),
);
}
if (!context.mounted) return;
final uploaded = [for (final result in results) ?result.uploaded];
if (uploaded.isNotEmpty) {
attachments.value = [...attachments.value, ...uploaded];
}
final firstError = results
.map((result) => result.error)
.whereType<Object>()
.firstOrNull;
if (firstError != null) {
uploadError.value = _formatUploadError(firstError);
}
} finally {
if (context.mounted) {
uploadingCount.value = math.max(
0,
uploadingCount.value - images.length,
);
}
}
}
Widget buildContextMenu(
BuildContext context,
EditableTextState editableTextState,
@@ -621,31 +703,88 @@ class ComposeBar extends HookConsumerWidget {
// ----- Widget tree ----------------------------------------------------
void chooseAttachment(Future<BlobDescriptor?> Function() pick) {
showAttachments.value = false;
showCamera.value = false;
pickAndUpload(pick);
void chooseAttachment(
Future<void> Function() choose, {
String? errorMessage,
}) {
attachmentSurface.value = _AttachmentSurface.closed;
unawaited(() async {
try {
await choose();
} catch (error) {
if (context.mounted) {
uploadError.value = errorMessage ?? _formatUploadError(error);
}
}
}());
}
void toggleAttachments() {
if (showCamera.value) {
showCamera.value = false;
showAttachments.value = false;
attachmentSurface.value = switch (attachmentSurface.value) {
_AttachmentSurface.closed => _AttachmentSurface.menu,
_AttachmentSurface.menu => _AttachmentSurface.closed,
_AttachmentSurface.camera ||
_AttachmentSurface.photos => _AttachmentSurface.menu,
};
}
void handleAttachmentTap(BuildContext triggerContext) {
if (defaultTargetPlatform != TargetPlatform.iOS ||
attachmentSurface.value != _AttachmentSurface.closed) {
toggleAttachments();
return;
}
showCamera.value = false;
showAttachments.value = !showAttachments.value;
focusNode.unfocus();
unawaited(
iosAttachmentPopover
.present(
sourceContext: triggerContext,
onCapture: (image) => pickAndUpload(
() => ref.read(mediaUploadServiceProvider).uploadImage(image),
),
onChoosePhotos: uploadImages,
onAllPhotos: () => chooseAttachment(() async {
final photos = await ref
.read(mediaUploadServiceProvider)
.pickGalleryImages();
await uploadImages(photos);
}, errorMessage: 'Unable to open your photo library.'),
onVideo: () => chooseAttachment(() {
final service = ref.read(mediaUploadServiceProvider);
return pickThenUpload(
pick: service.pickGalleryVideo,
upload: service.uploadVideo,
);
}),
onFiles: () => chooseAttachment(() {
final service = ref.read(mediaUploadServiceProvider);
return pickThenUpload(
pick: service.pickAttachmentFile,
upload: service.uploadFile,
);
}),
)
.then((didPresent) {
if (!didPresent && context.mounted) toggleAttachments();
}),
);
}
void openCamera() {
focusNode.unfocus();
showAttachments.value = false;
showCamera.value = true;
attachmentSurface.value = _AttachmentSurface.camera;
}
final motionDuration = reducedMotion
? Duration.zero
: const Duration(milliseconds: 180);
: Duration(
milliseconds:
attachmentSurface.value == _AttachmentSurface.camera ||
attachmentSurface.value == _AttachmentSurface.photos
? 320
: 250,
);
final suggestionOverlayController = useMemoized(
OverlayPortalController.new,
);
@@ -659,8 +798,7 @@ class ComposeBar extends HookConsumerWidget {
void expandComposer() {
if (isComposerExpanded.value) return;
showAttachments.value = false;
showCamera.value = false;
attachmentSurface.value = _AttachmentSurface.closed;
isComposerExpanded.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) focusNode.requestFocus();
@@ -687,40 +825,48 @@ class ComposeBar extends HookConsumerWidget {
),
)
: const SizedBox.shrink(key: ValueKey('no-suggestions'));
final overlayPanel = showCamera.value
? KeyedSubtree(
key: const ValueKey('camera-preview'),
child: _InlineCameraPreview(
onClose: () => showCamera.value = false,
onCapture: (image) async {
showCamera.value = false;
await pickAndUpload(
() => ref.read(mediaUploadServiceProvider).uploadImage(image),
);
},
),
)
: showAttachments.value
? KeyedSubtree(
key: const ValueKey('attachment-menu'),
child: Align(
alignment: Alignment.bottomLeft,
heightFactor: 1,
child: _AttachmentMenu(
onCamera: openCamera,
onPhotos: () => chooseAttachment(
ref.read(mediaUploadServiceProvider).pickAndUploadImage,
),
onVideo: () => chooseAttachment(
ref.read(mediaUploadServiceProvider).pickAndUploadVideo,
),
onFiles: () => chooseAttachment(
ref.read(mediaUploadServiceProvider).pickAndUploadFile,
),
),
),
)
: suggestionPanel;
Widget buildOverlayPanel(_AttachmentSurface surface) {
return _AttachmentSurfacePanel(
key: ValueKey(
surface == _AttachmentSurface.closed
? 'composer-suggestions'
: 'attachment-surface',
),
surface: surface,
suggestionPanel: suggestionPanel,
onBack: () => attachmentSurface.value = _AttachmentSurface.menu,
onCamera: openCamera,
onPhotos: () {
focusNode.unfocus();
attachmentSurface.value = _AttachmentSurface.photos;
},
onVideo: () => chooseAttachment(() {
final service = ref.read(mediaUploadServiceProvider);
return pickThenUpload(
pick: service.pickGalleryVideo,
upload: service.uploadVideo,
);
}),
onFiles: () => chooseAttachment(() {
final service = ref.read(mediaUploadServiceProvider);
return pickThenUpload(
pick: service.pickAttachmentFile,
upload: service.uploadFile,
);
}),
onCapture: (image) async {
attachmentSurface.value = _AttachmentSurface.closed;
await pickAndUpload(
() => ref.read(mediaUploadServiceProvider).uploadImage(image),
);
},
onPickAllPhotos: ref.read(mediaUploadServiceProvider).pickGalleryImages,
onChoosePhotos: (photos) async {
attachmentSurface.value = _AttachmentSurface.closed;
await uploadImages(photos);
},
);
}
// Suggestions and attachments live in the overlay so showing them cannot
// reflow the composer. Both stay anchored just above the capsule.
@@ -737,233 +883,91 @@ class ComposeBar extends HookConsumerWidget {
layoutInfo.childPaintTransform,
Offset.zero,
);
return Positioned(
left: composerOrigin.dx,
bottom: layoutInfo.overlaySize.height - composerOrigin.dy,
width: layoutInfo.childSize.width,
child: ClipRect(
child: Padding(
padding: const EdgeInsets.only(bottom: Grid.xxs),
child: _SuggestionPanelMotion(
duration: motionDuration,
child: overlayPanel,
return ValueListenableBuilder<_AttachmentSurface>(
valueListenable: attachmentSurface,
builder: (context, surface, _) {
final surfaceDuration = reducedMotion
? Duration.zero
: Duration(
milliseconds:
surface == _AttachmentSurface.camera ||
surface == _AttachmentSurface.photos
? 320
: 250,
);
final expandedSurfaceCoversComposer =
surface == _AttachmentSurface.camera ||
surface == _AttachmentSurface.photos;
final overlayAnchorY =
composerOrigin.dy +
(expandedSurfaceCoversComposer
? layoutInfo.childSize.height + Grid.twelve
: 0);
return AnimatedPositioned(
duration: surfaceDuration,
curve:
surface == _AttachmentSurface.camera ||
surface == _AttachmentSurface.photos
? const Cubic(0.34, 1.25, 0.64, 1)
: const Cubic(0.22, 1, 0.36, 1),
left: composerOrigin.dx,
bottom: layoutInfo.overlaySize.height - overlayAnchorY,
width: layoutInfo.childSize.width,
child: ClipRect(
child: Padding(
padding: const EdgeInsets.only(bottom: Grid.xxs),
child: surface == _AttachmentSurface.closed
? _SuggestionPanelMotion(
duration: surfaceDuration,
alignment: Alignment.bottomLeft,
child: buildOverlayPanel(surface),
)
: buildOverlayPanel(surface),
),
),
),
),
);
},
);
},
child: Container(
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.dialog),
border: Border.all(
color: Colors.black.withValues(alpha: 0.04),
width: 1,
),
),
padding: const EdgeInsets.all(Grid.xxs),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (hasAttachments || hasPendingUploads) ...[
_AttachmentStrip(
attachments: attachments.value,
uploadingCount: uploadingCount.value,
onRemove: removeAttachment,
),
const SizedBox(height: Grid.xxs),
],
if (uploadError.value case final error?) ...[
Align(
alignment: Alignment.centerLeft,
child: Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
),
const SizedBox(height: Grid.xxs),
],
// Keep the default state out of the focus system entirely so
// restored native focus cannot expand a newly opened channel.
if (isComposerExpanded.value)
TextField(
controller: controller,
focusNode: focusNode,
textInputAction: TextInputAction.send,
contextMenuBuilder: buildContextMenu,
contentInsertionConfiguration: ContentInsertionConfiguration(
allowedMimeTypes: _pastedImageMimeTypes,
onContentInserted: uploadPastedImage,
),
onSubmitted: (_) => send(),
minLines: 1,
maxLines: 5,
style: context.textTheme.bodyLarge,
decoration: InputDecoration(
hintText: resolvedHint,
hintStyle: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.half,
vertical: Grid.half,
),
isDense: true,
),
)
else
Row(
children: [
_AttachmentTrigger(
open: showAttachments.value || showCamera.value,
onTap: toggleAttachments,
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Semantics(
button: true,
label: resolvedHint,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: expandComposer,
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: Grid.half,
),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
resolvedHint,
style: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
),
),
),
),
],
),
ClipRect(
child: Align(
alignment: Alignment.topCenter,
heightFactor: composerExpansionValue,
child: IgnorePointer(
ignoring: composerExpansionValue < 0.98,
child: Opacity(
opacity: composerExpansionProgress,
child: Transform.translate(
offset: Offset(
0,
Grid.xxs * (1 - composerExpansionProgress),
),
child: Column(
children: [
const SizedBox(height: Grid.xxs),
Row(
children: [
_AttachmentTrigger(
open:
showAttachments.value ||
showCamera.value ||
showFormatting.value,
onTap: () {
if (showFormatting.value) {
showFormatting.value = false;
} else {
toggleAttachments();
}
},
),
const SizedBox(width: Grid.half),
Expanded(
child: AnimatedSwitcher(
duration: motionDuration,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
layoutBuilder:
(currentChild, previousChildren) =>
Stack(
alignment: Alignment.centerLeft,
children: [
...previousChildren,
?currentChild,
],
),
child: showFormatting.value
? _FormattingToolbar(
onFormat: applyFormat,
)
: Row(
key: const ValueKey(
'standard-actions',
),
children: [
_ComposeAction(
icon: LucideIcons.atSign,
onTap: () {
showAttachments.value = false;
showCamera.value = false;
triggerMention();
},
),
_ComposeAction(
icon: LucideIcons.hash,
onTap: () {
showAttachments.value = false;
showCamera.value = false;
triggerChannel();
},
),
_ComposeAction(
icon: LucideIcons.smilePlus,
onTap: () {
showAttachments.value = false;
showCamera.value = false;
showEmojiPicker(
context: context,
onSelect: insertEmoji,
);
},
),
_ComposeAction(
icon: LucideIcons.aLargeSmall,
onTap: () {
showAttachments.value = false;
showCamera.value = false;
showFormatting.value = true;
},
),
const Spacer(),
_SendButton(
isDisabled: hasPendingUploads,
isSending: isSending.value,
onTap: send,
),
],
),
),
),
],
),
],
),
),
),
),
),
),
],
),
child: _ComposeBarLayout(
attachments: attachments.value,
uploadingCount: uploadingCount.value,
onRemoveAttachment: removeAttachment,
uploadError: uploadError.value,
isExpanded: isComposerExpanded.value,
controller: controller,
focusNode: focusNode,
contextMenuBuilder: buildContextMenu,
onContentInserted: uploadPastedImage,
onSend: () => unawaited(send()),
resolvedHint: resolvedHint,
attachmentSurface: attachmentSurface.value,
onAttachmentTap: handleAttachmentTap,
onExpand: expandComposer,
expansionValue: composerExpansionValue,
expansionProgress: composerExpansionProgress,
formattingOpen: showFormatting.value,
onCloseFormatting: () => showFormatting.value = false,
motionDuration: motionDuration,
onFormat: applyFormat,
onMention: () {
attachmentSurface.value = _AttachmentSurface.closed;
triggerMention();
},
onChannel: () {
attachmentSurface.value = _AttachmentSurface.closed;
triggerChannel();
},
onEmoji: () {
attachmentSurface.value = _AttachmentSurface.closed;
showEmojiPicker(context: context, onSelect: insertEmoji);
},
onOpenFormatting: () {
attachmentSurface.value = _AttachmentSurface.closed;
showFormatting.value = true;
},
hasPendingUploads: hasPendingUploads,
isSending: isSending.value,
),
),
);

View File

@@ -1,5 +1,195 @@
part of '../compose_bar.dart';
enum _AttachmentSurface { closed, menu, camera, photos }
const _attachmentMenuWidth = 176.0;
const _attachmentMenuHeight = 208.0;
const _attachmentExpandedHeight = 372.0;
class _AttachmentSurfacePanel extends HookWidget {
final _AttachmentSurface surface;
final Widget suggestionPanel;
final VoidCallback onBack;
final VoidCallback onCamera;
final VoidCallback onPhotos;
final VoidCallback onVideo;
final VoidCallback onFiles;
final Future<void> Function(XFile image) onCapture;
final Future<List<XFile>> Function() onPickAllPhotos;
final Future<void> Function(List<XFile> photos) onChoosePhotos;
const _AttachmentSurfacePanel({
super.key,
required this.surface,
required this.suggestionPanel,
required this.onBack,
required this.onCamera,
required this.onPhotos,
required this.onVideo,
required this.onFiles,
required this.onCapture,
required this.onPickAllPhotos,
required this.onChoosePhotos,
});
@override
Widget build(BuildContext context) {
if (surface == _AttachmentSurface.closed) return suggestionPanel;
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final isExpanded =
surface == _AttachmentSurface.camera ||
surface == _AttachmentSurface.photos;
final morphController = useAnimationController(
initialValue: isExpanded ? 1 : 0,
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 320),
reverseDuration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 250),
);
final rawProgress = useAnimation(morphController);
final renderedExpandedSurface = useState<_AttachmentSurface?>(
isExpanded ? surface : null,
);
final latestSurface = useRef(surface);
latestSurface.value = surface;
useEffect(() {
void disposeCollapsedContent(AnimationStatus status) {
if (status == AnimationStatus.dismissed &&
latestSurface.value == _AttachmentSurface.menu) {
renderedExpandedSurface.value = null;
}
}
morphController.addStatusListener(disposeCollapsedContent);
return () =>
morphController.removeStatusListener(disposeCollapsedContent);
}, [morphController]);
useEffect(() {
if (isExpanded) {
renderedExpandedSurface.value = surface;
morphController.forward();
} else {
morphController.reverse();
}
return null;
}, [isExpanded, morphController, surface]);
final visibleExpandedSurface =
renderedExpandedSurface.value ?? (isExpanded ? surface : null);
final expandedContent = switch (visibleExpandedSurface) {
_AttachmentSurface.camera => KeyedSubtree(
key: const ValueKey('camera-preview'),
child: _InlineCameraPreview(onClose: onBack, onCapture: onCapture),
),
_AttachmentSurface.photos => KeyedSubtree(
key: const ValueKey('photo-gallery'),
child: _PhotoGalleryPicker(
onBack: onBack,
onPickAllPhotos: onPickAllPhotos,
onChoosePhotos: onChoosePhotos,
),
),
_AttachmentSurface.closed ||
_AttachmentSurface.menu ||
null => const SizedBox.shrink(),
};
double interval(double value, double begin, double end) {
return ((value - begin) / (end - begin)).clamp(0.0, 1.0);
}
final menuOpacity = 1 - interval(rawProgress, 0.12, 0.38);
final expandedOpacity = interval(rawProgress, 0.28, 0.65);
final sizeProgress = morphController.status == AnimationStatus.reverse
? const Cubic(0.22, 1, 0.36, 1).transform(rawProgress)
: Curves.easeInOutCubic.transform(rawProgress);
return LayoutBuilder(
builder: (context, constraints) {
final expandedWidth = constraints.maxWidth;
const expandedHeight = _attachmentExpandedHeight;
final width =
_attachmentMenuWidth +
((expandedWidth - _attachmentMenuWidth) * sizeProgress);
final height =
_attachmentMenuHeight +
((expandedHeight - _attachmentMenuHeight) * sizeProgress);
final baseColor = context.colors.surfaceContainerHighest;
final expandedColor =
visibleExpandedSurface == _AttachmentSurface.camera
? Colors.black
: baseColor;
return Align(
alignment: Alignment.topLeft,
heightFactor: 1,
child: SizedBox(
width: width,
height: height,
child: DecoratedBox(
decoration: BoxDecoration(
color: Color.lerp(baseColor, expandedColor, sizeProgress),
borderRadius: BorderRadius.circular(Radii.dialog),
border: Border.all(
color: Colors.black.withValues(alpha: 0.04),
width: 1,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(Radii.dialog),
child: Material(
type: MaterialType.transparency,
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
Positioned(
left: 0,
top: 0,
width: _attachmentMenuWidth,
height: _attachmentMenuHeight,
child: IgnorePointer(
ignoring: surface != _AttachmentSurface.menu,
child: Opacity(
opacity: menuOpacity,
child: _AttachmentMenu(
onCamera: onCamera,
onPhotos: onPhotos,
onVideo: onVideo,
onFiles: onFiles,
),
),
),
),
Positioned(
left: 0,
top: 0,
width: expandedWidth,
height: expandedHeight,
child: IgnorePointer(
ignoring: !isExpanded,
child: Opacity(
opacity: expandedOpacity,
child: expandedContent,
),
),
),
],
),
),
),
),
),
);
},
);
}
}
@immutable
class _ComposeDraftPayload {
final String content;
@@ -24,16 +214,21 @@ class _ComposeDraftPayload {
}
class _AttachmentTrigger extends StatelessWidget {
final bool open;
final VoidCallback onTap;
final _AttachmentSurface surface;
final bool formattingOpen;
final ValueChanged<BuildContext> onTap;
const _AttachmentTrigger({required this.open, required this.onTap});
const _AttachmentTrigger({
required this.surface,
required this.formattingOpen,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final duration = MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 180);
: const Duration(milliseconds: 240);
return SizedBox.square(
dimension: 36,
@@ -47,18 +242,44 @@ class _AttachmentTrigger extends StatelessWidget {
),
),
child: IconButton(
tooltip: open ? 'Close attachments' : 'Add attachment',
onPressed: onTap,
tooltip: switch (surface) {
_AttachmentSurface.closed =>
formattingOpen ? 'Close formatting' : 'Add attachment',
_AttachmentSurface.menu => 'Close attachments',
_AttachmentSurface.camera ||
_AttachmentSurface.photos => 'Back to attachment options',
},
onPressed: () => onTap(context),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
icon: AnimatedRotation(
duration: duration,
curve: Curves.easeInOutCubic,
turns: open ? 0.125 : 0,
child: Icon(
LucideIcons.plus,
size: 20,
color: context.colors.onSurfaceVariant,
curve: Curves.easeOutBack,
turns: surface == _AttachmentSurface.menu || formattingOpen
? 0.125
: 0,
child: AnimatedSwitcher(
duration: duration,
switchInCurve: Curves.easeOutBack,
switchOutCurve: Curves.easeInOutCubic,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween<double>(begin: 0.92, end: 1).animate(animation),
child: child,
),
),
child: Icon(
switch (surface) {
_AttachmentSurface.camera => LucideIcons.camera,
_AttachmentSurface.photos => LucideIcons.images,
_AttachmentSurface.closed ||
_AttachmentSurface.menu => LucideIcons.plus,
},
key: ValueKey('attachment-trigger-${surface.name}'),
size: 20,
color: context.colors.onSurfaceVariant,
),
),
),
),
@@ -82,43 +303,37 @@ class _AttachmentMenu extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
width: 176,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.dialog),
border: Border.all(
color: Colors.black.withValues(alpha: 0.04),
width: 1,
return SizedBox(
width: _attachmentMenuWidth,
height: _attachmentMenuHeight,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_AttachmentMenuItem(
icon: LucideIcons.camera,
label: 'Camera',
onTap: onCamera,
),
_AttachmentMenuItem(
icon: LucideIcons.images,
label: 'Photos',
onTap: onPhotos,
),
_AttachmentMenuItem(
icon: LucideIcons.video,
label: 'Video',
onTap: onVideo,
),
_AttachmentMenuItem(
icon: LucideIcons.file,
label: 'Files',
onTap: onFiles,
),
],
),
),
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_AttachmentMenuItem(
icon: LucideIcons.camera,
label: 'Camera',
onTap: onCamera,
),
_AttachmentMenuItem(
icon: LucideIcons.images,
label: 'Photos',
onTap: onPhotos,
),
_AttachmentMenuItem(
icon: LucideIcons.video,
label: 'Video',
onTap: onVideo,
),
_AttachmentMenuItem(
icon: LucideIcons.file,
label: 'Files',
onTap: onFiles,
),
],
),
);
}
}
@@ -201,38 +416,52 @@ class _AttachmentStrip extends StatelessWidget {
? 'Uploading attachment…'
: 'Uploading $uploadingCount attachments…';
return Semantics(
excludeSemantics: true,
liveRegion: true,
label: label,
child: Container(
key: const ValueKey('compose-upload-progress'),
width: 128,
width: thumbWidth,
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: BorderRadius.circular(Radii.md),
border: Border.all(color: context.colors.outlineVariant),
),
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox.square(
dimension: 18,
dimension: 34,
child: CircularProgressIndicator(
strokeWidth: 2,
strokeWidth: 3,
color: context.colors.primary,
),
),
const SizedBox(width: Grid.half),
Flexible(
child: Text(
label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant,
if (uploadingCount > 1)
PositionedDirectional(
top: Grid.quarter,
end: Grid.quarter,
child: Container(
key: const ValueKey('compose-upload-count'),
constraints: const BoxConstraints(
minWidth: 22,
minHeight: 22,
),
alignment: Alignment.center,
decoration: BoxDecoration(
color: context.colors.primary,
shape: BoxShape.circle,
),
padding: const EdgeInsets.all(3),
child: Text(
'$uploadingCount',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onPrimary,
fontWeight: FontWeight.w700,
),
),
),
),
),
],
),
),

View File

@@ -115,50 +115,44 @@ class _InlineCameraPreview extends HookConsumerWidget {
}
final activeController = controller.value;
return Container(
width: double.infinity,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(Radii.dialog),
),
foregroundDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(Radii.dialog),
border: Border.all(
color: Colors.black.withValues(alpha: 0.04),
width: 1,
),
),
child: AspectRatio(
aspectRatio: 4 / 3,
child: Stack(
fit: StackFit.expand,
children: [
if (activeController case final initialized?)
_CameraFeed(controller: initialized)
else
_CameraPlaceholder(
isInitializing: isInitializing.value,
message: error.value,
),
if (activeController != null)
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.all(Grid.twelve),
child: _CameraCaptureButton(
isPressed: isCapturing.value,
onTap: capture,
),
final usesAndroidCameraLayout =
defaultTargetPlatform == TargetPlatform.android;
return ColoredBox(
color: Colors.black,
child: Stack(
fit: StackFit.expand,
children: [
if (activeController case final initialized?)
_CameraFeed(controller: initialized)
else
_CameraPlaceholder(
isInitializing: isInitializing.value,
message: error.value,
),
if (activeController != null)
Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: const EdgeInsets.all(Grid.twelve),
child: _CameraCaptureButton(
isPressed: isCapturing.value,
onTap: capture,
),
),
Positioned(
top: Grid.xxs,
right: Grid.xxs,
child: _CameraCloseButton(onTap: onClose),
),
],
),
Positioned(
top: usesAndroidCameraLayout ? null : Grid.xxs,
left: usesAndroidCameraLayout ? Grid.twelve : null,
right: usesAndroidCameraLayout ? null : Grid.xxs,
bottom: usesAndroidCameraLayout
? Grid.twelve + ((_cameraCaptureSize - _cameraBackSize) / 2)
: null,
child: _CameraCloseButton(
onTap: onClose,
emphasized: usesAndroidCameraLayout,
),
),
],
),
);
}
@@ -206,7 +200,7 @@ class _CameraPlaceholder extends StatelessWidget {
child: isInitializing
? const CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
strokeWidth: 3,
)
: Column(
mainAxisSize: MainAxisSize.min,
@@ -254,8 +248,8 @@ class _CameraCaptureButton extends StatelessWidget {
duration: duration,
curve: Curves.easeOutCubic,
child: Container(
width: 64,
height: 64,
width: _cameraCaptureSize,
height: _cameraCaptureSize,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.24),
shape: BoxShape.circle,
@@ -277,27 +271,33 @@ class _CameraCaptureButton extends StatelessWidget {
class _CameraCloseButton extends StatelessWidget {
final VoidCallback onTap;
final bool emphasized;
const _CameraCloseButton({required this.onTap});
const _CameraCloseButton({required this.onTap, required this.emphasized});
@override
Widget build(BuildContext context) {
return SizedBox.square(
dimension: 36,
dimension: emphasized ? _cameraBackSize : 36,
child: IconButton(
onPressed: onTap,
tooltip: 'Close camera',
tooltip: 'Back to attachment options',
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
backgroundColor: Colors.black.withValues(alpha: 0.56),
backgroundColor: Colors.black.withValues(
alpha: emphasized ? 0.68 : 0.56,
),
foregroundColor: Colors.white,
),
icon: const Icon(LucideIcons.x, size: 18),
icon: Icon(LucideIcons.arrowLeft, size: emphasized ? 24 : 18),
),
);
}
}
const _cameraCaptureSize = 64.0;
const _cameraBackSize = 44.0;
String _cameraErrorMessage(Object error) {
if (error is camera.CameraException) {
return switch (error.code) {

View File

@@ -1,6 +1,12 @@
part of '../compose_bar.dart';
const _typingThrottleMs = 3000;
const _pastedImageMimeTypes = <String>[
'image/jpeg',
'image/jpg',
'image/png',
'image/webp',
];
/// Cap on ranked mention suggestions shown — matches desktop's
/// `MENTION_SUGGESTION_LIMIT`.

View File

@@ -0,0 +1,174 @@
part of '../compose_bar.dart';
const _nativeAttachmentPopoverChannel = MethodChannel(
'buzz/native_attachment_popover',
);
final _iosAttachmentPopoverCoordinator = _IOSAttachmentPopoverCoordinator(
_nativeAttachmentPopoverChannel,
);
class _IOSAttachmentPopoverCallbacks {
final Future<void> Function(XFile image) onCapture;
final Future<void> Function(List<XFile> photos) onChoosePhotos;
final VoidCallback onAllPhotos;
final VoidCallback onVideo;
final VoidCallback onFiles;
const _IOSAttachmentPopoverCallbacks({
required this.onCapture,
required this.onChoosePhotos,
required this.onAllPhotos,
required this.onVideo,
required this.onFiles,
});
}
class _IOSAttachmentPopoverCoordinator {
final MethodChannel _channel;
Object? _activeOwner;
_IOSAttachmentPopoverCallbacks? _callbacks;
bool _didPresent = false;
bool _handlerInstalled = false;
_IOSAttachmentPopoverCoordinator(this._channel);
Future<bool> present({
required Object owner,
required BuildContext sourceContext,
required Future<void> Function(XFile image) onCapture,
required Future<void> Function(List<XFile> photos) onChoosePhotos,
required VoidCallback onAllPhotos,
required VoidCallback onVideo,
required VoidCallback onFiles,
}) async {
if (defaultTargetPlatform != TargetPlatform.iOS) return false;
if (_activeOwner != null) return true;
_activeOwner = owner;
_callbacks = _IOSAttachmentPopoverCallbacks(
onCapture: onCapture,
onChoosePhotos: onChoosePhotos,
onAllPhotos: onAllPhotos,
onVideo: onVideo,
onFiles: onFiles,
);
_ensureHandler();
try {
final supported =
await _channel.invokeMethod<bool>('isSupported') ?? false;
if (!identical(_activeOwner, owner)) return false;
if (!supported || !sourceContext.mounted) {
_clearOwner(owner);
return false;
}
final renderObject = sourceContext.findRenderObject();
if (renderObject is! RenderBox || !renderObject.hasSize) {
_clearOwner(owner);
return false;
}
final origin = renderObject.localToGlobal(Offset.zero);
_didPresent = true;
final didPresent =
await _channel.invokeMethod<bool>('present', {
'x': origin.dx,
'y': origin.dy,
'width': renderObject.size.width,
'height': renderObject.size.height,
}) ??
false;
if (!identical(_activeOwner, owner)) return false;
if (!didPresent) _clearOwner(owner);
return didPresent;
} on PlatformException {
_clearOwner(owner);
return false;
}
}
Future<void> disposeOwner(Object owner) async {
if (!identical(_activeOwner, owner)) return;
_callbacks = null;
if (!_didPresent) {
_clearOwner(owner);
return;
}
try {
await _channel.invokeMethod<void>('dismiss');
} on PlatformException {
_clearOwner(owner);
}
}
void _ensureHandler() {
if (_handlerInstalled) return;
_handlerInstalled = true;
_channel.setMethodCallHandler(_handleMethodCall);
}
Future<void> _handleMethodCall(MethodCall call) async {
final callbacks = _callbacks;
switch (call.method) {
case 'cameraCaptured':
if (call.arguments case final String path) {
final activeCallbacks = callbacks;
if (activeCallbacks != null) {
await processCapturedImage(XFile(path), activeCallbacks.onCapture);
}
}
case 'photosSelected':
final paths = (call.arguments as List<Object?>? ?? const [])
.whereType<String>()
.toList();
if (callbacks != null && paths.isNotEmpty) {
await processTemporaryImages([
for (final path in paths) XFile(path),
], callbacks.onChoosePhotos);
}
case 'pickAllPhotos':
callbacks?.onAllPhotos();
case 'pickVideo':
callbacks?.onVideo();
case 'pickFiles':
callbacks?.onFiles();
case 'dismissed':
_activeOwner = null;
_callbacks = null;
_didPresent = false;
}
}
void _clearOwner(Object owner) {
if (!identical(_activeOwner, owner)) return;
_activeOwner = null;
_callbacks = null;
_didPresent = false;
}
}
class _IOSAttachmentPopoverController {
Future<bool> present({
required BuildContext sourceContext,
required Future<void> Function(XFile image) onCapture,
required Future<void> Function(List<XFile> photos) onChoosePhotos,
required VoidCallback onAllPhotos,
required VoidCallback onVideo,
required VoidCallback onFiles,
}) => _iosAttachmentPopoverCoordinator.present(
owner: this,
sourceContext: sourceContext,
onCapture: onCapture,
onChoosePhotos: onChoosePhotos,
onAllPhotos: onAllPhotos,
onVideo: onVideo,
onFiles: onFiles,
);
Future<void> dispose() => _iosAttachmentPopoverCoordinator.disposeOwner(this);
}

View File

@@ -0,0 +1,228 @@
part of '../compose_bar.dart';
const _inlinePhotoPickerViewType = 'buzz/inline_photo_picker';
const _inlinePhotoPickerSupportChannel = MethodChannel(
'buzz/inline_photo_picker',
);
class _IOSInlinePhotoPicker extends HookWidget {
final VoidCallback onBack;
final Future<List<XFile>> Function() onPickAllPhotos;
final Future<void> Function(List<XFile> photos) onChoosePhotos;
final Widget fallback;
const _IOSInlinePhotoPicker({
required this.onBack,
required this.onPickAllPhotos,
required this.onChoosePhotos,
required this.fallback,
});
@override
Widget build(BuildContext context) {
final supportFuture = useMemoized(
() async =>
await _inlinePhotoPickerSupportChannel.invokeMethod<bool>(
'isSupported',
) ??
false,
);
final support = useFuture(supportFuture);
final pickerChannel = useState<MethodChannel?>(null);
final selectedCount = useState(0);
final selectedPaths = useState<List<String>>(const []);
final isPreparingSelection = useState(false);
final isProcessing = useState(false);
useEffect(() {
final channel = pickerChannel.value;
if (channel == null) return null;
channel.setMethodCallHandler((call) async {
switch (call.method) {
case 'selectionCountChanged':
selectedCount.value = call.arguments as int? ?? 0;
selectedPaths.value = const [];
isPreparingSelection.value = selectedCount.value > 0;
case 'selectionDidChange':
final paths = (call.arguments as List<Object?>? ?? const [])
.whereType<String>()
.toList();
selectedPaths.value = paths;
isPreparingSelection.value = false;
case 'didFail':
isPreparingSelection.value = false;
if (!context.mounted) return;
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(
content: Text(
call.arguments as String? ??
'Unable to prepare the selected photos.',
),
),
);
}
});
return () => channel.setMethodCallHandler(null);
}, [pickerChannel.value]);
final canSelect =
selectedCount.value > 0 &&
selectedPaths.value.length == selectedCount.value &&
!isPreparingSelection.value &&
!isProcessing.value;
Future<void> submitSelection() async {
if (!canSelect) return;
isProcessing.value = true;
try {
final paths = List<String>.from(selectedPaths.value);
final claimed =
await pickerChannel.value?.invokeMethod<bool>(
'claimSelection',
paths,
) ??
false;
if (!claimed) return;
final photos = [for (final path in paths) XFile(path)];
await processTemporaryImages(photos, onChoosePhotos);
} finally {
if (context.mounted) isProcessing.value = false;
}
}
Future<void> openAllPhotos() async {
if (isPreparingSelection.value || isProcessing.value) return;
isProcessing.value = true;
try {
final photos = await onPickAllPhotos();
if (photos.isNotEmpty) {
await onChoosePhotos(photos);
}
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
const SnackBar(content: Text('Unable to open your photo library.')),
);
}
} finally {
if (context.mounted) isProcessing.value = false;
}
}
if (support.connectionState != ConnectionState.done) {
return const _NativePhotoPickerLoading();
}
if (support.hasError || support.data != true) return fallback;
return SizedBox(
key: const ValueKey('ios-inline-photo-picker'),
height: _attachmentExpandedHeight,
width: double.infinity,
child: Stack(
fit: StackFit.expand,
children: [
UiKitView(
viewType: _inlinePhotoPickerViewType,
creationParamsCodec: const StandardMessageCodec(),
onPlatformViewCreated: (viewId) {
pickerChannel.value = MethodChannel(
'buzz/inline_photo_picker/$viewId',
);
},
),
PositionedDirectional(
start: Grid.twelve,
bottom: Grid.twelve,
child: SafeArea(
top: false,
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.62),
shape: BoxShape.circle,
border: Border.all(
color: Colors.white.withValues(alpha: 0.28),
),
),
child: IconButton(
key: const ValueKey('ios-inline-photo-picker-back'),
onPressed: isProcessing.value ? null : onBack,
tooltip: 'Back to attachment options',
icon: const Icon(
LucideIcons.chevronLeft,
color: Colors.white,
),
),
),
),
),
PositionedDirectional(
end: Grid.twelve,
bottom: Grid.twelve,
child: SafeArea(
top: false,
child: FilledButton(
key: const ValueKey('ios-inline-photo-picker-select'),
onPressed: canSelect
? submitSelection
: selectedCount.value == 0 &&
!isPreparingSelection.value &&
!isProcessing.value
? openAllPhotos
: null,
style: FilledButton.styleFrom(
backgroundColor: Colors.black.withValues(alpha: 0.76),
disabledBackgroundColor: Colors.black.withValues(alpha: 0.32),
foregroundColor: Colors.white,
disabledForegroundColor: Colors.white70,
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: Grid.twelve),
shape: const StadiumBorder(),
side: BorderSide(color: Colors.white.withValues(alpha: 0.28)),
),
child: isPreparingSelection.value
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
selectedCount.value == 0
? 'All Photos'
: 'Add ${selectedCount.value} '
'${selectedCount.value == 1 ? 'photo' : 'photos'}',
),
),
),
),
if (isProcessing.value)
const ColoredBox(
color: Color.fromRGBO(0, 0, 0, 0.28),
child: Center(
child: CircularProgressIndicator(
strokeWidth: 3,
color: Colors.white,
),
),
),
],
),
);
}
}
class _NativePhotoPickerLoading extends StatelessWidget {
const _NativePhotoPickerLoading();
@override
Widget build(BuildContext context) {
return SizedBox(
key: const ValueKey('ios-inline-photo-picker-loading'),
height: _attachmentExpandedHeight,
width: double.infinity,
child: const Center(child: CircularProgressIndicator(strokeWidth: 3)),
);
}
}

View File

@@ -0,0 +1,245 @@
part of '../compose_bar.dart';
class _ComposeBarLayout extends StatelessWidget {
final List<BlobDescriptor> attachments;
final int uploadingCount;
final ValueChanged<String> onRemoveAttachment;
final String? uploadError;
final bool isExpanded;
final TextEditingController controller;
final FocusNode focusNode;
final EditableTextContextMenuBuilder contextMenuBuilder;
final ValueChanged<KeyboardInsertedContent> onContentInserted;
final VoidCallback onSend;
final String resolvedHint;
final _AttachmentSurface attachmentSurface;
final ValueChanged<BuildContext> onAttachmentTap;
final VoidCallback onExpand;
final double expansionValue;
final double expansionProgress;
final bool formattingOpen;
final VoidCallback onCloseFormatting;
final Duration motionDuration;
final void Function(String prefix, [String? suffix]) onFormat;
final VoidCallback onMention;
final VoidCallback onChannel;
final VoidCallback onEmoji;
final VoidCallback onOpenFormatting;
final bool hasPendingUploads;
final bool isSending;
const _ComposeBarLayout({
required this.attachments,
required this.uploadingCount,
required this.onRemoveAttachment,
required this.uploadError,
required this.isExpanded,
required this.controller,
required this.focusNode,
required this.contextMenuBuilder,
required this.onContentInserted,
required this.onSend,
required this.resolvedHint,
required this.attachmentSurface,
required this.onAttachmentTap,
required this.onExpand,
required this.expansionValue,
required this.expansionProgress,
required this.formattingOpen,
required this.onCloseFormatting,
required this.motionDuration,
required this.onFormat,
required this.onMention,
required this.onChannel,
required this.onEmoji,
required this.onOpenFormatting,
required this.hasPendingUploads,
required this.isSending,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.dialog),
border: Border.all(
color: Colors.black.withValues(alpha: 0.04),
width: 1,
),
),
padding: const EdgeInsets.all(Grid.xxs),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (attachments.isNotEmpty || hasPendingUploads) ...[
_AttachmentStrip(
attachments: attachments,
uploadingCount: uploadingCount,
onRemove: onRemoveAttachment,
),
const SizedBox(height: Grid.xxs),
],
if (uploadError case final error?) ...[
Align(
alignment: Alignment.centerLeft,
child: Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
),
const SizedBox(height: Grid.xxs),
],
// Keep the default state out of the focus system entirely so
// restored native focus cannot expand a newly opened channel.
if (isExpanded)
TextField(
controller: controller,
focusNode: focusNode,
textInputAction: TextInputAction.send,
contextMenuBuilder: contextMenuBuilder,
contentInsertionConfiguration: ContentInsertionConfiguration(
allowedMimeTypes: _pastedImageMimeTypes,
onContentInserted: onContentInserted,
),
onSubmitted: (_) => onSend(),
minLines: 1,
maxLines: 5,
style: context.textTheme.bodyLarge,
decoration: InputDecoration(
hintText: resolvedHint,
hintStyle: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.half,
vertical: Grid.half,
),
isDense: true,
),
)
else
Row(
children: [
_AttachmentTrigger(
surface: attachmentSurface,
formattingOpen: false,
onTap: onAttachmentTap,
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Semantics(
button: true,
label: resolvedHint,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onExpand,
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: Grid.half,
),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
resolvedHint,
style: context.textTheme.bodyLarge?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
),
),
),
),
],
),
ClipRect(
child: Align(
alignment: Alignment.topCenter,
heightFactor: expansionValue,
child: IgnorePointer(
ignoring: expansionValue < 0.98,
child: Opacity(
opacity: expansionProgress,
child: Transform.translate(
offset: Offset(0, Grid.xxs * (1 - expansionProgress)),
child: Column(
children: [
const SizedBox(height: Grid.xxs),
Row(
children: [
_AttachmentTrigger(
surface: attachmentSurface,
formattingOpen: formattingOpen,
onTap: (triggerContext) {
if (formattingOpen) {
onCloseFormatting();
} else {
onAttachmentTap(triggerContext);
}
},
),
const SizedBox(width: Grid.half),
Expanded(
child: AnimatedSwitcher(
duration: motionDuration,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
layoutBuilder:
(currentChild, previousChildren) => Stack(
alignment: Alignment.centerLeft,
children: [
...previousChildren,
?currentChild,
],
),
child: formattingOpen
? _FormattingToolbar(onFormat: onFormat)
: Row(
key: const ValueKey('standard-actions'),
children: [
_ComposeAction(
icon: LucideIcons.atSign,
onTap: onMention,
),
_ComposeAction(
icon: LucideIcons.hash,
onTap: onChannel,
),
_ComposeAction(
icon: LucideIcons.smilePlus,
onTap: onEmoji,
),
_ComposeAction(
icon: LucideIcons.aLargeSmall,
onTap: onOpenFormatting,
),
const Spacer(),
_SendButton(
isDisabled: hasPendingUploads,
isSending: isSending,
onTap: onSend,
),
],
),
),
),
],
),
],
),
),
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,381 @@
part of '../compose_bar.dart';
class _PhotoGalleryPicker extends StatelessWidget {
final VoidCallback onBack;
final Future<List<XFile>> Function() onPickAllPhotos;
final Future<void> Function(List<XFile> photos) onChoosePhotos;
const _PhotoGalleryPicker({
required this.onBack,
required this.onPickAllPhotos,
required this.onChoosePhotos,
});
@override
Widget build(BuildContext context) {
final fallback = _RecentPhotoGalleryPicker(
onBack: onBack,
onPickAllPhotos: onPickAllPhotos,
onChoosePhotos: onChoosePhotos,
);
if (defaultTargetPlatform != TargetPlatform.iOS) return fallback;
return _IOSInlinePhotoPicker(
onBack: onBack,
onPickAllPhotos: onPickAllPhotos,
onChoosePhotos: onChoosePhotos,
fallback: fallback,
);
}
}
class _RecentPhotoGalleryPicker extends HookConsumerWidget {
final VoidCallback onBack;
final Future<List<XFile>> Function() onPickAllPhotos;
final Future<void> Function(List<XFile> photos) onChoosePhotos;
const _RecentPhotoGalleryPicker({
required this.onBack,
required this.onPickAllPhotos,
required this.onChoosePhotos,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final recentPhotos = useMemoized(
ref.read(photoLibraryProvider).loadRecentPhotos,
);
final recentSnapshot = useFuture(recentPhotos);
final selection = useState<List<RecentPhoto>>([]);
final isResolving = useState(false);
final actionError = useState<String?>(null);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
void togglePhoto(RecentPhoto photo) {
if (isResolving.value) return;
final current = selection.value;
final existingIndex = current.indexWhere((item) => item.id == photo.id);
selection.value = existingIndex < 0
? [...current, photo]
: [
...current.take(existingIndex),
...current.skip(existingIndex + 1),
];
}
Future<void> choosePhotos() async {
if (isResolving.value) return;
isResolving.value = true;
actionError.value = null;
try {
final photos = selection.value.isEmpty
? await onPickAllPhotos()
: await ref
.read(photoLibraryProvider)
.resolveSelectedPhotos(selection.value);
if (photos.isNotEmpty && context.mounted) {
await onChoosePhotos(photos);
}
} catch (_) {
if (context.mounted) {
actionError.value = selection.value.isEmpty
? 'Unable to open your photo library.'
: 'Unable to prepare the selected photos.';
}
} finally {
if (context.mounted) isResolving.value = false;
}
}
final selectedCount = selection.value.length;
final actionLabel = selectedCount == 0
? 'All photos'
: selectedCount == 1
? 'Add 1 photo'
: 'Add $selectedCount photos';
Widget buildGalleryBody() {
if (recentSnapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator(strokeWidth: 3));
}
if (recentSnapshot.hasError) {
return const _PhotoGalleryMessage(
icon: LucideIcons.images,
title: 'Recent photos aren’t available',
message: 'Use All photos to choose with the system photo picker.',
);
}
final photos = recentSnapshot.data ?? const <RecentPhoto>[];
if (photos.isEmpty) {
return const _PhotoGalleryMessage(
icon: LucideIcons.images,
title: 'No recent photos',
message: 'Use All photos to browse your photo library.',
);
}
return GridView.builder(
key: const ValueKey('recent-photo-grid'),
padding: EdgeInsets.zero,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: Grid.quarter,
mainAxisSpacing: Grid.quarter,
),
itemCount: photos.length,
itemBuilder: (context, index) {
final photo = photos[index];
final selectionIndex = selection.value.indexWhere(
(item) => item.id == photo.id,
);
return _RecentPhotoTile(
photo: photo,
selectionIndex: selectionIndex,
reducedMotion: reducedMotion,
onTap: () => togglePhoto(photo),
);
},
);
}
return Padding(
key: const ValueKey('photo-gallery-picker'),
padding: const EdgeInsets.all(Grid.xxs),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: 40,
child: Row(
children: [
IconButton(
key: const ValueKey('photo-gallery-back'),
onPressed: isResolving.value ? null : onBack,
tooltip: 'Back to attachment options',
visualDensity: VisualDensity.compact,
icon: const Icon(LucideIcons.arrowLeft, size: 20),
),
const SizedBox(width: Grid.quarter),
Expanded(
child: Text(
'Recent photos',
style: context.textTheme.titleSmall?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
),
),
if (selectedCount > 0)
Padding(
padding: const EdgeInsets.only(right: Grid.half),
child: Text(
'$selectedCount selected',
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
],
),
),
const SizedBox(height: Grid.half),
Expanded(
child: Column(
children: [
Expanded(child: buildGalleryBody()),
if (actionError.value case final error?) ...[
const SizedBox(height: Grid.half),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 72),
child: SingleChildScrollView(
child: Text(
error,
key: const ValueKey('photo-gallery-error'),
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
textAlign: TextAlign.center,
),
),
),
],
],
),
),
const SizedBox(height: Grid.xxs),
SizedBox(
width: double.infinity,
child: selectedCount == 0
? OutlinedButton.icon(
key: const ValueKey('photo-gallery-action'),
onPressed: isResolving.value ? null : choosePhotos,
icon: isResolving.value
? SizedBox.square(
dimension: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: context.colors.primary,
),
)
: const Icon(LucideIcons.images, size: 18),
label: Text(actionLabel),
)
: FilledButton.icon(
key: const ValueKey('photo-gallery-action'),
onPressed: isResolving.value ? null : choosePhotos,
icon: isResolving.value
? const SizedBox.square(
dimension: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(LucideIcons.plus, size: 18),
label: Text(actionLabel),
),
),
],
),
);
}
}
class _RecentPhotoTile extends StatelessWidget {
final RecentPhoto photo;
final int selectionIndex;
final bool reducedMotion;
final VoidCallback onTap;
const _RecentPhotoTile({
required this.photo,
required this.selectionIndex,
required this.reducedMotion,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final isSelected = selectionIndex >= 0;
return Semantics(
button: true,
selected: isSelected,
label: isSelected
? 'Photo ${selectionIndex + 1} selected'
: 'Select photo',
child: GestureDetector(
key: ValueKey('recent-photo-${photo.id}'),
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Stack(
fit: StackFit.expand,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(Radii.sm),
child: Image.memory(
photo.thumbnailBytes,
fit: BoxFit.cover,
gaplessPlayback: true,
),
),
AnimatedContainer(
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(Radii.sm),
border: Border.all(
color: isSelected
? context.colors.primary
: Colors.transparent,
width: isSelected ? 3 : 0,
),
color: isSelected
? Colors.black.withValues(alpha: 0.08)
: Colors.transparent,
),
),
PositionedDirectional(
top: Grid.half,
end: Grid.half,
child: AnimatedScale(
scale: isSelected ? 1 : 0.8,
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 140),
curve: Curves.easeOutCubic,
child: AnimatedOpacity(
opacity: isSelected ? 1 : 0,
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 100),
child: Container(
key: ValueKey('photo-selection-index-${photo.id}'),
width: 24,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: context.colors.primary,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 1.5),
),
child: Text(
'${selectionIndex + 1}',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onPrimary,
fontWeight: FontWeight.w700,
),
),
),
),
),
),
],
),
),
);
}
}
class _PhotoGalleryMessage extends StatelessWidget {
final IconData icon;
final String title;
final String message;
const _PhotoGalleryMessage({
required this.icon,
required this.title,
required this.message,
});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(Grid.sm),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 32, color: context.colors.onSurfaceVariant),
const SizedBox(height: Grid.xxs),
Text(
title,
style: context.textTheme.titleSmall?.copyWith(
color: context.colors.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: Grid.quarter),
Text(
message,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
],
),
),
);
}
}

View File

@@ -1,44 +1,95 @@
part of '../compose_bar.dart';
class _SuggestionPanelMotion extends StatelessWidget {
class _SuggestionPanelMotion extends HookWidget {
final Duration duration;
final Alignment alignment;
final Widget child;
const _SuggestionPanelMotion({required this.duration, required this.child});
const _SuggestionPanelMotion({
required this.duration,
required this.alignment,
required this.child,
});
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: duration,
reverseDuration: duration,
layoutBuilder: (currentChild, previousChildren) => Stack(
alignment: Alignment.bottomLeft,
clipBehavior: Clip.none,
children: [...previousChildren, ?currentChild],
),
transitionBuilder: (child, animation) {
final curvedAnimation = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final springController = useAnimationController(
initialValue: 1,
upperBound: 1.08,
);
final springValue = useAnimation(springController);
final previousChildKey = useRef<Key?>(child.key);
return AnimatedBuilder(
animation: curvedAnimation,
child: child,
builder: (context, child) => IgnorePointer(
ignoring: animation.status == AnimationStatus.reverse,
child: Opacity(
opacity: curvedAnimation.value,
child: Transform.translate(
offset: Offset(0, Grid.xs * (1 - curvedAnimation.value)),
child: child,
useEffect(() {
if (previousChildKey.value == child.key) return null;
previousChildKey.value = child.key;
if (reducedMotion) {
springController.value = 1;
} else {
springController
..stop()
..value = 0.9
..animateWith(
SpringSimulation(
SpringDescription.withDurationAndBounce(
duration: const Duration(milliseconds: 320),
bounce: 0.18,
),
0.9,
1,
0,
snapToEnd: true,
),
);
}
return null;
}, [child.key, reducedMotion]);
return Transform.scale(
scale: springValue,
alignment: alignment,
child: AnimatedSize(
duration: duration,
curve: Curves.easeInOutCubic,
alignment: alignment,
child: AnimatedSwitcher(
duration: duration,
reverseDuration: duration,
layoutBuilder: (currentChild, previousChildren) => Stack(
alignment: alignment,
clipBehavior: Clip.none,
children: [...previousChildren, ?currentChild],
),
);
},
child: child,
transitionBuilder: (child, animation) {
final curvedAnimation = CurvedAnimation(
parent: animation,
curve: Curves.easeOutBack,
reverseCurve: Curves.easeInOutCubic,
);
return AnimatedBuilder(
animation: curvedAnimation,
child: child,
builder: (context, child) => IgnorePointer(
ignoring: animation.status == AnimationStatus.reverse,
child: Opacity(
opacity: animation.value.clamp(0.0, 1.0),
child: Transform.translate(
offset: Offset(0, Grid.xs * (1 - animation.value)),
child: Transform.scale(
scale: 0.92 + (0.08 * curvedAnimation.value),
alignment: alignment,
child: child,
),
),
),
),
);
},
child: child,
),
),
);
}
}

View File

@@ -0,0 +1,104 @@
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:photo_manager/photo_manager.dart';
const _recentPhotoCount = 30;
const _photoPermissionRequest = PermissionRequestOption(
androidPermission: AndroidPermission(
type: RequestType.image,
mediaLocation: false,
),
);
/// A recent photo exposed to Buzz's compact in-composer gallery.
@immutable
class RecentPhoto {
/// The platform photo-library identifier.
final String id;
/// Thumbnail bytes sized for the compact picker grid.
final Uint8List thumbnailBytes;
/// Creates a recent photo value.
const RecentPhoto({required this.id, required this.thumbnailBytes});
}
/// Reads recent photos and resolves selected library assets to uploadable files.
abstract interface class PhotoLibrary {
/// Requests access when needed and returns the newest visible photos.
Future<List<RecentPhoto>> loadRecentPhotos();
/// Resolves selected photos in the supplied selection order.
Future<List<XFile>> resolveSelectedPhotos(List<RecentPhoto> photos);
}
/// Indicates that the compact gallery cannot read the device photo library.
class PhotoLibraryAccessException implements Exception {
/// Creates a photo-library access error.
const PhotoLibraryAccessException();
@override
String toString() => 'Photo access is turned off for Buzz.';
}
/// Provides the device photo library. Tests can override this with fixtures.
final photoLibraryProvider = Provider<PhotoLibrary>(
(ref) => const DevicePhotoLibrary(),
);
/// The device-backed implementation of [PhotoLibrary].
class DevicePhotoLibrary implements PhotoLibrary {
/// Creates the device photo library.
const DevicePhotoLibrary();
@override
Future<List<RecentPhoto>> loadRecentPhotos() async {
final permission = await PhotoManager.requestPermissionExtend(
requestOption: _photoPermissionRequest,
);
if (!permission.hasAccess) {
throw const PhotoLibraryAccessException();
}
final assets = await PhotoManager.getAssetListPaged(
page: 0,
pageCount: _recentPhotoCount,
type: RequestType.image,
filterOption: FilterOptionGroup(
imageOption: const FilterOption(needTitle: true),
orders: const [
OrderOption(type: OrderOptionType.createDate, asc: false),
],
),
);
final loaded = await Future.wait([
for (final asset in assets) _loadRecentPhoto(asset),
]);
return [for (final photo in loaded) ?photo];
}
Future<RecentPhoto?> _loadRecentPhoto(AssetEntity asset) async {
final bytes = await asset.thumbnailDataWithSize(
const ThumbnailSize.square(256),
quality: 84,
);
if (bytes == null || bytes.isEmpty) return null;
return RecentPhoto(id: asset.id, thumbnailBytes: bytes);
}
@override
Future<List<XFile>> resolveSelectedPhotos(List<RecentPhoto> photos) async {
final resolved = <XFile>[];
for (final photo in photos) {
final asset = await AssetEntity.fromId(photo.id);
final file = await asset?.file;
if (file == null) {
throw const PhotoLibraryAccessException();
}
resolved.add(XFile(file.path, name: asset?.title));
}
return resolved;
}
}

View File

@@ -64,6 +64,9 @@ const _maxFileSizeBytes = 100 * 1024 * 1024; // 100MB
const _mediaPolicyUploadMessage = "We couldn't prepare this image for upload.";
typedef PickGalleryImage = Future<XFile?> Function();
/// Selects multiple gallery images for upload in picker order.
typedef PickGalleryImages = Future<List<XFile>> Function();
typedef PickGalleryVideo = Future<XFile?> Function();
typedef PickAttachmentFile = Future<XFile?> Function();
typedef SanitizeImageBytes =
@@ -172,6 +175,7 @@ class MediaUploadService {
final String _baseUrl;
final String? _nsec;
final PickGalleryImage _pickGalleryImage;
final PickGalleryImages _pickGalleryImages;
final PickGalleryVideo _pickGalleryVideo;
final PickAttachmentFile? _pickAttachmentFile;
final SanitizeImageBytes _sanitizeImageBytes;
@@ -186,6 +190,7 @@ class MediaUploadService {
required String baseUrl,
required String? nsec,
required PickGalleryImage pickGalleryImage,
PickGalleryImages? pickGalleryImages,
required PickGalleryVideo pickGalleryVideo,
PickAttachmentFile? pickAttachmentFile,
SanitizeImageBytes? sanitizeImageBytes,
@@ -197,6 +202,12 @@ class MediaUploadService {
}) : _baseUrl = baseUrl,
_nsec = nsec,
_pickGalleryImage = pickGalleryImage,
_pickGalleryImages =
pickGalleryImages ??
(() async {
final image = await pickGalleryImage();
return image == null ? const <XFile>[] : [image];
}),
_pickGalleryVideo = pickGalleryVideo,
_pickAttachmentFile = pickAttachmentFile,
_sanitizeImageBytes = sanitizeImageBytes ?? _sanitizePickedImageBytes,
@@ -220,6 +231,9 @@ class MediaUploadService {
return uploadImage(pickedImage);
}
/// Opens the system picker with multi-selection enabled.
Future<List<XFile>> pickGalleryImages() => _pickGalleryImages();
Future<BlobDescriptor> uploadImage(XFile image) async {
final preparedImage = await _prepareUploadImage(image);
return _uploadPreparedBytes(
@@ -243,9 +257,11 @@ class MediaUploadService {
return uploadImage(XFile.fromData(bytes));
}
Future<BlobDescriptor?> pickAndUploadVideo() async {
final pickedVideo = await _pickGalleryVideo();
if (pickedVideo == null) return null;
/// Opens the system gallery video picker.
Future<XFile?> pickGalleryVideo() => _pickGalleryVideo();
/// Sanitizes and uploads [pickedVideo] as an MP4 attachment.
Future<BlobDescriptor> uploadVideo(XFile pickedVideo) async {
final length = await pickedVideo.length();
if (length > _maxVideoSizeBytes) {
throw Exception(
@@ -278,14 +294,23 @@ class MediaUploadService {
}
}
Future<BlobDescriptor?> pickAndUploadFile() async {
Future<BlobDescriptor?> pickAndUploadVideo() async {
final pickedVideo = await pickGalleryVideo();
if (pickedVideo == null) return null;
return uploadVideo(pickedVideo);
}
/// Opens the system document picker for a generic file attachment.
Future<XFile?> pickAttachmentFile() async {
final pickAttachmentFile = _pickAttachmentFile;
if (pickAttachmentFile == null) {
throw Exception("File attachments aren't available on this device.");
}
final pickedFile = await pickAttachmentFile();
if (pickedFile == null) return null;
return pickAttachmentFile();
}
/// Uploads [pickedFile] as a size-limited generic attachment.
Future<BlobDescriptor> uploadFile(XFile pickedFile) async {
final length = await pickedFile.length();
if (length == 0) {
throw Exception('File is empty.');
@@ -304,6 +329,12 @@ class MediaUploadService {
return descriptor.withFilename(_safeAttachmentFilename(pickedFile.name));
}
Future<BlobDescriptor?> pickAndUploadFile() async {
final pickedFile = await pickAttachmentFile();
if (pickedFile == null) return null;
return uploadFile(pickedFile);
}
Future<BlobDescriptor> uploadBytes(
Uint8List bytes, {
required String mimeType,
@@ -781,6 +812,7 @@ final mediaUploadServiceProvider = Provider<MediaUploadService>((ref) {
source: ImageSource.gallery,
requestFullMetadata: false,
),
pickGalleryImages: () => picker.pickMultiImage(requestFullMetadata: false),
pickGalleryVideo: () => picker.pickVideo(source: ImageSource.gallery),
pickAttachmentFile: file_selector.openFile,
);

View File

@@ -32,4 +32,23 @@ void main() {
expect(await file.exists(), isFalse);
});
test('deletes every native picker file after processing', () async {
final suffix = DateTime.now().microsecondsSinceEpoch;
final files = [
File('${Directory.systemTemp.path}/buzz-photo-$suffix-1.jpg'),
File('${Directory.systemTemp.path}/buzz-photo-$suffix-2.jpg'),
];
for (final file in files) {
await file.writeAsBytes([1, 2, 3]);
}
await processTemporaryImages([
for (final file in files) XFile(file.path),
], (_) async {});
for (final file in files) {
expect(await file.exists(), isFalse);
}
});
}

View File

@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@@ -18,6 +19,7 @@ import 'package:buzz/features/channels/compose_bar.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/mentions/mention_candidates.dart';
import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart';
import 'package:buzz/features/channels/photo_library.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
@@ -127,6 +129,9 @@ List<int> _testPngChunk(String type, List<int> payload) {
}
const _mediaUploadPlatformChannel = MethodChannel('buzz/media_upload');
const _nativeAttachmentPopoverChannel = MethodChannel(
'buzz/native_attachment_popover',
);
void _setMockMediaUploadPlatformHandler(
Future<Object?> Function(MethodCall call)? handler,
@@ -135,6 +140,27 @@ void _setMockMediaUploadPlatformHandler(
.setMockMethodCallHandler(_mediaUploadPlatformChannel, handler);
}
void _setMockNativeAttachmentPopoverHandler(
Future<Object?> Function(MethodCall call)? handler,
) {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(_nativeAttachmentPopoverChannel, handler);
}
Future<void> _sendNativeAttachmentPopoverCall(
WidgetTester tester,
String method, [
Object? arguments,
]) async {
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
_nativeAttachmentPopoverChannel.name,
_nativeAttachmentPopoverChannel.codec.encodeMethodCall(
MethodCall(method, arguments),
),
null,
);
}
/// Shared mock prefs for the compose bar's draft store. Initialized in
/// [main].
late SharedPreferences _testPrefs;
@@ -148,13 +174,16 @@ Widget _buildComposeBar({
List<Channel> channels = const <Channel>[],
String? currentPubkey,
bool? supportsShowingSystemContextMenu,
TextScaler? textScaler,
List<CustomEmoji> customEmoji = const <CustomEmoji>[],
RelayConfigNotifier Function()? relayConfig,
PhotoLibrary photoLibrary = const _EmptyPhotoLibrary(),
}) {
return ProviderScope(
overrides: [
customEmojiListProvider.overrideWithValue(customEmoji),
mediaUploadServiceProvider.overrideWithValue(uploadService),
photoLibraryProvider.overrideWithValue(photoLibrary),
currentPubkeyProvider.overrideWith((ref) => currentPubkey),
channelMembersProvider(
'channel-1',
@@ -172,12 +201,14 @@ Widget _buildComposeBar({
],
child: MaterialApp(
theme: AppTheme.light(),
builder: supportsShowingSystemContextMenu == null
builder: supportsShowingSystemContextMenu == null && textScaler == null
? null
: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(
supportsShowingSystemContextMenu:
supportsShowingSystemContextMenu,
supportsShowingSystemContextMenu ??
MediaQuery.of(context).supportsShowingSystemContextMenu,
textScaler: textScaler ?? MediaQuery.textScalerOf(context),
),
child: child!,
),
@@ -193,6 +224,54 @@ Widget _buildComposeBar({
);
}
Widget _buildNativePopoverOwnershipHarness({
required MediaUploadService uploadService,
required bool includeFirstComposer,
}) {
return ProviderScope(
overrides: [
customEmojiListProvider.overrideWithValue(const []),
mediaUploadServiceProvider.overrideWithValue(uploadService),
photoLibraryProvider.overrideWithValue(const _EmptyPhotoLibrary()),
currentPubkeyProvider.overrideWith((ref) => null),
channelMembersProvider(
'channel-1',
).overrideWith((ref) => Future.value(const <ChannelMember>[])),
agentDirectoryProvider.overrideWith(
(ref) async => const <AgentDirectoryEntry>[],
),
agentOwnersProvider.overrideWith((ref) async => const <String, String>{}),
relayClientProvider.overrideWithValue(
RelayClient(baseUrl: 'http://localhost:3000'),
),
relayConfigProvider.overrideWith(_FakeRelayConfigNotifier.new),
savedPrefsProvider.overrideWithValue(_testPrefs),
channelsProvider.overrideWith(() => _FakeChannelsNotifier(const [])),
],
child: MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (includeFirstComposer)
ComposeBar(
key: const ValueKey('first-composer'),
channelId: 'channel-1',
onSend: (_, _, {mediaTags = const []}) async {},
),
ComposeBar(
key: const ValueKey('second-composer'),
channelId: 'channel-1',
onSend: (_, _, {mediaTags = const []}) async {},
),
],
),
),
),
);
}
class _FakeRelayConfigNotifier extends RelayConfigNotifier {
@override
RelayConfig build() => RelayConfig(
@@ -201,6 +280,32 @@ class _FakeRelayConfigNotifier extends RelayConfigNotifier {
);
}
class _EmptyPhotoLibrary implements PhotoLibrary {
const _EmptyPhotoLibrary();
@override
Future<List<RecentPhoto>> loadRecentPhotos() async => const [];
@override
Future<List<XFile>> resolveSelectedPhotos(List<RecentPhoto> photos) async =>
const [];
}
class _FakePhotoLibrary implements PhotoLibrary {
final List<RecentPhoto> photos;
const _FakePhotoLibrary(this.photos);
@override
Future<List<RecentPhoto>> loadRecentPhotos() async => photos;
@override
Future<List<XFile>> resolveSelectedPhotos(List<RecentPhoto> photos) async => [
for (final photo in photos)
XFile.fromData(_pngBytes, name: '${photo.id}.png'),
];
}
/// Relay config that starts from a fixed identity and can be switched
/// in place via [RelayConfigNotifier.update] — simulates a community or
/// account switch while widgets stay mounted.
@@ -390,6 +495,121 @@ void main() {
expect(textField.controller!.selection.baseOffset, 12);
});
testWidgets('native All Photos picker failures show an error', (
tester,
) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
_setMockNativeAttachmentPopoverHandler((call) async {
return switch (call.method) {
'isSupported' || 'present' => true,
'dismiss' => null,
_ => null,
};
});
final uploadService = MediaUploadService(
baseUrl: 'https://relay.example',
nsec: nostr.Keys.generate().nsec,
pickGalleryImage: () async => null,
pickGalleryImages: () async =>
throw PlatformException(code: 'photo_picker_failed'),
pickGalleryVideo: () async => null,
);
try {
await tester.pumpWidget(
_buildComposeBar(
uploadService: uploadService,
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
await tester.tap(find.byTooltip('Add attachment').hitTestable());
await tester.pumpAndSettle();
await _sendNativeAttachmentPopoverCall(tester, 'pickAllPhotos');
await _sendNativeAttachmentPopoverCall(tester, 'dismissed');
await tester.pumpAndSettle();
expect(find.text('Unable to open your photo library.'), findsOneWidget);
} finally {
await _sendNativeAttachmentPopoverCall(tester, 'dismissed');
await tester.pumpWidget(const SizedBox.shrink());
_setMockNativeAttachmentPopoverHandler(null);
debugDefaultTargetPlatformOverride = previousPlatform;
}
});
testWidgets('disposing a non-owner keeps native popover callbacks active', (
tester,
) async {
final previousPlatform = debugDefaultTargetPlatformOverride;
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
var presentCalls = 0;
var dismissCalls = 0;
var pickAllPhotosCalls = 0;
_setMockNativeAttachmentPopoverHandler((call) async {
switch (call.method) {
case 'isSupported':
return true;
case 'present':
presentCalls += 1;
return true;
case 'dismiss':
dismissCalls += 1;
return null;
}
return null;
});
final uploadService = MediaUploadService(
baseUrl: 'https://relay.example',
nsec: nostr.Keys.generate().nsec,
pickGalleryImage: () async => null,
pickGalleryImages: () async {
pickAllPhotosCalls += 1;
return const [];
},
pickGalleryVideo: () async => null,
);
try {
await tester.pumpWidget(
_buildNativePopoverOwnershipHarness(
uploadService: uploadService,
includeFirstComposer: true,
),
);
await tester.tap(find.byTooltip('Add attachment').hitTestable().at(1));
await tester.pumpAndSettle();
expect(presentCalls, 1);
await tester.pumpWidget(
_buildNativePopoverOwnershipHarness(
uploadService: uploadService,
includeFirstComposer: false,
),
);
await tester.pumpAndSettle();
expect(dismissCalls, 0);
await _sendNativeAttachmentPopoverCall(tester, 'pickAllPhotos');
await tester.pumpAndSettle();
expect(pickAllPhotosCalls, 1);
await _sendNativeAttachmentPopoverCall(tester, 'dismissed');
} finally {
await _sendNativeAttachmentPopoverCall(tester, 'dismissed');
await tester.pumpWidget(const SizedBox.shrink());
_setMockNativeAttachmentPopoverHandler(null);
debugDefaultTargetPlatformOverride = previousPlatform;
}
});
testWidgets('uploads an image and sends markdown plus imeta tags', (
tester,
) async {
@@ -434,8 +654,7 @@ void main() {
),
);
await _openAttachmentMenu(tester);
await tester.tap(find.text('Photos'));
await _openSystemPhotoPicker(tester);
await tester.pumpAndSettle();
expect(find.byTooltip('Remove attachment'), findsOneWidget);
@@ -455,15 +674,115 @@ void main() {
expect(find.byTooltip('Remove attachment'), findsNothing);
});
testWidgets('keeps upload progress visible after the picker closes', (
testWidgets('uploads multiple system-selected photos in picker order', (
tester,
) async {
final pickedImage = Completer<XFile?>();
final uploadService = MediaUploadService(
baseUrl: 'https://relay.example',
nsec: nostr.Keys.generate().nsec,
httpClient: http_testing.MockClient((request) async {
final mimeType = request.headers.entries
.firstWhere((entry) => entry.key.toLowerCase() == 'content-type')
.value;
final isGif = mimeType == 'image/gif';
return http.Response(
jsonEncode({
'url': isGif
? 'https://relay.example/media/two.gif'
: 'https://relay.example/media/one.png',
'sha256': isGif
? '2222222222222222222222222222222222222222222222222222222222222222'
: '1111111111111111111111111111111111111111111111111111111111111111',
'size': request.bodyBytes.length,
'type': mimeType,
'uploaded': 1,
}),
200,
);
}),
pickGalleryImage: () async => null,
pickGalleryImages: () async => [
XFile.fromData(_pngBytes, name: 'one.png'),
XFile.fromData(_gifBytes, name: 'two.gif'),
],
pickGalleryVideo: () async => null,
);
String? sentContent;
List<List<String>> sentMediaTags = const [];
await tester.pumpWidget(
_buildComposeBar(
uploadService: uploadService,
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {
sentContent = content;
sentMediaTags = mediaTags;
},
),
);
await _openSystemPhotoPicker(tester);
await tester.pumpAndSettle();
expect(find.byTooltip('Remove attachment'), findsNWidgets(2));
await _expandComposer(tester);
await tester.tap(find.byIcon(LucideIcons.arrowUp));
await tester.pumpAndSettle();
expect(
sentContent,
'\n![image](https://relay.example/media/one.png)'
'\n![image](https://relay.example/media/two.gif)',
);
expect(sentMediaTags, hasLength(2));
expect(sentMediaTags.map((tag) => tag[1]), [
'url https://relay.example/media/one.png',
'url https://relay.example/media/two.gif',
]);
});
testWidgets('bounds concurrent system-selected photo uploads', (
tester,
) async {
final releaseFirstBatch = Completer<void>();
var requestsStarted = 0;
var activeRequests = 0;
var peakActiveRequests = 0;
final uploadService = MediaUploadService(
baseUrl: 'https://relay.example',
nsec: nostr.Keys.generate().nsec,
httpClient: http_testing.MockClient((request) async {
requestsStarted += 1;
final requestNumber = requestsStarted;
activeRequests += 1;
peakActiveRequests = math.max(peakActiveRequests, activeRequests);
if (requestNumber <= 3) {
await releaseFirstBatch.future;
}
activeRequests -= 1;
return http.Response(
jsonEncode({
'url': 'https://relay.example/media/photo-$requestNumber.png',
'sha256':
'1111111111111111111111111111111111111111111111111111111111111111',
'size': request.bodyBytes.length,
'type': 'image/png',
'uploaded': 1,
}),
200,
);
}),
pickGalleryImage: () async => null,
pickGalleryImages: () async => [
for (var index = 0; index < 5; index += 1)
XFile.fromData(_pngBytes, name: 'photo-$index.png'),
],
pickGalleryVideo: () async => null,
pickGalleryImage: () => pickedImage.future,
);
await tester.pumpWidget(
@@ -478,17 +797,191 @@ void main() {
),
);
await _openSystemPhotoPicker(tester);
for (var frame = 0; frame < 20 && requestsStarted < 3; frame += 1) {
await tester.pump(const Duration(milliseconds: 20));
}
expect(requestsStarted, 3);
expect(peakActiveRequests, 3);
releaseFirstBatch.complete();
await tester.pumpAndSettle();
expect(requestsStarted, 5);
expect(peakActiveRequests, 3);
expect(find.byTooltip('Remove attachment'), findsNWidgets(5));
});
testWidgets('numbers recent photo selection and returns to the menu', (
tester,
) async {
final photoLibrary = _FakePhotoLibrary([
RecentPhoto(id: 'one', thumbnailBytes: _gifBytes),
RecentPhoto(id: 'two', thumbnailBytes: _gifBytes),
RecentPhoto(id: 'three', thumbnailBytes: _gifBytes),
]);
await tester.pumpWidget(
_buildComposeBar(
uploadService: _testUploadService(nostr.Keys.generate().nsec),
photoLibrary: photoLibrary,
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
await _openAttachmentMenu(tester);
await tester.tap(find.text('Photos'));
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('photo-gallery-picker')),
findsOneWidget,
);
expect(find.byTooltip('Back to attachment options'), findsWidgets);
expect(find.text('All photos'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('recent-photo-two')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('recent-photo-one')));
await tester.pumpAndSettle();
expect(find.text('Add 2 photos'), findsOneWidget);
expect(
find.descendant(
of: find.byKey(const ValueKey('photo-selection-index-two')),
matching: find.text('1'),
),
findsOneWidget,
);
expect(
find.descendant(
of: find.byKey(const ValueKey('photo-selection-index-one')),
matching: find.text('2'),
),
findsOneWidget,
);
await tester.tap(find.byKey(const ValueKey('recent-photo-two')));
await tester.pumpAndSettle();
expect(find.text('Add 1 photo'), findsOneWidget);
expect(
find.descendant(
of: find.byKey(const ValueKey('photo-selection-index-one')),
matching: find.text('1'),
),
findsOneWidget,
);
await tester.tap(find.byKey(const ValueKey('photo-gallery-back')));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('photo-gallery-picker')), findsNothing);
expect(
find.byKey(const ValueKey('attachment-trigger-menu')).hitTestable(),
findsOneWidget,
);
expect(find.text('Camera'), findsOneWidget);
expect(find.text('Photos'), findsOneWidget);
});
testWidgets('photo picker errors keep the action visible at large text', (
tester,
) async {
final uploadService = MediaUploadService(
baseUrl: 'https://relay.example',
nsec: nostr.Keys.generate().nsec,
pickGalleryImage: () async => null,
pickGalleryImages: () async =>
throw PlatformException(code: 'photo_picker_failed'),
pickGalleryVideo: () async => null,
);
await tester.pumpWidget(
_buildComposeBar(
uploadService: uploadService,
textScaler: const TextScaler.linear(1.2),
photoLibrary: _FakePhotoLibrary([
RecentPhoto(id: 'one', thumbnailBytes: _gifBytes),
RecentPhoto(id: 'two', thumbnailBytes: _gifBytes),
RecentPhoto(id: 'three', thumbnailBytes: _gifBytes),
RecentPhoto(id: 'four', thumbnailBytes: _gifBytes),
]),
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
await _openSystemPhotoPicker(tester);
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(find.byKey(const ValueKey('photo-gallery-error')), findsOneWidget);
expect(
find.byKey(const ValueKey('photo-gallery-action')).hitTestable(),
findsOneWidget,
);
});
testWidgets('keeps upload progress visible after the picker closes', (
tester,
) async {
final uploadResponse = Completer<http.Response>();
final uploadService = MediaUploadService(
baseUrl: 'https://relay.example',
nsec: nostr.Keys.generate().nsec,
httpClient: http_testing.MockClient((request) => uploadResponse.future),
pickGalleryVideo: () async => null,
pickGalleryImage: () async => null,
pickGalleryImages: () async => [
XFile.fromData(_pngBytes, name: 'tiny.png'),
],
);
await tester.pumpWidget(
_buildComposeBar(
uploadService: uploadService,
onSend:
(
content,
mentionPubkeys, {
mediaTags = const <List<String>>[],
}) async {},
),
);
await _openSystemPhotoPicker(tester);
await tester.pump();
expect(
find.byKey(const ValueKey('compose-upload-progress')),
findsOneWidget,
);
expect(find.text('Uploading attachment…'), findsOneWidget);
expect(find.bySemanticsLabel('Uploading attachment…'), findsOneWidget);
pickedImage.complete(null);
uploadResponse.complete(
http.Response(
jsonEncode({
'url': 'https://relay.example/media/test.png',
'sha256':
'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef',
'size': 16,
'type': 'image/png',
'uploaded': 1,
}),
200,
),
);
await tester.pumpAndSettle();
expect(
@@ -1053,8 +1546,7 @@ void main() {
),
);
await _openAttachmentMenu(tester);
await tester.tap(find.text('Photos'));
await _openSystemPhotoPicker(tester);
await tester.pumpAndSettle();
final attachmentFinder = find.byKey(
@@ -1109,8 +1601,7 @@ void main() {
),
);
await _openAttachmentMenu(tester);
await tester.tap(find.text('Photos'));
await _openSystemPhotoPicker(tester);
await tester.pumpAndSettle();
expect(find.textContaining('upload failed'), findsOneWidget);
@@ -1150,8 +1641,7 @@ void main() {
),
);
await _openAttachmentMenu(tester);
await tester.tap(find.text('Photos'));
await _openSystemPhotoPicker(tester);
await tester.pumpAndSettle();
expect(
@@ -1199,8 +1689,7 @@ void main() {
),
);
await _openAttachmentMenu(tester);
await tester.tap(find.text('Photos'));
await _openSystemPhotoPicker(tester);
await tester.pumpAndSettle();
expect(
@@ -1426,8 +1915,7 @@ void main() {
),
);
await _openAttachmentMenu(tester);
await tester.tap(find.text('Photos'));
await _openSystemPhotoPicker(tester);
await tester.pumpAndSettle();
expect(
@@ -1747,6 +2235,13 @@ Future<void> _openAttachmentMenu(WidgetTester tester) async {
await tester.pumpAndSettle();
}
Future<void> _openSystemPhotoPicker(WidgetTester tester) async {
await _openAttachmentMenu(tester);
await tester.tap(find.text('Photos'));
await tester.pumpAndSettle();
await tester.tap(find.text('All photos'));
}
Future<void> _selectAndSendAgentMention(WidgetTester tester) async {
await _expandComposer(tester);
await tester.enterText(find.byType(TextField), '@hel');