mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
Refine the mobile emoji picker (#5853)
## Summary - use a native detented emoji sheet on iOS that opens at two-thirds and keeps scrolling active at every height - align the search, close control, background, and full-width category row while retaining the Flutter tray on Android - add a persisted desktop-style skin-tone selector and show one selected variant per emoji ## Testing - `just ci` gates completed, with the disk-heavy stages resumed individually after generated artifacts filled the worktree volume - full mobile suite: 1,360 tests passed - focused picker/composer/reaction suite: 145 tests passed - signed iOS Release build succeeded - Android debug build installed and launched on Pixel 10 Snapshots are attached in a PR comment. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: Watcher <bb7abfd757d0af7b66569d02ab9c0316b616f9d0c151ecf5b964344c462e7f8f@buzz.block.builderlab.xyz> Signed-off-by: Kenny Lopez <klopez4212@gmail.com> Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Watcher <bb7abfd757d0af7b66569d02ab9c0316b616f9d0c151ecf5b964344c462e7f8f@buzz.block.builderlab.xyz> Co-authored-by: Mongo <9cfd347903944d5b85aa6c93d2ab67381b978a92a31914bca69998968752a1d7@buzz.block.builderlab.xyz> Co-authored-by: Princess Donut <b238ea756dee4d98afa5883fc7f1de61eeabe65bf700e3a5a5a80db5e42e2c2b@buzz.block.builderlab.xyz> Co-authored-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -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 */; };
|
||||
@@ -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; };
|
||||
@@ -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 */,
|
||||
);
|
||||
@@ -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 */,
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
@@ -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"
|
||||
|
||||
193
mobile/ios/Runner/NativeEmojiPicker.swift
Normal file
193
mobile/ios/Runner/NativeEmojiPicker.swift
Normal file
@@ -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()
|
||||
}
|
||||
|
||||
@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
|
||||
}
|
||||
}
|
||||
437
mobile/ios/Runner/NativeEmojiPickerModel.swift
Normal file
437
mobile/ios/Runner/NativeEmojiPickerModel.swift
Normal file
@@ -0,0 +1,437 @@
|
||||
import Flutter
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct NativeEmojiPickerAppearance {
|
||||
let surface: UIColor
|
||||
let control: UIColor
|
||||
let text: UIColor
|
||||
let secondaryText: UIColor
|
||||
let accent: UIColor
|
||||
let divider: UIColor
|
||||
let isDark: Bool
|
||||
|
||||
init(arguments: [String: Any]) {
|
||||
surface = Self.color(arguments["surfaceColor"], fallback: .systemBackground)
|
||||
control = Self.color(
|
||||
arguments["controlColor"],
|
||||
fallback: .secondarySystemBackground
|
||||
)
|
||||
text = Self.color(arguments["textColor"], fallback: .label)
|
||||
secondaryText = Self.color(
|
||||
arguments["secondaryTextColor"],
|
||||
fallback: .secondaryLabel
|
||||
)
|
||||
accent = Self.color(arguments["accentColor"], fallback: .systemBlue)
|
||||
divider = Self.color(arguments["dividerColor"], fallback: .separator)
|
||||
isDark = arguments["isDark"] as? Bool ?? false
|
||||
}
|
||||
|
||||
private static func color(_ raw: Any?, fallback: UIColor) -> UIColor {
|
||||
guard let value = (raw as? NSNumber)?.uint32Value else { return fallback }
|
||||
let alpha = CGFloat((value >> 24) & 0xFF) / 255
|
||||
let red = CGFloat((value >> 16) & 0xFF) / 255
|
||||
let green = CGFloat((value >> 8) & 0xFF) / 255
|
||||
let blue = CGFloat(value & 0xFF) / 255
|
||||
return UIColor(red: red, green: green, blue: blue, alpha: alpha)
|
||||
}
|
||||
}
|
||||
|
||||
struct NativeEmojiItem: Identifiable, Hashable {
|
||||
let id: String
|
||||
let shortcode: String
|
||||
let value: String
|
||||
let name: String
|
||||
let keywords: [String]
|
||||
let glyph: String?
|
||||
let skinVariants: [String]
|
||||
let imageURL: URL?
|
||||
}
|
||||
|
||||
struct NativeEmojiSkinTone: Identifiable {
|
||||
let id: Int
|
||||
let label: String
|
||||
let color: UIColor
|
||||
}
|
||||
|
||||
let nativeEmojiSkinTones = [
|
||||
NativeEmojiSkinTone(
|
||||
id: 0,
|
||||
label: "Default",
|
||||
color: UIColor(red: 1, green: 0.788, blue: 0.227, alpha: 1)
|
||||
),
|
||||
NativeEmojiSkinTone(
|
||||
id: 1,
|
||||
label: "Light",
|
||||
color: UIColor(red: 1, green: 0.855, blue: 0.718, alpha: 1)
|
||||
),
|
||||
NativeEmojiSkinTone(
|
||||
id: 2,
|
||||
label: "Medium-light",
|
||||
color: UIColor(red: 0.906, green: 0.725, blue: 0.561, alpha: 1)
|
||||
),
|
||||
NativeEmojiSkinTone(
|
||||
id: 3,
|
||||
label: "Medium",
|
||||
color: UIColor(red: 0.784, green: 0.549, blue: 0.38, alpha: 1)
|
||||
),
|
||||
NativeEmojiSkinTone(
|
||||
id: 4,
|
||||
label: "Medium-dark",
|
||||
color: UIColor(red: 0.643, green: 0.38, blue: 0.204, alpha: 1)
|
||||
),
|
||||
NativeEmojiSkinTone(
|
||||
id: 5,
|
||||
label: "Dark",
|
||||
color: UIColor(red: 0.365, green: 0.267, blue: 0.216, alpha: 1)
|
||||
),
|
||||
]
|
||||
|
||||
func validNativeEmojiSkinTone(_ value: Int) -> Int {
|
||||
nativeEmojiSkinTones.indices.contains(value) ? value : 0
|
||||
}
|
||||
|
||||
struct NativeEmojiSection: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let systemImage: String
|
||||
let items: [NativeEmojiItem]
|
||||
}
|
||||
|
||||
struct NativeEmojiPickerData {
|
||||
let sections: [NativeEmojiSection]
|
||||
let standardItems: [NativeEmojiItem]
|
||||
let customItems: [NativeEmojiItem]
|
||||
}
|
||||
|
||||
enum NativeEmojiPickerDataLoader {
|
||||
static let assetPath = "assets/emoji/emoji-data.json"
|
||||
|
||||
static func load(arguments: [String: Any]) -> NativeEmojiPickerData? {
|
||||
let key = FlutterDartProject.lookupKey(forAsset: assetPath)
|
||||
let url = Bundle.main.bundleURL.appendingPathComponent(key)
|
||||
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||
return parse(data: data, arguments: arguments)
|
||||
}
|
||||
|
||||
static func parse(
|
||||
data: Data,
|
||||
arguments: [String: Any]
|
||||
) -> NativeEmojiPickerData? {
|
||||
guard
|
||||
let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let rawCategories = root["categories"] as? [[String: Any]],
|
||||
let rawEmoji = root["emoji"] as? [String: Any]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var sections: [NativeEmojiSection] = []
|
||||
var standardItems: [NativeEmojiItem] = []
|
||||
var byValue: [String: NativeEmojiItem] = [:]
|
||||
|
||||
for category in rawCategories {
|
||||
guard
|
||||
let categoryID = category["id"] as? String,
|
||||
let emojiIDs = category["emoji"] as? [String]
|
||||
else {
|
||||
continue
|
||||
}
|
||||
|
||||
var items: [NativeEmojiItem] = []
|
||||
for emojiID in emojiIDs {
|
||||
guard let record = rawEmoji[emojiID] as? [String: Any] else { continue }
|
||||
let name = record["n"] as? String ?? emojiID
|
||||
let keywords = record["k"] as? [String] ?? []
|
||||
let glyphs: [String]
|
||||
if let values = record["u"] as? [String] {
|
||||
glyphs = values
|
||||
} else if let value = record["u"] as? String {
|
||||
glyphs = [value]
|
||||
} else {
|
||||
glyphs = []
|
||||
}
|
||||
|
||||
guard let defaultGlyph = glyphs.first else { continue }
|
||||
let item = NativeEmojiItem(
|
||||
id: emojiID,
|
||||
shortcode: emojiID,
|
||||
value: defaultGlyph,
|
||||
name: name,
|
||||
keywords: keywords,
|
||||
glyph: defaultGlyph,
|
||||
skinVariants: glyphs,
|
||||
imageURL: nil
|
||||
)
|
||||
items.append(item)
|
||||
standardItems.append(item)
|
||||
for glyph in glyphs where byValue[glyph] == nil {
|
||||
byValue[glyph] = item
|
||||
}
|
||||
}
|
||||
|
||||
sections.append(
|
||||
NativeEmojiSection(
|
||||
id: categoryID,
|
||||
title: categoryTitle(categoryID),
|
||||
systemImage: categorySymbol(categoryID),
|
||||
items: items
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
let rawCustomEmoji = arguments["customEmoji"] as? [[String: Any]] ?? []
|
||||
let customItems = rawCustomEmoji.compactMap { raw -> NativeEmojiItem? in
|
||||
guard
|
||||
let shortcode = raw["shortcode"] as? String,
|
||||
let urlString = raw["url"] as? String,
|
||||
let url = URL(string: urlString)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return NativeEmojiItem(
|
||||
id: "custom-\(shortcode)",
|
||||
shortcode: shortcode,
|
||||
value: ":\(shortcode):",
|
||||
name: shortcode,
|
||||
keywords: [],
|
||||
glyph: nil,
|
||||
skinVariants: [],
|
||||
imageURL: url
|
||||
)
|
||||
}
|
||||
let customByValue = Dictionary(
|
||||
customItems.map { ($0.value, $0) },
|
||||
uniquingKeysWith: { first, _ in first }
|
||||
)
|
||||
|
||||
let recentValues = arguments["recent"] as? [String] ?? []
|
||||
var seenRecentIDs: Set<String> = []
|
||||
let recentItems = recentValues.compactMap { value -> NativeEmojiItem? in
|
||||
guard let item = byValue[value] ?? customByValue[value] else { return nil }
|
||||
return seenRecentIDs.insert(item.id).inserted ? item : nil
|
||||
}
|
||||
if !recentItems.isEmpty {
|
||||
sections.insert(
|
||||
NativeEmojiSection(
|
||||
id: "frequent",
|
||||
title: "Frequently used",
|
||||
systemImage: "clock",
|
||||
items: recentItems
|
||||
),
|
||||
at: 0
|
||||
)
|
||||
}
|
||||
|
||||
if !customItems.isEmpty {
|
||||
sections.append(
|
||||
NativeEmojiSection(
|
||||
id: "custom",
|
||||
title: "Custom",
|
||||
systemImage: "sparkles",
|
||||
items: customItems
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return NativeEmojiPickerData(
|
||||
sections: sections,
|
||||
standardItems: standardItems,
|
||||
customItems: customItems
|
||||
)
|
||||
}
|
||||
|
||||
private static func categoryTitle(_ id: String) -> String {
|
||||
switch id {
|
||||
case "people": return "Smileys & People"
|
||||
case "nature": return "Animals & Nature"
|
||||
case "foods": return "Food & Drink"
|
||||
case "activity": return "Activity"
|
||||
case "places": return "Travel & Places"
|
||||
case "objects": return "Objects"
|
||||
case "symbols": return "Symbols"
|
||||
case "flags": return "Flags"
|
||||
default: return id.capitalized
|
||||
}
|
||||
}
|
||||
|
||||
private static func categorySymbol(_ id: String) -> String {
|
||||
switch id {
|
||||
case "people": return "face.smiling"
|
||||
case "nature": return "leaf"
|
||||
case "foods": return "fork.knife"
|
||||
case "activity": return "figure.run"
|
||||
case "places": return "airplane"
|
||||
case "objects": return "lightbulb"
|
||||
case "symbols": return "heart"
|
||||
case "flags": return "flag"
|
||||
default: return "circle.grid.3x3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NativeEmojiSearchScore: Comparable {
|
||||
let tier: Int
|
||||
let detail: Int
|
||||
let length: Int
|
||||
let code: String
|
||||
|
||||
static func < (lhs: Self, rhs: Self) -> Bool {
|
||||
if lhs.tier != rhs.tier { return lhs.tier < rhs.tier }
|
||||
if lhs.detail != rhs.detail { return lhs.detail < rhs.detail }
|
||||
if lhs.length != rhs.length { return lhs.length < rhs.length }
|
||||
return lhs.code < rhs.code
|
||||
}
|
||||
}
|
||||
|
||||
enum NativeEmojiSearch {
|
||||
static func results(
|
||||
query: String,
|
||||
items: [NativeEmojiItem]
|
||||
) -> [NativeEmojiItem] {
|
||||
items.compactMap { item -> (NativeEmojiItem, NativeEmojiSearchScore)? in
|
||||
guard let score = score(query: query, item: item) else { return nil }
|
||||
return (item, score)
|
||||
}
|
||||
.sorted { $0.1 < $1.1 }
|
||||
.map(\.0)
|
||||
}
|
||||
|
||||
private static func score(
|
||||
query: String,
|
||||
item: NativeEmojiItem
|
||||
) -> NativeEmojiSearchScore? {
|
||||
let normalizedQuery = collapse(query)
|
||||
guard !normalizedQuery.isEmpty else { return nil }
|
||||
let code = item.shortcode.lowercased()
|
||||
let normalizedCode = collapse(code)
|
||||
|
||||
if normalizedCode == normalizedQuery {
|
||||
return makeScore(tier: 0, detail: 0, code: code)
|
||||
}
|
||||
if normalizedCode.hasPrefix(normalizedQuery) {
|
||||
return makeScore(tier: 1, detail: 0, code: code)
|
||||
}
|
||||
|
||||
let words = ([item.name] + item.keywords)
|
||||
.flatMap { $0.lowercased().split(whereSeparator: { " _-".contains($0) }) }
|
||||
.map(String.init)
|
||||
if let index = words.firstIndex(where: { $0.hasPrefix(query.lowercased()) }) {
|
||||
return makeScore(tier: 2, detail: index, code: code)
|
||||
}
|
||||
if let range = normalizedCode.range(of: normalizedQuery) {
|
||||
return makeScore(
|
||||
tier: 3,
|
||||
detail: normalizedCode.distance(from: normalizedCode.startIndex, to: range.lowerBound),
|
||||
code: code
|
||||
)
|
||||
}
|
||||
if let index = words.firstIndex(where: { $0.contains(query.lowercased()) }) {
|
||||
return makeScore(tier: 4, detail: index, code: code)
|
||||
}
|
||||
if let span = subsequenceSpan(normalizedQuery, in: normalizedCode) {
|
||||
return makeScore(tier: 5, detail: span, code: code)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func makeScore(
|
||||
tier: Int,
|
||||
detail: Int,
|
||||
code: String
|
||||
) -> NativeEmojiSearchScore {
|
||||
NativeEmojiSearchScore(
|
||||
tier: tier,
|
||||
detail: detail,
|
||||
length: code.count,
|
||||
code: code
|
||||
)
|
||||
}
|
||||
|
||||
private static func collapse(_ value: String) -> String {
|
||||
value.lowercased().filter { !":_ -\t\n".contains($0) }
|
||||
}
|
||||
|
||||
private static func subsequenceSpan(_ query: String, in target: String) -> Int? {
|
||||
let queryCharacters = Array(query)
|
||||
guard !queryCharacters.isEmpty else { return nil }
|
||||
var queryIndex = 0
|
||||
var first: Int?
|
||||
var last = 0
|
||||
for (targetIndex, character) in target.enumerated() {
|
||||
guard character == queryCharacters[queryIndex] else { continue }
|
||||
if first == nil { first = targetIndex }
|
||||
last = targetIndex
|
||||
queryIndex += 1
|
||||
if queryIndex == queryCharacters.count {
|
||||
return last - (first ?? last)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// The top offset of each pinned section header, keyed by section id, reported
|
||||
/// up from the scrolling grid so the rail can follow manual scrolling.
|
||||
///
|
||||
/// The same stream also carries two viewport measurements under the reserved
|
||||
/// keys below, so the tracker sees the section offsets and the viewport bounds
|
||||
/// consistently in a single update. Section ids come from the emoji dataset and
|
||||
/// never collide with these dotted reserved keys.
|
||||
let nativeEmojiViewportBottomKey = "buzz.emoji.viewportBottom"
|
||||
let nativeEmojiContentBottomKey = "buzz.emoji.contentBottom"
|
||||
|
||||
struct NativeEmojiSectionOffsetsKey: PreferenceKey {
|
||||
static let defaultValue: [String: CGFloat] = [:]
|
||||
|
||||
static func reduce(
|
||||
value: inout [String: CGFloat],
|
||||
nextValue: () -> [String: CGFloat]
|
||||
) {
|
||||
value.merge(nextValue(), uniquingKeysWith: { _, next in next })
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure selection logic: the highlighted section is the last one whose header
|
||||
/// has scrolled to or above the top of the viewport. Extracted so the
|
||||
/// scroll-tracking behaviour can be unit-tested without a live scroll view.
|
||||
enum NativeEmojiCategoryTracker {
|
||||
static func selectedSectionID(
|
||||
order: [String],
|
||||
offsets: [String: CGFloat],
|
||||
viewportTop: CGFloat,
|
||||
viewportBottom: CGFloat? = nil,
|
||||
contentBottom: CGFloat? = nil
|
||||
) -> String? {
|
||||
// At the clamped bottom of an overflowing list, a final section shorter
|
||||
// than the viewport can never scroll its header to the top, so the
|
||||
// header-at-top rule would keep the preceding section highlighted while the
|
||||
// user is plainly viewing the last one. Detect that case first: the content
|
||||
// end is on screen (`contentBottom <= viewportBottom`) and the top has
|
||||
// scrolled away (`firstTop < viewportTop`, so the list really did overflow
|
||||
// rather than merely fitting). Highlight the last section then.
|
||||
if let viewportBottom,
|
||||
let contentBottom,
|
||||
contentBottom <= viewportBottom + 1,
|
||||
let firstID = order.first,
|
||||
let firstTop = offsets[firstID],
|
||||
firstTop < viewportTop,
|
||||
let lastID = order.last
|
||||
{
|
||||
return lastID
|
||||
}
|
||||
|
||||
var selected: String?
|
||||
for id in order {
|
||||
guard let top = offsets[id] else { continue }
|
||||
// A small tolerance keeps the header that is flush with the top pinned as
|
||||
// selected rather than flickering to the next section.
|
||||
if top <= viewportTop + 1 {
|
||||
selected = id
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return selected ?? order.first
|
||||
}
|
||||
}
|
||||
596
mobile/ios/Runner/NativeEmojiPickerView.swift
Normal file
596
mobile/ios/Runner/NativeEmojiPickerView.swift
Normal file
@@ -0,0 +1,596 @@
|
||||
import ImageIO
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct NativeEmojiPickerView: View {
|
||||
let data: NativeEmojiPickerData
|
||||
let appearance: NativeEmojiPickerAppearance
|
||||
let onSelect: (String) -> Void
|
||||
let onSkinToneChanged: (Int) -> Void
|
||||
let onClose: () -> Void
|
||||
|
||||
@State private var query = ""
|
||||
@State private var selectedSectionID: String?
|
||||
@State private var selectedSkinTone: Int
|
||||
|
||||
private let columns = Array(
|
||||
repeating: GridItem(.flexible(minimum: 36), spacing: 0),
|
||||
count: 8
|
||||
)
|
||||
|
||||
private let sectionListSpace = "buzz.emoji.sectionList"
|
||||
|
||||
init(
|
||||
data: NativeEmojiPickerData,
|
||||
appearance: NativeEmojiPickerAppearance,
|
||||
initialSkinTone: Int,
|
||||
onSelect: @escaping (String) -> Void,
|
||||
onSkinToneChanged: @escaping (Int) -> Void,
|
||||
onClose: @escaping () -> Void
|
||||
) {
|
||||
self.data = data
|
||||
self.appearance = appearance
|
||||
self.onSelect = onSelect
|
||||
self.onSkinToneChanged = onSkinToneChanged
|
||||
self.onClose = onClose
|
||||
_selectedSkinTone = State(
|
||||
initialValue: validNativeEmojiSkinTone(initialSkinTone)
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
if query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
categoryRail(proxy)
|
||||
}
|
||||
Divider().overlay(Color(uiColor: appearance.divider))
|
||||
pickerContent
|
||||
}
|
||||
.background(Color(uiColor: appearance.surface))
|
||||
.onAppear {
|
||||
selectedSectionID = data.sections.first?.id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 17, weight: .medium))
|
||||
.foregroundStyle(Color(uiColor: appearance.secondaryText))
|
||||
TextField("Search emoji", text: $query)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.submitLabel(.search)
|
||||
.foregroundStyle(Color(uiColor: appearance.text))
|
||||
if !query.isEmpty {
|
||||
Button {
|
||||
query = ""
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(Color(uiColor: appearance.secondaryText))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Clear search")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.frame(height: 44)
|
||||
.background(Color(uiColor: appearance.control), in: Capsule())
|
||||
.overlay {
|
||||
Capsule()
|
||||
.stroke(Color(uiColor: appearance.divider), lineWidth: 1)
|
||||
}
|
||||
|
||||
Button(action: onClose) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundStyle(Color(uiColor: appearance.text))
|
||||
.frame(width: 44, height: 44)
|
||||
.background(Color(uiColor: appearance.control), in: Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close sheet")
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
private func categoryRail(_ proxy: ScrollViewProxy) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(data.sections) { section in
|
||||
Button {
|
||||
selectedSectionID = section.id
|
||||
withAnimation(.easeOut(duration: 0.24)) {
|
||||
proxy.scrollTo("section-\(section.id)", anchor: .top)
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: section.systemImage)
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundStyle(
|
||||
Color(
|
||||
uiColor: selectedSectionID == section.id
|
||||
? appearance.accent : appearance.secondaryText
|
||||
)
|
||||
)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 36)
|
||||
.background(
|
||||
selectedSectionID == section.id
|
||||
? Color(uiColor: appearance.control) : Color.clear,
|
||||
in: Circle()
|
||||
)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(section.title)
|
||||
.accessibilityAddTraits(
|
||||
selectedSectionID == section.id ? .isSelected : []
|
||||
)
|
||||
}
|
||||
Divider()
|
||||
.frame(height: 24)
|
||||
.overlay(Color(uiColor: appearance.divider))
|
||||
skinToneSelector
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 44)
|
||||
}
|
||||
|
||||
private var skinToneSelector: some View {
|
||||
Menu {
|
||||
ForEach(nativeEmojiSkinTones) { tone in
|
||||
Button {
|
||||
selectedSkinTone = tone.id
|
||||
onSkinToneChanged(tone.id)
|
||||
} label: {
|
||||
Label {
|
||||
Text(tone.label)
|
||||
} icon: {
|
||||
Image(uiImage: skinTonePreviewImage(tone))
|
||||
.renderingMode(.original)
|
||||
}
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
skinToneDot(nativeEmojiSkinTones[selectedSkinTone])
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Skin tone")
|
||||
}
|
||||
|
||||
private func skinToneDot(_ tone: NativeEmojiSkinTone) -> some View {
|
||||
Circle()
|
||||
.fill(Color(uiColor: tone.color))
|
||||
.frame(width: 16, height: 16)
|
||||
.overlay {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [.white.opacity(0.2), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
.blendMode(.overlay)
|
||||
}
|
||||
.overlay {
|
||||
Circle().stroke(.black.opacity(0.8), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private func skinTonePreviewImage(_ tone: NativeEmojiSkinTone) -> UIImage {
|
||||
let size = CGSize(width: 16, height: 16)
|
||||
return UIGraphicsImageRenderer(size: size).image { rendererContext in
|
||||
let context = rendererContext.cgContext
|
||||
let rect = CGRect(origin: .zero, size: size).insetBy(dx: 0.5, dy: 0.5)
|
||||
let circle = UIBezierPath(ovalIn: rect)
|
||||
|
||||
tone.color.setFill()
|
||||
circle.fill()
|
||||
|
||||
if let gradient = CGGradient(
|
||||
colorsSpace: CGColorSpaceCreateDeviceRGB(),
|
||||
colors: [
|
||||
UIColor.white.withAlphaComponent(0.2).cgColor,
|
||||
UIColor.clear.cgColor,
|
||||
] as CFArray,
|
||||
locations: [0, 1]
|
||||
) {
|
||||
context.saveGState()
|
||||
circle.addClip()
|
||||
context.setBlendMode(.overlay)
|
||||
context.drawLinearGradient(
|
||||
gradient,
|
||||
start: CGPoint(x: size.width / 2, y: 0),
|
||||
end: CGPoint(x: size.width / 2, y: size.height),
|
||||
options: []
|
||||
)
|
||||
context.restoreGState()
|
||||
}
|
||||
|
||||
UIColor.black.withAlphaComponent(0.8).setStroke()
|
||||
circle.lineWidth = 1
|
||||
circle.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var pickerContent: some View {
|
||||
let trimmedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedQuery.isEmpty {
|
||||
sectionList(data.sections, tracksSelection: true)
|
||||
} else {
|
||||
let custom = NativeEmojiSearch.results(
|
||||
query: trimmedQuery,
|
||||
items: data.customItems
|
||||
)
|
||||
let standard = NativeEmojiSearch.results(
|
||||
query: trimmedQuery,
|
||||
items: data.standardItems
|
||||
)
|
||||
let sections = [
|
||||
NativeEmojiSection(
|
||||
id: "search-custom",
|
||||
title: "Custom",
|
||||
systemImage: "sparkles",
|
||||
items: custom
|
||||
),
|
||||
NativeEmojiSection(
|
||||
id: "search-standard",
|
||||
title: "Emoji",
|
||||
systemImage: "face.smiling",
|
||||
items: standard
|
||||
),
|
||||
].filter { !$0.items.isEmpty }
|
||||
|
||||
if sections.isEmpty {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 28))
|
||||
Text("No emoji found").font(.body)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.foregroundStyle(Color(uiColor: appearance.secondaryText))
|
||||
} else {
|
||||
sectionList(sections, tracksSelection: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionList(
|
||||
_ sections: [NativeEmojiSection],
|
||||
tracksSelection: Bool
|
||||
) -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||
ForEach(sections) { section in
|
||||
Section {
|
||||
LazyVGrid(columns: columns, spacing: 0) {
|
||||
ForEach(section.items) { item in
|
||||
emojiButton(item)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
} header: {
|
||||
HStack {
|
||||
Text(section.title)
|
||||
.font(.footnote.weight(.semibold))
|
||||
.foregroundStyle(Color(uiColor: appearance.secondaryText))
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.frame(height: 30)
|
||||
.background(Color(uiColor: appearance.surface))
|
||||
.background(sectionOffsetReporter(id: section.id))
|
||||
.id("section-\(section.id)")
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
.background(contentBoundaryReporter())
|
||||
}
|
||||
.coordinateSpace(name: sectionListSpace)
|
||||
.background(viewportBoundaryReporter())
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onPreferenceChange(NativeEmojiSectionOffsetsKey.self) { offsets in
|
||||
guard tracksSelection else { return }
|
||||
selectedSectionID = NativeEmojiCategoryTracker.selectedSectionID(
|
||||
order: data.sections.map(\.id),
|
||||
offsets: offsets,
|
||||
viewportTop: 0,
|
||||
viewportBottom: offsets[nativeEmojiViewportBottomKey],
|
||||
contentBottom: offsets[nativeEmojiContentBottomKey]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func sectionOffsetReporter(id: String) -> some View {
|
||||
GeometryReader { geometry in
|
||||
Color.clear.preference(
|
||||
key: NativeEmojiSectionOffsetsKey.self,
|
||||
value: [id: geometry.frame(in: .named(sectionListSpace)).minY]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The end of the scrolling content, relative to the viewport top. At the
|
||||
// clamped bottom of an overflowing list this converges on the viewport
|
||||
// height, which lets the tracker highlight a short final section that can
|
||||
// never scroll its own header to the top.
|
||||
private func contentBoundaryReporter() -> some View {
|
||||
GeometryReader { geometry in
|
||||
Color.clear.preference(
|
||||
key: NativeEmojiSectionOffsetsKey.self,
|
||||
value: [
|
||||
nativeEmojiContentBottomKey:
|
||||
geometry.frame(in: .named(sectionListSpace)).maxY
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The fixed viewport height, reported through the same preference stream so
|
||||
// it stays consistent with the section offsets in each update.
|
||||
private func viewportBoundaryReporter() -> some View {
|
||||
GeometryReader { geometry in
|
||||
Color.clear.preference(
|
||||
key: NativeEmojiSectionOffsetsKey.self,
|
||||
value: [nativeEmojiViewportBottomKey: geometry.size.height]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func emojiButton(_ item: NativeEmojiItem) -> some View {
|
||||
let value = displayValue(for: item)
|
||||
return Button {
|
||||
onSelect(value)
|
||||
} label: {
|
||||
Group {
|
||||
if let url = item.imageURL {
|
||||
NativeEmojiRemoteImage(
|
||||
url: url,
|
||||
fallbackColor: appearance.secondaryText
|
||||
)
|
||||
.frame(width: 28, height: 28)
|
||||
} else {
|
||||
Text(value).font(.system(size: 28))
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 44)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(item.name)
|
||||
}
|
||||
|
||||
private func displayValue(for item: NativeEmojiItem) -> String {
|
||||
guard item.imageURL == nil else { return item.value }
|
||||
guard item.skinVariants.indices.contains(selectedSkinTone) else {
|
||||
return item.skinVariants.first ?? item.value
|
||||
}
|
||||
return item.skinVariants[selectedSkinTone]
|
||||
}
|
||||
}
|
||||
|
||||
struct NativeEmojiRemoteImage: View {
|
||||
let url: URL
|
||||
let fallbackColor: UIColor
|
||||
|
||||
@State private var phase: Phase = .loading
|
||||
|
||||
private enum Phase {
|
||||
case loading
|
||||
case success(UIImage)
|
||||
case failure
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch phase {
|
||||
case .loading:
|
||||
ProgressView().controlSize(.mini)
|
||||
case .success(let image):
|
||||
Image(uiImage: image).resizable().scaledToFit()
|
||||
case .failure:
|
||||
Image(systemName: "sparkles")
|
||||
.foregroundStyle(Color(uiColor: fallbackColor))
|
||||
}
|
||||
}
|
||||
.task(id: requestIdentity) {
|
||||
do {
|
||||
let requestHeaders = try await NativeEmojiPickerCoordinator.mediaHeaders(
|
||||
for: url
|
||||
)
|
||||
var request = URLRequest(url: url)
|
||||
for (name, value) in requestHeaders {
|
||||
request.setValue(value, forHTTPHeaderField: name)
|
||||
}
|
||||
phase = .success(
|
||||
try await NativeEmojiRemoteImageLoader.shared.image(for: request)
|
||||
)
|
||||
} catch {
|
||||
if !Task.isCancelled { phase = .failure }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var requestIdentity: String {
|
||||
url.absoluteString
|
||||
}
|
||||
}
|
||||
|
||||
enum NativeEmojiRemoteImageError: Error {
|
||||
case invalidResponse
|
||||
case responseTooLarge
|
||||
case invalidImage
|
||||
}
|
||||
|
||||
actor NativeEmojiRemoteImageLoader {
|
||||
typealias Downloader = (URLRequest) async throws -> UIImage
|
||||
|
||||
static let shared = NativeEmojiRemoteImageLoader()
|
||||
static let defaultMaximumConcurrentDownloads = 4
|
||||
|
||||
private static let maximumDownloadBytes = 10 * 1024 * 1024
|
||||
private static let maximumThumbnailPixels = 84
|
||||
private static let defaultCacheByteLimit = 8 * 1024 * 1024
|
||||
|
||||
private struct Waiter {
|
||||
let id: UUID
|
||||
let continuation: CheckedContinuation<Void, Error>
|
||||
}
|
||||
|
||||
private let maximumConcurrentDownloads: Int
|
||||
private let downloader: Downloader
|
||||
private let admissionAttemptForTesting: (() -> Void)?
|
||||
private let cache = NSCache<NSURLRequest, UIImage>()
|
||||
private var activeDownloadCount = 0
|
||||
private var waiters: [Waiter] = []
|
||||
|
||||
init(
|
||||
maximumConcurrentDownloads: Int = defaultMaximumConcurrentDownloads,
|
||||
cacheByteLimit: Int = defaultCacheByteLimit,
|
||||
admissionAttemptForTesting: (() -> Void)? = nil,
|
||||
downloader: @escaping Downloader = NativeEmojiRemoteImageLoader.download
|
||||
) {
|
||||
precondition(maximumConcurrentDownloads > 0)
|
||||
precondition(cacheByteLimit >= 0)
|
||||
self.maximumConcurrentDownloads = maximumConcurrentDownloads
|
||||
self.admissionAttemptForTesting = admissionAttemptForTesting
|
||||
self.downloader = downloader
|
||||
cache.totalCostLimit = cacheByteLimit
|
||||
}
|
||||
|
||||
func image(for request: URLRequest) async throws -> UIImage {
|
||||
let cacheKey = request as NSURLRequest
|
||||
if let cached = cache.object(forKey: cacheKey) {
|
||||
return cached
|
||||
}
|
||||
|
||||
recordAdmissionAttemptForTesting()
|
||||
try await acquireDownloadSlot()
|
||||
defer { releaseDownloadSlot() }
|
||||
|
||||
try Task.checkCancellation()
|
||||
if let cached = cache.object(forKey: cacheKey) {
|
||||
return cached
|
||||
}
|
||||
|
||||
let image = try await downloader(request)
|
||||
cache.setObject(image, forKey: cacheKey, cost: Self.cacheCost(for: image))
|
||||
return image
|
||||
}
|
||||
|
||||
private func recordAdmissionAttemptForTesting() {
|
||||
admissionAttemptForTesting?()
|
||||
}
|
||||
|
||||
private func acquireDownloadSlot() async throws {
|
||||
try Task.checkCancellation()
|
||||
guard activeDownloadCount >= maximumConcurrentDownloads else {
|
||||
activeDownloadCount += 1
|
||||
return
|
||||
}
|
||||
|
||||
let waiterID = UUID()
|
||||
try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation {
|
||||
(continuation: CheckedContinuation<Void, Error>) in
|
||||
if Task.isCancelled {
|
||||
continuation.resume(throwing: CancellationError())
|
||||
} else {
|
||||
waiters.append(Waiter(id: waiterID, continuation: continuation))
|
||||
}
|
||||
}
|
||||
} onCancel: {
|
||||
Task { await self.cancelWaiter(id: waiterID) }
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelWaiter(id: UUID) {
|
||||
guard let index = waiters.firstIndex(where: { $0.id == id }) else { return }
|
||||
let waiter = waiters.remove(at: index)
|
||||
waiter.continuation.resume(throwing: CancellationError())
|
||||
}
|
||||
|
||||
private func releaseDownloadSlot() {
|
||||
while !waiters.isEmpty {
|
||||
let waiter = waiters.removeFirst()
|
||||
waiter.continuation.resume()
|
||||
return
|
||||
}
|
||||
activeDownloadCount -= 1
|
||||
}
|
||||
|
||||
private static func download(_ request: URLRequest) async throws -> UIImage {
|
||||
let (bytes, response) = try await URLSession.shared.bytes(for: request)
|
||||
guard
|
||||
let httpResponse = response as? HTTPURLResponse,
|
||||
(200..<300).contains(httpResponse.statusCode)
|
||||
else {
|
||||
throw NativeEmojiRemoteImageError.invalidResponse
|
||||
}
|
||||
if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"),
|
||||
let byteCount = Int(contentLength),
|
||||
byteCount > maximumDownloadBytes
|
||||
{
|
||||
throw NativeEmojiRemoteImageError.responseTooLarge
|
||||
}
|
||||
|
||||
var data = Data()
|
||||
let expected = httpResponse.expectedContentLength
|
||||
if expected > 0 {
|
||||
data.reserveCapacity(Int(min(expected, Int64(maximumDownloadBytes))))
|
||||
}
|
||||
for try await byte in bytes {
|
||||
guard data.count < maximumDownloadBytes else {
|
||||
throw NativeEmojiRemoteImageError.responseTooLarge
|
||||
}
|
||||
data.append(byte)
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
guard let image = thumbnail(from: data) else {
|
||||
throw NativeEmojiRemoteImageError.invalidImage
|
||||
}
|
||||
return image
|
||||
}
|
||||
|
||||
private static func thumbnail(from data: Data) -> UIImage? {
|
||||
guard let source = CGImageSourceCreateWithData(data as CFData, nil) else {
|
||||
return nil
|
||||
}
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: maximumThumbnailPixels,
|
||||
kCGImageSourceShouldCacheImmediately: true,
|
||||
]
|
||||
guard
|
||||
let image = CGImageSourceCreateThumbnailAtIndex(
|
||||
source,
|
||||
0,
|
||||
options as CFDictionary
|
||||
)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return UIImage(cgImage: image)
|
||||
}
|
||||
|
||||
private static func cacheCost(for image: UIImage) -> Int {
|
||||
guard let cgImage = image.cgImage else { return 0 }
|
||||
let (cost, overflow) = cgImage.bytesPerRow.multipliedReportingOverflow(
|
||||
by: cgImage.height
|
||||
)
|
||||
return overflow ? Int.max : cost
|
||||
}
|
||||
}
|
||||
@@ -403,6 +403,173 @@ class RunnerTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
func testCategoryTrackerHighlightsLastHeaderAtOrAboveTop() {
|
||||
let order = ["people", "nature", "flags"]
|
||||
let offsets: [String: CGFloat] = [
|
||||
"people": -320,
|
||||
"nature": -12,
|
||||
"flags": 200,
|
||||
]
|
||||
|
||||
XCTAssertEqual(
|
||||
NativeEmojiCategoryTracker.selectedSectionID(
|
||||
order: order,
|
||||
offsets: offsets,
|
||||
viewportTop: 0
|
||||
),
|
||||
"nature"
|
||||
)
|
||||
}
|
||||
|
||||
func testCategoryTrackerFollowsScrollPastEachHeader() {
|
||||
let order = ["people", "nature", "flags"]
|
||||
|
||||
// Scrolled to the very top: the first section is highlighted.
|
||||
XCTAssertEqual(
|
||||
NativeEmojiCategoryTracker.selectedSectionID(
|
||||
order: order,
|
||||
offsets: ["people": 0, "nature": 400, "flags": 800],
|
||||
viewportTop: 0
|
||||
),
|
||||
"people"
|
||||
)
|
||||
|
||||
// Scrolled far enough that Flags has reached the top.
|
||||
XCTAssertEqual(
|
||||
NativeEmojiCategoryTracker.selectedSectionID(
|
||||
order: order,
|
||||
offsets: ["people": -800, "nature": -400, "flags": 0],
|
||||
viewportTop: 0
|
||||
),
|
||||
"flags"
|
||||
)
|
||||
}
|
||||
|
||||
func testCategoryTrackerFallsBackToFirstSectionBeforeAnyHeaderReachesTop() {
|
||||
XCTAssertEqual(
|
||||
NativeEmojiCategoryTracker.selectedSectionID(
|
||||
order: ["people", "nature"],
|
||||
offsets: ["people": 40, "nature": 400],
|
||||
viewportTop: 0
|
||||
),
|
||||
"people"
|
||||
)
|
||||
}
|
||||
|
||||
func testCategoryTrackerSelectsShortFinalSectionAtClampedBottom() {
|
||||
// The list has overflowed (People scrolled above the top) and its end is on
|
||||
// screen, but the short Custom section's header sits below the top because
|
||||
// the content clamps before it can reach it. The rail must still highlight
|
||||
// Custom rather than leaving Nature — its predecessor — selected.
|
||||
let order = ["people", "nature", "custom"]
|
||||
let offsets: [String: CGFloat] = [
|
||||
"people": -900,
|
||||
"nature": -420,
|
||||
"custom": 360,
|
||||
]
|
||||
|
||||
XCTAssertEqual(
|
||||
NativeEmojiCategoryTracker.selectedSectionID(
|
||||
order: order,
|
||||
offsets: offsets,
|
||||
viewportTop: 0,
|
||||
viewportBottom: 500,
|
||||
contentBottom: 500
|
||||
),
|
||||
"custom"
|
||||
)
|
||||
}
|
||||
|
||||
func testCategoryTrackerKeepsHeaderRuleWhenContentEndIsOffscreen() {
|
||||
// The same short-final geometry, but the content end is still below the
|
||||
// viewport (the user has not reached the bottom), so the ordinary
|
||||
// header-at-top rule applies and Nature stays selected.
|
||||
let order = ["people", "nature", "custom"]
|
||||
let offsets: [String: CGFloat] = [
|
||||
"people": -900,
|
||||
"nature": -420,
|
||||
"custom": 360,
|
||||
]
|
||||
|
||||
XCTAssertEqual(
|
||||
NativeEmojiCategoryTracker.selectedSectionID(
|
||||
order: order,
|
||||
offsets: offsets,
|
||||
viewportTop: 0,
|
||||
viewportBottom: 500,
|
||||
contentBottom: 900
|
||||
),
|
||||
"nature"
|
||||
)
|
||||
}
|
||||
|
||||
func testCategoryTrackerDoesNotForceLastSectionForAShortList() {
|
||||
// A list that fits without scrolling has its content end on screen too, but
|
||||
// its first header is still at the top — so the bottom rule must not fire
|
||||
// and steal the highlight to the final section.
|
||||
let order = ["people", "nature"]
|
||||
let offsets: [String: CGFloat] = ["people": 0, "nature": 120]
|
||||
|
||||
XCTAssertEqual(
|
||||
NativeEmojiCategoryTracker.selectedSectionID(
|
||||
order: order,
|
||||
offsets: offsets,
|
||||
viewportTop: 0,
|
||||
viewportBottom: 500,
|
||||
contentBottom: 240
|
||||
),
|
||||
"people"
|
||||
)
|
||||
}
|
||||
|
||||
func testRemoteEmojiLoaderLimitsConcurrentDownloads() async throws {
|
||||
let maximumConcurrentDownloads = 3
|
||||
let taskCount = 8
|
||||
let probe = NativeEmojiDownloadProbe()
|
||||
let tasksAttemptedAdmission = XCTestExpectation(
|
||||
description: "all download tasks attempted admission"
|
||||
)
|
||||
tasksAttemptedAdmission.expectedFulfillmentCount = taskCount
|
||||
let loader = NativeEmojiRemoteImageLoader(
|
||||
maximumConcurrentDownloads: maximumConcurrentDownloads,
|
||||
cacheByteLimit: 0,
|
||||
admissionAttemptForTesting: { tasksAttemptedAdmission.fulfill() },
|
||||
downloader: { _ in
|
||||
await probe.holdDownload()
|
||||
return UIImage()
|
||||
}
|
||||
)
|
||||
let tasks = (0..<taskCount).map { index in
|
||||
Task {
|
||||
try await loader.image(
|
||||
for: URLRequest(
|
||||
url: try XCTUnwrap(URL(string: "https://example.com/\(index).png"))
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await fulfillment(of: [tasksAttemptedAdmission], timeout: 2)
|
||||
await probe.waitUntilStarted(maximumConcurrentDownloads)
|
||||
var snapshot = await probe.snapshot()
|
||||
XCTAssertEqual(snapshot.started, maximumConcurrentDownloads)
|
||||
XCTAssertEqual(snapshot.peakActive, maximumConcurrentDownloads)
|
||||
|
||||
for expectedStarted in (maximumConcurrentDownloads + 1)...tasks.count {
|
||||
await probe.releaseOne()
|
||||
await probe.waitUntilStarted(expectedStarted)
|
||||
}
|
||||
await probe.releaseAll()
|
||||
for task in tasks {
|
||||
_ = try await task.value
|
||||
}
|
||||
|
||||
snapshot = await probe.snapshot()
|
||||
XCTAssertEqual(snapshot.started, tasks.count)
|
||||
XCTAssertEqual(snapshot.peakActive, maximumConcurrentDownloads)
|
||||
XCTAssertEqual(snapshot.active, 0)
|
||||
}
|
||||
|
||||
func testNativeMessageActionsPreserveRequestedGroupsAndHeight() throws {
|
||||
let actionArguments: [[String: Any]] = [
|
||||
[
|
||||
@@ -781,3 +948,61 @@ private func readUInt32BigEndian(_ data: Data, at offset: Int) throws -> UInt32
|
||||
return UInt32(data[offset]) << 24 | UInt32(data[offset + 1]) << 16
|
||||
| UInt32(data[offset + 2]) << 8 | UInt32(data[offset + 3])
|
||||
}
|
||||
|
||||
private actor NativeEmojiDownloadProbe {
|
||||
private struct MilestoneWaiter {
|
||||
let count: Int
|
||||
let continuation: CheckedContinuation<Void, Never>
|
||||
}
|
||||
|
||||
private var active = 0
|
||||
private var peakActive = 0
|
||||
private var started = 0
|
||||
private var releaseContinuations: [CheckedContinuation<Void, Never>] = []
|
||||
private var milestoneWaiters: [MilestoneWaiter] = []
|
||||
|
||||
func holdDownload() async {
|
||||
active += 1
|
||||
started += 1
|
||||
peakActive = max(peakActive, active)
|
||||
resumeReachedMilestones()
|
||||
await withCheckedContinuation { continuation in
|
||||
releaseContinuations.append(continuation)
|
||||
}
|
||||
active -= 1
|
||||
}
|
||||
|
||||
func waitUntilStarted(_ count: Int) async {
|
||||
guard started < count else { return }
|
||||
await withCheckedContinuation { continuation in
|
||||
milestoneWaiters.append(
|
||||
MilestoneWaiter(count: count, continuation: continuation)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func releaseOne() {
|
||||
guard !releaseContinuations.isEmpty else { return }
|
||||
releaseContinuations.removeFirst().resume()
|
||||
}
|
||||
|
||||
func releaseAll() {
|
||||
let continuations = releaseContinuations
|
||||
releaseContinuations.removeAll()
|
||||
for continuation in continuations {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
func snapshot() -> (active: Int, peakActive: Int, started: Int) {
|
||||
(active, peakActive, started)
|
||||
}
|
||||
|
||||
private func resumeReachedMilestones() {
|
||||
let reached = milestoneWaiters.filter { $0.count <= started }
|
||||
milestoneWaiters.removeAll { $0.count <= started }
|
||||
for waiter in reached {
|
||||
waiter.continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
@@ -10,17 +14,19 @@ import '../../shared/emoji/emoji_data.dart';
|
||||
import '../../shared/emoji/emoji_data_provider.dart';
|
||||
import '../../shared/emoji/emoji_search.dart';
|
||||
import '../../shared/emoji/native_emoji_glyph.dart';
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/buzz_sheet_header.dart';
|
||||
import '../../shared/widgets/modal_presentation.dart';
|
||||
import 'recent_emoji_provider.dart';
|
||||
|
||||
part 'emoji_picker/search_field.dart';
|
||||
part 'emoji_picker/category_rail.dart';
|
||||
part 'emoji_picker/emoji_grid.dart';
|
||||
part 'emoji_picker/ios_native_picker.dart';
|
||||
|
||||
/// Height of the picker sheet as a fraction of the screen. The full emoji set
|
||||
/// is ~1.9k glyphs; the old fixed 340px sheet only ever showed a hand-picked
|
||||
/// subset and had no room to browse.
|
||||
/// Android keeps the established Flutter tray height. iOS is presented by a
|
||||
/// native sheet with system detents in [ios_native_picker.dart].
|
||||
const _sheetHeightFactor = 0.62;
|
||||
|
||||
/// Opens the full emoji picker as a modal bottom sheet.
|
||||
@@ -33,12 +39,34 @@ void showEmojiPicker({
|
||||
required BuildContext context,
|
||||
required void Function(String emoji) onSelect,
|
||||
VoidCallback? onDismiss,
|
||||
}) {
|
||||
if (defaultTargetPlatform == TargetPlatform.iOS) {
|
||||
unawaited(
|
||||
_presentIosEmojiPicker(
|
||||
context: context,
|
||||
onSelect: onSelect,
|
||||
onDismiss: onDismiss,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_showFlutterEmojiPicker(
|
||||
context: context,
|
||||
onSelect: onSelect,
|
||||
onDismiss: onDismiss,
|
||||
);
|
||||
}
|
||||
|
||||
void _showFlutterEmojiPicker({
|
||||
required BuildContext context,
|
||||
required void Function(String emoji) onSelect,
|
||||
VoidCallback? onDismiss,
|
||||
}) {
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
backgroundColor: context.colors.surfaceContainerHighest,
|
||||
showCloseButton: false,
|
||||
builder: (sheetContext) => EmojiPickerSheet(
|
||||
onSelect: (emoji) {
|
||||
Navigator.of(sheetContext).pop();
|
||||
@@ -53,11 +81,41 @@ class EmojiPickerSheet extends HookConsumerWidget {
|
||||
|
||||
const EmojiPickerSheet({super.key, required this.onSelect});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return SizedBox(
|
||||
height: MediaQuery.sizeOf(context).height * _sheetHeightFactor,
|
||||
child: _EmojiPickerContent(onSelect: onSelect),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmojiPickerContent extends HookConsumerWidget {
|
||||
const _EmojiPickerContent({required this.onSelect});
|
||||
|
||||
final void Function(String emoji) onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final dataset = ref.watch(emojiDatasetOrEmptyProvider);
|
||||
final customEmoji = ref.watch(customEmojiListProvider);
|
||||
final recent = ref.watch(recentEmojiProvider);
|
||||
final prefs = ref.read(savedPrefsProvider);
|
||||
final skinTone = useState(
|
||||
_validSkinTone(prefs.getInt(_emojiSkinTonePrefsKey)),
|
||||
);
|
||||
|
||||
void selectSkinTone(int value) {
|
||||
final next = _validSkinTone(value);
|
||||
if (skinTone.value == next) return;
|
||||
skinTone.value = next;
|
||||
unawaited(prefs.setInt(_emojiSkinTonePrefsKey, next));
|
||||
}
|
||||
|
||||
final visibleDataset = useMemoized(
|
||||
() => _datasetForSkinTone(dataset, skinTone.value),
|
||||
[dataset, skinTone.value],
|
||||
);
|
||||
|
||||
final searchController = useTextEditingController();
|
||||
final query = useState('');
|
||||
@@ -74,20 +132,37 @@ class EmojiPickerSheet extends HookConsumerWidget {
|
||||
|
||||
final sections = useMemoized(
|
||||
() => _buildSections(
|
||||
dataset: dataset,
|
||||
dataset: visibleDataset,
|
||||
sourceDataset: dataset,
|
||||
customEmoji: customEmoji,
|
||||
recent: recent,
|
||||
onSelect: select,
|
||||
),
|
||||
[dataset, customEmoji, recent],
|
||||
[visibleDataset, dataset, customEmoji, recent],
|
||||
);
|
||||
final offsets = useMemoized(() => _sectionOffsets(sections), [sections]);
|
||||
|
||||
final scrollController = useScrollController();
|
||||
|
||||
// A notifier rather than state: the highlight changes on every scroll frame
|
||||
// and only the rail needs to hear about it. Rebuilding the sheet would
|
||||
// rebuild the grid underneath it.
|
||||
final activeSection = useMemoized(() => ValueNotifier(0), [sections]);
|
||||
//
|
||||
// Seed it from the current scroll offset rather than 0: a skin-tone change
|
||||
// rebuilds [sections] and so replaces this notifier, but the grid keeps its
|
||||
// scroll position (same controller, same section extents). Resetting to 0
|
||||
// here would falsely highlight the first category until the next scroll.
|
||||
final activeSection = useMemoized(
|
||||
() => ValueNotifier(
|
||||
scrollController.hasClients
|
||||
? _activeSectionIndex(
|
||||
offsets,
|
||||
scrollController.offset,
|
||||
maxScrollExtent: scrollController.position.maxScrollExtent,
|
||||
)
|
||||
: 0,
|
||||
),
|
||||
[sections],
|
||||
);
|
||||
useEffect(() => activeSection.dispose, [activeSection]);
|
||||
|
||||
useEffect(() {
|
||||
@@ -96,6 +171,7 @@ class EmojiPickerSheet extends HookConsumerWidget {
|
||||
activeSection.value = _activeSectionIndex(
|
||||
offsets,
|
||||
scrollController.offset,
|
||||
maxScrollExtent: scrollController.position.maxScrollExtent,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,9 +195,9 @@ class EmojiPickerSheet extends HookConsumerWidget {
|
||||
// while the sheet animates.
|
||||
final results = useMemoized(
|
||||
() => isSearching
|
||||
? searchEmoji(trimmedQuery, dataset.all)
|
||||
? searchEmoji(trimmedQuery, visibleDataset.all)
|
||||
: const <EmojiEntry>[],
|
||||
[trimmedQuery, dataset],
|
||||
[trimmedQuery, visibleDataset],
|
||||
);
|
||||
final customResults = useMemoized(
|
||||
() => isSearching
|
||||
@@ -134,37 +210,48 @@ class EmojiPickerSheet extends HookConsumerWidget {
|
||||
[trimmedQuery, customEmoji],
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
height: MediaQuery.sizeOf(context).height * _sheetHeightFactor,
|
||||
child: Column(
|
||||
children: [
|
||||
_EmojiSearchField(controller: searchController),
|
||||
if (!isSearching && sections.isNotEmpty)
|
||||
ValueListenableBuilder<int>(
|
||||
valueListenable: activeSection,
|
||||
builder: (context, active, _) => _CategoryRail(
|
||||
sections: sections,
|
||||
activeIndex: active,
|
||||
onSelect: jumpToSection,
|
||||
return Column(
|
||||
children: [
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) => BuzzSheetHeader(
|
||||
showDragHandle: true,
|
||||
leading: SizedBox(
|
||||
width: constraints.maxWidth - Grid.gutter * 2 - 44 - Grid.xxs,
|
||||
child: _EmojiSearchField(
|
||||
controller: searchController,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
Divider(height: 1, color: context.colors.outlineVariant),
|
||||
Expanded(
|
||||
child: dataset.isEmpty && customEmoji.isEmpty
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: isSearching
|
||||
? _EmojiSearchResults(
|
||||
entries: results,
|
||||
customEmoji: customResults,
|
||||
onSelect: select,
|
||||
)
|
||||
: _ContinuousEmojiGrid(
|
||||
sections: sections,
|
||||
controller: scrollController,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!isSearching && sections.isNotEmpty)
|
||||
ValueListenableBuilder<int>(
|
||||
valueListenable: activeSection,
|
||||
builder: (context, active, _) => _CategoryRail(
|
||||
sections: sections,
|
||||
activeIndex: active,
|
||||
onSelect: jumpToSection,
|
||||
skinTone: skinTone.value,
|
||||
onSkinToneChanged: selectSkinTone,
|
||||
),
|
||||
),
|
||||
Divider(height: 1, color: context.colors.outlineVariant),
|
||||
Expanded(
|
||||
child: dataset.isEmpty && customEmoji.isEmpty
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: isSearching
|
||||
? _EmojiSearchResults(
|
||||
entries: results,
|
||||
customEmoji: customResults,
|
||||
onSelect: select,
|
||||
controller: scrollController,
|
||||
)
|
||||
: _ContinuousEmojiGrid(
|
||||
sections: sections,
|
||||
controller: scrollController,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -177,6 +264,7 @@ class EmojiPickerSheet extends HookConsumerWidget {
|
||||
/// nowhere.
|
||||
List<_EmojiSection> _buildSections({
|
||||
required EmojiDataset dataset,
|
||||
required EmojiDataset sourceDataset,
|
||||
required List<CustomEmoji> customEmoji,
|
||||
required List<RecentEmojiEntry> recent,
|
||||
required void Function(String emoji) onSelect,
|
||||
@@ -186,6 +274,7 @@ List<_EmojiSection> _buildSections({
|
||||
final recentTiles = _resolveRecentTiles(
|
||||
recent: recent,
|
||||
dataset: dataset,
|
||||
sourceDataset: sourceDataset,
|
||||
customEmoji: customEmoji,
|
||||
onSelect: onSelect,
|
||||
);
|
||||
@@ -246,15 +335,18 @@ List<_EmojiSection> _buildSections({
|
||||
List<Widget> _resolveRecentTiles({
|
||||
required List<RecentEmojiEntry> recent,
|
||||
required EmojiDataset dataset,
|
||||
required EmojiDataset sourceDataset,
|
||||
required List<CustomEmoji> customEmoji,
|
||||
required void Function(String emoji) onSelect,
|
||||
}) {
|
||||
final customByShortcode = {
|
||||
for (final emoji in customEmoji) emoji.shortcode.toLowerCase(): emoji,
|
||||
};
|
||||
final entriesByNative = {
|
||||
for (final entry in dataset.all) entry.native: entry,
|
||||
final sourceEntriesByNative = {
|
||||
for (final entry in sourceDataset.all) entry.native: entry,
|
||||
};
|
||||
final visibleEntriesById = {for (final entry in dataset.all) entry.id: entry};
|
||||
final seenStandardIds = <String>{};
|
||||
|
||||
final tiles = <Widget>[];
|
||||
for (final item in recent) {
|
||||
@@ -272,7 +364,9 @@ List<Widget> _resolveRecentTiles({
|
||||
);
|
||||
continue;
|
||||
}
|
||||
final entry = entriesByNative[value];
|
||||
final sourceEntry = sourceEntriesByNative[value];
|
||||
if (sourceEntry == null || !seenStandardIds.add(sourceEntry.id)) continue;
|
||||
final entry = visibleEntriesById[sourceEntry.id];
|
||||
if (entry == null) continue;
|
||||
tiles.add(
|
||||
_EmojiTile(
|
||||
@@ -284,3 +378,37 @@ List<Widget> _resolveRecentTiles({
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
/// Project the dataset to one visible tile per shortcode. Emoji that support
|
||||
/// skin tones use the selected variant; everything else keeps its default.
|
||||
EmojiDataset _datasetForSkinTone(EmojiDataset dataset, int skinTone) {
|
||||
if (dataset.isEmpty) return dataset;
|
||||
final categories = <EmojiCategory>[];
|
||||
final all = <EmojiEntry>[];
|
||||
|
||||
for (final category in dataset.categories) {
|
||||
final variantsById = <String, List<EmojiEntry>>{};
|
||||
for (final entry in category.emoji) {
|
||||
variantsById.putIfAbsent(entry.id, () => []).add(entry);
|
||||
}
|
||||
final visible = <EmojiEntry>[];
|
||||
for (final variants in variantsById.values) {
|
||||
final selected = variants.firstWhere(
|
||||
(entry) => entry.skinIndex == skinTone,
|
||||
orElse: () => variants.firstWhere(
|
||||
(entry) => entry.skinIndex == 0,
|
||||
orElse: () => variants.first,
|
||||
),
|
||||
);
|
||||
visible.add(selected);
|
||||
all.add(selected);
|
||||
}
|
||||
categories.add(EmojiCategory(id: category.id, emoji: visible));
|
||||
}
|
||||
|
||||
return EmojiDataset(
|
||||
categories: categories,
|
||||
all: all,
|
||||
nativeToShortcode: dataset.nativeToShortcode,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,20 @@ IconData _categoryIcon(String categoryId) => switch (categoryId) {
|
||||
/// 18px icon it holds.
|
||||
const _railHeight = 36.0;
|
||||
|
||||
const _emojiSkinTonePrefsKey = 'buzz.emoji-picker.skin-tone.v1';
|
||||
|
||||
const _skinTones = [
|
||||
(label: 'Default', color: Color(0xFFFFC93A)),
|
||||
(label: 'Light', color: Color(0xFFFFDAB7)),
|
||||
(label: 'Medium-light', color: Color(0xFFE7B98F)),
|
||||
(label: 'Medium', color: Color(0xFFC88C61)),
|
||||
(label: 'Medium-dark', color: Color(0xFFA46134)),
|
||||
(label: 'Dark', color: Color(0xFF5D4437)),
|
||||
];
|
||||
|
||||
int _validSkinTone(int? value) =>
|
||||
value != null && value >= 0 && value < _skinTones.length ? value : 0;
|
||||
|
||||
/// Category selector: one icon per section of the continuous grid, in scroll
|
||||
/// order. Tapping jumps to that section; scrolling moves the highlight.
|
||||
///
|
||||
@@ -28,11 +42,15 @@ class _CategoryRail extends StatelessWidget {
|
||||
final List<_EmojiSection> sections;
|
||||
final int activeIndex;
|
||||
final ValueChanged<int> onSelect;
|
||||
final int skinTone;
|
||||
final ValueChanged<int> onSkinToneChanged;
|
||||
|
||||
const _CategoryRail({
|
||||
required this.sections,
|
||||
required this.activeIndex,
|
||||
required this.onSelect,
|
||||
required this.skinTone,
|
||||
required this.onSkinToneChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -52,6 +70,12 @@ class _CategoryRail extends StatelessWidget {
|
||||
onTap: () => onSelect(i),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _SkinToneSelector(
|
||||
value: skinTone,
|
||||
onChanged: onSkinToneChanged,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -59,6 +83,99 @@ class _CategoryRail extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SkinToneSelector extends StatelessWidget {
|
||||
const _SkinToneSelector({required this.value, required this.onChanged});
|
||||
|
||||
final int value;
|
||||
final ValueChanged<int> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selected = _skinTones[_validSkinTone(value)];
|
||||
return PopupMenuButton<int>(
|
||||
key: const ValueKey('emoji-skin-tone-selector'),
|
||||
initialValue: value,
|
||||
tooltip: 'Skin tone',
|
||||
position: PopupMenuPosition.under,
|
||||
onSelected: onChanged,
|
||||
itemBuilder: (context) => [
|
||||
for (final (index, tone) in _skinTones.indexed)
|
||||
PopupMenuItem<int>(
|
||||
key: ValueKey('emoji-skin-tone-$index'),
|
||||
value: index,
|
||||
child: Row(
|
||||
children: [
|
||||
_SkinToneDot(
|
||||
key: ValueKey('emoji-skin-tone-dot-$index'),
|
||||
color: tone.color,
|
||||
),
|
||||
const SizedBox(width: Grid.xs),
|
||||
Expanded(child: Text(tone.label)),
|
||||
if (index == value)
|
||||
Icon(
|
||||
LucideIcons.check,
|
||||
size: 18,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
child: Semantics(
|
||||
button: true,
|
||||
label: 'Skin tone',
|
||||
child: Center(
|
||||
child: _SkinToneDot(
|
||||
key: const ValueKey('emoji-skin-tone-dot-selected'),
|
||||
color: selected.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SkinToneDot extends StatelessWidget {
|
||||
const _SkinToneDot({super.key, required this.color});
|
||||
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox.square(
|
||||
dimension: 16,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
ClipOval(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0.2),
|
||||
Colors.transparent,
|
||||
],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.black.withValues(alpha: 0.8)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryIcon extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
|
||||
@@ -63,7 +63,19 @@ List<double> _sectionOffsets(List<_EmojiSection> sections) {
|
||||
}
|
||||
|
||||
/// Which section owns [offset] — the one whose header is pinned right now.
|
||||
int _activeSectionIndex(List<double> offsets, double offset) {
|
||||
/// At the clamped bottom, the final visible section owns the viewport even when
|
||||
/// it is too short for its header to reach the top.
|
||||
int _activeSectionIndex(
|
||||
List<double> offsets,
|
||||
double offset, {
|
||||
required double maxScrollExtent,
|
||||
}) {
|
||||
if (offsets.isEmpty) return 0;
|
||||
if (maxScrollExtent > 0 &&
|
||||
offset >= maxScrollExtent - precisionErrorTolerance) {
|
||||
return offsets.length - 1;
|
||||
}
|
||||
|
||||
var active = 0;
|
||||
for (var i = 0; i < offsets.length; i++) {
|
||||
// Half a header of slack so the highlight flips as a header reaches the
|
||||
@@ -92,7 +104,7 @@ class _EmojiTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
key: ValueKey('$keyPrefix-${entry.tileId}'),
|
||||
key: ValueKey('$keyPrefix-${entry.id}'),
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Semantics(
|
||||
@@ -196,11 +208,13 @@ class _EmojiSearchResults extends StatelessWidget {
|
||||
final List<EmojiEntry> entries;
|
||||
final List<CustomEmoji> customEmoji;
|
||||
final void Function(String emoji) onSelect;
|
||||
final ScrollController controller;
|
||||
|
||||
const _EmojiSearchResults({
|
||||
required this.entries,
|
||||
required this.customEmoji,
|
||||
required this.onSelect,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -214,6 +228,7 @@ class _EmojiSearchResults extends StatelessWidget {
|
||||
|
||||
return CustomScrollView(
|
||||
key: const ValueKey('emoji-picker-search-results'),
|
||||
controller: controller,
|
||||
slivers: [
|
||||
if (customEmoji.isNotEmpty) ...[
|
||||
const _SectionHeader(label: 'Custom'),
|
||||
@@ -279,7 +294,7 @@ class _SectionHeaderDelegate extends SliverPersistentHeaderDelegate {
|
||||
bool overlapsContent,
|
||||
) {
|
||||
return Container(
|
||||
color: context.colors.surfaceContainerHighest,
|
||||
color: context.colors.surface,
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
||||
child: Text(label, style: _sectionLabelStyle(context)),
|
||||
|
||||
191
mobile/lib/features/channels/emoji_picker/ios_native_picker.dart
Normal file
191
mobile/lib/features/channels/emoji_picker/ios_native_picker.dart
Normal file
@@ -0,0 +1,191 @@
|
||||
part of '../emoji_picker.dart';
|
||||
|
||||
const _nativeEmojiPickerChannel = MethodChannel('buzz/native_emoji_picker');
|
||||
|
||||
/// Guards the process-global native method-call handler: one native sheet may
|
||||
/// own it at a time. A reentrant open would replace the handler and hijack the
|
||||
/// live sheet's select/dismiss callbacks, so [_presentIosEmojiPicker] coalesces
|
||||
/// reentry while a presentation is in flight.
|
||||
bool _iosEmojiPickerPresenting = false;
|
||||
|
||||
@visibleForTesting
|
||||
void resetIosEmojiPickerPresentationForTest() {
|
||||
_iosEmojiPickerPresenting = false;
|
||||
}
|
||||
|
||||
Future<void> _presentIosEmojiPicker({
|
||||
required BuildContext context,
|
||||
required void Function(String emoji) onSelect,
|
||||
VoidCallback? onDismiss,
|
||||
}) async {
|
||||
// Only one native sheet owns the handler at a time. Reject a reentrant open
|
||||
// without replacing the live sheet's callbacks, and complete the rejected
|
||||
// caller so its local picker-open lifecycle is not stranded.
|
||||
if (_iosEmojiPickerPresenting) {
|
||||
onDismiss?.call();
|
||||
return;
|
||||
}
|
||||
_iosEmojiPickerPresenting = true;
|
||||
|
||||
final container = ProviderScope.containerOf(context, listen: false);
|
||||
final paletteState = container.read(customEmojiPaletteProvider);
|
||||
final List<CustomEmoji> customEmoji;
|
||||
BuildContext? loadingSheetContext;
|
||||
var leavingLoadingSheet = false;
|
||||
var loadingCancelled = false;
|
||||
Future<void>? loadingSheet;
|
||||
|
||||
if (paletteState case AsyncData(:final value)) {
|
||||
customEmoji = value;
|
||||
} else {
|
||||
// Palette history can take the relay timeout to resolve. Give the tap an
|
||||
// immediate, cancellable surface instead of holding the global guard while
|
||||
// the composer appears unresponsive.
|
||||
final loadingSheetBuilt = Completer<void>();
|
||||
loadingSheet =
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
showCloseButton: false,
|
||||
builder: (sheetContext) {
|
||||
loadingSheetContext = sheetContext;
|
||||
if (!loadingSheetBuilt.isCompleted) loadingSheetBuilt.complete();
|
||||
return const SizedBox(
|
||||
key: Key('ios-emoji-picker-palette-loading'),
|
||||
height: 180,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: Grid.sm),
|
||||
Text('Loading emoji…'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
).whenComplete(() {
|
||||
if (leavingLoadingSheet) return;
|
||||
loadingCancelled = true;
|
||||
_iosEmojiPickerPresenting = false;
|
||||
onDismiss?.call();
|
||||
});
|
||||
|
||||
try {
|
||||
customEmoji = await container.read(customEmojiPaletteProvider.future);
|
||||
} catch (_) {
|
||||
if (loadingCancelled) return;
|
||||
await loadingSheetBuilt.future;
|
||||
if (loadingCancelled) return;
|
||||
leavingLoadingSheet = true;
|
||||
if (loadingSheetContext case final sheetContext?
|
||||
when sheetContext.mounted) {
|
||||
Navigator.of(sheetContext).pop();
|
||||
}
|
||||
await loadingSheet;
|
||||
// A palette fetch failure must not strand the composer's open state: fall
|
||||
// back to the Flutter picker, which watches the palette itself.
|
||||
_iosEmojiPickerPresenting = false;
|
||||
if (context.mounted) {
|
||||
_showFlutterEmojiPicker(
|
||||
context: context,
|
||||
onSelect: onSelect,
|
||||
onDismiss: onDismiss,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (loadingCancelled) return;
|
||||
await loadingSheetBuilt.future;
|
||||
if (loadingCancelled) return;
|
||||
leavingLoadingSheet = true;
|
||||
if (loadingSheetContext case final sheetContext?
|
||||
when sheetContext.mounted) {
|
||||
Navigator.of(sheetContext).pop();
|
||||
}
|
||||
await loadingSheet;
|
||||
}
|
||||
if (!context.mounted) {
|
||||
_iosEmojiPickerPresenting = false;
|
||||
return;
|
||||
}
|
||||
final recent = container.read(recentEmojiProvider);
|
||||
final mediaAuth = container.read(mediaGetAuthServiceProvider);
|
||||
final prefs = container.read(savedPrefsProvider);
|
||||
final colors = context.colors;
|
||||
var dismissed = false;
|
||||
|
||||
void finish() {
|
||||
if (dismissed) return;
|
||||
dismissed = true;
|
||||
_iosEmojiPickerPresenting = false;
|
||||
_nativeEmojiPickerChannel.setMethodCallHandler(null);
|
||||
onDismiss?.call();
|
||||
}
|
||||
|
||||
_nativeEmojiPickerChannel.setMethodCallHandler((call) async {
|
||||
switch (call.method) {
|
||||
case 'mediaHeaders':
|
||||
final url = call.arguments;
|
||||
return url is String
|
||||
? mediaAuth.headersFor(url)
|
||||
: const <String, String>{};
|
||||
case 'selected':
|
||||
final emoji = call.arguments;
|
||||
if (emoji is String && emoji.isNotEmpty) onSelect(emoji);
|
||||
return null;
|
||||
case 'dismissed':
|
||||
finish();
|
||||
return null;
|
||||
case 'skinToneChanged':
|
||||
final value = call.arguments;
|
||||
if (value is int) {
|
||||
await prefs.setInt(_emojiSkinTonePrefsKey, _validSkinTone(value));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
final presented = await _nativeEmojiPickerChannel.invokeMethod<bool>(
|
||||
'present',
|
||||
<String, Object>{
|
||||
'customEmoji': [
|
||||
for (final emoji in customEmoji)
|
||||
<String, String>{'shortcode': emoji.shortcode, 'url': emoji.url},
|
||||
],
|
||||
'recent': [for (final entry in recent) entry.emoji],
|
||||
'skinTone': _validSkinTone(prefs.getInt(_emojiSkinTonePrefsKey)),
|
||||
'surfaceColor': colors.surface.toARGB32(),
|
||||
'controlColor': colors.surfaceContainerHighest.toARGB32(),
|
||||
'textColor': colors.onSurface.toARGB32(),
|
||||
'secondaryTextColor': colors.onSurfaceVariant.toARGB32(),
|
||||
'accentColor': colors.primary.toARGB32(),
|
||||
'dividerColor': colors.outlineVariant.toARGB32(),
|
||||
'isDark': Theme.of(context).brightness == Brightness.dark,
|
||||
},
|
||||
);
|
||||
if (presented == true) return;
|
||||
} on MissingPluginException {
|
||||
// Older builds keep the complete Flutter picker as a safe fallback.
|
||||
} on PlatformException {
|
||||
// A native presentation failure should not remove the emoji affordance.
|
||||
}
|
||||
|
||||
if (dismissed || !context.mounted) {
|
||||
// `dismissed` means finish() already released the guard; the unmounted
|
||||
// path releases it here so a future open is not blocked.
|
||||
_iosEmojiPickerPresenting = false;
|
||||
return;
|
||||
}
|
||||
dismissed = true;
|
||||
_iosEmojiPickerPresenting = false;
|
||||
_nativeEmojiPickerChannel.setMethodCallHandler(null);
|
||||
_showFlutterEmojiPicker(
|
||||
context: context,
|
||||
onSelect: onSelect,
|
||||
onDismiss: onDismiss,
|
||||
);
|
||||
}
|
||||
@@ -8,68 +8,80 @@ part of '../emoji_picker.dart';
|
||||
/// words and the OS mangles them. Flutter exposes the same switches directly.
|
||||
class _EmojiSearchField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
const _EmojiSearchField({required this.controller});
|
||||
const _EmojiSearchField({
|
||||
required this.controller,
|
||||
this.padding = const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
Grid.xxs,
|
||||
),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = context.colors;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(Grid.gutter, 0, Grid.gutter, Grid.xxs),
|
||||
child: TextField(
|
||||
key: const ValueKey('emoji-picker-search'),
|
||||
controller: controller,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
textCapitalization: TextCapitalization.none,
|
||||
textInputAction: TextInputAction.search,
|
||||
style: searchInputTextStyle.copyWith(color: colors.onSurface),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search emoji',
|
||||
hintStyle: searchInputTextStyle.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
LucideIcons.search,
|
||||
size: 18,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
prefixIconConstraints: const BoxConstraints(
|
||||
minWidth: Grid.md,
|
||||
minHeight: Grid.md,
|
||||
),
|
||||
suffixIcon: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: controller,
|
||||
builder: (context, value, _) {
|
||||
if (value.text.isEmpty) return const SizedBox.shrink();
|
||||
return IconButton(
|
||||
key: const ValueKey('emoji-picker-search-clear'),
|
||||
onPressed: controller.clear,
|
||||
icon: Icon(
|
||||
LucideIcons.x,
|
||||
size: 16,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: 'Clear search',
|
||||
);
|
||||
},
|
||||
),
|
||||
filled: true,
|
||||
fillColor: colors.surface,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.lg),
|
||||
borderSide: BorderSide(color: colors.outlineVariant),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.lg),
|
||||
borderSide: BorderSide(color: colors.outlineVariant),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.lg),
|
||||
borderSide: BorderSide(color: colors.primary),
|
||||
padding: padding,
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
child: TextField(
|
||||
key: const ValueKey('emoji-picker-search'),
|
||||
controller: controller,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
textCapitalization: TextCapitalization.none,
|
||||
textInputAction: TextInputAction.search,
|
||||
style: searchInputTextStyle.copyWith(color: colors.onSurface),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search emoji',
|
||||
hintStyle: searchInputTextStyle.copyWith(
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
LucideIcons.search,
|
||||
size: 18,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
prefixIconConstraints: const BoxConstraints(
|
||||
minWidth: Grid.md,
|
||||
minHeight: Grid.md,
|
||||
),
|
||||
suffixIcon: ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: controller,
|
||||
builder: (context, value, _) {
|
||||
if (value.text.isEmpty) return const SizedBox.shrink();
|
||||
return IconButton(
|
||||
key: const ValueKey('emoji-picker-search-clear'),
|
||||
onPressed: controller.clear,
|
||||
icon: Icon(
|
||||
LucideIcons.x,
|
||||
size: 16,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: 'Clear search',
|
||||
);
|
||||
},
|
||||
),
|
||||
filled: true,
|
||||
fillColor: colors.surface,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: Grid.xxs),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.full),
|
||||
borderSide: BorderSide(color: colors.outlineVariant),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.full),
|
||||
borderSide: BorderSide(color: colors.outlineVariant),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(Radii.full),
|
||||
borderSide: BorderSide(color: colors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:buzz/features/channels/emoji_picker.dart';
|
||||
import 'package:buzz/features/channels/recent_emoji_provider.dart';
|
||||
import 'package:buzz/shared/custom_emoji/custom_emoji.dart';
|
||||
@@ -6,9 +8,13 @@ import 'package:buzz/shared/emoji/emoji_data.dart';
|
||||
import 'package:buzz/shared/emoji/emoji_data_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:nostr/nostr.dart' as nostr;
|
||||
|
||||
import '../../helpers/widget_helpers.dart';
|
||||
|
||||
@@ -41,11 +47,39 @@ final _dataset = () {
|
||||
),
|
||||
_entry(
|
||||
'point_up',
|
||||
native: '\u{261D}\u{1F3FD}',
|
||||
native: '\u{261D}\u{1F3FB}',
|
||||
categoryId: 'people',
|
||||
name: 'Index Pointing Up',
|
||||
skinIndex: 1,
|
||||
),
|
||||
_entry(
|
||||
'point_up',
|
||||
native: '\u{261D}\u{1F3FC}',
|
||||
categoryId: 'people',
|
||||
name: 'Index Pointing Up',
|
||||
skinIndex: 2,
|
||||
),
|
||||
_entry(
|
||||
'point_up',
|
||||
native: '\u{261D}\u{1F3FD}',
|
||||
categoryId: 'people',
|
||||
name: 'Index Pointing Up',
|
||||
skinIndex: 3,
|
||||
),
|
||||
_entry(
|
||||
'point_up',
|
||||
native: '\u{261D}\u{1F3FE}',
|
||||
categoryId: 'people',
|
||||
name: 'Index Pointing Up',
|
||||
skinIndex: 4,
|
||||
),
|
||||
_entry(
|
||||
'point_up',
|
||||
native: '\u{261D}\u{1F3FF}',
|
||||
categoryId: 'people',
|
||||
name: 'Index Pointing Up',
|
||||
skinIndex: 5,
|
||||
),
|
||||
];
|
||||
final nature = [
|
||||
_entry(
|
||||
@@ -95,6 +129,49 @@ final _tallDataset = () {
|
||||
const _customEmoji = [
|
||||
CustomEmoji(shortcode: 'partyparrot', url: 'https://example.test/parrot.gif'),
|
||||
];
|
||||
const _relayCustomEmoji = [
|
||||
CustomEmoji(
|
||||
shortcode: 'buzzbee',
|
||||
url: 'https://relay.example/media/buzzbee.png',
|
||||
),
|
||||
];
|
||||
|
||||
class _FakeCustomEmojiPaletteNotifier extends CustomEmojiPaletteNotifier {
|
||||
_FakeCustomEmojiPaletteNotifier(this.palette);
|
||||
|
||||
final Future<List<CustomEmoji>> palette;
|
||||
|
||||
@override
|
||||
Future<List<CustomEmoji>> build() => palette;
|
||||
}
|
||||
|
||||
const _nativeEmojiPickerChannel = MethodChannel('buzz/native_emoji_picker');
|
||||
|
||||
void _setMockNativeEmojiPickerHandler(
|
||||
Future<Object?> Function(MethodCall call)? handler,
|
||||
) {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(_nativeEmojiPickerChannel, handler);
|
||||
}
|
||||
|
||||
Future<Object?> _sendNativeEmojiPickerCall(
|
||||
WidgetTester tester,
|
||||
String method, [
|
||||
Object? arguments,
|
||||
]) async {
|
||||
final response = Completer<ByteData?>();
|
||||
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
|
||||
_nativeEmojiPickerChannel.name,
|
||||
_nativeEmojiPickerChannel.codec.encodeMethodCall(
|
||||
MethodCall(method, arguments),
|
||||
),
|
||||
response.complete,
|
||||
);
|
||||
final envelope = await response.future;
|
||||
return envelope == null
|
||||
? null
|
||||
: _nativeEmojiPickerChannel.codec.decodeEnvelope(envelope);
|
||||
}
|
||||
|
||||
Future<SharedPreferences> _prefs() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
@@ -147,26 +224,113 @@ void main() {
|
||||
// shortcut into it, not a page switcher.
|
||||
final grid = find.byKey(const ValueKey('emoji-picker-grid'));
|
||||
expect(grid, findsOneWidget);
|
||||
final sectionKeys = tester
|
||||
.widget<CustomScrollView>(grid)
|
||||
.slivers
|
||||
.whereType<SliverMainAxisGroup>()
|
||||
.map((sliver) => sliver.key);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: grid,
|
||||
matching: find.byKey(const ValueKey('emoji-tile-grinning')),
|
||||
sectionKeys,
|
||||
containsAllInOrder(const [
|
||||
ValueKey('emoji-section-people'),
|
||||
ValueKey('emoji-section-nature'),
|
||||
ValueKey('emoji-section-custom'),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'search shares the sheet header with the shared close control',
|
||||
(tester) async {
|
||||
await _pumpPicker(tester, prefs: await _prefs());
|
||||
|
||||
final search = tester.getRect(
|
||||
find.byKey(const ValueKey('emoji-picker-search')),
|
||||
);
|
||||
final close = tester.getRect(find.byTooltip('Close sheet'));
|
||||
|
||||
expect(close.size, const Size.square(44));
|
||||
expect(search.center.dy, close.center.dy);
|
||||
expect(close.left - search.right, Grid.xxs);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('the Flutter picker keeps the established tray height', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpPicker(tester, prefs: await _prefs());
|
||||
|
||||
final picker = find.byType(EmojiPickerSheet);
|
||||
final context = tester.element(picker);
|
||||
expect(
|
||||
tester.getSize(picker).height,
|
||||
closeTo(MediaQuery.sizeOf(context).height * 0.62, 0.5),
|
||||
);
|
||||
expect(find.byType(DraggableScrollableSheet), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the Flutter search field is a full pill', (tester) async {
|
||||
await _pumpPicker(tester, prefs: await _prefs());
|
||||
|
||||
final field = tester.widget<TextField>(
|
||||
find.byKey(const ValueKey('emoji-picker-search')),
|
||||
);
|
||||
final border = field.decoration!.border! as OutlineInputBorder;
|
||||
expect(border.borderRadius, BorderRadius.circular(Radii.full));
|
||||
});
|
||||
|
||||
testWidgets('uses the shared sheet surface instead of a picker override', (
|
||||
tester,
|
||||
) async {
|
||||
final prefs = await _prefs();
|
||||
final theme = AppTheme.light().copyWith(
|
||||
colorScheme: lightColorScheme.copyWith(
|
||||
surfaceContainerHighest: Colors.grey,
|
||||
),
|
||||
findsOneWidget,
|
||||
bottomSheetTheme: const BottomSheetThemeData(
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
myPubkeyProvider.overrideWithValue('self'),
|
||||
emojiDatasetOrEmptyProvider.overrideWithValue(_dataset),
|
||||
customEmojiListProvider.overrideWithValue(_customEmoji),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: theme,
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => FilledButton(
|
||||
onPressed: () =>
|
||||
showEmojiPicker(context: context, onSelect: (_) {}),
|
||||
child: const Text('Open picker'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open picker'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final ancestorMaterials = find
|
||||
.ancestor(
|
||||
of: find.byType(EmojiPickerSheet),
|
||||
matching: find.byType(Material),
|
||||
)
|
||||
.evaluate()
|
||||
.map((element) => element.widget as Material);
|
||||
expect(
|
||||
ancestorMaterials.map((material) => material.color),
|
||||
contains(Colors.green),
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: grid,
|
||||
matching: find.byKey(const ValueKey('emoji-tile-fire')),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: grid,
|
||||
matching: find.byKey(const ValueKey('emoji-tile-custom-partyparrot')),
|
||||
),
|
||||
findsOneWidget,
|
||||
ancestorMaterials.map((material) => material.color),
|
||||
isNot(contains(Colors.grey)),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -174,21 +338,27 @@ void main() {
|
||||
await _pumpPicker(tester, prefs: await _prefs());
|
||||
|
||||
// The rail used to be a short left-aligned strip. Every section now gets
|
||||
// one evenly-sized slot across the same width the search field spans.
|
||||
final searchField = tester.getRect(
|
||||
find.byKey(const ValueKey('emoji-picker-search')),
|
||||
);
|
||||
// one evenly-sized slot across the tray, while search shares its row with
|
||||
// the close control above.
|
||||
final picker = tester.getRect(find.byType(EmojiPickerSheet));
|
||||
final people = tester.getRect(find.byTooltip('Smileys & People'));
|
||||
final nature = tester.getRect(find.byTooltip('Animals & Nature'));
|
||||
final custom = tester.getRect(find.byTooltip('Custom'));
|
||||
final skinTone = tester.getRect(find.byTooltip('Skin tone'));
|
||||
|
||||
expect(nature.left, greaterThan(people.left));
|
||||
expect(custom.left, greaterThan(nature.left));
|
||||
expect(people.width, closeTo(nature.width, 0.5));
|
||||
expect(people.width, closeTo(custom.width, 0.5));
|
||||
// First slot starts and last slot ends on the search field's edges.
|
||||
expect(people.left, closeTo(searchField.left, 0.5));
|
||||
expect(custom.right, closeTo(searchField.right, 0.5));
|
||||
expect(people.width, closeTo(skinTone.width, 0.5));
|
||||
expect(people.left, closeTo(picker.left + Grid.gutter, 0.5));
|
||||
expect(skinTone.right, closeTo(picker.right - Grid.gutter, 0.5));
|
||||
expect(
|
||||
tester.getSize(
|
||||
find.byKey(const ValueKey('emoji-skin-tone-dot-selected')),
|
||||
),
|
||||
const Size.square(16),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('tapping the rail scrolls the grid instead of replacing it', (
|
||||
@@ -211,6 +381,37 @@ void main() {
|
||||
expect(offset(), closeTo(28 + 25 * 40, 0.5));
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'a bottom-clamped final section stays highlighted after a rail tap',
|
||||
(tester) async {
|
||||
await _pumpPicker(tester, prefs: await _prefs(), dataset: _tallDataset);
|
||||
final colors = Theme.of(
|
||||
tester.element(find.byType(EmojiPickerSheet)),
|
||||
).colorScheme;
|
||||
Color iconColor(String tooltip) => tester
|
||||
.widget<Icon>(
|
||||
find.descendant(
|
||||
of: find.byTooltip(tooltip),
|
||||
matching: find.byType(Icon),
|
||||
),
|
||||
)
|
||||
.color!;
|
||||
|
||||
await tester.tap(find.byTooltip('Custom'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final grid = tester.widget<CustomScrollView>(
|
||||
find.byKey(const ValueKey('emoji-picker-grid')),
|
||||
);
|
||||
expect(
|
||||
grid.controller!.offset,
|
||||
closeTo(grid.controller!.position.maxScrollExtent, 0.5),
|
||||
);
|
||||
expect(iconColor('Custom'), colors.primary);
|
||||
expect(iconColor('Animals & Nature'), colors.onSurfaceVariant);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('the custom section only exists when the palette has emoji', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -223,6 +424,8 @@ void main() {
|
||||
|
||||
await _pumpPicker(tester, prefs: await _prefs());
|
||||
expect(find.byTooltip('Custom'), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Custom'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(const ValueKey('emoji-tile-custom-partyparrot')),
|
||||
findsOneWidget,
|
||||
@@ -236,14 +439,16 @@ void main() {
|
||||
|
||||
// A community's own emoji used to get their own looser 6-per-row grid,
|
||||
// which read as a different component bolted onto the sheet.
|
||||
final native = tester.getRect(
|
||||
find.byKey(const ValueKey('emoji-tile-fire')),
|
||||
);
|
||||
final nativeWidth = tester
|
||||
.getRect(find.byKey(const ValueKey('emoji-tile-grinning')))
|
||||
.width;
|
||||
await tester.tap(find.byTooltip('Custom'));
|
||||
await tester.pumpAndSettle();
|
||||
final custom = tester.getRect(
|
||||
find.byKey(const ValueKey('emoji-tile-custom-partyparrot')),
|
||||
);
|
||||
expect(custom.width, closeTo(native.width, 0.5));
|
||||
expect(custom.height, closeTo(native.height, 0.5));
|
||||
expect(custom.width, closeTo(nativeWidth, 0.5));
|
||||
expect(custom.height, closeTo(40, 0.5));
|
||||
});
|
||||
|
||||
testWidgets('typing filters across the standard and custom sets', (
|
||||
@@ -323,27 +528,116 @@ void main() {
|
||||
expect(find.byTooltip('Smileys & People'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'changing skin tone keeps the scrolled-to category highlighted',
|
||||
(tester) async {
|
||||
// A skin-tone change rebuilds the sections and the active-section
|
||||
// notifier. Regression: the notifier was recreated at index 0, so a
|
||||
// user parked on a later category snapped back to the first one in the
|
||||
// rail while the grid stayed put. The notifier now seeds from the live
|
||||
// scroll offset, so the highlight survives the rebuild.
|
||||
await _pumpPicker(tester, prefs: await _prefs(), dataset: _tallDataset);
|
||||
final colors = Theme.of(
|
||||
tester.element(find.byType(EmojiPickerSheet)),
|
||||
).colorScheme;
|
||||
Color iconColor(String tooltip) => tester
|
||||
.widget<Icon>(
|
||||
find.descendant(
|
||||
of: find.byTooltip(tooltip),
|
||||
matching: find.byType(Icon),
|
||||
),
|
||||
)
|
||||
.color!;
|
||||
|
||||
await tester.tap(find.byTooltip('Animals & Nature'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(iconColor('Animals & Nature'), colors.primary);
|
||||
expect(iconColor('Smileys & People'), colors.onSurfaceVariant);
|
||||
|
||||
await tester.tap(find.byTooltip('Skin tone'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const ValueKey('emoji-skin-tone-3')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Still on Nature after the tone rebuild — not reset to People.
|
||||
expect(iconColor('Animals & Nature'), colors.primary);
|
||||
expect(iconColor('Smileys & People'), colors.onSurfaceVariant);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('a standard emoji emits its glyph', (tester) async {
|
||||
final selected = await _pumpPicker(tester, prefs: await _prefs());
|
||||
|
||||
await tester.tap(find.byTooltip('Animals & Nature'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const ValueKey('emoji-tile-fire')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(selected, ['\u{1F525}']);
|
||||
});
|
||||
|
||||
testWidgets('a skin-tone variant is selectable', (tester) async {
|
||||
testWidgets('skin tone choice shows and emits one selected variant', (
|
||||
tester,
|
||||
) async {
|
||||
final selected = await _pumpPicker(tester, prefs: await _prefs());
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('emoji-tile-point_up-1')));
|
||||
expect(find.byKey(const ValueKey('emoji-tile-point_up')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('emoji-tile-point_up-1')), findsNothing);
|
||||
|
||||
await tester.tap(find.byTooltip('Skin tone'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const ValueKey('emoji-skin-tone-3')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const ValueKey('emoji-tile-point_up')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(selected, ['\u{261D}\u{1F3FD}']);
|
||||
});
|
||||
|
||||
testWidgets('skin tone choices paint their colors on a solid layer', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpPicker(tester, prefs: await _prefs());
|
||||
|
||||
await tester.tap(find.byTooltip('Skin tone'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
const expectedColors = [
|
||||
Color(0xFFFFC93A),
|
||||
Color(0xFFFFDAB7),
|
||||
Color(0xFFE7B98F),
|
||||
Color(0xFFC88C61),
|
||||
Color(0xFFA46134),
|
||||
Color(0xFF5D4437),
|
||||
];
|
||||
for (final (index, expectedColor) in expectedColors.indexed) {
|
||||
final decorations = tester
|
||||
.widgetList<DecoratedBox>(
|
||||
find.descendant(
|
||||
of: find.byKey(ValueKey('emoji-skin-tone-dot-$index')),
|
||||
matching: find.byType(DecoratedBox),
|
||||
),
|
||||
)
|
||||
.map((widget) => widget.decoration)
|
||||
.whereType<BoxDecoration>();
|
||||
|
||||
expect(
|
||||
decorations.any(
|
||||
(decoration) =>
|
||||
decoration.color == expectedColor &&
|
||||
decoration.gradient == null &&
|
||||
decoration.backgroundBlendMode == null,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('a custom emoji emits :shortcode:', (tester) async {
|
||||
final selected = await _pumpPicker(tester, prefs: await _prefs());
|
||||
|
||||
await tester.tap(find.byTooltip('Custom'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.byKey(const ValueKey('emoji-tile-custom-partyparrot')),
|
||||
);
|
||||
@@ -358,6 +652,8 @@ void main() {
|
||||
final prefs = await _prefs();
|
||||
final selected = await _pumpPicker(tester, prefs: prefs);
|
||||
|
||||
await tester.tap(find.byTooltip('Animals & Nature'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const ValueKey('emoji-tile-fire')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
@@ -392,6 +688,422 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
group('iOS native emoji picker', () {
|
||||
setUp(resetIosEmojiPickerPresentationForTest);
|
||||
|
||||
testWidgets('passes shared theme and custom emoji into the native sheet', (
|
||||
tester,
|
||||
) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
final prefs = await _prefs();
|
||||
final mediaAuth = MediaGetAuthService(
|
||||
baseUrl: 'https://relay.example',
|
||||
nsec: nostr.Keys.generate().nsec,
|
||||
);
|
||||
MethodCall? presentation;
|
||||
final selected = <String>[];
|
||||
var dismissals = 0;
|
||||
_setMockNativeEmojiPickerHandler((call) async {
|
||||
presentation = call;
|
||||
return true;
|
||||
});
|
||||
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
myPubkeyProvider.overrideWithValue('self'),
|
||||
customEmojiPaletteProvider.overrideWith(
|
||||
() => _FakeCustomEmojiPaletteNotifier(
|
||||
Future.value([..._customEmoji, ..._relayCustomEmoji]),
|
||||
),
|
||||
),
|
||||
mediaGetAuthServiceProvider.overrideWithValue(mediaAuth),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => FilledButton(
|
||||
onPressed: () => showEmojiPicker(
|
||||
context: context,
|
||||
onSelect: selected.add,
|
||||
onDismiss: () => dismissals += 1,
|
||||
),
|
||||
child: const Text('Open picker'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open picker'));
|
||||
await tester.pump();
|
||||
|
||||
expect(presentation?.method, 'present');
|
||||
final arguments = presentation?.arguments as Map<Object?, Object?>;
|
||||
expect(arguments['surfaceColor'], lightColorScheme.surface.toARGB32());
|
||||
expect(arguments['skinTone'], 0);
|
||||
expect(arguments['customEmoji'], [
|
||||
{
|
||||
'shortcode': 'partyparrot',
|
||||
'url': 'https://example.test/parrot.gif',
|
||||
},
|
||||
{
|
||||
'shortcode': 'buzzbee',
|
||||
'url': 'https://relay.example/media/buzzbee.png',
|
||||
},
|
||||
]);
|
||||
expect(find.byType(EmojiPickerSheet), findsNothing);
|
||||
|
||||
final externalHeaders = await _sendNativeEmojiPickerCall(
|
||||
tester,
|
||||
'mediaHeaders',
|
||||
'https://example.test/parrot.gif',
|
||||
);
|
||||
expect(externalHeaders, <String, String>{});
|
||||
final relayHeaders = await _sendNativeEmojiPickerCall(
|
||||
tester,
|
||||
'mediaHeaders',
|
||||
'https://relay.example/media/buzzbee.png',
|
||||
);
|
||||
expect(
|
||||
(relayHeaders as Map<Object?, Object?>)['Authorization'],
|
||||
startsWith('Nostr '),
|
||||
);
|
||||
|
||||
await _sendNativeEmojiPickerCall(tester, 'skinToneChanged', 4);
|
||||
await _sendNativeEmojiPickerCall(tester, 'selected', '\u{1F525}');
|
||||
await _sendNativeEmojiPickerCall(tester, 'dismissed');
|
||||
expect(selected, ['\u{1F525}']);
|
||||
expect(dismissals, 1);
|
||||
expect(prefs.getInt('buzz.emoji-picker.skin-tone.v1'), 4);
|
||||
} finally {
|
||||
_setMockNativeEmojiPickerHandler(null);
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('shows cancellable feedback while loading the custom palette', (
|
||||
tester,
|
||||
) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
final prefs = await _prefs();
|
||||
final palette = Completer<List<CustomEmoji>>();
|
||||
MethodCall? presentation;
|
||||
_setMockNativeEmojiPickerHandler((call) async {
|
||||
presentation = call;
|
||||
return true;
|
||||
});
|
||||
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
customEmojiPaletteProvider.overrideWith(
|
||||
() => _FakeCustomEmojiPaletteNotifier(palette.future),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => FilledButton(
|
||||
onPressed: () =>
|
||||
showEmojiPicker(context: context, onSelect: (_) {}),
|
||||
child: const Text('Open picker'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open picker'));
|
||||
await tester.pump();
|
||||
expect(presentation, isNull);
|
||||
expect(
|
||||
find.byKey(const Key('ios-emoji-picker-palette-loading')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Loading emoji…'), findsOneWidget);
|
||||
|
||||
palette.complete(_customEmoji);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(const Key('ios-emoji-picker-palette-loading')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(presentation?.method, 'present');
|
||||
} finally {
|
||||
_setMockNativeEmojiPickerHandler(null);
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('cancels palette loading without presenting native picker', (
|
||||
tester,
|
||||
) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
final prefs = await _prefs();
|
||||
final palette = Completer<List<CustomEmoji>>();
|
||||
var presents = 0;
|
||||
var dismissals = 0;
|
||||
_setMockNativeEmojiPickerHandler((call) async {
|
||||
if (call.method == 'present') presents += 1;
|
||||
return true;
|
||||
});
|
||||
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
customEmojiPaletteProvider.overrideWith(
|
||||
() => _FakeCustomEmojiPaletteNotifier(palette.future),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => FilledButton(
|
||||
onPressed: () => showEmojiPicker(
|
||||
context: context,
|
||||
onSelect: (_) {},
|
||||
onDismiss: () => dismissals += 1,
|
||||
),
|
||||
child: const Text('Open picker'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open picker'));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
expect(
|
||||
find.byKey(const Key('ios-emoji-picker-palette-loading')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tapAt(const Offset(20, 20));
|
||||
await tester.pumpAndSettle();
|
||||
expect(dismissals, 1);
|
||||
expect(presents, 0);
|
||||
|
||||
palette.complete(_customEmoji);
|
||||
await tester.pumpAndSettle();
|
||||
expect(presents, 0);
|
||||
expect(dismissals, 1);
|
||||
} finally {
|
||||
_setMockNativeEmojiPickerHandler(null);
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('falls back to the Flutter picker when native cannot present', (
|
||||
tester,
|
||||
) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
final prefs = await _prefs();
|
||||
_setMockNativeEmojiPickerHandler((_) async => false);
|
||||
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
myPubkeyProvider.overrideWithValue('self'),
|
||||
emojiDatasetOrEmptyProvider.overrideWithValue(_dataset),
|
||||
customEmojiPaletteProvider.overrideWith(
|
||||
() =>
|
||||
_FakeCustomEmojiPaletteNotifier(Future.value(_customEmoji)),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => FilledButton(
|
||||
onPressed: () =>
|
||||
showEmojiPicker(context: context, onSelect: (_) {}),
|
||||
child: const Text('Open picker'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open picker'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(EmojiPickerSheet), findsOneWidget);
|
||||
} finally {
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
_setMockNativeEmojiPickerHandler(null);
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'a palette load failure falls back to the Flutter picker exactly once',
|
||||
(tester) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
final prefs = await _prefs();
|
||||
var presents = 0;
|
||||
_setMockNativeEmojiPickerHandler((_) async {
|
||||
presents += 1;
|
||||
return true;
|
||||
});
|
||||
var dismissals = 0;
|
||||
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
myPubkeyProvider.overrideWithValue('self'),
|
||||
emojiDatasetOrEmptyProvider.overrideWithValue(_dataset),
|
||||
customEmojiPaletteProvider.overrideWith(
|
||||
() => _FakeCustomEmojiPaletteNotifier(
|
||||
Future.error(StateError('palette unavailable')),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => FilledButton(
|
||||
onPressed: () => showEmojiPicker(
|
||||
context: context,
|
||||
onSelect: (_) {},
|
||||
onDismiss: () => dismissals += 1,
|
||||
),
|
||||
child: const Text('Open picker'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open picker'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// The native sheet is never presented on a palette error; the
|
||||
// Flutter picker takes over so the composer's open state is not
|
||||
// stranded.
|
||||
expect(presents, 0);
|
||||
expect(find.byType(EmojiPickerSheet), findsOneWidget);
|
||||
|
||||
// Dismissing the fallback runs onDismiss exactly once.
|
||||
await tester.tapAt(const Offset(20, 20));
|
||||
await tester.pumpAndSettle();
|
||||
expect(dismissals, 1);
|
||||
} finally {
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
_setMockNativeEmojiPickerHandler(null);
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('a reentrant open cannot steal the live sheet callbacks', (
|
||||
tester,
|
||||
) async {
|
||||
final previousPlatform = debugDefaultTargetPlatformOverride;
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
final prefs = await _prefs();
|
||||
var presents = 0;
|
||||
_setMockNativeEmojiPickerHandler((call) async {
|
||||
if (call.method == 'present') presents += 1;
|
||||
return true;
|
||||
});
|
||||
|
||||
final firstSelected = <String>[];
|
||||
final secondSelected = <String>[];
|
||||
var firstDismissals = 0;
|
||||
var secondDismissals = 0;
|
||||
|
||||
try {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
myPubkeyProvider.overrideWithValue('self'),
|
||||
customEmojiPaletteProvider.overrideWith(
|
||||
() =>
|
||||
_FakeCustomEmojiPaletteNotifier(Future.value(_customEmoji)),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) => Column(
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: () => showEmojiPicker(
|
||||
context: context,
|
||||
onSelect: firstSelected.add,
|
||||
onDismiss: () => firstDismissals += 1,
|
||||
),
|
||||
child: const Text('Open first'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => showEmojiPicker(
|
||||
context: context,
|
||||
onSelect: secondSelected.add,
|
||||
onDismiss: () => secondDismissals += 1,
|
||||
),
|
||||
child: const Text('Open second'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open first'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(presents, 1);
|
||||
|
||||
// A second open while the first sheet is live is rejected: it neither
|
||||
// presents again nor replaces the live sheet's method-call handler,
|
||||
// and its independent lifecycle is completed immediately.
|
||||
await tester.tap(find.text('Open second'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(presents, 1);
|
||||
expect(secondDismissals, 1);
|
||||
|
||||
// Native events still reach the original owner, and only it.
|
||||
await _sendNativeEmojiPickerCall(tester, 'selected', '\u{1F525}');
|
||||
await _sendNativeEmojiPickerCall(tester, 'dismissed');
|
||||
expect(firstSelected, ['\u{1F525}']);
|
||||
expect(secondSelected, isEmpty);
|
||||
expect(firstDismissals, 1);
|
||||
expect(secondDismissals, 1);
|
||||
} finally {
|
||||
_setMockNativeEmojiPickerHandler(null);
|
||||
debugDefaultTargetPlatformOverride = previousPlatform;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('recent emoji ranking', () {
|
||||
test('promotes by use count, breaking ties on recency', () {
|
||||
var entries = <RecentEmojiEntry>[];
|
||||
|
||||
Reference in New Issue
Block a user