diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index f127990b390..f6f66ee2dc2 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToLatestGlassButton.swift; sourceTree = ""; }; 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickyDateGlassHeader.swift; sourceTree = ""; }; - 4A71C00E2F40800100A17E01 /* NativeMessageActionSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageActionSurface.swift; sourceTree = ""; }; + 4A71C00E2F40700100A17E01 /* NativeEmojiPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiPicker.swift; sourceTree = ""; }; + 4A71C0102F40800100A17E01 /* NativeEmojiPickerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiPickerModel.swift; sourceTree = ""; }; + 4A71C0122F40900100A17E01 /* NativeEmojiPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeEmojiPickerView.swift; sourceTree = ""; }; + 4A71C0142F40A00100A17E01 /* NativeMessageActionSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeMessageActionSurface.swift; sourceTree = ""; }; 331C809C294A618700263BE5 /* UIKitEncoded.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = UIKitEncoded.png; sourceTree = ""; }; 331C809E294A618700263BE5 /* UIKitEncoded.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = UIKitEncoded.jpg; sourceTree = ""; }; 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 */, ); diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index e8f3d9d1e16..e4ee6dbd916 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -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" diff --git a/mobile/ios/Runner/NativeEmojiPicker.swift b/mobile/ios/Runner/NativeEmojiPicker.swift new file mode 100644 index 00000000000..3109923daba --- /dev/null +++ b/mobile/ios/Runner/NativeEmojiPicker.swift @@ -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 + } +} diff --git a/mobile/ios/Runner/NativeEmojiPickerModel.swift b/mobile/ios/Runner/NativeEmojiPickerModel.swift new file mode 100644 index 00000000000..75bf46446cd --- /dev/null +++ b/mobile/ios/Runner/NativeEmojiPickerModel.swift @@ -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 = [] + 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 + } +} diff --git a/mobile/ios/Runner/NativeEmojiPickerView.swift b/mobile/ios/Runner/NativeEmojiPickerView.swift new file mode 100644 index 00000000000..a71de4b815a --- /dev/null +++ b/mobile/ios/Runner/NativeEmojiPickerView.swift @@ -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 + } + + private let maximumConcurrentDownloads: Int + private let downloader: Downloader + private let admissionAttemptForTesting: (() -> Void)? + private let cache = NSCache() + 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) 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 + } +} diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index e26aa11525c..e4d56c4ed5a 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -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.. 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 + } + + private var active = 0 + private var peakActive = 0 + private var started = 0 + private var releaseContinuations: [CheckedContinuation] = [] + 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() + } + } +} diff --git a/mobile/lib/features/channels/emoji_picker.dart b/mobile/lib/features/channels/emoji_picker.dart index e8e62199d70..b66d526e7ee 100644 --- a/mobile/lib/features/channels/emoji_picker.dart +++ b/mobile/lib/features/channels/emoji_picker.dart @@ -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( 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 [], - [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( - 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( + 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, required List 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 _resolveRecentTiles({ required List recent, required EmojiDataset dataset, + required EmojiDataset sourceDataset, required List 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 = {}; final tiles = []; for (final item in recent) { @@ -272,7 +364,9 @@ List _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 _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 = []; + final all = []; + + for (final category in dataset.categories) { + final variantsById = >{}; + for (final entry in category.emoji) { + variantsById.putIfAbsent(entry.id, () => []).add(entry); + } + final visible = []; + 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, + ); +} diff --git a/mobile/lib/features/channels/emoji_picker/category_rail.dart b/mobile/lib/features/channels/emoji_picker/category_rail.dart index 50df1406886..e654134ec66 100644 --- a/mobile/lib/features/channels/emoji_picker/category_rail.dart +++ b/mobile/lib/features/channels/emoji_picker/category_rail.dart @@ -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 onSelect; + final int skinTone; + final ValueChanged 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 onChanged; + + @override + Widget build(BuildContext context) { + final selected = _skinTones[_validSkinTone(value)]; + return PopupMenuButton( + 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( + 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; diff --git a/mobile/lib/features/channels/emoji_picker/emoji_grid.dart b/mobile/lib/features/channels/emoji_picker/emoji_grid.dart index 354a31e44c6..eb7217be027 100644 --- a/mobile/lib/features/channels/emoji_picker/emoji_grid.dart +++ b/mobile/lib/features/channels/emoji_picker/emoji_grid.dart @@ -63,7 +63,19 @@ List _sectionOffsets(List<_EmojiSection> sections) { } /// Which section owns [offset] — the one whose header is pinned right now. -int _activeSectionIndex(List 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 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 entries; final List 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)), diff --git a/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart b/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart new file mode 100644 index 00000000000..2986af96360 --- /dev/null +++ b/mobile/lib/features/channels/emoji_picker/ios_native_picker.dart @@ -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 _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; + BuildContext? loadingSheetContext; + var leavingLoadingSheet = false; + var loadingCancelled = false; + Future? 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(); + loadingSheet = + showBuzzModalBottomSheet( + 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 {}; + 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( + 'present', + { + 'customEmoji': [ + for (final emoji in customEmoji) + {'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, + ); +} diff --git a/mobile/lib/features/channels/emoji_picker/search_field.dart b/mobile/lib/features/channels/emoji_picker/search_field.dart index dd4c4ee9233..c61dd9d2cc8 100644 --- a/mobile/lib/features/channels/emoji_picker/search_field.dart +++ b/mobile/lib/features/channels/emoji_picker/search_field.dart @@ -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( - 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( + 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), + ), ), ), ), diff --git a/mobile/test/features/channels/emoji_picker_test.dart b/mobile/test/features/channels/emoji_picker_test.dart index bf8086da4c7..9c1cf556169 100644 --- a/mobile/test/features/channels/emoji_picker_test.dart +++ b/mobile/test/features/channels/emoji_picker_test.dart @@ -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> palette; + + @override + Future> build() => palette; +} + +const _nativeEmojiPickerChannel = MethodChannel('buzz/native_emoji_picker'); + +void _setMockNativeEmojiPickerHandler( + Future Function(MethodCall call)? handler, +) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_nativeEmojiPickerChannel, handler); +} + +Future _sendNativeEmojiPickerCall( + WidgetTester tester, + String method, [ + Object? arguments, +]) async { + final response = Completer(); + 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 _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(grid) + .slivers + .whereType() + .map((sliver) => sliver.key); expect( - find.descendant( - of: grid, - matching: find.byKey(const ValueKey('emoji-tile-grinning')), - ), - findsOneWidget, + 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( - find.descendant( - of: grid, - matching: find.byKey(const ValueKey('emoji-tile-fire')), + 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( + 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, + ), + bottomSheetTheme: const BottomSheetThemeData( + backgroundColor: Colors.green, ), - findsOneWidget, ); - expect( - find.descendant( - of: grid, - matching: find.byKey(const ValueKey('emoji-tile-custom-partyparrot')), + 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'), + ), + ), + ), + ), ), - findsOneWidget, + ); + + 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( + 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( + find.descendant( + of: find.byTooltip(tooltip), + matching: find.byType(Icon), + ), + ) + .color!; + + await tester.tap(find.byTooltip('Custom')); + await tester.pumpAndSettle(); + + final grid = tester.widget( + 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( + 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( + find.descendant( + of: find.byKey(ValueKey('emoji-skin-tone-dot-$index')), + matching: find.byType(DecoratedBox), + ), + ) + .map((widget) => widget.decoration) + .whereType(); + + 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 = []; + 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; + 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, {}); + final relayHeaders = await _sendNativeEmojiPickerCall( + tester, + 'mediaHeaders', + 'https://relay.example/media/buzzbee.png', + ); + expect( + (relayHeaders as Map)['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>(); + 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>(); + 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 = []; + final secondSelected = []; + 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 = [];