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:
klopez4212
2026-08-18 23:32:19 +01:00
committed by GitHub
parent 121e4b3ce7
commit 359fe64675
12 changed files with 2782 additions and 136 deletions

View File

@@ -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 */,
);

View File

@@ -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"

View 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
}
}

View 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
}
}

View 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
}
}

View File

@@ -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()
}
}
}

View File

@@ -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,
);
}

View File

@@ -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;

View File

@@ -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)),

View 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,
);
}

View File

@@ -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),
),
),
),
),

View File

@@ -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>[];