Files
buzz/mobile/ios/Runner/NativeEmojiPicker.swift
klopez4212 b4509dee8b feat(mobile): add Buzzies and refresh agent avatar picker (#8921)
## Summary

Adds Buzzies and refreshes the agent avatar picker with larger previews,
rounded option strips, and clearer category choices. Also moves Agents
above Appearance in the home menu and adds a custom agent icon.

### Related issue

None found.

### Testing

Analysis, 4,138 app tests, package tests, and 121 configured-auth tests
pass. Earlier visual build exercised on Pixel and iOS simulator, with
iPhone review; snapshots below show that build.

| Android · Pixel | iOS · simulator preview |
| --- | --- |
| ![Buzzies in the Android avatar
picker](https://github.com/user-attachments/assets/bf56120c-0128-4683-a0ad-189587a13c8d)
| ![Buzzies in the iOS avatar
picker](https://github.com/user-attachments/assets/63c692f5-822c-4490-883f-36bec8d2be15)
|

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-11-02 17:00:12 +00:00

195 lines
6.2 KiB
Swift

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,
largeEmoji: arguments["largeEmoji"] as? Bool ?? false,
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()
}
@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
}
}