Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions mobile/ios/Runner.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */; };
4A71C0092F40500100A17E01 /* JumpToLatestGlassButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */; };
4A71C00B2F40600100A17E01 /* StickyDateGlassHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */; };
4A71C00D2F40700100A17E01 /* NativeMessageActionSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00E2F40800100A17E01 /* NativeMessageActionSurface.swift */; };
4A71C00D2F40700100A17E01 /* NativeEmojiPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00E2F40700100A17E01 /* NativeEmojiPicker.swift */; };
4A71C00F2F40800100A17E01 /* NativeEmojiPickerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0102F40800100A17E01 /* NativeEmojiPickerModel.swift */; };
4A71C0112F40900100A17E01 /* NativeEmojiPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0122F40900100A17E01 /* NativeEmojiPickerView.swift */; };
4A71C0132F40A00100A17E01 /* NativeMessageActionSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0142F40A00100A17E01 /* NativeMessageActionSurface.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 */; };
Expand Down Expand Up @@ -64,7 +67,10 @@
4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcentricSheetSurface.swift; sourceTree = "<group>"; };
4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToLatestGlassButton.swift; sourceTree = "<group>"; };
4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickyDateGlassHeader.swift; sourceTree = "<group>"; };
4A71C00E2F40800100A17E01 /* NativeMessageActionSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageActionSurface.swift; sourceTree = "<group>"; };
4A71C00E2F40700100A17E01 /* NativeEmojiPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiPicker.swift; sourceTree = "<group>"; };
4A71C0102F40800100A17E01 /* NativeEmojiPickerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiPickerModel.swift; sourceTree = "<group>"; };
4A71C0122F40900100A17E01 /* NativeEmojiPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiPickerView.swift; sourceTree = "<group>"; };
4A71C0142F40A00100A17E01 /* NativeMessageActionSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageActionSurface.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; };
Expand Down Expand Up @@ -190,7 +196,10 @@
4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */,
4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */,
4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */,
4A71C00E2F40800100A17E01 /* NativeMessageActionSurface.swift */,
4A71C00E2F40700100A17E01 /* NativeEmojiPicker.swift */,
4A71C0102F40800100A17E01 /* NativeEmojiPickerModel.swift */,
4A71C0122F40900100A17E01 /* NativeEmojiPickerView.swift */,
4A71C0142F40A00100A17E01 /* NativeMessageActionSurface.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
Expand Down Expand Up @@ -428,7 +437,10 @@
4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */,
4A71C0092F40500100A17E01 /* JumpToLatestGlassButton.swift in Sources */,
4A71C00B2F40600100A17E01 /* StickyDateGlassHeader.swift in Sources */,
4A71C00D2F40700100A17E01 /* NativeMessageActionSurface.swift in Sources */,
4A71C00D2F40700100A17E01 /* NativeEmojiPicker.swift in Sources */,
4A71C00F2F40800100A17E01 /* NativeEmojiPickerModel.swift in Sources */,
4A71C0112F40900100A17E01 /* NativeEmojiPickerView.swift in Sources */,
4A71C0132F40A00100A17E01 /* NativeMessageActionSurface.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
Expand Down
8 changes: 8 additions & 0 deletions mobile/ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import UserNotifications
private var inlinePhotoPickerSupportChannel: FlutterMethodChannel?
private var concentricSheetSurfaceChannel: FlutterMethodChannel?
private var nativeAttachmentPopoverCoordinator: NativeAttachmentPopoverCoordinator?
private var nativeEmojiPickerCoordinator: NativeEmojiPickerCoordinator?
private var nativeMessageActionSurfaceSupportChannel: FlutterMethodChannel?

override func application(
Expand Down Expand Up @@ -115,6 +116,13 @@ import UserNotifications
parentViewController: nativeAttachmentRegistrar?.viewController
)

let nativeEmojiPickerRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzNativeEmojiPicker"
)
nativeEmojiPickerCoordinator = NativeEmojiPickerCoordinator(
messenger: messenger,
parentViewController: nativeEmojiPickerRegistrar?.viewController
)
if #available(iOS 16.0, *),
let nativeMessageActionsRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzNativeMessageActionSurface"
Expand Down
193 changes: 193 additions & 0 deletions mobile/ios/Runner/NativeEmojiPicker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import Flutter
import SwiftUI
import UIKit

private struct NativeEmojiMediaHeaderError: Error {}

final class NativeEmojiPickerCoordinator: NSObject,
UIAdaptivePresentationControllerDelegate
{
private let channel: FlutterMethodChannel
private static weak var activeCoordinator: NativeEmojiPickerCoordinator?
private weak var parentViewController: UIViewController?
private weak var presentedController: UIViewController?
private var didNotifyDismissal = false
private var isDismissing = false

init(
messenger: FlutterBinaryMessenger,
parentViewController: UIViewController?
) {
channel = FlutterMethodChannel(
name: "buzz/native_emoji_picker",
binaryMessenger: messenger
)
self.parentViewController = parentViewController
super.init()
Self.activeCoordinator = self
channel.setMethodCallHandler { [weak self] call, result in
self?.handle(call, result: result)
}
}

static func mediaHeaders(for url: URL) async throws -> [String: String] {
guard let channel = activeCoordinator?.channel else { return [:] }
return try await withCheckedThrowingContinuation { continuation in
channel.invokeMethod("mediaHeaders", arguments: url.absoluteString) { result in
if result is FlutterError {
continuation.resume(throwing: NativeEmojiMediaHeaderError())
return
}
continuation.resume(returning: result as? [String: String] ?? [:])
}
}
}

private func handle(
_ call: FlutterMethodCall,
result: @escaping FlutterResult
) {
guard call.method == "present" else {
result(FlutterMethodNotImplemented)
return
}
guard let arguments = call.arguments as? [String: Any] else {
result(
FlutterError(
code: "invalid_arguments",
message: "Expected emoji-picker configuration.",
details: nil
)
)
return
}

DispatchQueue.main.async { [weak self] in
result(self?.present(arguments: arguments) ?? false)
}
}

@MainActor
private func present(arguments: [String: Any]) -> Bool {
// A sheet is already owned by an earlier caller; report busy rather than a
// successful presentation so the caller does not treat this as its own.
guard presentedController == nil else { return false }
guard
let data = NativeEmojiPickerDataLoader.load(arguments: arguments),
let presenter = topViewController(
from: parentViewController ?? activeWindowRootViewController()
)
else {
return false
}

didNotifyDismissal = false
isDismissing = false
let appearance = NativeEmojiPickerAppearance(arguments: arguments)
let content = NativeEmojiPickerView(
data: data,
appearance: appearance,
initialSkinTone: (arguments["skinTone"] as? NSNumber)?.intValue ?? 0,
onSelect: { [weak self] emoji in self?.select(emoji) },
onSkinToneChanged: { [weak self] value in
self?.channel.invokeMethod("skinToneChanged", arguments: value)
},
onClose: { [weak self] in self?.dismiss() }
)
let controller = UIHostingController(rootView: content)
controller.view.backgroundColor = appearance.surface
controller.modalPresentationStyle = .pageSheet
controller.overrideUserInterfaceStyle = appearance.isDark ? .dark : .light

if let sheet = controller.sheetPresentationController {
let compactID = UISheetPresentationController.Detent.Identifier(
"buzz.emoji.compact"
)
let mediumID = UISheetPresentationController.Detent.Identifier(
"buzz.emoji.medium"
)
sheet.detents = [
.custom(identifier: compactID) { context in
context.maximumDetentValue * 0.34
},
.custom(identifier: mediumID) { context in
context.maximumDetentValue * 0.67
},
.large(),
]
sheet.selectedDetentIdentifier = mediumID
sheet.prefersGrabberVisible = true
sheet.prefersScrollingExpandsWhenScrolledToEdge = false
sheet.prefersEdgeAttachedInCompactHeight = false
sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true
}

presentedController = controller
presenter.present(controller, animated: true) { [weak self, weak controller] in
controller?.presentationController?.delegate = self
}
return true
}

@MainActor
private func select(_ emoji: String) {
// A single presentation returns at most one selection. The sheet stays
// live through its dismissal animation, so ignore extra taps that arrive
// before dismissal completes to avoid emitting duplicate selections.
guard !isDismissing else { return }
channel.invokeMethod("selected", arguments: emoji)
dismiss()
Comment thread
klopez4212 marked this conversation as resolved.
}

@MainActor
private func dismiss() {
isDismissing = true
guard let controller = presentedController else {
notifyDismissalIfNeeded()
return
}
controller.dismiss(animated: true) { [weak self] in
self?.notifyDismissalIfNeeded()
}
}

func presentationControllerDidDismiss(
_ presentationController: UIPresentationController
) {
notifyDismissalIfNeeded()
}

@MainActor
private func notifyDismissalIfNeeded() {
guard !didNotifyDismissal else { return }
didNotifyDismissal = true
presentedController = nil
channel.invokeMethod("dismissed", arguments: nil)
}

@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
}
}
Comment thread
klopez4212 marked this conversation as resolved.
Loading
Loading