mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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}"
|
||||
|
||||
@@ -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 */,
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
235
mobile/ios/Runner/InlinePhotoPicker.swift
Normal file
235
mobile/ios/Runner/InlinePhotoPicker.swift
Normal 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
|
||||
}
|
||||
969
mobile/ios/Runner/NativeAttachmentPopover.swift
Normal file
969
mobile/ios/Runner/NativeAttachmentPopover.swift
Normal 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
|
||||
}
|
||||
228
mobile/ios/Runner/NativeAttachmentPopoverCoordinator.swift
Normal file
228
mobile/ios/Runner/NativeAttachmentPopoverCoordinator.swift
Normal 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
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
228
mobile/lib/features/channels/compose_bar/ios_photo_picker.dart
Normal file
228
mobile/lib/features/channels/compose_bar/ios_photo_picker.dart
Normal 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
245
mobile/lib/features/channels/compose_bar/layout.dart
Normal file
245
mobile/lib/features/channels/compose_bar/layout.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
104
mobile/lib/features/channels/photo_library.dart
Normal file
104
mobile/lib/features/channels/photo_library.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
'\n',
|
||||
);
|
||||
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');
|
||||
|
||||
Reference in New Issue
Block a user