import CryptoKit import Flutter import ImageIO import PhotosUI import SafariServices import StoreKit import UIKit import UniformTypeIdentifiers func loadFlutterAssetImage(_ asset: String, bundle: Bundle = .main) -> UIImage? { let assetKey = FlutterDartProject.lookupKey(forAsset: asset) guard let assetPath = bundle.path(forResource: assetKey, ofType: nil) else { return nil } return UIImage(contentsOfFile: assetPath) } func nativeDownsampleImageFile( at url: URL, maxPixelSize: Int ) -> UIImage? { guard maxPixelSize > 0, let source = CGImageSourceCreateWithURL( url as CFURL, [kCGImageSourceShouldCache: false] as CFDictionary ) else { return nil } let options: [CFString: Any] = [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceShouldCacheImmediately: true, kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, ] guard let image = CGImageSourceCreateThumbnailAtIndex( source, 0, options as CFDictionary ) else { return nil } return UIImage(cgImage: image) } func nativeAvatarImage(_ image: UIImage, isTemplate: Bool) -> UIImage { isTemplate ? image.withRenderingMode(.alwaysTemplate) : image } func applyNativeAvatarImage( _ image: UIImage, isTemplate: Bool, to imageView: UIImageView, initialsLabel: UILabel ) { imageView.image = nativeAvatarImage(image, isTemplate: isTemplate) imageView.tintColor = isTemplate ? NativeSheetTheme.shared.foreground : nil imageView.contentMode = isTemplate ? .scaleAspectFit : .scaleAspectFill imageView.isHidden = false initialsLabel.isHidden = true } private func nativeLocalized(_ key: String, _ fallback: String) -> String { NSLocalizedString(key, tableName: nil, bundle: .main, value: fallback, comment: "") } private struct NativeSheetProfile { let displayName: String let email: String let initials: String let avatarUrl: String? let avatarData: Data? let avatarIsTemplate: Bool let avatarHeaders: [String: String] let bio: String let gender: String let dateOfBirth: String? let savedProfileImageUrl: String? } private struct NativeEditProfileSheetCopy { let title: String let saveLabel: String let cancelLabel: String let okLabel: String let footerText: String let nameLabel: String let nameRequiredMessage: String let customGenderRequiredMessage: String let bioLabel: String let bioHint: String let genderLabel: String let genderPreferNotToSay: String let genderMale: String let genderFemale: String let genderCustom: String let customGenderLabel: String let customGenderHint: String let birthDateLabel: String let selectBirthDateLabel: String let clearLabel: String let uploadFromDeviceLabel: String let useInitialsLabel: String let removeAvatarLabel: String let currentAvatarLabel: String init(_ payload: [String: Any]?) { let p = payload ?? [:] title = (p["title"] as? String) ?? nativeLocalized("native.editProfile", "Edit profile") saveLabel = (p["saveLabel"] as? String) ?? nativeLocalized("native.saveProfile", "Save profile") cancelLabel = (p["cancelLabel"] as? String) ?? nativeLocalized("native.cancel", "Cancel") okLabel = (p["okLabel"] as? String) ?? nativeLocalized("native.ok", "OK") footerText = (p["footerText"] as? String) ?? "" nameLabel = (p["nameLabel"] as? String) ?? nativeLocalized("native.name", "Name") nameRequiredMessage = (p["nameRequiredMessage"] as? String) ?? "" customGenderRequiredMessage = (p["customGenderRequiredMessage"] as? String) ?? "" bioLabel = (p["bioLabel"] as? String) ?? nativeLocalized("native.bio", "Bio") bioHint = (p["bioHint"] as? String) ?? "" genderLabel = (p["genderLabel"] as? String) ?? nativeLocalized("native.gender", "Gender") genderPreferNotToSay = (p["genderPreferNotToSay"] as? String) ?? nativeLocalized("native.genderPreferNotToSay", "Prefer not to say") genderMale = (p["genderMale"] as? String) ?? nativeLocalized("native.genderMale", "Male") genderFemale = (p["genderFemale"] as? String) ?? nativeLocalized("native.genderFemale", "Female") genderCustom = (p["genderCustom"] as? String) ?? nativeLocalized("native.genderCustom", "Custom") customGenderLabel = (p["customGenderLabel"] as? String) ?? nativeLocalized("native.customGender", "Custom gender") customGenderHint = (p["customGenderHint"] as? String) ?? "" birthDateLabel = (p["birthDateLabel"] as? String) ?? nativeLocalized("native.dateOfBirth", "Date of birth") selectBirthDateLabel = (p["selectBirthDateLabel"] as? String) ?? nativeLocalized("native.selectDate", "Select a date") clearLabel = (p["clearLabel"] as? String) ?? nativeLocalized("native.clear", "Clear") uploadFromDeviceLabel = (p["uploadFromDeviceLabel"] as? String) ?? nativeLocalized("native.upload", "Upload") useInitialsLabel = (p["useInitialsLabel"] as? String) ?? nativeLocalized("native.initials", "Initials") removeAvatarLabel = (p["removeAvatarLabel"] as? String) ?? nativeLocalized("native.remove", "Remove") currentAvatarLabel = (p["currentAvatarLabel"] as? String) ?? nativeLocalized("native.avatar", "Avatar") } } private struct NativeSheetOption { let id: String let label: String let subtitle: String? let sfSymbol: String? let enabled: Bool let destructive: Bool let ancestorHasMoreSiblings: [Bool] let showBranch: Bool let hasMoreSiblings: Bool init?(_ payload: [String: Any]) { guard let id = payload["id"] as? String, let label = payload["label"] as? String, !label.isEmpty else { return nil } self.id = id self.label = label subtitle = payload["subtitle"] as? String sfSymbol = payload["sfSymbol"] as? String enabled = payload["enabled"] as? Bool ?? true destructive = payload["destructive"] as? Bool ?? false ancestorHasMoreSiblings = (payload["ancestorHasMoreSiblings"] as? [Bool]) ?? [] showBranch = payload["showBranch"] as? Bool ?? false hasMoreSiblings = payload["hasMoreSiblings"] as? Bool ?? false } } private struct NativeSheetItem { let id: String let title: String let subtitle: String? let sfSymbol: String let iconAsset: String? let iconSize: Double? let showsDisclosure: Bool? let destructive: Bool let dismissOnSelect: Bool let actionId: String? let actionValue: Any? let url: URL? let kind: String let value: Any? let placeholder: String? let options: [NativeSheetOption] let sourceIndex: Int? let sourceUrl: String? let sourceType: String? let snippet: String? let faviconUrl: String? let queries: [String] let links: [NativeSheetLink] let pending: Bool let sliderMin: Double? let sliderMax: Double? let sliderDivisions: Int? init?(_ payload: [String: Any]) { guard let id = payload["id"] as? String, !id.isEmpty, let title = payload["title"] as? String, !title.isEmpty else { return nil } self.id = id self.title = title subtitle = payload["subtitle"] as? String sfSymbol = (payload["sfSymbol"] as? String) ?? "circle" iconAsset = payload["iconAsset"] as? String iconSize = NativeSheetItem.optionalDouble(payload["iconSize"]) showsDisclosure = payload["showsDisclosure"] as? Bool destructive = payload["destructive"] as? Bool ?? false dismissOnSelect = payload["dismissOnSelect"] as? Bool ?? false actionId = payload["actionId"] as? String actionValue = payload["actionValue"] if let urlString = payload["url"] as? String { url = URL(string: urlString) } else { url = nil } kind = (payload["kind"] as? String) ?? "navigation" value = payload["value"] placeholder = payload["placeholder"] as? String options = (payload["options"] as? [[String: Any]] ?? []) .compactMap(NativeSheetOption.init) sourceIndex = NativeSheetItem.optionalInt(payload["sourceIndex"]) sourceUrl = payload["sourceUrl"] as? String sourceType = payload["sourceType"] as? String snippet = payload["snippet"] as? String faviconUrl = payload["faviconUrl"] as? String queries = (payload["queries"] as? [Any] ?? []).compactMap { value in guard let raw = value as? String else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } links = (payload["links"] as? [[String: Any]] ?? []) .compactMap(NativeSheetLink.init) pending = payload["pending"] as? Bool ?? false sliderMin = NativeSheetItem.optionalDouble(payload["min"]) sliderMax = NativeSheetItem.optionalDouble(payload["max"]) if let n = payload["divisions"] as? NSNumber { sliderDivisions = n.intValue } else { sliderDivisions = nil } } private static func optionalDouble(_ value: Any?) -> Double? { switch value { case let n as NSNumber: return n.doubleValue case let d as Double: return d case let i as Int: return Double(i) default: return nil } } private static func optionalInt(_ value: Any?) -> Int? { switch value { case let n as NSNumber: return n.intValue case let i as Int: return i default: return nil } } } private extension NativeSheetItem { var sliderNumericValue: Double { switch value { case let n as NSNumber: return n.doubleValue case let d as Double: return d case let i as Int: return Double(i) default: return sliderMin ?? 0 } } var selectedOptionId: String? { value as? String } var selectedOptionLabel: String? { guard let selectedOptionId else { return nil } return options.first(where: { $0.id == selectedOptionId })?.label } var sourceDisplayUrl: String? { if let sourceUrl, !sourceUrl.isEmpty { return sourceUrl } return url?.absoluteString } var sourceDisplayType: String? { guard let sourceType, !sourceType.isEmpty else { return nil } return sourceType } var sourceDisplaySnippet: String? { if let snippet, !snippet.isEmpty { return snippet } return subtitle } } private struct NativeSheetLink { let title: String? let rawUrl: String let url: URL? let faviconUrl: String? init?(_ payload: [String: Any]) { guard let rawUrl = payload["url"] as? String, !rawUrl.isEmpty else { return nil } self.rawUrl = rawUrl url = URL(string: rawUrl) title = payload["title"] as? String faviconUrl = payload["faviconUrl"] as? String } } private enum NativeSheetURLFormatting { static func extractDomain(from rawUrl: String?) -> String? { guard let rawUrl, let url = URL(string: rawUrl), var host = url.host, !host.isEmpty else { return nil } if host.hasPrefix("www.") { host.removeFirst(4) } return host } static func googleFaviconUrl(rawUrl: String?, size: Int) -> String? { guard let domain = extractDomain(from: rawUrl) else { return nil } return "https://www.google.com/s2/favicons?sz=\(size)&domain=\(domain)" } static func displayLabel(for rawUrl: String) -> String { extractDomain(from: rawUrl) ?? rawUrl } } private extension NativeSheetOption { var showsHierarchyGuides: Bool { showBranch || !ancestorHasMoreSiblings.isEmpty } } private struct NativeModelSelectorOption { let id: String let name: String let subtitle: String? let sfSymbol: String? let avatarUrl: String? let avatarData: Data? let avatarHeaders: [String: String] let tags: [String] init?(_ payload: [String: Any]) { guard let id = payload["id"] as? String, !id.isEmpty, let name = payload["name"] as? String, !name.isEmpty else { return nil } self.id = id self.name = name subtitle = payload["subtitle"] as? String sfSymbol = payload["sfSymbol"] as? String avatarUrl = payload["avatarUrl"] as? String avatarData = (payload["avatarBytes"] as? FlutterStandardTypedData)?.data avatarHeaders = payload["avatarHeaders"] as? [String: String] ?? [:] var seenTags = Set() tags = (payload["tags"] as? [String] ?? []).compactMap { rawTag in let tag = rawTag.trimmingCharacters(in: .whitespacesAndNewlines) guard !tag.isEmpty, seenTags.insert(tag.lowercased()).inserted else { return nil } return tag } } } private struct NativeModelSelectorConfiguration { let presentationId: String let title: String let selectedModelId: String? let models: [NativeModelSelectorOption] let pinnedModelIds: [String] let featuredModelIds: [String] let allowsPinning: Bool let pinTitle: String let unpinTitle: String let moreModelsTitle: String let searchModelsTitle: String let reasoningEffortTitle: String let reasoningEffortValue: String let reasoningEffortOptions: [String] let reasoningEffortLabels: [String: String] let allowsCustomReasoningEffort: Bool let customReasoningEffortTitle: String let customReasoningEffortHint: String init?(_ arguments: Any?) { guard let payload = arguments as? [String: Any] else { return nil } guard let rawPresentationId = payload["presentationId"] as? String else { return nil } presentationId = rawPresentationId.trimmingCharacters( in: .whitespacesAndNewlines ) guard !presentationId.isEmpty else { return nil } title = (payload["title"] as? String) ?? nativeLocalized("native.chooseModel", "Choose Model") selectedModelId = payload["selectedModelId"] as? String models = (payload["models"] as? [[String: Any]] ?? []) .compactMap(NativeModelSelectorOption.init) var seenPinnedIds = Set() pinnedModelIds = (payload["pinnedModelIds"] as? [String] ?? []).compactMap { modelId in let trimmed = modelId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, seenPinnedIds.insert(trimmed).inserted else { return nil } return trimmed } featuredModelIds = payload["featuredModelIds"] as? [String] ?? [] allowsPinning = payload["allowsPinning"] as? Bool ?? false pinTitle = (payload["pinTitle"] as? String) ?? nativeLocalized("native.pin", "Pin") unpinTitle = (payload["unpinTitle"] as? String) ?? nativeLocalized("native.unpin", "Unpin") moreModelsTitle = (payload["moreModelsTitle"] as? String) ?? "More models" searchModelsTitle = (payload["searchModelsTitle"] as? String) ?? "Search models" reasoningEffortTitle = (payload["reasoningEffortTitle"] as? String) ?? "Effort" reasoningEffortValue = (payload["reasoningEffortValue"] as? String) ?? "medium" reasoningEffortOptions = payload["reasoningEffortOptions"] as? [String] ?? ["low", "medium", "high"] reasoningEffortLabels = payload["reasoningEffortLabels"] as? [String: String] ?? [:] allowsCustomReasoningEffort = payload["allowsCustomReasoningEffort"] as? Bool ?? true customReasoningEffortTitle = (payload["customReasoningEffortTitle"] as? String) ?? "Custom effort" customReasoningEffortHint = (payload["customReasoningEffortHint"] as? String) ?? "Enter effort" if models.isEmpty { return nil } } } private func nativeSheetParseDate(_ raw: String?) -> Date? { guard let raw, !raw.isEmpty else { return nil } let isoFormatter = ISO8601DateFormatter() isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let parsed = isoFormatter.date(from: raw) { return parsed } let fallbackPatterns = [ "yyyy-MM-dd'T'HH:mm:ss.SSS", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd" ] let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") for pattern in fallbackPatterns { formatter.dateFormat = pattern if let parsed = formatter.date(from: raw) { return parsed } } return nil } private func nativeSheetFormatDate(_ date: Date) -> String { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] return formatter.string(from: date) } private struct NativeOptionsSelectorConfiguration { let title: String let subtitle: String? let selectedOptionId: String? let searchable: Bool let options: [NativeSheetOption] init( title: String, subtitle: String?, selectedOptionId: String?, searchable: Bool, options: [NativeSheetOption] ) { self.title = title self.subtitle = subtitle self.selectedOptionId = selectedOptionId self.searchable = searchable self.options = options } init?(_ arguments: Any?) { guard let payload = arguments as? [String: Any] else { return nil } let parsedOptions = (payload["options"] as? [[String: Any]] ?? []) .compactMap(NativeSheetOption.init) guard !parsedOptions.isEmpty else { return nil } title = (payload["title"] as? String) ?? nativeLocalized("native.select", "Select") subtitle = payload["subtitle"] as? String selectedOptionId = payload["selectedOptionId"] as? String searchable = payload["searchable"] as? Bool ?? true options = parsedOptions } } private struct NativeDatePickerConfiguration { let title: String let initialDate: Date let firstDate: Date let lastDate: Date let doneLabel: String let cancelLabel: String init?(_ arguments: Any?) { guard let payload = arguments as? [String: Any], let initialDate = nativeSheetParseDate(payload["initialDate"] as? String), let firstDate = nativeSheetParseDate(payload["firstDate"] as? String), let lastDate = nativeSheetParseDate(payload["lastDate"] as? String) else { return nil } title = (payload["title"] as? String) ?? nativeLocalized("native.selectDateTitle", "Select Date") self.initialDate = initialDate self.firstDate = firstDate self.lastDate = lastDate doneLabel = (payload["doneLabel"] as? String) ?? nativeLocalized("native.done", "Done") cancelLabel = (payload["cancelLabel"] as? String) ?? nativeLocalized("native.cancel", "Cancel") } } private struct NativeTextEditorConfiguration { let title: String let initialValue: String let placeholder: String? let sendLabel: String let valueId: String let sendActionId: String let closeActionId: String init?(_ arguments: Any?) { guard let payload = arguments as? [String: Any] else { return nil } title = (payload["title"] as? String) ?? nativeLocalized("native.message", "Message") initialValue = (payload["value"] as? String) ?? "" placeholder = payload["placeholder"] as? String sendLabel = (payload["sendLabel"] as? String) ?? nativeLocalized("native.send", "Send") valueId = (payload["valueId"] as? String) ?? "text" sendActionId = (payload["sendActionId"] as? String) ?? "send" closeActionId = (payload["closeActionId"] as? String) ?? "close" } } private struct NativeResultSheetConfiguration { let root: NativeSheetDetail let details: [String: NativeSheetDetail] let initialValues: [String: Any] init?(_ arguments: Any?) { guard let payload = arguments as? [String: Any], let rootPayload = payload["root"] as? [String: Any], let root = NativeSheetDetail(rootPayload) else { return nil } self.root = root var detailsById: [String: NativeSheetDetail] = [root.id: root] let relatedDetails = (payload["detailSheets"] as? [[String: Any]] ?? []) .compactMap(NativeSheetDetail.init) for detail in relatedDetails { detailsById[detail.id] = detail } details = detailsById initialValues = Self.buildInitialValues(from: Array(detailsById.values)) } private static func buildInitialValues(from details: [NativeSheetDetail]) -> [String: Any] { var values: [String: Any] = [:] for detail in details { for item in detail.allItems { guard let value = initialValue(for: item) else { continue } values[item.id] = value } } return values } private static func initialValue(for item: NativeSheetItem) -> Any? { switch item.kind { case "textField", "secureTextField", "multilineTextField": return (item.value as? String) ?? "" case "dropdown", "searchablePicker", "segment": return item.selectedOptionId ?? item.options.first?.id case "toggle": return item.value as? Bool ?? false case "slider": return item.sliderNumericValue default: return nil } } } private struct NativeSheetDetail { let id: String let title: String let subtitle: String? let items: [NativeSheetItem] let sections: [NativeSheetSection] let confirmActionId: String? let confirmActionLabel: String? /// When set (0...1], sheet uses a single custom detent at this fraction of `maximumDetentValue`. let maxHeightFraction: CGFloat? init( id: String, title: String, subtitle: String?, items: [NativeSheetItem], sections: [NativeSheetSection] = [], confirmActionId: String? = nil, confirmActionLabel: String? = nil, maxHeightFraction: CGFloat? = nil ) { self.id = id self.title = title self.subtitle = subtitle self.items = items self.sections = sections self.confirmActionId = confirmActionId self.confirmActionLabel = confirmActionLabel self.maxHeightFraction = maxHeightFraction } init?(_ payload: [String: Any]) { guard let id = payload["id"] as? String, !id.isEmpty, let title = payload["title"] as? String, !title.isEmpty else { return nil } self.id = id self.title = title subtitle = payload["subtitle"] as? String confirmActionId = payload["confirmActionId"] as? String confirmActionLabel = payload["confirmActionLabel"] as? String items = (payload["items"] as? [[String: Any]] ?? []) .compactMap(NativeSheetItem.init) sections = (payload["sections"] as? [[String: Any]] ?? []) .compactMap(NativeSheetSection.init) maxHeightFraction = NativeSheetDetail.parseMaxHeightFraction(payload["maxHeightFraction"]) } private static func parseMaxHeightFraction(_ value: Any?) -> CGFloat? { let raw: CGFloat? switch value { case let n as NSNumber: raw = CGFloat(truncating: n) case let d as Double: raw = CGFloat(d) case let i as Int: raw = CGFloat(i) default: raw = nil } guard let raw, raw > 0, raw <= 1 else { return nil } return raw } var allItems: [NativeSheetItem] { if sections.isEmpty { return items } return sections.flatMap(\.items) } } private struct NativeSheetSection { let title: String? let footer: String? let items: [NativeSheetItem] init(title: String?, footer: String?, items: [NativeSheetItem]) { self.title = title self.footer = footer self.items = items } init?(_ payload: [String: Any]) { items = (payload["items"] as? [[String: Any]] ?? []) .compactMap(NativeSheetItem.init) guard !items.isEmpty else { return nil } title = payload["title"] as? String footer = payload["footer"] as? String } } private struct NativeSheetConfiguration { let profile: NativeSheetProfile let profileMenuTitle: String let editProfileLabel: String let editProfileSheet: NativeEditProfileSheetCopy let supportTitle: String? let supportSubtitle: String? let menuItems: [NativeSheetItem] let supportItems: [NativeSheetItem] let sections: [NativeSheetSection] let details: [String: NativeSheetDetail] init?(_ arguments: Any?) { guard let payload = arguments as? [String: Any], let profilePayload = payload["profile"] as? [String: Any] else { return nil } let displayName = (profilePayload["displayName"] as? String) ?? nativeLocalized("native.user", "User") let email = (profilePayload["email"] as? String) ?? nativeLocalized("native.noEmail", "No email") let initials = (profilePayload["initials"] as? String) ?? "U" let bio = (profilePayload["bio"] as? String) ?? "" let gender = (profilePayload["gender"] as? String) ?? "" let dateOfBirth = profilePayload["dateOfBirth"] as? String let savedUrl = profilePayload["profileImageUrl"] as? String profile = NativeSheetProfile( displayName: displayName, email: email, initials: initials, avatarUrl: profilePayload["avatarUrl"] as? String, avatarData: (profilePayload["avatarBytes"] as? FlutterStandardTypedData)?.data, avatarIsTemplate: (profilePayload["avatarIsTemplate"] as? Bool) ?? false, avatarHeaders: (profilePayload["avatarHeaders"] as? [String: String]) ?? [:], bio: bio, gender: gender, dateOfBirth: dateOfBirth, savedProfileImageUrl: savedUrl ) editProfileLabel = (payload["editProfileLabel"] as? String) ?? nativeLocalized("native.editProfile", "Edit Profile") profileMenuTitle = (payload["profileMenuTitle"] as? String) ?? editProfileLabel editProfileSheet = NativeEditProfileSheetCopy(payload["editProfileSheet"] as? [String: Any]) supportTitle = payload["supportTitle"] as? String supportSubtitle = payload["supportSubtitle"] as? String menuItems = (payload["menuItems"] as? [[String: Any]] ?? []) .compactMap(NativeSheetItem.init) supportItems = (payload["supportItems"] as? [[String: Any]] ?? []) .compactMap(NativeSheetItem.init) sections = (payload["sections"] as? [[String: Any]] ?? []) .compactMap(NativeSheetSection.init) let detailPayloads = payload["detailSheets"] as? [[String: Any]] ?? [] var detailsById: [String: NativeSheetDetail] = [:] for payload in detailPayloads { guard let detail = NativeSheetDetail(payload) else { continue } detailsById[detail.id] = detail } details = detailsById } } private extension PlatformNativeProfileSheetConfig { func asPayload() -> [String: Any] { var payload: [String: Any] = [ "profile": profile.asPayload(), "editProfileLabel": editProfileLabel, "menuItems": menuItems.map { $0.asPayload() }, "supportItems": supportItems.map { $0.asPayload() }, "sections": sections.map { $0.asPayload() }, "detailSheets": detailSheets.map { $0.asPayload() }, ] payload["profileMenuTitle"] = profileMenuTitle payload["editProfileSheet"] = editProfileSheet?.asPayload() payload["supportTitle"] = supportTitle payload["supportSubtitle"] = supportSubtitle return payload } } private extension PlatformNativeProfileSheetUser { func asPayload() -> [String: Any] { var payload: [String: Any] = [ "displayName": displayName, "email": email, "initials": initials, "avatarIsTemplate": avatarIsTemplate, "avatarHeaders": avatarHeaders, ] payload["avatarUrl"] = avatarUrl payload["avatarBytes"] = avatarBytes payload["bio"] = bio payload["gender"] = gender payload["dateOfBirth"] = dateOfBirth payload["profileImageUrl"] = profileImageUrl return payload } } private extension PlatformNativeEditProfileSheetConfig { func asPayload() -> [String: Any] { [ "title": title, "saveLabel": saveLabel, "cancelLabel": cancelLabel, "okLabel": okLabel, "footerText": footerText, "nameLabel": nameLabel, "nameRequiredMessage": nameRequiredMessage, "customGenderRequiredMessage": customGenderRequiredMessage, "bioLabel": bioLabel, "bioHint": bioHint, "genderLabel": genderLabel, "genderPreferNotToSay": genderPreferNotToSay, "genderMale": genderMale, "genderFemale": genderFemale, "genderCustom": genderCustom, "customGenderLabel": customGenderLabel, "customGenderHint": customGenderHint, "birthDateLabel": birthDateLabel, "selectBirthDateLabel": selectBirthDateLabel, "clearLabel": clearLabel, "uploadFromDeviceLabel": uploadFromDeviceLabel, "useInitialsLabel": useInitialsLabel, "removeAvatarLabel": removeAvatarLabel, "currentAvatarLabel": currentAvatarLabel, ] } } private extension PlatformNativeSheetSection { func asPayload() -> [String: Any] { var payload: [String: Any] = [ "items": items.map { $0.asPayload() }, ] payload["title"] = title payload["footer"] = footer return payload } } private extension PlatformNativeSheetDetail { func asPayload() -> [String: Any] { var payload: [String: Any] = [ "id": id, "title": title, "items": items.map { $0.asPayload() }, "sections": sections.map { $0.asPayload() }, ] payload["subtitle"] = subtitle payload["confirmActionId"] = confirmActionId payload["confirmActionLabel"] = confirmActionLabel payload["maxHeightFraction"] = maxHeightFraction return payload } } private extension PlatformNativeSheetItem { func asPayload() -> [String: Any] { var payload: [String: Any] = [ "id": id, "title": title, "sfSymbol": sfSymbol, "destructive": destructive, "dismissOnSelect": dismissOnSelect, "kind": kind.payloadName, "options": options.map { $0.asPayload() }, "queries": queries, "links": links.map { $0.asPayload() }, "pending": pending, ] payload["subtitle"] = subtitle payload["iconAsset"] = iconAsset payload["iconSize"] = iconSize payload["showsDisclosure"] = showsDisclosure payload["actionId"] = actionId payload["actionValue"] = actionValue payload["url"] = url payload["value"] = value payload["placeholder"] = placeholder if let sourceIndex { payload["sourceIndex"] = Int(sourceIndex) } payload["sourceUrl"] = sourceUrl payload["sourceType"] = sourceType payload["snippet"] = snippet payload["faviconUrl"] = faviconUrl payload["min"] = min payload["max"] = max if let divisions { payload["divisions"] = Int(divisions) } return payload } } private extension PlatformNativeSheetItemKind { var payloadName: String { switch self { case .navigation: "navigation" case .textField: "textField" case .multilineTextField: "multilineTextField" case .secureTextField: "secureTextField" case .dropdown: "dropdown" case .searchablePicker: "searchablePicker" case .toggle: "toggle" case .segment: "segment" case .slider: "slider" case .info: "info" case .readOnlyText: "readOnlyText" case .source: "source" case .statusUpdate: "statusUpdate" } } } private extension PlatformNativeSheetOption { func asPayload() -> [String: Any] { var payload: [String: Any] = [ "id": id, "label": label, "enabled": enabled, "destructive": destructive, "ancestorHasMoreSiblings": ancestorHasMoreSiblings, "showBranch": showBranch, "hasMoreSiblings": hasMoreSiblings, ] payload["subtitle"] = subtitle payload["sfSymbol"] = sfSymbol return payload } } private extension PlatformNativeSheetLink { func asPayload() -> [String: Any] { var payload: [String: Any] = ["url": url] payload["title"] = title payload["faviconUrl"] = faviconUrl return payload } } private extension PlatformNativeSheetModelOption { func asPayload() -> [String: Any] { var payload: [String: Any] = [ "id": id, "name": name, "avatarHeaders": avatarHeaders, ] payload["subtitle"] = subtitle payload["sfSymbol"] = sfSymbol payload["avatarUrl"] = avatarUrl payload["avatarBytes"] = avatarBytes payload["tags"] = tags return payload } } private extension PlatformNativeSheetModelSelectorRequest { func asPayload() -> [String: Any] { var payload: [String: Any] = [ "presentationId": presentationId, "title": title, "models": models.map { $0.asPayload() }, "pinnedModelIds": pinnedModelIds, "featuredModelIds": featuredModelIds, "allowsPinning": allowsPinning, "moreModelsTitle": moreModelsTitle, "searchModelsTitle": searchModelsTitle, "reasoningEffortTitle": reasoningEffortTitle, "reasoningEffortValue": reasoningEffortValue, "reasoningEffortOptions": reasoningEffortOptions, "reasoningEffortLabels": reasoningEffortLabels, "allowsCustomReasoningEffort": allowsCustomReasoningEffort, "customReasoningEffortTitle": customReasoningEffortTitle, "customReasoningEffortHint": customReasoningEffortHint, ] payload["selectedModelId"] = selectedModelId payload["pinTitle"] = pinTitle payload["unpinTitle"] = unpinTitle return payload } } private extension PlatformNativeSheetOptionsSelectorRequest { func asPayload() -> [String: Any] { var payload: [String: Any] = [ "title": title, "searchable": searchable, "options": options.map { $0.asPayload() }, ] payload["subtitle"] = subtitle payload["selectedOptionId"] = selectedOptionId return payload } } private extension PlatformNativeSheetDatePickerRequest { func asPayload() -> [String: Any] { var payload: [String: Any] = [ "title": title, "initialDate": initialDateIso8601, "firstDate": firstDateIso8601, "lastDate": lastDateIso8601, ] payload["doneLabel"] = doneLabel payload["cancelLabel"] = cancelLabel return payload } } private extension PlatformNativeSheetTextEditorRequest { func asPayload() -> [String: Any] { var payload: [String: Any] = [ "title": title, "value": value, "valueId": valueId, "sendActionId": sendActionId, "closeActionId": closeActionId, ] payload["placeholder"] = placeholder payload["sendLabel"] = sendLabel return payload } } private extension PlatformNativeSheetResultRequest { func asPayload() -> [String: Any] { [ "root": root.asPayload(), "detailSheets": detailSheets.map { $0.asPayload() }, ] } } private final class NativeSheetPresentationDelegate: NSObject, UIAdaptivePresentationControllerDelegate { private let onWillDismiss: () -> Void private let onDismiss: () -> Void init( onWillDismiss: @escaping () -> Void = {}, onDismiss: @escaping () -> Void ) { self.onWillDismiss = onWillDismiss self.onDismiss = onDismiss } func presentationControllerWillDismiss( _ presentationController: UIPresentationController ) { onWillDismiss() } func presentationControllerDidDismiss( _ presentationController: UIPresentationController ) { onDismiss() } } /// Applies a model-selector hydration update before its synchronous Pigeon /// call returns while keeping all UIKit and presentation-state access on main. func applyNativeSheetModelUpdateSynchronouslyOnMain( presentationId: String, activePresentationId: @escaping () -> String?, update: @escaping () -> Void ) { let applyUpdate = { guard activePresentationId() == presentationId else { return } update() } if Thread.isMainThread { applyUpdate() } else { DispatchQueue.main.sync(execute: applyUpdate) } } final class NativeSheetBridge: ConduitBridge, NativeSheetHostApi { static let shared = NativeSheetBridge() private enum ActiveSheetMode { case profileMenu case resultSheet } private typealias PendingStringResult = (Result) -> Void private typealias PendingActionResult = (Result) -> Void private var flutterApi: NativeSheetFlutterApi? private var activeController: UIViewController? private var presentationDelegate: NativeSheetPresentationDelegate? private var configuration: NativeSheetConfiguration? private var detailPayloads: [String: NativeSheetDetail] = [:] private weak var activeDetailTableController: NativeDetailTableViewController? private var activeSheetMode: ActiveSheetMode = .profileMenu private var pendingModelSelectorResult: PendingStringResult? private var pendingOptionsSelectorResult: PendingStringResult? private var pendingTextEditorResult: PendingActionResult? private var pendingResultSheetResult: PendingActionResult? private var resultSheetValues: [String: Any] = [:] /// True while the result sheet is itself a confirmation (ThemedDialogs.confirm), /// so its destructive action must not ask a second time. private var resultSheetIsConfirmation = false private weak var activeTextEditorController: NativeTextEditorViewController? private weak var activeModelSelectorController: NativeModelSelectorTableViewController? private var activeModelSelectorPresentationId: String? private var host: ConduitBridgeHost? private init() {} func attach(to host: ConduitBridgeHost) { self.host = host let messenger = host.messenger flutterApi = NativeSheetFlutterApi(binaryMessenger: messenger) NativeSheetHostApiSetup.setUp( binaryMessenger: messenger, api: self ) } func setTheme(theme: PlatformNativeSheetTheme) throws { let applyTheme = { [weak self] in NativeSheetTheme.shared.update(theme) if let activeController = self?.activeController { NativeSheetTheme.shared.apply(to: activeController) } } if Thread.isMainThread { applyTheme() } else { DispatchQueue.main.sync(execute: applyTheme) } } func presentProfileMenu( config: PlatformNativeProfileSheetConfig, completion: @escaping (Result) -> Void ) { DispatchQueue.main.async { [weak self] in guard let self else { return } guard let configuration = NativeSheetConfiguration(config.asPayload()) else { completion(.failure(PigeonError( code: "INVALID_ARGS", message: "Missing native profile sheet configuration", details: nil ))) return } self.activeSheetMode = .profileMenu self.configuration = configuration self.detailPayloads = configuration.details completion(.success(self.presentProfileMenu(configuration))) } } func dismiss() throws -> Bool { if Thread.isMainThread { dismissActive() } else { DispatchQueue.main.sync { dismissActive() } } return true } func requestAppStoreReview() throws -> Bool { let request = { MainActor.assumeIsolated { guard let scene = self.host?.activeWindowScene else { return false } AppStore.requestReview(in: scene) return true } } if Thread.isMainThread { return request() } return DispatchQueue.main.sync(execute: request) } func presentModelSelector( request: PlatformNativeSheetModelSelectorRequest, completion: @escaping (Result) -> Void ) { DispatchQueue.main.async { [weak self] in guard let self else { return } guard let configuration = NativeModelSelectorConfiguration(request.asPayload()) else { completion(.failure(PigeonError( code: "INVALID_ARGS", message: "Missing native model selector configuration", details: nil ))) return } self.presentModelSelector(configuration, result: completion) } } func updateModelSelectorModels( presentationId: String, models: [PlatformNativeSheetModelOption] ) throws { let normalizedPresentationId = presentationId.trimmingCharacters( in: .whitespacesAndNewlines ) guard !normalizedPresentationId.isEmpty else { return } let hydratedModels = models.compactMap { NativeModelSelectorOption($0.asPayload()) } guard !hydratedModels.isEmpty else { return } applyNativeSheetModelUpdateSynchronouslyOnMain( presentationId: normalizedPresentationId, activePresentationId: { self.activeModelSelectorPresentationId }, update: { self.activeModelSelectorController?.updateModels(hydratedModels) } ) } func updateModelSelectorReasoningEffort( presentationId: String, value: String, options: [String], allowsCustom: Bool ) throws { let normalizedPresentationId = presentationId.trimmingCharacters( in: .whitespacesAndNewlines ) guard !normalizedPresentationId.isEmpty else { return } applyNativeSheetModelUpdateSynchronouslyOnMain( presentationId: normalizedPresentationId, activePresentationId: { self.activeModelSelectorPresentationId }, update: { self.activeModelSelectorController?.updateReasoningEffort( value: value, options: options, allowsCustom: allowsCustom ) } ) } func presentOptionsSelector( request: PlatformNativeSheetOptionsSelectorRequest, completion: @escaping (Result) -> Void ) { DispatchQueue.main.async { [weak self] in guard let self else { return } guard let configuration = NativeOptionsSelectorConfiguration(request.asPayload()) else { completion(.failure(PigeonError( code: "INVALID_ARGS", message: "Missing native options selector configuration", details: nil ))) return } self.presentOptionsSelector(configuration, result: completion) } } func presentDatePicker( request: PlatformNativeSheetDatePickerRequest, completion: @escaping (Result) -> Void ) { DispatchQueue.main.async { [weak self] in guard let self else { return } guard let configuration = NativeDatePickerConfiguration(request.asPayload()) else { completion(.failure(PigeonError( code: "INVALID_ARGS", message: "Missing native date picker configuration", details: nil ))) return } self.presentDatePicker(configuration, result: completion) } } func presentTextEditor( request: PlatformNativeSheetTextEditorRequest, completion: @escaping (Result) -> Void ) { DispatchQueue.main.async { [weak self] in guard let self else { return } guard let configuration = NativeTextEditorConfiguration(request.asPayload()) else { completion(.failure(PigeonError( code: "INVALID_ARGS", message: "Missing native text editor configuration", details: nil ))) return } self.presentTextEditor(configuration, result: completion) } } func presentResultSheet( request: PlatformNativeSheetResultRequest, completion: @escaping (Result) -> Void ) { DispatchQueue.main.async { [weak self] in guard let self else { return } guard let configuration = NativeResultSheetConfiguration(request.asPayload()) else { completion(.failure(PigeonError( code: "INVALID_ARGS", message: "Missing native result sheet configuration", details: nil ))) return } self.presentResultSheet(configuration, result: completion) } } func applyDetailPatch( request: PlatformNativeSheetApplyDetailPatchRequest ) throws -> Bool { let apply = { let detailId = request.detailId let items = request.items .map { $0.asPayload() } .compactMap(NativeSheetItem.init) let sections = request.sections .map { $0.asPayload() } .compactMap(NativeSheetSection.init) let relatedDetails = (request.detailSheets ?? []) .map { $0.asPayload() } .compactMap(NativeSheetDetail.init) guard let existing = self.detailPayloads[detailId] else { return false } for detail in relatedDetails { self.detailPayloads[detail.id] = detail } let patched = NativeSheetDetail( id: existing.id, title: request.title ?? existing.title, subtitle: request.clearSubtitle ? nil : request.subtitle ?? existing.subtitle, items: items, sections: sections, confirmActionId: existing.confirmActionId, confirmActionLabel: existing.confirmActionLabel, maxHeightFraction: existing.maxHeightFraction ) self.detailPayloads[detailId] = patched if self.activeDetailTableController?.detailId == detailId { self.activeDetailTableController?.applyUpdatedDetail(patched) } return true } if Thread.isMainThread { return apply() } var applied = false DispatchQueue.main.sync { applied = apply() } return applied } private func presentProfileMenu(_ configuration: NativeSheetConfiguration) -> Bool { let controller = NativeProfileMenuTableViewController( configuration: configuration, onSelect: { [weak self] item in self?.handleSelection(item) }, onClose: { [weak self] in self?.dismissActive() } ) let navigation = NativeSheetNavigationController(rootViewController: controller) return present(navigation, initialDetent: .large) } private func sendControlChanged(id: String, value: Any?) { flutterApi?.onControlChanged( event: PlatformNativeSheetControlChangedEvent(id: id, value: value) ) { _ in } } private func sendEditProfileCommitted(_ payload: [String: Any]) { guard let name = payload["name"] as? String, let profileImageUrl = payload["profileImageUrl"] as? String else { return } flutterApi?.commitEditProfile( event: PlatformNativeEditProfileCommittedEvent( name: name, profileImageUrl: profileImageUrl, bio: payload["bio"] as? String ?? "", gender: payload["gender"] as? String, dateOfBirth: payload["dateOfBirth"] as? String ) ) { _ in } } private func actionResult( actionId: String, values: [String: Any] ) -> PlatformNativeSheetActionResult { PlatformNativeSheetActionResult(actionId: actionId, values: values) } private func actionResult( from payload: [String: Any]? ) -> PlatformNativeSheetActionResult? { guard let payload, let actionId = payload["actionId"] as? String else { return nil } return actionResult( actionId: actionId, values: payload["values"] as? [String: Any] ?? [:] ) } private func presentResultSheet( _ configuration: NativeResultSheetConfiguration, result: @escaping PendingActionResult ) { if pendingResultSheetResult != nil { result(.failure(PigeonError( code: "ALREADY_PRESENTING", message: "A native result sheet is already open", details: nil ))) return } activeSheetMode = .resultSheet self.configuration = nil detailPayloads = configuration.details resultSheetValues = configuration.initialValues resultSheetIsConfirmation = configuration.root.id == "confirmation-dialog" pendingResultSheetResult = result let navigation = NativeSheetNavigationController( rootViewController: makeDetailController(detail: configuration.root) ) if !present( navigation, initialDetent: .large, maxHeightFraction: configuration.root.maxHeightFraction ) { let pending = pendingResultSheetResult pendingResultSheetResult = nil resultSheetValues = [:] detailPayloads = [:] activeSheetMode = .profileMenu pending?(.failure(PigeonError( code: "PRESENTATION_FAILED", message: "Unable to present native result sheet", details: nil ))) } } private func makeDetailController(detail: NativeSheetDetail) -> NativeDetailTableViewController { NativeDetailTableViewController( detail: detail, canNavigate: { [weak self] item in self?.detailPayloads[item.id] != nil }, onSelect: { [weak self] item in self?.handleCurrentSheetSelection(item) }, onControlChanged: { [weak self] item, value in self?.handleCurrentSheetControlChanged(item, value: value) }, onConfirmAction: { [weak self] actionId in self?.handleCurrentSheetConfirmAction(actionId) }, onClose: { [weak self] in self?.closeActiveSheet() } ) } private func presentDetail(id: String) { guard let detail = detailPayloads[id] else { return } let controller = makeDetailController(detail: detail) if let navigation = activeNavigationController { navigation.pushViewController(controller, animated: true) return } let navigation = NativeSheetNavigationController(rootViewController: controller) _ = present(navigation, initialDetent: .large) } private func closeActiveSheet() { if activeSheetMode == .resultSheet { resolvePendingResultSheetAfterDismiss(nil) return } dismissActive() } private func handleCurrentSheetSelection(_ item: NativeSheetItem) { switch activeSheetMode { case .profileMenu: handleSelection(item) case .resultSheet: handleResultSheetSelection(item) } } private func handleCurrentSheetControlChanged(_ item: NativeSheetItem, value: Any?) { switch activeSheetMode { case .profileMenu: sendControlChanged(id: item.id, value: value) case .resultSheet: if let value { resultSheetValues[item.id] = value } else { resultSheetValues.removeValue(forKey: item.id) } } } private func handleCurrentSheetConfirmAction(_ actionId: String) { switch activeSheetMode { case .profileMenu: sendControlChanged(id: actionId, value: true) case .resultSheet: resolvePendingResultSheetAfterDismiss( actionResult(actionId: actionId, values: resultSheetValues) ) } } private func presentInlineOptionsSelector(for item: NativeSheetItem) { guard let navigation = activeNavigationController else { return } let configuration = NativeOptionsSelectorConfiguration( title: item.title, subtitle: item.subtitle, selectedOptionId: item.selectedOptionId, searchable: true, options: item.options ) let controller = NativeOptionsSelectorTableViewController( configuration: configuration, onSelect: { [weak self, weak navigation] optionId in guard let self else { return } self.handleCurrentSheetControlChanged(item, value: optionId) navigation?.popViewController(animated: true) }, onClose: { [weak navigation] in navigation?.popViewController(animated: true) } ) navigation.pushViewController(controller, animated: true) } private func handleResultSheetSelection(_ item: NativeSheetItem) { flushActiveSheetEditing() if item.kind == "searchablePicker" { presentInlineOptionsSelector(for: item) return } if item.destructive, !resultSheetIsConfirmation { presentDestructiveConfirm(for: item) return } if let url = item.url { if item.kind == "source", let presenter = activeNavigationController?.visibleViewController { presenter.present(SFSafariViewController(url: url), animated: true) return } UIApplication.shared.open(url) return } if detailPayloads[item.id] != nil { presentDetail(id: item.id) return } resolvePendingResultSheetAfterDismiss( actionResult(actionId: item.id, values: resultSheetValues) ) } private func resolvePendingResultSheet( _ payload: PlatformNativeSheetActionResult? ) { if let pending = pendingResultSheetResult { pendingResultSheetResult = nil pending(.success(payload)) } } private func resolvePendingResultSheetAfterDismiss( _ payload: PlatformNativeSheetActionResult? ) { guard let pending = pendingResultSheetResult else { dismissActive() return } pendingResultSheetResult = nil flushActiveSheetEditing() let controller = activeController let completion = { [weak self] in pending(.success(payload)) self?.activeController = nil self?.presentationDelegate = nil self?.activeDetailTableController = nil self?.detailPayloads = [:] self?.resultSheetValues = [:] self?.activeSheetMode = .profileMenu } guard let controller else { completion() return } controller.dismiss(animated: true, completion: completion) } private func presentProfilePhotoEditor() { guard let configuration = configuration else { return } guard let presenter = activeNavigationController?.visibleViewController else { return } let controller = NativeProfilePhotoEditorViewController( profile: configuration.profile, copy: configuration.editProfileSheet, onCommit: { [weak self] payload in self?.sendEditProfileCommitted(payload) } ) let navigation = NativeSheetNavigationController(rootViewController: controller) navigation.modalPresentationStyle = .pageSheet applySheetStyle(to: navigation, initialDetent: .large) presenter.present(navigation, animated: true) } private func presentProfileNameEditor() { guard let configuration = configuration else { return } guard let presenter = activeNavigationController?.visibleViewController else { return } let controller = NativeProfileNameEditorViewController( profile: configuration.profile, copy: configuration.editProfileSheet, onCommit: { [weak self] payload in self?.sendEditProfileCommitted(payload) } ) let navigation = NativeSheetNavigationController(rootViewController: controller) navigation.modalPresentationStyle = .pageSheet applySheetStyle(to: navigation, initialDetent: .large) presenter.present(navigation, animated: true) } private func presentProfileAboutEditor() { guard let configuration = configuration else { return } guard let presenter = activeNavigationController?.visibleViewController else { return } let controller = NativeProfileAboutEditorViewController( profile: configuration.profile, copy: configuration.editProfileSheet, onCommit: { [weak self] payload in self?.sendEditProfileCommitted(payload) } ) let navigation = NativeSheetNavigationController(rootViewController: controller) navigation.modalPresentationStyle = .pageSheet applySheetStyle(to: navigation, initialDetent: .large) presenter.present(navigation, animated: true) } private func presentProfileDetailsEditor() { guard let configuration = configuration else { return } guard let presenter = activeNavigationController?.visibleViewController else { return } let controller = NativeProfileDetailsEditorViewController( profile: configuration.profile, copy: configuration.editProfileSheet, onCommit: { [weak self] payload in self?.sendEditProfileCommitted(payload) } ) let navigation = NativeSheetNavigationController(rootViewController: controller) navigation.modalPresentationStyle = .pageSheet applySheetStyle(to: navigation, initialDetent: .large) presenter.present(navigation, animated: true) } private func present( _ controller: UIViewController, initialDetent: UISheetPresentationController.Detent.Identifier? = nil, maxHeightFraction: CGFloat? = nil ) -> Bool { guard let presenter = topViewController() else { return false } activeController = controller presentationDelegate = NativeSheetPresentationDelegate( onWillDismiss: { [weak self] in self?.flushActiveSheetEditing() }, onDismiss: { [weak self] in if let pending = self?.pendingModelSelectorResult { self?.pendingModelSelectorResult = nil pending(.success(nil)) } if let pending = self?.pendingOptionsSelectorResult { self?.pendingOptionsSelectorResult = nil pending(.success(nil)) } if let pending = self?.pendingTextEditorResult { self?.pendingTextEditorResult = nil pending(.success(self?.actionResult( from: self?.activeTextEditorController?.resultPayload( actionId: self?.activeTextEditorController?.closeActionId ?? "close" ) ))) } if let pending = self?.pendingResultSheetResult { self?.pendingResultSheetResult = nil pending(.success(nil)) } self?.activeController = nil self?.presentationDelegate = nil self?.activeTextEditorController = nil self?.activeDetailTableController = nil self?.activeModelSelectorController = nil self?.activeModelSelectorPresentationId = nil self?.detailPayloads = [:] self?.resultSheetValues = [:] let shouldNotifyDismiss = self?.activeSheetMode == .profileMenu self?.activeSheetMode = .profileMenu if shouldNotifyDismiss == true { self?.flutterApi?.onDismissed { _ in } } } ) controller.modalPresentationStyle = .pageSheet controller.presentationController?.delegate = presentationDelegate applySheetStyle( to: controller, initialDetent: initialDetent, maxHeightFraction: maxHeightFraction ) presenter.present(controller, animated: true) return true } private func presentModelSelector( _ configuration: NativeModelSelectorConfiguration, result: @escaping PendingStringResult ) { if pendingModelSelectorResult != nil { result(.failure(PigeonError( code: "ALREADY_PRESENTING", message: "A native model selector is already open", details: nil ))) return } activeSheetMode = .resultSheet pendingModelSelectorResult = result let controller = NativeModelSelectorTableViewController( configuration: configuration, onSelect: { [weak self] modelId in guard let self else { return } self.completeModelSelector(with: modelId) }, onTogglePin: { [weak self] modelId in guard let self else { return } self.flutterApi?.onModelPinToggled( event: PlatformNativeSheetModelPinToggledEvent(modelId: modelId) ) { _ in } }, onEffortChanged: { [weak self] value in self?.flutterApi?.onReasoningEffortChanged( event: PlatformNativeSheetReasoningEffortChangedEvent(value: value) ) { _ in } }, onClose: { [weak self] in guard let self else { return } self.completeModelSelector(with: nil) } ) activeModelSelectorController = controller activeModelSelectorPresentationId = configuration.presentationId let navigation = NativeSheetNavigationController(rootViewController: controller) if !present(navigation, initialDetent: .large) { pendingModelSelectorResult = nil activeModelSelectorController = nil activeModelSelectorPresentationId = nil activeSheetMode = .profileMenu result(.failure(PigeonError( code: "PRESENTATION_FAILED", message: "Unable to present native model selector", details: nil ))) } } private func completeModelSelector(with value: String?) { let pending = pendingModelSelectorResult pendingModelSelectorResult = nil flushActiveSheetEditing() activeController?.dismiss(animated: true) activeController = nil presentationDelegate = nil activeDetailTableController = nil activeModelSelectorController = nil activeModelSelectorPresentationId = nil detailPayloads = [:] resultSheetValues = [:] activeSheetMode = .profileMenu pending?(.success(value)) } private func presentOptionsSelector( _ configuration: NativeOptionsSelectorConfiguration, result: @escaping PendingStringResult ) { if pendingOptionsSelectorResult != nil { result(.failure(PigeonError( code: "ALREADY_PRESENTING", message: "A native options selector is already open", details: nil ))) return } activeSheetMode = .resultSheet pendingOptionsSelectorResult = result let controller = NativeOptionsSelectorTableViewController( configuration: configuration, onSelect: { [weak self] optionId in guard let self else { return } let pending = self.pendingOptionsSelectorResult self.pendingOptionsSelectorResult = nil self.activeController?.dismiss(animated: true) self.activeController = nil self.presentationDelegate = nil self.activeDetailTableController = nil self.detailPayloads = [:] self.resultSheetValues = [:] self.activeSheetMode = .profileMenu pending?(.success(optionId)) }, onClose: { [weak self] in guard let self else { return } let pending = self.pendingOptionsSelectorResult self.pendingOptionsSelectorResult = nil self.dismissActive() pending?(.success(nil)) } ) let navigation = NativeSheetNavigationController(rootViewController: controller) if !present(navigation, initialDetent: .large) { pendingOptionsSelectorResult = nil activeSheetMode = .profileMenu result(.failure(PigeonError( code: "PRESENTATION_FAILED", message: "Unable to present native options selector", details: nil ))) } } private func presentDatePicker( _ configuration: NativeDatePickerConfiguration, result: @escaping PendingStringResult ) { activeSheetMode = .resultSheet let controller = NativeDatePickerViewController( configuration: configuration, onConfirm: { [weak self] date in result(.success(nativeSheetFormatDate(date))) self?.dismissActive() }, onClose: { [weak self] in result(.success(nil)) self?.dismissActive() } ) let navigation = NativeSheetNavigationController(rootViewController: controller) if !present(navigation, initialDetent: .large) { activeSheetMode = .profileMenu result(.failure(PigeonError( code: "PRESENTATION_FAILED", message: "Unable to present native date picker", details: nil ))) } } private func presentTextEditor( _ configuration: NativeTextEditorConfiguration, result: @escaping PendingActionResult ) { if pendingTextEditorResult != nil { result(.failure(PigeonError( code: "ALREADY_PRESENTING", message: "A native text editor sheet is already open", details: nil ))) return } activeSheetMode = .resultSheet pendingTextEditorResult = result let controller = NativeTextEditorViewController( configuration: configuration, onClose: { [weak self] in self?.resolvePendingTextEditorAfterDismiss( actionId: configuration.closeActionId ) }, onSend: { [weak self] in self?.resolvePendingTextEditorAfterDismiss( actionId: configuration.sendActionId ) } ) activeTextEditorController = controller let navigation = NativeSheetNavigationController(rootViewController: controller) if present(navigation, initialDetent: .large) { navigation.sheetPresentationController?.detents = [.large()] controller.focusEditor() return } pendingTextEditorResult = nil activeTextEditorController = nil activeSheetMode = .profileMenu result(.failure(PigeonError( code: "PRESENTATION_FAILED", message: "Unable to present native text editor", details: nil ))) } private func resolvePendingTextEditorAfterDismiss(actionId: String) { guard let pending = pendingTextEditorResult else { dismissActive() return } pendingTextEditorResult = nil flushActiveSheetEditing() let payload = actionResult( from: activeTextEditorController?.resultPayload(actionId: actionId) ) let controller = activeController let completion = { [weak self] in pending(.success(payload)) self?.activeController = nil self?.presentationDelegate = nil self?.activeTextEditorController = nil self?.activeDetailTableController = nil self?.detailPayloads = [:] self?.resultSheetValues = [:] self?.activeSheetMode = .profileMenu } guard let controller else { completion() return } controller.dismiss(animated: true, completion: completion) } private func applySheetStyle( to controller: UIViewController, initialDetent: UISheetPresentationController.Detent.Identifier? = nil, maxHeightFraction: CGFloat? = nil ) { guard let sheet = controller.sheetPresentationController else { return } if let fraction = maxHeightFraction, fraction > 0, fraction <= 1 { let cappedId = UISheetPresentationController.Detent.Identifier("conduit.sheetMaxHeightFraction") let cappedDetent = UISheetPresentationController.Detent.custom(identifier: cappedId) { context in max(context.maximumDetentValue * fraction, 1) } sheet.detents = [cappedDetent] sheet.selectedDetentIdentifier = cappedId sheet.prefersScrollingExpandsWhenScrolledToEdge = false } else { sheet.detents = [.medium(), .large()] if let initialDetent = initialDetent { sheet.selectedDetentIdentifier = initialDetent } sheet.prefersScrollingExpandsWhenScrolledToEdge = true } sheet.prefersGrabberVisible = true sheet.prefersEdgeAttachedInCompactHeight = true sheet.widthFollowsPreferredContentSizeWhenEdgeAttached = true } private func handleSelection(_ item: NativeSheetItem) { if item.kind == "searchablePicker" { presentInlineOptionsSelector(for: item) return } if item.id == "sign-out" { presentSignOutOptions(for: item) return } if item.dismissOnSelect { let actionId = (item.actionId?.isEmpty == false ? item.actionId : nil) ?? item.id let actionValue = item.actionValue ?? item.value ?? true dismissActive { [weak self] in self?.sendControlChanged(id: actionId, value: actionValue) } return } switch item.id { case "profile-photo": presentProfilePhotoEditor() return case "profile-name": presentProfileNameEditor() return case "profile-about": presentProfileAboutEditor() return case "profile-details": presentProfileDetailsEditor() return default: break } if item.destructive, item.id == "memory-clear-all" || item.id.hasPrefix("memory-delete:") { presentDestructiveConfirm(for: item) return } if let url = item.url { UIApplication.shared.open(url) return } if detailPayloads[item.id] != nil { presentDetail(id: item.id) return } sendControlChanged(id: item.id, value: item.value ?? true) } private func presentSignOutOptions(for item: NativeSheetItem) { guard let presenter = activeNavigationController?.visibleViewController else { return } let option = item.options.first let controller = NativeSignOutOptionsViewController( title: item.title, message: item.placeholder ?? item.subtitle, optionTitle: option?.label ?? nativeLocalized("native.keepServerDetails", "Keep server details"), optionSubtitle: option?.subtitle, cancelTitle: configuration?.editProfileSheet.cancelLabel ?? nativeLocalized("native.cancel", "Cancel"), confirmTitle: item.title, onConfirm: { [weak self] keepServerDetails in self?.dismissActive { [weak self] in self?.sendControlChanged( id: item.id, value: keepServerDetails ) } } ) let navigation = NativeSheetNavigationController( rootViewController: controller ) navigation.modalPresentationStyle = .pageSheet if let sheet = navigation.sheetPresentationController { sheet.detents = [.medium()] sheet.prefersGrabberVisible = true sheet.prefersScrollingExpandsWhenScrolledToEdge = false } presenter.present(navigation, animated: true) } private func presentDestructiveConfirm(for item: NativeSheetItem) { guard let presenter = activeNavigationController?.visibleViewController else { switch activeSheetMode { case .profileMenu: sendControlChanged(id: item.id, value: true) case .resultSheet: resolvePendingResultSheetAfterDismiss( actionResult(actionId: item.id, values: resultSheetValues) ) } return } let alert = UIAlertController( title: item.title, message: item.subtitle, preferredStyle: .alert ) let cancelTitle = configuration?.editProfileSheet.cancelLabel ?? nativeLocalized("native.cancel", "Cancel") alert.addAction(UIAlertAction(title: cancelTitle, style: .cancel)) alert.addAction(UIAlertAction(title: item.title, style: .destructive) { [weak self] _ in guard let self else { return } switch self.activeSheetMode { case .profileMenu: self.sendControlChanged(id: item.id, value: true) case .resultSheet: self.resolvePendingResultSheetAfterDismiss( self.actionResult( actionId: item.id, values: self.resultSheetValues ) ) } }) presenter.present(alert, animated: true) } private func flushActiveSheetEditing() { activeController?.view.endEditing(true) activeNavigationController?.view.endEditing(true) } private func dismissActive(completion: (() -> Void)? = nil) { flushActiveSheetEditing() let controller = activeController controller?.dismiss(animated: true, completion: completion) if controller == nil { completion?() } activeController = nil presentationDelegate = nil activeDetailTableController = nil activeModelSelectorController = nil activeModelSelectorPresentationId = nil detailPayloads = [:] resultSheetValues = [:] if let pending = pendingModelSelectorResult { pendingModelSelectorResult = nil pending(.success(nil)) } if let pending = pendingOptionsSelectorResult { pendingOptionsSelectorResult = nil pending(.success(nil)) } if let pending = pendingTextEditorResult { pendingTextEditorResult = nil pending(.success(actionResult( from: activeTextEditorController?.resultPayload( actionId: activeTextEditorController?.closeActionId ?? "close" ) ))) } if let pending = pendingResultSheetResult { pendingResultSheetResult = nil pending(.success(nil)) } activeTextEditorController = nil activeSheetMode = .profileMenu } private var activeNavigationController: UINavigationController? { activeController as? UINavigationController } private func topViewController() -> UIViewController? { topViewController(from: host?.presentingViewController) } private func topViewController(from root: UIViewController?) -> UIViewController? { if let navigation = root as? UINavigationController { return topViewController(from: navigation.visibleViewController) } if let tab = root as? UITabBarController { return topViewController(from: tab.selectedViewController) } if let presented = root?.presentedViewController { return topViewController(from: presented) } return root } fileprivate func markDetailVisible(_ controller: NativeDetailTableViewController) { activeDetailTableController = controller guard activeSheetMode == .profileMenu else { return } flutterApi?.onDetailAppeared( event: PlatformNativeSheetDetailAppearedEvent( detailId: controller.detailId ) ) { _ in } } } // MARK: - Edit profile helpers (Flutter account_settings_page parity) private func nativeExtractInitials(from name: String) -> String { let parts = name .components(separatedBy: .whitespacesAndNewlines) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } if parts.isEmpty { return "U" } if parts.count == 1 { let w = parts[0] return String(w.prefix(w.count >= 2 ? 2 : 1)).uppercased() } let a = parts[0].first! let b = parts[1].first! return "\(a)\(b)".uppercased() } private func nativeAvatarAccentColor(seed: String) -> UIColor { let normalized = seed.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() var hash: UInt32 = 5381 for byte in normalized.utf8 { hash = ((hash << 5) &+ hash) &+ UInt32(byte) } let hue = CGFloat(hash % 360) / 360.0 return UIColor(hue: hue, saturation: 0.55, brightness: 0.52, alpha: 1) } private func nativeInitialsAvatarUIImage(name: String, diameter: CGFloat = 250) -> UIImage? { let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) let seed = trimmed.isEmpty ? "user" : trimmed let initials = nativeExtractInitials(from: trimmed.isEmpty ? "User" : trimmed) let fill = nativeAvatarAccentColor(seed: seed) let size = CGSize(width: diameter, height: diameter) let renderer = UIGraphicsImageRenderer(size: size) return renderer.image { ctx in let rect = CGRect(origin: .zero, size: size) ctx.cgContext.addEllipse(in: rect) ctx.cgContext.setFillColor(fill.cgColor) ctx.cgContext.fillPath() let fontSize = diameter * 0.35 let font = UIFont.systemFont(ofSize: fontSize, weight: .bold) let paragraph = NSMutableParagraphStyle() paragraph.alignment = .center let attrs: [NSAttributedString.Key: Any] = [ .font: font, .foregroundColor: UIColor.white, .paragraphStyle: paragraph, ] let text = NSString(string: initials) let bounds = text.boundingRect( with: size, options: [.usesLineFragmentOrigin], attributes: attrs, context: nil ) let drawRect = CGRect( x: (diameter - bounds.width) / 2, y: (diameter - bounds.height) / 2, width: bounds.width, height: bounds.height ) text.draw(in: drawRect, withAttributes: attrs) } } private func nativeInitialsAvatarDataUrl(name: String, diameter: CGFloat = 250) -> String? { guard let image = nativeInitialsAvatarUIImage(name: name, diameter: diameter) else { return nil } guard let data = image.pngData() else { return nil } return "data:image/png;base64," + data.base64EncodedString() } private func nativeFormatBirthDateIso(_ date: Date) -> String { let cal = Calendar(identifier: .gregorian) let c = cal.dateComponents([.year, .month, .day], from: date) guard let y = c.year, let m = c.month, let d = c.day else { return "" } return String(format: "%04d-%02d-%02d", y, m, d) } private func nativeParseBirthDateIso(_ raw: String) -> Date? { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return nil } let parts = trimmed.split(separator: "-").compactMap { Int($0) } guard parts.count == 3 else { return nil } var dc = DateComponents() dc.year = parts[0] dc.month = parts[1] dc.day = parts[2] return Calendar(identifier: .gregorian).date(from: dc) } private func nativeProfileCommitPayload( profile: NativeSheetProfile, name: String? = nil, profileImageUrl: String? = nil, bio: String? = nil, gender: String? = nil, dateOfBirth: String? = nil ) -> [String: Any] { [ "name": name ?? profile.displayName, "profileImageUrl": profileImageUrl ?? profile.savedProfileImageUrl ?? "", "bio": bio ?? profile.bio, "gender": gender ?? profile.gender, "dateOfBirth": dateOfBirth ?? profile.dateOfBirth ?? "", ] } final class NativeAvatarSelectionGeneration { private let lock = NSLock() private var generation: UInt64 = 0 func begin() -> UInt64 { lock.lock() generation &+= 1 let value = generation lock.unlock() return value } func invalidate() { lock.lock() generation &+= 1 lock.unlock() } func isCurrent(_ value: UInt64) -> Bool { lock.lock() let current = generation == value lock.unlock() return current } } private final class NativeProfilePhotoEditorViewController: UIViewController, PHPickerViewControllerDelegate { private enum AvatarIntent { case unchanged case pickedJPEG(Data) case initialsGenerated case removed } private let profile: NativeSheetProfile private let copy: NativeEditProfileSheetCopy private let onCommit: ([String: Any]) -> Void private let avatarView: NativeAvatarView private let clearButton = UIButton(type: .system) private let avatarSelectionGeneration = NativeAvatarSelectionGeneration() private var pendingPhotoGeneration: UInt64? private var avatarIntent: AvatarIntent = .unchanged { didSet { updateSetButton() } } init( profile: NativeSheetProfile, copy: NativeEditProfileSheetCopy, onCommit: @escaping ([String: Any]) -> Void ) { self.profile = profile self.copy = copy self.onCommit = onCommit self.avatarView = NativeAvatarView(profile: profile, diameter: 136) super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { nil } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = NativeSheetTheme.shared.background navigationItem.title = nativeLocalized("native.editPhoto", "Edit Photo") navigationItem.leftBarButtonItem = iconBarButton( systemName: "xmark", action: UIAction { [weak self] _ in self?.cancelTapped() } ) navigationItem.rightBarButtonItem = iconBarButton( systemName: "checkmark", style: .done, action: UIAction { [weak self] _ in self?.saveTapped() } ) updateSetButton() let avatarWrap = UIView() avatarWrap.translatesAutoresizingMaskIntoConstraints = false avatarWrap.addSubview(avatarView) avatarWrap.addSubview(clearButton) var clearConfiguration = UIButton.Configuration.filled() clearConfiguration.image = UIImage(systemName: "xmark") clearConfiguration.cornerStyle = .capsule clearConfiguration.baseBackgroundColor = NativeSheetTheme.shared.surface clearConfiguration.baseForegroundColor = NativeSheetTheme.shared.secondaryForeground clearButton.configuration = clearConfiguration clearButton.translatesAutoresizingMaskIntoConstraints = false clearButton.addAction(UIAction { [weak self] _ in self?.removeAvatarTapped() }, for: .touchUpInside) clearButton.accessibilityLabel = copy.removeAvatarLabel let photoButton = makeActionButton(title: nativeLocalized("native.photo", "Photo"), symbol: "photo.on.rectangle") { [weak self] in self?.presentPhotoPicker() } let initialsButton = makeActionButton(title: nativeLocalized("native.initials", "Initials"), symbol: "textformat.abc") { [weak self] in self?.useInitialsTapped() } let actionStack = UIStackView(arrangedSubviews: [photoButton, initialsButton]) actionStack.axis = .horizontal actionStack.distribution = .fillEqually actionStack.spacing = 12 let stack = UIStackView(arrangedSubviews: [avatarWrap, actionStack]) stack.axis = .vertical stack.alignment = .fill stack.spacing = 28 stack.translatesAutoresizingMaskIntoConstraints = false view.addSubview(stack) NSLayoutConstraint.activate([ avatarView.centerXAnchor.constraint(equalTo: avatarWrap.centerXAnchor), avatarView.topAnchor.constraint(equalTo: avatarWrap.topAnchor, constant: 24), avatarView.bottomAnchor.constraint(equalTo: avatarWrap.bottomAnchor), clearButton.widthAnchor.constraint(equalToConstant: 32), clearButton.heightAnchor.constraint(equalToConstant: 32), clearButton.trailingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: -4), clearButton.topAnchor.constraint(equalTo: avatarView.topAnchor, constant: 4), stack.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), stack.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16), ]) } private func makeActionButton( title: String, symbol: String, handler: @escaping () -> Void ) -> UIButton { var configuration = UIButton.Configuration.gray() configuration.title = title configuration.image = UIImage(systemName: symbol) configuration.imagePlacement = .top configuration.imagePadding = 6 configuration.cornerStyle = .medium let button = UIButton(configuration: configuration) button.titleLabel?.adjustsFontForContentSizeCategory = true button.titleLabel?.numberOfLines = 2 button.heightAnchor.constraint(greaterThanOrEqualToConstant: 76).isActive = true button.addAction(UIAction { _ in NativeSheetHaptics.selection() handler() }, for: .touchUpInside) return button } private func updateSetButton() { navigationItem.rightBarButtonItem?.isEnabled = { if case .unchanged = avatarIntent { return false } return true }() } @objc private func cancelTapped() { invalidatePendingPhotoSelection() dismiss(animated: true) } @objc private func saveTapped() { invalidatePendingPhotoSelection() let imageUrl: String switch avatarIntent { case .unchanged: return case .pickedJPEG(let data): imageUrl = "data:image/jpeg;base64," + data.base64EncodedString() case .initialsGenerated: imageUrl = nativeInitialsAvatarDataUrl(name: profile.displayName) ?? "" case .removed: imageUrl = "/user.png" } NativeSheetHaptics.success() onCommit(nativeProfileCommitPayload(profile: profile, profileImageUrl: imageUrl)) dismiss(animated: true) } private func presentPhotoPicker() { pendingPhotoGeneration = avatarSelectionGeneration.begin() var configuration = PHPickerConfiguration() configuration.filter = .images configuration.selectionLimit = 1 let picker = PHPickerViewController(configuration: configuration) picker.delegate = self present(picker, animated: true) } func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { picker.dismiss(animated: true) guard let selectionGeneration = pendingPhotoGeneration else { return } pendingPhotoGeneration = nil guard let provider = results.first?.itemProvider, provider.hasItemConformingToTypeIdentifier( UTType.image.identifier ) else { return } provider.loadFileRepresentation( forTypeIdentifier: UTType.image.identifier ) { [weak self] url, _ in guard let self, self.avatarSelectionGeneration.isCurrent( selectionGeneration ), let url else { return } autoreleasepool { guard let thumbnail = nativeDownsampleImageFile( at: url, maxPixelSize: 1024 ), let data = thumbnail.jpegData( compressionQuality: 0.85 ) else { return } DispatchQueue.main.async { [weak self] in guard let self, self.avatarSelectionGeneration.isCurrent( selectionGeneration ) else { return } self.avatarIntent = .pickedJPEG(data) self.avatarView.setPickedPreview(thumbnail) } } } } private func useInitialsTapped() { invalidatePendingPhotoSelection() avatarIntent = .initialsGenerated if let image = nativeInitialsAvatarUIImage(name: profile.displayName) { avatarView.setPickedPreview(image) } } private func removeAvatarTapped() { invalidatePendingPhotoSelection() avatarIntent = .removed avatarView.showRemovedPlaceholder() } private func invalidatePendingPhotoSelection() { pendingPhotoGeneration = nil avatarSelectionGeneration.invalidate() } } private final class NativeProfileNameEditorViewController: UIViewController { private let profile: NativeSheetProfile private let copy: NativeEditProfileSheetCopy private let onCommit: ([String: Any]) -> Void private let textField = UITextField() init( profile: NativeSheetProfile, copy: NativeEditProfileSheetCopy, onCommit: @escaping ([String: Any]) -> Void ) { self.profile = profile self.copy = copy self.onCommit = onCommit super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { nil } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = NativeSheetTheme.shared.background navigationItem.title = copy.nameLabel navigationItem.leftBarButtonItem = iconBarButton( systemName: "xmark", action: UIAction { [weak self] _ in self?.cancelTapped() } ) navigationItem.rightBarButtonItem = iconBarButton( systemName: "checkmark", style: .done, action: UIAction { [weak self] _ in self?.saveTapped() } ) let row = inputContainer(caption: copy.nameLabel, field: textField) textField.text = profile.displayName textField.placeholder = copy.nameLabel textField.returnKeyType = .done textField.addAction(UIAction { [weak self] _ in self?.saveTapped() }, for: .primaryActionTriggered) view.addSubview(row) NSLayoutConstraint.activate([ row.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), row.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), row.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 24), ]) } @objc private func cancelTapped() { dismiss(animated: true) } @objc private func saveTapped() { let name = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard !name.isEmpty else { presentNativeValidationAlert(message: copy.nameRequiredMessage) return } NativeSheetHaptics.success() onCommit(nativeProfileCommitPayload(profile: profile, name: name)) dismiss(animated: true) } } private final class NativeProfileAboutEditorViewController: UIViewController { private let profile: NativeSheetProfile private let copy: NativeEditProfileSheetCopy private let onCommit: ([String: Any]) -> Void private let textView = UITextView() init( profile: NativeSheetProfile, copy: NativeEditProfileSheetCopy, onCommit: @escaping ([String: Any]) -> Void ) { self.profile = profile self.copy = copy self.onCommit = onCommit super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { nil } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = NativeSheetTheme.shared.background navigationItem.title = copy.bioLabel navigationItem.leftBarButtonItem = iconBarButton( systemName: "xmark", action: UIAction { [weak self] _ in self?.cancelTapped() } ) navigationItem.rightBarButtonItem = iconBarButton( systemName: "checkmark", style: .done, action: UIAction { [weak self] _ in self?.saveTapped() } ) let caption = UILabel() caption.text = copy.bioLabel caption.font = .preferredFont(forTextStyle: .caption1) caption.textColor = NativeSheetTheme.shared.secondaryForeground caption.adjustsFontForContentSizeCategory = true textView.text = profile.bio textView.font = .preferredFont(forTextStyle: .body) textView.adjustsFontForContentSizeCategory = true textView.backgroundColor = NativeSheetTheme.shared.surface textView.layer.cornerRadius = 12 textView.textContainerInset = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) textView.heightAnchor.constraint(greaterThanOrEqualToConstant: 160).isActive = true let stack = UIStackView(arrangedSubviews: [caption, textView]) stack.axis = .vertical stack.spacing = 8 stack.translatesAutoresizingMaskIntoConstraints = false view.addSubview(stack) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), stack.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 24), ]) } @objc private func cancelTapped() { dismiss(animated: true) } @objc private func saveTapped() { NativeSheetHaptics.success() onCommit(nativeProfileCommitPayload(profile: profile, bio: textView.text ?? "")) dismiss(animated: true) } } private final class NativeProfileDetailsEditorViewController: UIViewController { private let profile: NativeSheetProfile private let copy: NativeEditProfileSheetCopy private let onCommit: ([String: Any]) -> Void private let genderButton = UIButton(type: .system) private let customGenderField = UITextField() private let customGenderContainer = UIStackView() private let birthPicker = UIDatePicker() private let clearBirthButton = UIButton(type: .system) private var selectedGenderKey = "" private var birthIso: String init( profile: NativeSheetProfile, copy: NativeEditProfileSheetCopy, onCommit: @escaping ([String: Any]) -> Void ) { self.profile = profile self.copy = copy self.onCommit = onCommit self.birthIso = profile.dateOfBirth?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" super.init(nibName: nil, bundle: nil) applyGenderSelectionFromProfile() } required init?(coder: NSCoder) { nil } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = NativeSheetTheme.shared.background navigationItem.title = copy.title navigationItem.leftBarButtonItem = iconBarButton( systemName: "xmark", action: UIAction { [weak self] _ in self?.cancelTapped() } ) navigationItem.rightBarButtonItem = iconBarButton( systemName: "checkmark", style: .done, action: UIAction { [weak self] _ in self?.saveTapped() } ) var genderConfiguration = UIButton.Configuration.bordered() genderConfiguration.titleAlignment = .leading genderButton.configuration = genderConfiguration genderButton.contentHorizontalAlignment = .leading genderButton.showsMenuAsPrimaryAction = true genderButton.changesSelectionAsPrimaryAction = true rebuildGenderMenu() customGenderField.borderStyle = .none customGenderField.backgroundColor = NativeSheetTheme.shared.input customGenderField.layer.cornerRadius = 22 customGenderField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 16, height: 44)) customGenderField.leftViewMode = .always customGenderField.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true configureBirthPicker() clearBirthButton.setTitle(copy.clearLabel, for: .normal) clearBirthButton.addAction(UIAction { [weak self] _ in self?.clearBirthTapped() }, for: .touchUpInside) let genderStack = labeledStack(caption: copy.genderLabel, arrangedSubviews: [genderButton]) customGenderContainer.axis = .vertical customGenderContainer.spacing = 8 customGenderContainer.addArrangedSubview(captionLabel(copy.customGenderLabel)) customGenderContainer.addArrangedSubview(customGenderField) let birthRow = UIStackView(arrangedSubviews: [birthPicker, clearBirthButton]) birthRow.axis = .horizontal birthRow.spacing = 12 birthRow.alignment = .center let birthStack = labeledStack(caption: copy.birthDateLabel, arrangedSubviews: [birthRow]) let stack = UIStackView(arrangedSubviews: [genderStack, customGenderContainer, birthStack]) stack.axis = .vertical stack.spacing = 24 stack.translatesAutoresizingMaskIntoConstraints = false view.addSubview(stack) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), stack.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), stack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 24), ]) refreshCustomGenderVisibility() } private func applyGenderSelectionFromProfile() { let gender = profile.gender.trimmingCharacters(in: .whitespacesAndNewlines) if gender.isEmpty { selectedGenderKey = "" customGenderField.text = "" } else if gender == "male" || gender == "female" { selectedGenderKey = gender customGenderField.text = "" } else { selectedGenderKey = "custom" customGenderField.text = gender } } private func rebuildGenderMenu() { let options: [(String, String)] = [ ("", copy.genderPreferNotToSay), ("male", copy.genderMale), ("female", copy.genderFemale), ("custom", copy.genderCustom), ] genderButton.menu = UIMenu(children: options.map { id, label in UIAction(title: label, state: id == selectedGenderKey ? .on : .off) { [weak self] _ in self?.selectedGenderKey = id self?.refreshGenderTitle() self?.refreshCustomGenderVisibility() } }) refreshGenderTitle() } private func refreshGenderTitle() { var configuration = genderButton.configuration ?? .bordered() configuration.title = genderTitle(for: selectedGenderKey) genderButton.configuration = configuration } private func genderTitle(for key: String) -> String { switch key { case "male": return copy.genderMale case "female": return copy.genderFemale case "custom": return copy.genderCustom default: return copy.genderPreferNotToSay } } private func refreshCustomGenderVisibility() { customGenderContainer.isHidden = selectedGenderKey != "custom" customGenderField.placeholder = copy.customGenderHint } private func configureBirthPicker() { birthPicker.datePickerMode = .date birthPicker.preferredDatePickerStyle = .compact birthPicker.maximumDate = Date() if let min = Calendar.current.date(from: DateComponents(year: 1900, month: 1, day: 1)) { birthPicker.minimumDate = min } if let parsed = nativeParseBirthDateIso(birthIso) { birthPicker.date = parsed } else { birthPicker.date = Calendar.current.date(from: DateComponents(year: 1990, month: 1, day: 1)) ?? Date() } birthPicker.addAction(UIAction { [weak self] _ in guard let self else { return } self.birthIso = nativeFormatBirthDateIso(self.birthPicker.date) }, for: .valueChanged) } private func clearBirthTapped() { NativeSheetHaptics.selection() birthIso = "" birthPicker.date = Calendar.current.date(from: DateComponents(year: 1990, month: 1, day: 1)) ?? Date() } private func resolvedGenderPayload() -> String { switch selectedGenderKey { case "male", "female": return selectedGenderKey case "custom": return customGenderField.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" default: return "" } } @objc private func cancelTapped() { dismiss(animated: true) } @objc private func saveTapped() { if selectedGenderKey == "custom", customGenderField.text?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true { presentNativeValidationAlert(message: copy.customGenderRequiredMessage) return } NativeSheetHaptics.success() onCommit(nativeProfileCommitPayload( profile: profile, gender: resolvedGenderPayload(), dateOfBirth: birthIso.trimmingCharacters(in: .whitespacesAndNewlines) )) dismiss(animated: true) } } private func captionLabel(_ text: String) -> UILabel { let label = UILabel() label.text = text label.font = .preferredFont(forTextStyle: .caption1) label.textColor = NativeSheetTheme.shared.secondaryForeground label.adjustsFontForContentSizeCategory = true return label } private func labeledStack(caption: String, arrangedSubviews: [UIView]) -> UIStackView { let stack = UIStackView(arrangedSubviews: [captionLabel(caption)] + arrangedSubviews) stack.axis = .vertical stack.spacing = 8 return stack } private func inputContainer(caption: String, field: UITextField) -> UIStackView { field.font = .preferredFont(forTextStyle: .body) field.adjustsFontForContentSizeCategory = true field.backgroundColor = NativeSheetTheme.shared.surface field.borderStyle = .none field.layer.cornerRadius = 12 field.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44)) field.leftViewMode = .always field.rightView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44)) field.rightViewMode = .always field.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true let stack = labeledStack(caption: caption, arrangedSubviews: [field]) stack.translatesAutoresizingMaskIntoConstraints = false return stack } private func iconBarButton( systemName: String, style: UIBarButtonItem.Style = .plain, action: UIAction ) -> UIBarButtonItem { let button = UIBarButtonItem( image: UIImage(systemName: systemName), primaryAction: action ) button.style = style return button } private extension UIViewController { func presentNativeValidationAlert(message: String) { let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: nativeLocalized("native.ok", "OK"), style: .default)) present(alert, animated: true) } } private extension UIImage { func preparingThumbnailSide(_ maxSide: CGFloat) -> UIImage? { let w = size.width let h = size.height guard w > 0, h > 0 else { return nil } let scale = min(maxSide / w, maxSide / h, 1) if scale >= 1 { return self } let nw = w * scale let nh = h * scale let renderer = UIGraphicsImageRenderer(size: CGSize(width: nw, height: nh)) return renderer.image { _ in draw(in: CGRect(x: 0, y: 0, width: nw, height: nh)) } } } private final class NativeSignOutOptionsViewController: UITableViewController { private let message: String? private let optionTitle: String private let optionSubtitle: String? private let cancelTitle: String private let confirmTitle: String private let onConfirm: (Bool) -> Void private var keepServerDetails = false init( title: String, message: String?, optionTitle: String, optionSubtitle: String?, cancelTitle: String, confirmTitle: String, onConfirm: @escaping (Bool) -> Void ) { self.message = message self.optionTitle = optionTitle self.optionSubtitle = optionSubtitle self.cancelTitle = cancelTitle self.confirmTitle = confirmTitle self.onConfirm = onConfirm super.init(style: .insetGrouped) self.title = title } required init?(coder: NSCoder) { nil } override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = NativeSheetTheme.shared.background tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 72 navigationItem.leftBarButtonItem = UIBarButtonItem( title: cancelTitle, primaryAction: UIAction { [weak self] _ in self?.dismiss(animated: true) } ) let confirmButton = UIBarButtonItem( title: confirmTitle, primaryAction: UIAction { [weak self] _ in self?.confirm() } ) confirmButton.tintColor = NativeSheetTheme.shared.destructive confirmButton.accessibilityIdentifier = "sign-out-confirm" navigationItem.rightBarButtonItem = confirmButton configureHeader() } private func configureHeader() { guard let message, !message.isEmpty else { return } let label = UILabel() label.font = .preferredFont(forTextStyle: .body) label.adjustsFontForContentSizeCategory = true label.textColor = NativeSheetTheme.shared.secondaryForeground label.numberOfLines = 0 label.text = message label.translatesAutoresizingMaskIntoConstraints = false let container = UIView() container.addSubview(label) NSLayoutConstraint.activate([ label.leadingAnchor.constraint( equalTo: container.layoutMarginsGuide.leadingAnchor ), label.trailingAnchor.constraint( equalTo: container.layoutMarginsGuide.trailingAnchor ), label.topAnchor.constraint(equalTo: container.topAnchor, constant: 16), label.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -8), ]) tableView.tableHeaderView = container } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() guard let header = tableView.tableHeaderView else { return } let target = CGSize( width: tableView.bounds.width, height: UIView.layoutFittingCompressedSize.height ) let height = header.systemLayoutSizeFitting( target, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel ).height if abs(header.frame.height - height) > 0.5 { header.frame.size.height = height tableView.tableHeaderView = header } } override func numberOfSections(in tableView: UITableView) -> Int { 1 } override func tableView( _ tableView: UITableView, numberOfRowsInSection section: Int ) -> Int { 1 } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) var content = cell.defaultContentConfiguration() content.text = optionTitle content.secondaryText = optionSubtitle content.secondaryTextProperties.numberOfLines = 0 content.image = UIImage( systemName: keepServerDetails ? "checkmark.square.fill" : "square" ) content.imageProperties.tintColor = keepServerDetails ? view.tintColor : NativeSheetTheme.shared.secondaryForeground cell.contentConfiguration = content applyTextAccessibilityLabel(to: cell, from: content) cell.selectionStyle = .default cell.accessibilityIdentifier = "sign-out-keep-server-details" cell.accessibilityTraits = keepServerDetails ? [.button, .selected] : [.button] return cell } override func tableView( _ tableView: UITableView, didSelectRowAt indexPath: IndexPath ) { NativeSheetHaptics.selection() keepServerDetails.toggle() tableView.reloadRows(at: [indexPath], with: .none) } private func confirm() { NativeSheetHaptics.mediumImpact() let selection = keepServerDetails dismiss(animated: true) { [onConfirm] in onConfirm(selection) } } } private final class NativeProfileMenuTableViewController: UITableViewController { private let configuration: NativeSheetConfiguration private let onSelect: (NativeSheetItem) -> Void private let onClose: () -> Void private var tableSections: [NativeSheetSection] { if !configuration.sections.isEmpty { return configuration.sections } var sections: [NativeSheetSection] = [] let menuItems = configuration.menuItems.filter { !$0.destructive } if !menuItems.isEmpty { sections.append(NativeSheetSection(title: nil, footer: nil, items: menuItems)) } let destructiveItems = configuration.menuItems.filter(\.destructive) if !destructiveItems.isEmpty { sections.append(NativeSheetSection(title: nil, footer: nil, items: destructiveItems)) } if !configuration.supportItems.isEmpty { sections.append(NativeSheetSection( title: configuration.supportTitle, footer: configuration.supportSubtitle, items: configuration.supportItems )) } return sections } init( configuration: NativeSheetConfiguration, onSelect: @escaping (NativeSheetItem) -> Void, onClose: @escaping () -> Void ) { self.configuration = configuration self.onSelect = onSelect self.onClose = onClose super.init(style: .insetGrouped) } required init?(coder: NSCoder) { nil } override func viewDidLoad() { super.viewDidLoad() title = configuration.profileMenuTitle navigationItem.largeTitleDisplayMode = .never navigationItem.rightBarButtonItem = closeButton() navigationController?.navigationBar.prefersLargeTitles = false tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") tableView.register(UITableViewCell.self, forCellReuseIdentifier: "profileCell") NativeSheetSettingsStyle.apply(to: tableView) tableView.tableHeaderView = nil } override func numberOfSections(in tableView: UITableView) -> Int { tableSections.count } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { tableSections[section].items.count } override func tableView( _ tableView: UITableView, titleForHeaderInSection section: Int ) -> String? { tableSections[section].title } override func tableView( _ tableView: UITableView, titleForFooterInSection section: Int ) -> String? { tableSections[section].footer } override func tableView( _ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int ) { NativeSheetSettingsStyle.applyHeaderFooterStyle(view) } override func tableView( _ tableView: UITableView, willDisplayFooterView view: UIView, forSection section: Int ) { NativeSheetSettingsStyle.applyHeaderFooterStyle(view) } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { let item = item(at: indexPath) if item.id == "profile" { let cell = tableView.dequeueReusableCell(withIdentifier: "profileCell", for: indexPath) configureProfileSummaryCell(cell) return cell } let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) configureNavigationCell( cell, item: item, showsDisclosure: shouldShowDisclosure(for: item) ) return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) onSelect(item(at: indexPath)) } private func item(at indexPath: IndexPath) -> NativeSheetItem { tableSections[indexPath.section].items[indexPath.row] } private func shouldShowDisclosure(for item: NativeSheetItem) -> Bool { item.url != nil || item.dismissOnSelect || configuration.details[item.id] != nil } private func configureProfileSummaryCell(_ cell: UITableViewCell) { cell.accessoryType = .disclosureIndicator cell.selectionStyle = .default NativeSheetSettingsStyle.applyCellStyle(cell) let avatar = NativeAvatarView(profile: configuration.profile, diameter: 56) let titleLabel = UILabel() titleLabel.text = configuration.profile.displayName titleLabel.font = .preferredFont(forTextStyle: .body) titleLabel.textColor = NativeSheetTheme.shared.foreground titleLabel.adjustsFontForContentSizeCategory = true titleLabel.numberOfLines = 1 let subtitleLabel = UILabel() subtitleLabel.text = configuration.profile.email subtitleLabel.font = .preferredFont(forTextStyle: .footnote) subtitleLabel.textColor = NativeSheetTheme.shared.secondaryForeground subtitleLabel.adjustsFontForContentSizeCategory = true subtitleLabel.numberOfLines = 1 let textStack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel]) textStack.axis = .vertical textStack.spacing = 2 let row = UIStackView(arrangedSubviews: [avatar, textStack]) row.axis = .horizontal row.alignment = .center row.spacing = NativeSheetSettingsStyle.iconSpacing row.translatesAutoresizingMaskIntoConstraints = false cell.contentView.subviews.forEach { $0.removeFromSuperview() } cell.contentView.addSubview(row) NSLayoutConstraint.activate([ row.leadingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.leadingAnchor), row.trailingAnchor.constraint(equalTo: cell.contentView.layoutMarginsGuide.trailingAnchor), row.topAnchor.constraint(equalTo: cell.contentView.topAnchor, constant: 11), row.bottomAnchor.constraint(equalTo: cell.contentView.bottomAnchor, constant: -11), ]) } private func closeButton() -> UIBarButtonItem { UIBarButtonItem( systemItem: .close, primaryAction: UIAction { [weak self] _ in self?.onClose() } ) } } private final class NativeSheetSegmentTableViewCell: UITableViewCell { static let reuseId = "NativeSheetSegmentTableViewCell" private let stack = UIStackView() private let titleLabel = UILabel() private let subtitleLabel = UILabel() private let segmentedControl = UISegmentedControl() private var boundOptions: [NativeSheetOption] = [] private var valueChanged: ((String) -> Void)? override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none NativeSheetSettingsStyle.applyCellStyle(self) stack.axis = .vertical stack.spacing = 8 stack.translatesAutoresizingMaskIntoConstraints = false titleLabel.font = .preferredFont(forTextStyle: .body) titleLabel.adjustsFontForContentSizeCategory = true titleLabel.numberOfLines = 0 subtitleLabel.font = .preferredFont(forTextStyle: .footnote) subtitleLabel.textColor = NativeSheetTheme.shared.secondaryForeground subtitleLabel.adjustsFontForContentSizeCategory = true subtitleLabel.numberOfLines = 0 applyNativeSheetSegmentedControlTheme(segmentedControl) contentView.addSubview(stack) stack.addArrangedSubview(titleLabel) stack.addArrangedSubview(subtitleLabel) stack.addArrangedSubview(segmentedControl) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), stack.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 11), stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -11), ]) segmentedControl.addAction(UIAction { [weak self] _ in guard let self else { return } let idx = self.segmentedControl.selectedSegmentIndex guard idx >= 0, idx < self.boundOptions.count else { return } self.valueChanged?(self.boundOptions[idx].id) }, for: .valueChanged) } required init?(coder: NSCoder) { nil } override func prepareForReuse() { super.prepareForReuse() valueChanged = nil boundOptions = [] } func configure(item: NativeSheetItem, onValueChanged: @escaping (String) -> Void) { // Re-read the theme on every bind: reused cells otherwise keep the // palette that was active when they were first created. NativeSheetSettingsStyle.applyCellStyle(self) applyNativeSheetSegmentedControlTheme(segmentedControl) titleLabel.textColor = NativeSheetTheme.shared.foreground subtitleLabel.textColor = NativeSheetTheme.shared.secondaryForeground titleLabel.text = item.title if let subtitle = item.subtitle, !subtitle.isEmpty { subtitleLabel.text = subtitle subtitleLabel.isHidden = false } else { subtitleLabel.isHidden = true } boundOptions = item.options while segmentedControl.numberOfSegments > 0 { segmentedControl.removeSegment(at: 0, animated: false) } for (idx, opt) in item.options.enumerated() { segmentedControl.insertSegment(withTitle: opt.label, at: idx, animated: false) } let selectedId = item.value as? String if let ix = item.options.firstIndex(where: { $0.id == selectedId }) { segmentedControl.selectedSegmentIndex = ix } else if !item.options.isEmpty { segmentedControl.selectedSegmentIndex = 0 } valueChanged = onValueChanged } } private final class NativeSheetDropdownTableViewCell: UITableViewCell { static let reuseId = "NativeSheetDropdownTableViewCell" private let rootStack = UIStackView() private let headerStack = UIStackView() private let iconView = UIImageView() private let textStack = UIStackView() private let titleLabel = UILabel() private let subtitleLabel = UILabel() private let button = UIButton(type: .system) private var boundOptions: [NativeSheetOption] = [] private var valueChanged: ((String) -> Void)? override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none NativeSheetSettingsStyle.applyCellStyle(self) rootStack.axis = .vertical rootStack.spacing = 8 rootStack.translatesAutoresizingMaskIntoConstraints = false headerStack.axis = .horizontal headerStack.alignment = .top headerStack.spacing = NativeSheetSettingsStyle.iconSpacing textStack.axis = .vertical textStack.spacing = 2 textStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) iconView.tintColor = NativeSheetTheme.shared.icon iconView.contentMode = .scaleAspectFit iconView.setContentHuggingPriority(.required, for: .horizontal) titleLabel.font = .preferredFont(forTextStyle: .body) titleLabel.textColor = NativeSheetTheme.shared.foreground titleLabel.adjustsFontForContentSizeCategory = true titleLabel.numberOfLines = 0 subtitleLabel.font = .preferredFont(forTextStyle: .footnote) subtitleLabel.textColor = NativeSheetTheme.shared.secondaryForeground subtitleLabel.adjustsFontForContentSizeCategory = true subtitleLabel.numberOfLines = 0 button.titleLabel?.font = .preferredFont(forTextStyle: .body) button.titleLabel?.adjustsFontForContentSizeCategory = true button.titleLabel?.numberOfLines = 2 button.tintColor = NativeSheetTheme.shared.icon button.contentHorizontalAlignment = .leading button.showsMenuAsPrimaryAction = true button.changesSelectionAsPrimaryAction = true contentView.addSubview(rootStack) rootStack.addArrangedSubview(headerStack) rootStack.addArrangedSubview(button) headerStack.addArrangedSubview(iconView) headerStack.addArrangedSubview(textStack) textStack.addArrangedSubview(titleLabel) textStack.addArrangedSubview(subtitleLabel) NSLayoutConstraint.activate([ rootStack.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), rootStack.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), rootStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 11), rootStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -11), iconView.widthAnchor.constraint(equalToConstant: NativeSheetSettingsStyle.iconSize), iconView.heightAnchor.constraint(equalToConstant: NativeSheetSettingsStyle.iconSize), button.heightAnchor.constraint(greaterThanOrEqualToConstant: 36), ]) } required init?(coder: NSCoder) { nil } override func prepareForReuse() { super.prepareForReuse() boundOptions = [] valueChanged = nil button.menu = nil } func configure(item: NativeSheetItem, onValueChanged: @escaping (String) -> Void) { titleLabel.text = item.title subtitleLabel.text = item.subtitle subtitleLabel.isHidden = item.subtitle?.isEmpty ?? true iconView.image = UIImage(systemName: item.sfSymbol)?.withConfiguration( UIImage.SymbolConfiguration( pointSize: NativeSheetSettingsStyle.iconSize, weight: .regular ) ) boundOptions = item.options valueChanged = onValueChanged let selectedId = (item.value as? String) ?? item.options.first?.id setSelectedTitle(Self.optionLabel(selectedId: selectedId, options: item.options)) button.menu = UIMenu(children: item.options.map { option in UIAction( title: option.label, state: option.id == selectedId ? .on : .off ) { [weak self] _ in self?.setSelectedTitle(option.label) self?.valueChanged?(option.id) } }) } private func setSelectedTitle(_ title: String) { var configuration = UIButton.Configuration.gray() configuration.title = title configuration.titleLineBreakMode = .byTruncatingTail configuration.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 10, bottom: 6, trailing: 10) configuration.baseForegroundColor = NativeSheetTheme.shared.foreground configuration.cornerStyle = .medium button.configuration = configuration } private static func optionLabel( selectedId: String?, options: [NativeSheetOption] ) -> String { guard let selectedId else { return options.first?.label ?? nativeLocalized("native.select", "Select") } return options.first { $0.id == selectedId }?.label ?? options.first?.label ?? nativeLocalized("native.select", "Select") } } private final class NativeSheetMultilineTextTableViewCell: UITableViewCell, UITextViewDelegate { static let reuseId = "NativeSheetMultilineTextTableViewCell" private let stack = UIStackView() private let captionLabel = UILabel() private let textView = UITextView() private var onChange: ((String) -> Void)? override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none NativeSheetSettingsStyle.applyCellStyle(self) stack.axis = .vertical stack.spacing = 8 stack.translatesAutoresizingMaskIntoConstraints = false captionLabel.font = .preferredFont(forTextStyle: .caption1) captionLabel.textColor = NativeSheetTheme.shared.secondaryForeground captionLabel.adjustsFontForContentSizeCategory = true captionLabel.numberOfLines = 0 textView.font = .preferredFont(forTextStyle: .body) textView.adjustsFontForContentSizeCategory = true textView.layer.cornerCurve = .continuous textView.layer.cornerRadius = 12 textView.backgroundColor = NativeSheetTheme.shared.input textView.textContainerInset = UIEdgeInsets(top: 12, left: 12, bottom: 12, right: 12) textView.delegate = self textView.isScrollEnabled = false contentView.addSubview(stack) stack.addArrangedSubview(captionLabel) stack.addArrangedSubview(textView) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), stack.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 11), stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -11), textView.heightAnchor.constraint(greaterThanOrEqualToConstant: 120), ]) } required init?(coder: NSCoder) { nil } override func prepareForReuse() { super.prepareForReuse() onChange = nil textView.text = "" } func configure( item: NativeSheetItem, value: String, onTextChanged: @escaping (String) -> Void ) { captionLabel.text = item.title textView.text = value textView.accessibilityLabel = item.title onChange = onTextChanged } func textViewDidChange(_ textView: UITextView) { onChange?(textView.text ?? "") } } private final class NativeSheetTextFieldTableViewCell: UITableViewCell { static let reuseId = "NativeSheetTextFieldTableViewCell" private let stack = UIStackView() private let titleLabel = UILabel() private let subtitleLabel = UILabel() private let textField = UITextField() private var onChange: ((String) -> Void)? private var onDone: (() -> Void)? override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none NativeSheetSettingsStyle.applyCellStyle(self) stack.axis = .vertical stack.spacing = 8 stack.translatesAutoresizingMaskIntoConstraints = false titleLabel.font = .preferredFont(forTextStyle: .body) titleLabel.textColor = NativeSheetTheme.shared.foreground titleLabel.adjustsFontForContentSizeCategory = true titleLabel.numberOfLines = 0 subtitleLabel.font = .preferredFont(forTextStyle: .footnote) subtitleLabel.textColor = NativeSheetTheme.shared.secondaryForeground subtitleLabel.adjustsFontForContentSizeCategory = true subtitleLabel.numberOfLines = 0 textField.font = .preferredFont(forTextStyle: .body) textField.adjustsFontForContentSizeCategory = true textField.backgroundColor = NativeSheetTheme.shared.input textField.layer.cornerRadius = 12 textField.layer.cornerCurve = .continuous textField.clipsToBounds = true textField.textColor = NativeSheetTheme.shared.foreground textField.tintColor = NativeSheetTheme.shared.accent textField.returnKeyType = .done textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44)) textField.leftViewMode = .always textField.rightView = UIView(frame: CGRect(x: 0, y: 0, width: 12, height: 44)) textField.rightViewMode = .always textField.addAction(UIAction { [weak self] _ in self?.onChange?(self?.textField.text ?? "") }, for: .editingChanged) textField.addAction(UIAction { [weak self] _ in self?.onChange?(self?.textField.text ?? "") }, for: .editingDidEnd) textField.addAction(UIAction { [weak self] _ in self?.onChange?(self?.textField.text ?? "") self?.textField.resignFirstResponder() self?.onDone?() }, for: .primaryActionTriggered) contentView.addSubview(stack) stack.addArrangedSubview(titleLabel) stack.addArrangedSubview(subtitleLabel) stack.addArrangedSubview(textField) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), stack.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 11), stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -11), textField.heightAnchor.constraint(greaterThanOrEqualToConstant: 44), ]) } required init?(coder: NSCoder) { nil } override func prepareForReuse() { super.prepareForReuse() onChange = nil onDone = nil textField.text = "" textField.placeholder = nil textField.isSecureTextEntry = false } func configure( item: NativeSheetItem, value: String, onTextChanged: @escaping (String) -> Void, onReturn: @escaping () -> Void ) { titleLabel.text = item.title subtitleLabel.text = item.subtitle subtitleLabel.isHidden = item.subtitle?.isEmpty ?? true textField.text = value textField.placeholder = item.placeholder textField.isSecureTextEntry = item.kind == "secureTextField" textField.textContentType = item.kind == "secureTextField" ? .password : nil textField.autocorrectionType = item.kind == "secureTextField" ? .no : .yes textField.autocapitalizationType = item.kind == "secureTextField" ? .none : .sentences textField.accessibilityLabel = item.title onChange = onTextChanged onDone = onReturn } } private final class NativeSheetReadOnlyTextTableViewCell: UITableViewCell { static let reuseId = "NativeSheetReadOnlyTextTableViewCell" private let stack = UIStackView() private let captionLabel = UILabel() private let textView = UITextView() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none NativeSheetSettingsStyle.applyCellStyle(self) stack.axis = .vertical stack.spacing = 8 stack.translatesAutoresizingMaskIntoConstraints = false captionLabel.font = .preferredFont(forTextStyle: .caption1) captionLabel.textColor = NativeSheetTheme.shared.secondaryForeground captionLabel.adjustsFontForContentSizeCategory = true captionLabel.numberOfLines = 0 textView.font = .preferredFont(forTextStyle: .body) textView.adjustsFontForContentSizeCategory = true textView.isEditable = false textView.isSelectable = true textView.isScrollEnabled = false textView.backgroundColor = .clear textView.textContainerInset = .zero textView.textContainer.lineFragmentPadding = 0 contentView.addSubview(stack) stack.addArrangedSubview(captionLabel) stack.addArrangedSubview(textView) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), stack.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 11), stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -11), ]) } required init?(coder: NSCoder) { nil } func configure(item: NativeSheetItem) { captionLabel.text = item.title textView.text = (item.value as? String) ?? item.subtitle ?? "" } } private final class NativeSheetSliderTableViewCell: UITableViewCell { static let reuseId = "NativeSheetSliderTableViewCell" private let stack = UIStackView() private let titleLabel = UILabel() private let subtitleLabel = UILabel() private let slider = UISlider() private let valueLabel = UILabel() private var boundItem: NativeSheetItem? private var onCommit: ((Double) -> Void)? override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none NativeSheetSettingsStyle.applyCellStyle(self) stack.axis = .vertical stack.spacing = 8 stack.translatesAutoresizingMaskIntoConstraints = false titleLabel.font = .preferredFont(forTextStyle: .body) titleLabel.adjustsFontForContentSizeCategory = true titleLabel.numberOfLines = 0 subtitleLabel.font = .preferredFont(forTextStyle: .footnote) subtitleLabel.textColor = NativeSheetTheme.shared.secondaryForeground subtitleLabel.adjustsFontForContentSizeCategory = true subtitleLabel.numberOfLines = 0 valueLabel.font = .preferredFont(forTextStyle: .body) valueLabel.adjustsFontForContentSizeCategory = true valueLabel.textAlignment = .natural valueLabel.textColor = NativeSheetTheme.shared.secondaryForeground slider.addTarget(self, action: #selector(sliderEditingChanged), for: .valueChanged) slider.addTarget(self, action: #selector(sliderReleased), for: [.touchUpInside, .touchUpOutside, .touchCancel]) contentView.addSubview(stack) stack.addArrangedSubview(titleLabel) stack.addArrangedSubview(subtitleLabel) stack.addArrangedSubview(slider) stack.addArrangedSubview(valueLabel) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), stack.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), stack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 11), stack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -11), ]) } required init?(coder: NSCoder) { nil } override func prepareForReuse() { super.prepareForReuse() boundItem = nil onCommit = nil } func configure(item: NativeSheetItem, onValueCommitted: @escaping (Double) -> Void) { boundItem = item onCommit = onValueCommitted titleLabel.text = item.title if let subtitle = item.subtitle, !subtitle.isEmpty { subtitleLabel.text = subtitle subtitleLabel.isHidden = false } else { subtitleLabel.isHidden = true } let minV = Float(item.sliderMin ?? 0) let maxV = Float(item.sliderMax ?? 1) slider.minimumValue = minV slider.maximumValue = maxV var current = item.sliderNumericValue if let mn = item.sliderMin, let mx = item.sliderMax { current = min(mx, max(mn, current)) } slider.value = Float(current) refreshValueLabel(for: item, value: current) valueLabel.textAlignment = item.id == "tts-speech-rate" ? .natural : .right } private func refreshValueLabel(for item: NativeSheetItem, value: Double) { switch item.id { case "stt-silence-duration": valueLabel.text = String(format: "%.1fs", value / 1000) case "tts-speech-rate": valueLabel.text = "\(Int(round(value * 100)))%" default: valueLabel.text = String(format: "%.2f", value) } } @objc private func sliderEditingChanged() { guard let item = boundItem else { return } let value = Double(slider.value) refreshValueLabel(for: item, value: value) } @objc private func sliderReleased() { guard let item = boundItem else { return } var v = Double(slider.value) if let mn = item.sliderMin, let mx = item.sliderMax, let div = item.sliderDivisions, div > 0 { let step = (mx - mn) / Double(div) v = mn + (round((v - mn) / step) * step) v = min(mx, max(mn, v)) slider.value = Float(v) } refreshValueLabel(for: item, value: v) onCommit?(v) } } private func clearArrangedSubviews(from stack: UIStackView) { for view in stack.arrangedSubviews { stack.removeArrangedSubview(view) view.removeFromSuperview() } } private func nativeChipConfiguration( title: String, image: UIImage?, foregroundColor: UIColor ) -> UIButton.Configuration { var configuration = UIButton.Configuration.gray() configuration.title = title configuration.image = image configuration.imagePadding = image == nil ? 0 : 6 configuration.baseForegroundColor = foregroundColor configuration.buttonSize = .small configuration.cornerStyle = .capsule configuration.titleLineBreakMode = .byTruncatingTail configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration( pointSize: 12, weight: .medium ) configuration.contentInsets = NSDirectionalEdgeInsets( top: 4, leading: 8, bottom: 4, trailing: 8 ) return configuration } private final class NativeSheetQueryChipButton: UIButton { override init(frame: CGRect) { super.init(frame: frame) titleLabel?.font = .preferredFont(forTextStyle: .caption1) titleLabel?.adjustsFontForContentSizeCategory = true titleLabel?.numberOfLines = 1 titleLabel?.lineBreakMode = .byTruncatingTail contentHorizontalAlignment = .leading isUserInteractionEnabled = false setContentCompressionResistancePriority(.required, for: .horizontal) } required init?(coder: NSCoder) { nil } func configure(title: String) { configuration = nativeChipConfiguration( title: title, image: UIImage(systemName: "magnifyingglass"), foregroundColor: NativeSheetTheme.shared.secondaryForeground ) } } private final class NativeSheetFaviconView: UIView { private let imageView = UIImageView() private let fallbackView = UIView() private let fallbackIconView = UIImageView() private var expectedImageUrl: String? private var imageLoadToken: NativeSheetImageLoadToken? init(side: CGFloat) { super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false widthAnchor.constraint(equalToConstant: side).isActive = true heightAnchor.constraint(equalToConstant: side).isActive = true layer.cornerRadius = side / 2 layer.cornerCurve = .continuous layer.borderWidth = 1 layer.borderColor = NativeSheetTheme.shared.border.cgColor clipsToBounds = true backgroundColor = NativeSheetTheme.shared.surface imageView.translatesAutoresizingMaskIntoConstraints = false imageView.contentMode = .scaleAspectFill imageView.isHidden = true addSubview(imageView) fallbackView.translatesAutoresizingMaskIntoConstraints = false fallbackView.backgroundColor = NativeSheetTheme.shared.input addSubview(fallbackView) fallbackIconView.translatesAutoresizingMaskIntoConstraints = false fallbackIconView.contentMode = .scaleAspectFit fallbackIconView.tintColor = NativeSheetTheme.shared.icon fallbackView.addSubview(fallbackIconView) NSLayoutConstraint.activate([ imageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 1), imageView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -1), imageView.topAnchor.constraint(equalTo: topAnchor, constant: 1), imageView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -1), fallbackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 1), fallbackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -1), fallbackView.topAnchor.constraint(equalTo: topAnchor, constant: 1), fallbackView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -1), fallbackIconView.centerXAnchor.constraint(equalTo: fallbackView.centerXAnchor), fallbackIconView.centerYAnchor.constraint(equalTo: fallbackView.centerYAnchor), fallbackIconView.widthAnchor.constraint(equalTo: fallbackView.widthAnchor, multiplier: 0.6), fallbackIconView.heightAnchor.constraint(equalTo: fallbackView.heightAnchor, multiplier: 0.6), ]) } required init?(coder: NSCoder) { nil } deinit { imageLoadToken?.cancel() } func configure(rawUrl: String?, faviconUrl: String?, fallbackSystemName: String) { imageLoadToken?.cancel() imageLoadToken = nil expectedImageUrl = faviconUrl ?? NativeSheetURLFormatting.googleFaviconUrl(rawUrl: rawUrl, size: 32) imageView.image = nil imageView.isHidden = true fallbackView.isHidden = false fallbackIconView.image = UIImage(systemName: fallbackSystemName) guard let imageUrl = expectedImageUrl else { return } imageLoadToken = NativeSheetImageLoader.load( rawUrl: imageUrl, targetPixelSize: 96 ) { [weak self] image in guard let self, self.expectedImageUrl == imageUrl else { return } self.imageView.image = image self.imageView.isHidden = false self.fallbackView.isHidden = true } } } private final class NativeSheetLinkChipButton: UIButton { private var actionUrl: URL? private var onOpen: ((URL) -> Void)? private var expectedImageUrl: String? private var imageLoadToken: NativeSheetImageLoadToken? override init(frame: CGRect) { super.init(frame: frame) titleLabel?.font = .preferredFont(forTextStyle: .caption1) titleLabel?.adjustsFontForContentSizeCategory = true titleLabel?.numberOfLines = 1 titleLabel?.lineBreakMode = .byTruncatingTail contentHorizontalAlignment = .leading addAction( UIAction { [weak self] _ in guard let self, let actionUrl = self.actionUrl else { return } self.onOpen?(actionUrl) }, for: .touchUpInside ) } required init?(coder: NSCoder) { nil } deinit { imageLoadToken?.cancel() } func configure(link: NativeSheetLink, onOpen: @escaping (URL) -> Void) { imageLoadToken?.cancel() imageLoadToken = nil self.onOpen = onOpen actionUrl = link.url expectedImageUrl = link.faviconUrl ?? NativeSheetURLFormatting.googleFaviconUrl(rawUrl: link.rawUrl, size: 16) let buttonConfiguration = nativeChipConfiguration( title: link.title ?? NativeSheetURLFormatting.displayLabel(for: link.rawUrl), image: UIImage(systemName: "globe"), foregroundColor: NativeSheetTheme.shared.foreground ) configuration = buttonConfiguration isEnabled = link.url != nil alpha = link.url == nil ? 0.75 : 1 guard let imageUrl = expectedImageUrl else { return } imageLoadToken = NativeSheetImageLoader.load( rawUrl: imageUrl, targetPixelSize: 48 ) { [weak self] image in guard let self, self.expectedImageUrl == imageUrl else { return } var updated = self.configuration ?? buttonConfiguration updated.image = image.withRenderingMode(.alwaysOriginal) self.configuration = updated } } } private final class NativeSheetSourceTableViewCell: UITableViewCell { static let reuseId = "NativeSheetSourceTableViewCell" private let rootStack = UIStackView() private let headerStack = UIStackView() private let indexLabel = UILabel() private let faviconView = NativeSheetFaviconView(side: NativeSheetSettingsStyle.iconSize) private let textStack = UIStackView() private let titleLabel = UILabel() private let detailLabel = UILabel() private let snippetLabel = UILabel() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) NativeSheetSettingsStyle.applyCellStyle(self) rootStack.axis = .vertical rootStack.spacing = 6 rootStack.translatesAutoresizingMaskIntoConstraints = false headerStack.axis = .horizontal headerStack.alignment = .top headerStack.spacing = NativeSheetSettingsStyle.iconSpacing indexLabel.font = UIFontMetrics(forTextStyle: .caption1) .scaledFont(for: .systemFont(ofSize: 12, weight: .semibold)) indexLabel.adjustsFontForContentSizeCategory = true indexLabel.textColor = NativeSheetTheme.shared.secondaryForeground indexLabel.textAlignment = .natural indexLabel.setContentHuggingPriority(.required, for: .horizontal) indexLabel.setContentCompressionResistancePriority(.required, for: .horizontal) textStack.axis = .vertical textStack.spacing = 3 textStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) titleLabel.font = UIFontMetrics(forTextStyle: .body) .scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)) titleLabel.adjustsFontForContentSizeCategory = true titleLabel.textColor = NativeSheetTheme.shared.foreground titleLabel.numberOfLines = 2 detailLabel.font = .preferredFont(forTextStyle: .caption1) detailLabel.adjustsFontForContentSizeCategory = true detailLabel.textColor = NativeSheetTheme.shared.secondaryForeground detailLabel.numberOfLines = 1 detailLabel.lineBreakMode = .byTruncatingMiddle snippetLabel.font = .preferredFont(forTextStyle: .footnote) snippetLabel.adjustsFontForContentSizeCategory = true snippetLabel.textColor = NativeSheetTheme.shared.secondaryForeground snippetLabel.numberOfLines = 4 contentView.addSubview(rootStack) rootStack.addArrangedSubview(headerStack) rootStack.addArrangedSubview(snippetLabel) headerStack.addArrangedSubview(indexLabel) headerStack.addArrangedSubview(faviconView) headerStack.addArrangedSubview(textStack) textStack.addArrangedSubview(titleLabel) textStack.addArrangedSubview(detailLabel) NSLayoutConstraint.activate([ rootStack.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), rootStack.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), rootStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 11), rootStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -11), indexLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 18), ]) } required init?(coder: NSCoder) { nil } func configure(item: NativeSheetItem) { titleLabel.text = item.title detailLabel.text = item.sourceDisplayUrl ?? item.sourceDisplayType detailLabel.isHidden = detailLabel.text?.isEmpty ?? true snippetLabel.text = item.sourceDisplaySnippet snippetLabel.isHidden = snippetLabel.text?.isEmpty ?? true if let sourceIndex = item.sourceIndex { indexLabel.text = "\(sourceIndex)." indexLabel.isHidden = false } else { indexLabel.text = nil indexLabel.isHidden = true } let fallbackSymbol = item.url == nil ? "doc.text" : "globe" faviconView.configure( rawUrl: item.sourceDisplayUrl, faviconUrl: item.faviconUrl, fallbackSystemName: fallbackSymbol ) accessoryType = item.url == nil ? .none : .disclosureIndicator selectionStyle = item.url == nil ? .none : .default NativeSheetSettingsStyle.applyCellStyle(self) } } private final class NativeSheetStatusTableViewCell: UITableViewCell { static let reuseId = "NativeSheetStatusTableViewCell" private let rootStack = UIStackView() private let headerStack = UIStackView() private let iconView = UIImageView() private let contentStack = UIStackView() private let titleLabel = UILabel() private let querySectionLabel = UILabel() private let queryStack = UIStackView() private let linkSectionLabel = UILabel() private let linkStack = UIStackView() override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) selectionStyle = .none NativeSheetSettingsStyle.applyCellStyle(self) rootStack.axis = .vertical rootStack.spacing = 10 rootStack.translatesAutoresizingMaskIntoConstraints = false headerStack.axis = .horizontal headerStack.alignment = .top headerStack.spacing = NativeSheetSettingsStyle.iconSpacing iconView.tintColor = NativeSheetTheme.shared.icon iconView.contentMode = .scaleAspectFit iconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration( pointSize: NativeSheetSettingsStyle.iconSize, weight: .regular ) iconView.setContentHuggingPriority(.required, for: .horizontal) contentStack.axis = .vertical contentStack.spacing = 6 contentStack.alignment = .fill titleLabel.font = UIFontMetrics(forTextStyle: .body) .scaledFont(for: .systemFont(ofSize: 17, weight: .semibold)) titleLabel.adjustsFontForContentSizeCategory = true titleLabel.textColor = NativeSheetTheme.shared.foreground titleLabel.numberOfLines = 0 querySectionLabel.text = nativeLocalized("native.sheet.searches", "Searches") querySectionLabel.font = .preferredFont(forTextStyle: .caption1) querySectionLabel.adjustsFontForContentSizeCategory = true querySectionLabel.textColor = NativeSheetTheme.shared.secondaryForeground queryStack.axis = .vertical queryStack.spacing = 6 queryStack.alignment = .leading linkSectionLabel.text = nativeLocalized("native.sheet.sources", "Sources") linkSectionLabel.font = .preferredFont(forTextStyle: .caption1) linkSectionLabel.adjustsFontForContentSizeCategory = true linkSectionLabel.textColor = NativeSheetTheme.shared.secondaryForeground linkStack.axis = .vertical linkStack.spacing = 6 linkStack.alignment = .leading contentView.addSubview(rootStack) rootStack.addArrangedSubview(headerStack) headerStack.addArrangedSubview(iconView) headerStack.addArrangedSubview(contentStack) contentStack.addArrangedSubview(titleLabel) contentStack.addArrangedSubview(querySectionLabel) contentStack.addArrangedSubview(queryStack) contentStack.addArrangedSubview(linkSectionLabel) contentStack.addArrangedSubview(linkStack) NSLayoutConstraint.activate([ rootStack.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), rootStack.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), rootStack.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 11), rootStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -11), iconView.widthAnchor.constraint(equalToConstant: NativeSheetSettingsStyle.iconSize), iconView.heightAnchor.constraint(equalToConstant: NativeSheetSettingsStyle.iconSize), ]) } required init?(coder: NSCoder) { nil } override func prepareForReuse() { super.prepareForReuse() clearArrangedSubviews(from: queryStack) clearArrangedSubviews(from: linkStack) } func configure( item: NativeSheetItem, onOpenLink: @escaping (URL) -> Void ) { titleLabel.text = item.title let symbolName = item.pending ? "circle.dotted" : "circle.fill" iconView.image = UIImage(systemName: symbolName) iconView.tintColor = item.pending ? NativeSheetTheme.shared.accent : NativeSheetTheme.shared.icon clearArrangedSubviews(from: queryStack) clearArrangedSubviews(from: linkStack) for query in item.queries { let chip = NativeSheetQueryChipButton(frame: .zero) chip.configure(title: query) queryStack.addArrangedSubview(chip) } querySectionLabel.isHidden = item.queries.isEmpty queryStack.isHidden = item.queries.isEmpty for link in item.links { let button = NativeSheetLinkChipButton(frame: .zero) button.configure(link: link, onOpen: onOpenLink) linkStack.addArrangedSubview(button) } linkSectionLabel.isHidden = item.links.isEmpty linkStack.isHidden = item.links.isEmpty } } private final class NativeDetailTableViewController: UITableViewController { private var detail: NativeSheetDetail private let canNavigate: (NativeSheetItem) -> Bool private let onSelect: (NativeSheetItem) -> Void private let onControlChanged: (NativeSheetItem, Any?) -> Void private let onConfirmAction: (String) -> Void private let onClose: () -> Void private var pendingTextValues: [String: String] = [:] private var committedTextValues: [String: String] = [:] var detailId: String { detail.id } private var tableSections: [NativeSheetSection] { if !detail.sections.isEmpty { return detail.sections } var sections = [ NativeSheetSection( title: nil, footer: detail.subtitle, items: detail.items.filter { !$0.destructive } ), ].filter { !$0.items.isEmpty } let destructiveItems = detail.items.filter(\.destructive) if !destructiveItems.isEmpty { sections.append( NativeSheetSection(title: nil, footer: nil, items: destructiveItems) ) } return sections } init( detail: NativeSheetDetail, canNavigate: @escaping (NativeSheetItem) -> Bool, onSelect: @escaping (NativeSheetItem) -> Void, onControlChanged: @escaping (NativeSheetItem, Any?) -> Void, onConfirmAction: @escaping (String) -> Void, onClose: @escaping () -> Void ) { self.detail = detail self.canNavigate = canNavigate self.onSelect = onSelect self.onControlChanged = onControlChanged self.onConfirmAction = onConfirmAction self.onClose = onClose super.init(style: .insetGrouped) } required init?(coder: NSCoder) { nil } func applyUpdatedDetail(_ newDetail: NativeSheetDetail) { detail = newDetail pendingTextValues.removeAll() committedTextValues.removeAll() title = newDetail.title navigationItem.title = newDetail.title refreshNavigationAction() tableView.reloadData() } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) NativeSheetBridge.shared.markDetailVisible(self) refreshNavigationAction() } override func viewDidLoad() { super.viewDidLoad() title = detail.title navigationItem.largeTitleDisplayMode = .never refreshNavigationAction() tableView.register(UITableViewCell.self, forCellReuseIdentifier: "cell") tableView.register( NativeSheetSegmentTableViewCell.self, forCellReuseIdentifier: NativeSheetSegmentTableViewCell.reuseId ) tableView.register( NativeSheetDropdownTableViewCell.self, forCellReuseIdentifier: NativeSheetDropdownTableViewCell.reuseId ) tableView.register( NativeSheetMultilineTextTableViewCell.self, forCellReuseIdentifier: NativeSheetMultilineTextTableViewCell.reuseId ) tableView.register( NativeSheetTextFieldTableViewCell.self, forCellReuseIdentifier: NativeSheetTextFieldTableViewCell.reuseId ) tableView.register( NativeSheetReadOnlyTextTableViewCell.self, forCellReuseIdentifier: NativeSheetReadOnlyTextTableViewCell.reuseId ) tableView.register( NativeSheetSliderTableViewCell.self, forCellReuseIdentifier: NativeSheetSliderTableViewCell.reuseId ) tableView.register( NativeSheetSourceTableViewCell.self, forCellReuseIdentifier: NativeSheetSourceTableViewCell.reuseId ) tableView.register( NativeSheetStatusTableViewCell.self, forCellReuseIdentifier: NativeSheetStatusTableViewCell.reuseId ) tableView.estimatedRowHeight = NativeSheetSettingsStyle.defaultCellHeight tableView.rowHeight = UITableView.automaticDimension NativeSheetSettingsStyle.apply(to: tableView) } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) view.endEditing(true) } override func tableView( _ tableView: UITableView, titleForFooterInSection section: Int ) -> String? { tableSections[section].footer } override func tableView( _ tableView: UITableView, titleForHeaderInSection section: Int ) -> String? { tableSections[section].title } override func tableView( _ tableView: UITableView, willDisplayFooterView view: UIView, forSection section: Int ) { NativeSheetSettingsStyle.applyHeaderFooterStyle(view) } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { tableSections[section].items.count } override func numberOfSections(in tableView: UITableView) -> Int { tableSections.count } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { let item = item(at: indexPath) switch item.kind { case "segment": let cell = tableView.dequeueReusableCell( withIdentifier: NativeSheetSegmentTableViewCell.reuseId, for: indexPath ) as! NativeSheetSegmentTableViewCell cell.configure(item: item) { [weak self] newValue in self?.onControlChanged(item, newValue) } return cell case "dropdown": let cell = tableView.dequeueReusableCell( withIdentifier: NativeSheetDropdownTableViewCell.reuseId, for: indexPath ) as! NativeSheetDropdownTableViewCell cell.configure(item: item) { [weak self] newValue in self?.onControlChanged(item, newValue) } return cell case "multilineTextField": let cell = tableView.dequeueReusableCell( withIdentifier: NativeSheetMultilineTextTableViewCell.reuseId, for: indexPath ) as! NativeSheetMultilineTextTableViewCell cell.configure(item: item, value: currentTextValue(for: item)) { [weak self] text in self?.trackTextValueChanged(for: item, value: text) } return cell case "textField", "secureTextField": let cell = tableView.dequeueReusableCell( withIdentifier: NativeSheetTextFieldTableViewCell.reuseId, for: indexPath ) as! NativeSheetTextFieldTableViewCell cell.configure( item: item, value: currentTextValue(for: item), onTextChanged: { [weak self] text in self?.trackTextValueChanged(for: item, value: text) }, onReturn: { [weak self] in self?.confirmPendingTextChanges() } ) return cell case "readOnlyText": let cell = tableView.dequeueReusableCell( withIdentifier: NativeSheetReadOnlyTextTableViewCell.reuseId, for: indexPath ) as! NativeSheetReadOnlyTextTableViewCell cell.configure(item: item) return cell case "source": let cell = tableView.dequeueReusableCell( withIdentifier: NativeSheetSourceTableViewCell.reuseId, for: indexPath ) as! NativeSheetSourceTableViewCell cell.configure(item: item) return cell case "statusUpdate": let cell = tableView.dequeueReusableCell( withIdentifier: NativeSheetStatusTableViewCell.reuseId, for: indexPath ) as! NativeSheetStatusTableViewCell cell.configure(item: item) { url in UIApplication.shared.open(url) } return cell case "slider": let cell = tableView.dequeueReusableCell( withIdentifier: NativeSheetSliderTableViewCell.reuseId, for: indexPath ) as! NativeSheetSliderTableViewCell cell.configure(item: item) { [weak self] value in self?.onControlChanged(item, value) } return cell default: let cell = tableView.dequeueReusableCell(withIdentifier: "cell", for: indexPath) configureCell(cell, item: item) return cell } } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) let item = item(at: indexPath) switch item.kind { case "toggle": NativeSheetHaptics.selection() if let toggle = tableView.cellForRow(at: indexPath)?.accessoryView as? UISwitch { toggle.setOn(!toggle.isOn, animated: true) onControlChanged(item, toggle.isOn) } else { onControlChanged(item, !(item.value as? Bool ?? false)) } case "info", "textField", "secureTextField", "dropdown", "segment", "multilineTextField", "slider", "readOnlyText", "statusUpdate": break case "source": guard item.url != nil || canNavigate(item) else { break } commitPendingTextChanges() onSelect(item) default: commitPendingTextChanges() if item.destructive { NativeSheetHaptics.mediumImpact() } else if item.url == nil && !canNavigate(item) { NativeSheetHaptics.selection() } onSelect(item) } } private func item(at indexPath: IndexPath) -> NativeSheetItem { tableSections[indexPath.section].items[indexPath.row] } private func configureCell(_ cell: UITableViewCell, item: NativeSheetItem) { cell.accessoryView = nil cell.accessoryType = .none cell.selectionStyle = .default switch item.kind { case "info": configureNavigationCell(cell, item: item, showsDisclosure: false) cell.selectionStyle = .none case "toggle": configureNavigationCell(cell, item: item, showsDisclosure: false) let toggle = UISwitch() toggle.isOn = item.value as? Bool ?? false toggle.addAction(UIAction { [weak self, weak toggle] _ in self?.onControlChanged(item, toggle?.isOn ?? false) }, for: .valueChanged) cell.accessoryView = toggle cell.selectionStyle = .none case "searchablePicker": configureNavigationCell(cell, item: item, showsDisclosure: true) default: configureNavigationCell( cell, item: item, showsDisclosure: item.showsDisclosure ?? (item.url != nil || canNavigate(item)) ) } } private func closeButton() -> UIBarButtonItem { iconBarButton( systemName: "xmark", action: UIAction { [weak self] _ in self?.onClose() } ) } private func confirmButton(actionId: String, label: String? = nil) -> UIBarButtonItem { iconBarButton( systemName: "checkmark", style: .done, action: UIAction { [weak self] _ in self?.confirmConfiguredAction(actionId) } ) } private func refreshNavigationAction() { navigationItem.leftBarButtonItem = nil if let confirmActionId = detail.confirmActionId, !confirmActionId.isEmpty { if navigationController?.viewControllers.first === self { navigationItem.leftBarButtonItem = closeButton() } navigationItem.rightBarButtonItem = confirmButton( actionId: confirmActionId, label: detail.confirmActionLabel ) return } if pendingTextValues.isEmpty { // A pushed detail already has a Back button. Showing Close as // well creates two competing exit actions, so reserve Close for // the root controller of a presented sheet. navigationItem.rightBarButtonItem = navigationController?.viewControllers.first === self ? closeButton() : nil return } navigationItem.rightBarButtonItem = confirmButton(actionId: "") } private func currentTextValue(for item: NativeSheetItem) -> String { pendingTextValues[item.id] ?? committedTextValues[item.id] ?? (item.value as? String) ?? "" } private func baselineTextValue(for item: NativeSheetItem) -> String { committedTextValues[item.id] ?? (item.value as? String) ?? "" } private func trackTextValueChanged(for item: NativeSheetItem, value: String) { guard item.kind == "textField" || item.kind == "secureTextField" || item.kind == "multilineTextField" else { return } if value == baselineTextValue(for: item) { pendingTextValues.removeValue(forKey: item.id) } else { pendingTextValues[item.id] = value } refreshNavigationAction() } @discardableResult private func commitPendingTextChanges() -> Bool { guard !pendingTextValues.isEmpty else { return false } view.endEditing(true) let changes = pendingTextValues pendingTextValues.removeAll() for (id, value) in changes { guard let item = detail.allItems.first(where: { $0.id == id }) else { continue } committedTextValues[id] = value onControlChanged(item, value) } refreshNavigationAction() return true } private func confirmPendingTextChanges() { guard commitPendingTextChanges() else { return } NativeSheetHaptics.success() if let actionItem = textChangeConfirmationActionItem() { onSelect(actionItem) } else if let confirmActionId = detail.confirmActionId, !confirmActionId.isEmpty { onConfirmAction(confirmActionId) } } private func confirmConfiguredAction(_ actionId: String) { if actionId.isEmpty { confirmPendingTextChanges() return } NativeSheetHaptics.success() _ = commitPendingTextChanges() onConfirmAction(actionId) } private func textChangeConfirmationActionItem() -> NativeSheetItem? { tableSections.flatMap(\.items).first { item in item.kind == "navigation" && !item.destructive && item.url == nil && !canNavigate(item) && item.sfSymbol.contains("checkmark") } } } private final class NativeModelSelectorTableViewController: UITableViewController { private let configuration: NativeModelSelectorConfiguration private let onSelect: (String) -> Void private let onTogglePin: (String) -> Void private let onEffortChanged: (String) -> Void private let onClose: () -> Void private var pinnedModelIds: [String] private var pinnedModelIdSet: Set private var models: [NativeModelSelectorOption] private var featuredModels: [NativeModelSelectorOption] = [] private var moreModels: [NativeModelSelectorOption] = [] private var reasoningEffortValue: String private var reasoningEffortOptions: [String] private var allowsCustomReasoningEffort: Bool private weak var moreModelsController: NativeMoreModelsTableViewController? init( configuration: NativeModelSelectorConfiguration, onSelect: @escaping (String) -> Void, onTogglePin: @escaping (String) -> Void, onEffortChanged: @escaping (String) -> Void, onClose: @escaping () -> Void ) { self.configuration = configuration self.onSelect = onSelect self.onTogglePin = onTogglePin self.onEffortChanged = onEffortChanged self.onClose = onClose pinnedModelIds = configuration.pinnedModelIds pinnedModelIdSet = Set(configuration.pinnedModelIds) models = configuration.models reasoningEffortValue = configuration.reasoningEffortValue reasoningEffortOptions = configuration.reasoningEffortOptions allowsCustomReasoningEffort = configuration.allowsCustomReasoningEffort super.init(style: .insetGrouped) refreshModelPartitions() } required init?(coder: NSCoder) { nil } override func viewDidLoad() { super.viewDidLoad() title = configuration.title navigationItem.rightBarButtonItem = closeButton() tableView.register( NativeModelSelectorTableViewCell.self, forCellReuseIdentifier: "modelCell" ) NativeSheetSettingsStyle.apply(to: tableView) } override func numberOfSections(in tableView: UITableView) -> Int { 3 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { switch section { case 0: featuredModels.count // Like the Flutter sheet, offer the row only when the selected model // has an effort to choose; a disabled row looks tappable but does // nothing. A late hydration update reloads this section. case 1: effortSelectionEnabled ? 1 : 0 case 2: moreModels.isEmpty ? 0 : 1 default: 0 } } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { if indexPath.section == 0 { let model = featuredModels[indexPath.row] let cell = tableView.dequeueReusableCell( withIdentifier: "modelCell", for: indexPath ) as! NativeModelSelectorTableViewCell cell.configure( model: model, isSelected: model.id == configuration.selectedModelId, isPinned: pinnedModelIdSet.contains(model.id), avatarCacheIdentifier: "\(configuration.presentationId):\(model.id)" ) return cell } let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) var content = cell.defaultContentConfiguration() if indexPath.section == 1 { content.image = UIImage(systemName: "clock") content.text = configuration.reasoningEffortTitle content.secondaryText = effortLabel(reasoningEffortValue) cell.accessoryType = .disclosureIndicator } else { content.image = UIImage(systemName: "ellipsis") content.text = configuration.moreModelsTitle cell.accessoryType = .disclosureIndicator } content.imageProperties.tintColor = NativeSheetTheme.shared.icon cell.contentConfiguration = content applyTextAccessibilityLabel(to: cell, from: content) return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) switch indexPath.section { case 0: NativeSheetHaptics.selection() onSelect(featuredModels[indexPath.row].id) case 1: guard effortSelectionEnabled else { return } presentEffortSelector(sourceView: tableView.cellForRow(at: indexPath)) case 2: let controller = NativeMoreModelsTableViewController( title: configuration.moreModelsTitle, searchPlaceholder: configuration.searchModelsTitle, presentationId: configuration.presentationId, models: moreModels, selectedModelId: configuration.selectedModelId, pinnedModelIds: pinnedModelIdSet, allowsPinning: configuration.allowsPinning, pinTitle: configuration.pinTitle, unpinTitle: configuration.unpinTitle, onSelect: onSelect, onTogglePin: { [weak self] modelId in self?.togglePinnedModel(modelId) self?.onTogglePin(modelId) } ) moreModelsController = controller navigationController?.pushViewController(controller, animated: true) default: break } } override func tableView( _ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint ) -> UIContextMenuConfiguration? { guard configuration.allowsPinning else { return nil } guard indexPath.section == 0 else { return nil } let model = featuredModels[indexPath.row] let isPinned = pinnedModelIdSet.contains(model.id) let title = isPinned ? configuration.unpinTitle : configuration.pinTitle let image = UIImage(systemName: isPinned ? "pin.slash" : "pin") return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in let action = UIAction( title: title, image: image ) { [weak self] _ in NativeSheetHaptics.selection() self?.togglePinnedModel(model.id) self?.onTogglePin(model.id) } return UIMenu(children: [action]) } } private func closeButton() -> UIBarButtonItem { UIBarButtonItem( systemItem: .close, primaryAction: UIAction { [weak self] _ in self?.onClose() } ) } private func togglePinnedModel(_ modelId: String) { if pinnedModelIdSet.contains(modelId) { pinnedModelIdSet.remove(modelId) pinnedModelIds.removeAll { $0 == modelId } } else { pinnedModelIdSet.insert(modelId) pinnedModelIds.append(modelId) } refreshModelPartitions() moreModelsController?.updateModels(moreModels) tableView.reloadData() } func updateModels(_ updatedModels: [NativeModelSelectorOption]) { guard !updatedModels.isEmpty else { return } var updatesById: [String: NativeModelSelectorOption] = [:] updatedModels.forEach { updatesById[$0.id] = $0 } models = models.map { updatesById[$0.id] ?? $0 } refreshModelPartitions() moreModelsController?.updateModels(moreModels) if isViewLoaded { tableView.reloadData() } } func updateReasoningEffort( value: String, options: [String], allowsCustom: Bool ) { guard let normalized = normalizedEffort(value) else { return } reasoningEffortValue = normalized reasoningEffortOptions = options allowsCustomReasoningEffort = allowsCustom if isViewLoaded { tableView.reloadSections(IndexSet(integer: 1), with: .none) } } private func refreshModelPartitions() { let ids = nativeModelSelectorFeaturedIds( pinnedModelIds: pinnedModelIds, configuredFeaturedModelIds: configuration.featuredModelIds, selectedModelId: configuration.selectedModelId ) let byId = Dictionary( models.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first } ) featuredModels = ids.compactMap { byId[$0] } let featuredIds = Set(featuredModels.map(\.id)) moreModels = models.filter { !featuredIds.contains($0.id) } } private func effortLabel(_ value: String) -> String { configuration.reasoningEffortLabels[value] ?? value.capitalized } private var effortSelectionEnabled: Bool { !reasoningEffortOptions.isEmpty || allowsCustomReasoningEffort } private func presentEffortSelector(sourceView: UIView?) { let alert = UIAlertController( title: configuration.reasoningEffortTitle, message: nil, preferredStyle: .actionSheet ) for option in reasoningEffortOptions { let label = option == reasoningEffortValue ? "✓ \(effortLabel(option))" : effortLabel(option) let action = UIAlertAction(title: label, style: .default) { [weak self] _ in self?.commitEffort(option) } alert.addAction(action) } if allowsCustomReasoningEffort { alert.addAction(UIAlertAction( title: configuration.customReasoningEffortTitle, style: .default ) { [weak self] _ in self?.presentCustomEffort() }) } alert.addAction(UIAlertAction(title: nativeLocalized("native.cancel", "Cancel"), style: .cancel)) if let popover = alert.popoverPresentationController { popover.sourceView = sourceView ?? view popover.sourceRect = sourceView?.bounds ?? view.bounds } present(alert, animated: true) } private func presentCustomEffort() { let alert = UIAlertController( title: configuration.customReasoningEffortTitle, message: nil, preferredStyle: .alert ) alert.addTextField { [weak self] field in guard let self else { return } field.placeholder = configuration.customReasoningEffortHint if !reasoningEffortOptions.contains(reasoningEffortValue) { field.text = reasoningEffortValue } field.autocapitalizationType = .none field.autocorrectionType = .no } alert.addAction(UIAlertAction(title: nativeLocalized("native.cancel", "Cancel"), style: .cancel)) alert.addAction(UIAlertAction(title: nativeLocalized("native.save", "Save"), style: .default) { [weak self, weak alert] _ in guard let self else { return } guard let value = alert?.textFields?.first?.text? .trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return } guard let normalized = self.normalizedEffort(value) else { DispatchQueue.main.async { [weak self] in self?.presentCustomEffort() } return } self.commitEffort(normalized) }) present(alert, animated: true) } private func commitEffort(_ value: String) { guard let normalized = normalizedEffort(value) else { return } NativeSheetHaptics.selection() reasoningEffortValue = normalized tableView.reloadSections(IndexSet(integer: 1), with: .none) onEffortChanged(normalized) } private func normalizedEffort(_ value: String) -> String? { let normalized = value .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() guard normalized.count <= 64, normalized.range( of: #"^[a-z0-9][a-z0-9_-]{0,63}$"#, options: .regularExpression ) != nil else { return nil } return normalized } } func nativeModelSelectorFeaturedIds( pinnedModelIds: [String], configuredFeaturedModelIds: [String], selectedModelId: String? ) -> [String] { var ids = pinnedModelIds.isEmpty ? configuredFeaturedModelIds : pinnedModelIds if let selectedModelId, !selectedModelId.isEmpty, !ids.contains(selectedModelId) { ids.append(selectedModelId) } return ids } private final class NativeMoreModelsTableViewController: UITableViewController, UISearchResultsUpdating { private let searchPlaceholder: String private let presentationId: String private let selectedModelId: String? private var pinnedModelIds: Set private let allowsPinning: Bool private let pinTitle: String private let unpinTitle: String private let onSelect: (String) -> Void private let onTogglePin: (String) -> Void private var models: [NativeModelSelectorOption] private var filteredModels: [NativeModelSelectorOption] private var query = "" init( title: String, searchPlaceholder: String, presentationId: String, models: [NativeModelSelectorOption], selectedModelId: String?, pinnedModelIds: Set, allowsPinning: Bool, pinTitle: String, unpinTitle: String, onSelect: @escaping (String) -> Void, onTogglePin: @escaping (String) -> Void ) { self.searchPlaceholder = searchPlaceholder self.presentationId = presentationId self.models = models filteredModels = models self.selectedModelId = selectedModelId self.pinnedModelIds = pinnedModelIds self.allowsPinning = allowsPinning self.pinTitle = pinTitle self.unpinTitle = unpinTitle self.onSelect = onSelect self.onTogglePin = onTogglePin super.init(style: .insetGrouped) self.title = title } required init?(coder: NSCoder) { nil } override func viewDidLoad() { super.viewDidLoad() tableView.register(NativeModelSelectorTableViewCell.self, forCellReuseIdentifier: "modelCell") NativeSheetSettingsStyle.apply(to: tableView) let search = UISearchController(searchResultsController: nil) search.searchResultsUpdater = self search.searchBar.placeholder = searchPlaceholder search.obscuresBackgroundDuringPresentation = false navigationItem.searchController = search navigationItem.hidesSearchBarWhenScrolling = false } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { filteredModels.count } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let model = filteredModels[indexPath.row] let cell = tableView.dequeueReusableCell(withIdentifier: "modelCell", for: indexPath) as! NativeModelSelectorTableViewCell cell.configure( model: model, isSelected: model.id == selectedModelId, isPinned: pinnedModelIds.contains(model.id), avatarCacheIdentifier: "\(presentationId):\(model.id)" ) return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) NativeSheetHaptics.selection() onSelect(filteredModels[indexPath.row].id) } override func tableView( _ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint ) -> UIContextMenuConfiguration? { guard allowsPinning else { return nil } let model = filteredModels[indexPath.row] let pinned = pinnedModelIds.contains(model.id) return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in let action = UIAction( title: pinned ? self?.unpinTitle ?? "Unpin" : self?.pinTitle ?? "Pin", image: UIImage(systemName: pinned ? "pin.slash" : "pin") ) { [weak self] _ in NativeSheetHaptics.selection() if pinned { self?.pinnedModelIds.remove(model.id) } else { self?.pinnedModelIds.insert(model.id) } self?.tableView.reloadRows(at: [indexPath], with: .none) self?.onTogglePin(model.id) } return UIMenu(children: [action]) } } func updateModels(_ updated: [NativeModelSelectorOption]) { models = updated applyFilter() } func updateSearchResults(for searchController: UISearchController) { query = searchController.searchBar.text? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() ?? "" applyFilter() } private func applyFilter() { filteredModels = query.isEmpty ? models : models.filter { model in model.name.lowercased().contains(query) || model.id.lowercased().contains(query) || model.tags.contains { $0.lowercased().contains(query) } } if isViewLoaded { tableView.reloadData() } } } private final class NativeModelTagButton: UIButton { init(text: String) { super.init(frame: .zero) var configuration = UIButton.Configuration.gray() configuration.title = text.uppercased() configuration.buttonSize = .mini configuration.cornerStyle = .capsule configuration.contentInsets = NSDirectionalEdgeInsets( top: 2, leading: 6, bottom: 2, trailing: 6 ) configuration.baseForegroundColor = NativeSheetTheme.shared.secondaryForeground configuration.baseBackgroundColor = NativeSheetTheme.shared.elevatedSurface configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in var outgoing = incoming outgoing.font = .preferredFont(forTextStyle: .caption1) return outgoing } self.configuration = configuration isUserInteractionEnabled = false titleLabel?.adjustsFontForContentSizeCategory = true titleLabel?.numberOfLines = 1 titleLabel?.lineBreakMode = .byTruncatingTail setContentHuggingPriority(.required, for: .horizontal) setContentCompressionResistancePriority(.defaultLow, for: .horizontal) } required init?(coder: NSCoder) { nil } } private final class NativeOptionsSelectorTableViewController: UITableViewController { private let configuration: NativeOptionsSelectorConfiguration private let onSelect: (String) -> Void private let onClose: () -> Void private var filteredOptions: [NativeSheetOption] init( configuration: NativeOptionsSelectorConfiguration, onSelect: @escaping (String) -> Void, onClose: @escaping () -> Void ) { self.configuration = configuration self.onSelect = onSelect self.onClose = onClose filteredOptions = configuration.options super.init(style: .insetGrouped) } required init?(coder: NSCoder) { nil } override func viewDidLoad() { super.viewDidLoad() title = configuration.title navigationItem.rightBarButtonItem = closeButton() tableView.register( NativeSheetOptionTableViewCell.self, forCellReuseIdentifier: "optionCell" ) NativeSheetSettingsStyle.apply(to: tableView) if configuration.searchable { let searchController = UISearchController(searchResultsController: nil) searchController.searchResultsUpdater = self searchController.obscuresBackgroundDuringPresentation = false navigationItem.searchController = searchController navigationItem.hidesSearchBarWhenScrolling = false } } override func tableView( _ tableView: UITableView, titleForFooterInSection section: Int ) -> String? { configuration.subtitle } override func tableView( _ tableView: UITableView, willDisplayFooterView view: UIView, forSection section: Int ) { NativeSheetSettingsStyle.applyHeaderFooterStyle(view) } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { filteredOptions.count } override func tableView( _ tableView: UITableView, cellForRowAt indexPath: IndexPath ) -> UITableViewCell { let option = filteredOptions[indexPath.row] let cell = tableView.dequeueReusableCell( withIdentifier: "optionCell", for: indexPath ) as! NativeSheetOptionTableViewCell cell.configure( option: option, isSelected: option.id == configuration.selectedOptionId ) cell.accessoryType = option.id == configuration.selectedOptionId ? .checkmark : .none return cell } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) let option = filteredOptions[indexPath.row] guard option.enabled else { return } NativeSheetHaptics.selection() onSelect(option.id) } private func closeButton() -> UIBarButtonItem { UIBarButtonItem( systemItem: .close, primaryAction: UIAction { [weak self] _ in self?.onClose() } ) } } extension NativeOptionsSelectorTableViewController: UISearchResultsUpdating { func updateSearchResults(for searchController: UISearchController) { let query = searchController.searchBar.text? .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() ?? "" filteredOptions = query.isEmpty ? configuration.options : configuration.options.filter { option in option.label.lowercased().contains(query) || option.id.lowercased().contains(query) || (option.subtitle?.lowercased().contains(query) ?? false) } tableView.reloadData() } } private final class NativeModelSelectorTableViewCell: UITableViewCell { private let avatarView = NativeModelAvatarView(side: 32) private let titleLabel = UILabel() private let subtitleLabel = UILabel() private let tagsStack = UIStackView() private let textStack = UIStackView() private let pinImageView = UIImageView(image: UIImage(systemName: "pin.fill")) private var pinWidthConstraint: NSLayoutConstraint? private var textTrailingToPinConstraint: NSLayoutConstraint? override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) configureViews() } required init?(coder: NSCoder) { nil } override func prepareForReuse() { super.prepareForReuse() avatarView.cancelImageLoad() } func configure( model: NativeModelSelectorOption, isSelected: Bool, isPinned: Bool, avatarCacheIdentifier: String ) { titleLabel.text = model.name titleLabel.font = .preferredFont(forTextStyle: .body) titleLabel.textColor = NativeSheetTheme.shared.foreground titleLabel.adjustsFontForContentSizeCategory = true titleLabel.numberOfLines = 2 let subtitle = model.subtitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" subtitleLabel.text = subtitle subtitleLabel.font = .preferredFont(forTextStyle: .subheadline) subtitleLabel.textColor = NativeSheetTheme.shared.secondaryForeground subtitleLabel.adjustsFontForContentSizeCategory = true subtitleLabel.numberOfLines = 2 subtitleLabel.isHidden = subtitle.isEmpty configureTags(model.tags) avatarView.configure( name: model.name, avatarUrl: model.avatarUrl, avatarData: model.avatarData, avatarHeaders: model.avatarHeaders, sfSymbol: model.sfSymbol, usesTemplateImage: model.id.hasPrefix("hermes:agent:"), avatarCacheIdentifier: avatarCacheIdentifier ) accessoryType = isSelected ? .checkmark : .none pinImageView.isHidden = !isPinned pinWidthConstraint?.constant = isPinned ? 16 : 0 textTrailingToPinConstraint?.constant = isPinned ? -NativeSheetSettingsStyle.iconSpacing : 0 selectionStyle = .default isUserInteractionEnabled = true NativeSheetSettingsStyle.applyCellStyle(self) } private func configureTags(_ tags: [String]) { tagsStack.arrangedSubviews.forEach { view in tagsStack.removeArrangedSubview(view) view.removeFromSuperview() } let sortedTags = tags.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } let visibleTags = sortedTags.prefix(3) for tag in visibleTags { addTagLabel(tag) } if sortedTags.count > visibleTags.count { addTagLabel("+\(sortedTags.count - visibleTags.count)") } if !sortedTags.isEmpty { let spacer = UIView() spacer.setContentHuggingPriority(.defaultLow, for: .horizontal) spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) tagsStack.addArrangedSubview(spacer) } tagsStack.isHidden = sortedTags.isEmpty } private func addTagLabel(_ text: String) { let button = NativeModelTagButton(text: text) button.widthAnchor.constraint(lessThanOrEqualToConstant: 120).isActive = true tagsStack.addArrangedSubview(button) } private func configureViews() { backgroundColor = NativeSheetTheme.shared.surface contentView.addSubview(avatarView) contentView.addSubview(textStack) contentView.addSubview(pinImageView) titleLabel.translatesAutoresizingMaskIntoConstraints = false subtitleLabel.translatesAutoresizingMaskIntoConstraints = false textStack.translatesAutoresizingMaskIntoConstraints = false textStack.axis = .vertical textStack.spacing = 2 textStack.alignment = .fill textStack.addArrangedSubview(titleLabel) textStack.addArrangedSubview(subtitleLabel) textStack.addArrangedSubview(tagsStack) tagsStack.translatesAutoresizingMaskIntoConstraints = false tagsStack.axis = .horizontal tagsStack.spacing = 4 tagsStack.alignment = .center tagsStack.distribution = .fill tagsStack.isHidden = true pinImageView.translatesAutoresizingMaskIntoConstraints = false pinImageView.contentMode = .scaleAspectFit pinImageView.tintColor = NativeSheetTheme.shared.icon pinImageView.isHidden = true pinImageView.setContentHuggingPriority(.required, for: .horizontal) pinWidthConstraint = pinImageView.widthAnchor.constraint(equalToConstant: 0) textTrailingToPinConstraint = textStack.trailingAnchor.constraint( equalTo: pinImageView.leadingAnchor ) NSLayoutConstraint.activate([ avatarView.leadingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.leadingAnchor), avatarView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), avatarView.widthAnchor.constraint(equalToConstant: 32), avatarView.heightAnchor.constraint(equalToConstant: 32), textStack.leadingAnchor.constraint( equalTo: avatarView.trailingAnchor, constant: NativeSheetSettingsStyle.iconSpacing ), textTrailingToPinConstraint!, textStack.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor), textStack.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor), pinImageView.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), pinImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), pinImageView.heightAnchor.constraint(equalToConstant: 16), pinWidthConstraint!, ]) } } private final class NativeSheetOptionTableViewCell: UITableViewCell { private let hierarchyGuideView = NativeFolderHierarchyGuideView() private let iconView = UIImageView() private let titleLabel = UILabel() private let subtitleLabel = UILabel() private let textStack = UIStackView() private var hierarchyWidthConstraint: NSLayoutConstraint? override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { super.init(style: style, reuseIdentifier: reuseIdentifier) configureViews() } required init?(coder: NSCoder) { nil } func configure(option: NativeSheetOption, isSelected: Bool) { let isDestructive = option.destructive let tintColor: UIColor = isDestructive ? NativeSheetTheme.shared.destructive : NativeSheetTheme.shared.secondaryForeground let textColor: UIColor = isDestructive ? NativeSheetTheme.shared.destructive : NativeSheetTheme.shared.foreground titleLabel.text = option.label titleLabel.textColor = textColor titleLabel.numberOfLines = 2 subtitleLabel.text = option.subtitle subtitleLabel.textColor = NativeSheetTheme.shared.secondaryForeground subtitleLabel.numberOfLines = 2 subtitleLabel.isHidden = option.subtitle == nil || option.subtitle?.isEmpty == true if let sfSymbol = option.sfSymbol, !sfSymbol.isEmpty { iconView.image = UIImage(systemName: sfSymbol) } else { iconView.image = nil } iconView.tintColor = tintColor hierarchyGuideView.configure( ancestorHasMoreSiblings: option.ancestorHasMoreSiblings, showBranch: option.showBranch, hasMoreSiblings: option.hasMoreSiblings ) let hierarchyWidth = option.showsHierarchyGuides ? NativeFolderHierarchyGuideView.requiredWidth( ancestorCount: option.ancestorHasMoreSiblings.count, showBranch: option.showBranch ) : 0 hierarchyWidthConstraint?.constant = hierarchyWidth hierarchyGuideView.isHidden = hierarchyWidth == 0 accessoryType = isSelected ? .checkmark : .none selectionStyle = option.enabled ? .default : .none isUserInteractionEnabled = option.enabled contentView.alpha = option.enabled ? 1.0 : 0.55 NativeSheetSettingsStyle.applyCellStyle(self) } private func configureViews() { backgroundColor = NativeSheetTheme.shared.surface hierarchyGuideView.translatesAutoresizingMaskIntoConstraints = false iconView.translatesAutoresizingMaskIntoConstraints = false titleLabel.translatesAutoresizingMaskIntoConstraints = false subtitleLabel.translatesAutoresizingMaskIntoConstraints = false textStack.translatesAutoresizingMaskIntoConstraints = false iconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration( pointSize: NativeSheetSettingsStyle.iconSize, weight: .regular ) iconView.contentMode = .scaleAspectFit titleLabel.font = .preferredFont(forTextStyle: .body) subtitleLabel.font = .preferredFont(forTextStyle: .footnote) titleLabel.adjustsFontForContentSizeCategory = true subtitleLabel.adjustsFontForContentSizeCategory = true textStack.axis = .vertical textStack.spacing = 2 textStack.alignment = .fill textStack.addArrangedSubview(titleLabel) textStack.addArrangedSubview(subtitleLabel) contentView.addSubview(hierarchyGuideView) contentView.addSubview(iconView) contentView.addSubview(textStack) hierarchyWidthConstraint = hierarchyGuideView.widthAnchor.constraint(equalToConstant: 0) hierarchyWidthConstraint?.isActive = true NSLayoutConstraint.activate([ hierarchyGuideView.leadingAnchor.constraint( equalTo: contentView.layoutMarginsGuide.leadingAnchor ), hierarchyGuideView.topAnchor.constraint(equalTo: contentView.topAnchor), hierarchyGuideView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), iconView.leadingAnchor.constraint(equalTo: hierarchyGuideView.trailingAnchor), iconView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor), iconView.widthAnchor.constraint(equalToConstant: NativeSheetSettingsStyle.iconSize), iconView.heightAnchor.constraint(equalToConstant: NativeSheetSettingsStyle.iconSize), textStack.leadingAnchor.constraint( equalTo: iconView.trailingAnchor, constant: NativeSheetSettingsStyle.iconSpacing ), textStack.trailingAnchor.constraint(equalTo: contentView.layoutMarginsGuide.trailingAnchor), textStack.topAnchor.constraint(equalTo: contentView.layoutMarginsGuide.topAnchor), textStack.bottomAnchor.constraint(equalTo: contentView.layoutMarginsGuide.bottomAnchor), ]) } } private final class NativeFolderHierarchyGuideView: UIView { private var ancestorHasMoreSiblings: [Bool] = [] private var showBranch = false private var hasMoreSiblings = false static let segmentWidth: CGFloat = 15 override init(frame: CGRect) { super.init(frame: frame) backgroundColor = .clear isUserInteractionEnabled = false } required init?(coder: NSCoder) { nil } static func requiredWidth(ancestorCount: Int, showBranch: Bool) -> CGFloat { CGFloat(ancestorCount + (showBranch ? 1 : 0)) * segmentWidth } func configure( ancestorHasMoreSiblings: [Bool], showBranch: Bool, hasMoreSiblings: Bool ) { self.ancestorHasMoreSiblings = ancestorHasMoreSiblings self.showBranch = showBranch self.hasMoreSiblings = hasMoreSiblings setNeedsDisplay() } override func draw(_ rect: CGRect) { guard showBranch || ancestorHasMoreSiblings.contains(true) else { return } guard let context = UIGraphicsGetCurrentContext() else { return } context.setStrokeColor( NativeSheetTheme.shared.secondaryForeground.withAlphaComponent(0.28).cgColor ) context.setLineWidth(1.25) context.setLineCap(.square) context.setLineJoin(.miter) let centerY = rect.height / 2 let seg = Self.segmentWidth for (index, hasMore) in ancestorHasMoreSiblings.enumerated() { guard index > 0, hasMore else { continue } let x = (CGFloat(index) * seg) + (seg / 2) context.move(to: CGPoint(x: x, y: 0)) context.addLine(to: CGPoint(x: x, y: rect.height)) context.strokePath() } guard showBranch else { return } let branchX = (CGFloat(ancestorHasMoreSiblings.count) * seg) + (seg / 2) context.move(to: CGPoint(x: branchX, y: 0)) context.addLine(to: CGPoint(x: branchX, y: centerY)) context.addLine(to: CGPoint(x: rect.maxX, y: centerY)) context.strokePath() if hasMoreSiblings { context.move(to: CGPoint(x: branchX, y: centerY)) context.addLine(to: CGPoint(x: branchX, y: rect.height)) context.strokePath() } } } func nativeSheetImageCacheKey( rawUrl: String, headers: [String: String] ) -> NSString { guard !headers.isEmpty else { return NSString(string: rawUrl) } let canonicalHeaders = headers .map { ($0.key.lowercased(), $0.value) } .sorted { if $0.0 == $1.0 { return $0.1 < $1.1 } return $0.0 < $1.0 } var digestInput = Data() func appendComponent(_ value: String) { var byteCount = UInt64(value.utf8.count).bigEndian withUnsafeBytes(of: &byteCount) { digestInput.append(contentsOf: $0) } digestInput.append(contentsOf: value.utf8) } appendComponent(rawUrl) for (key, value) in canonicalHeaders { appendComponent(key) appendComponent(value) } let fingerprint = SHA256.hash(data: digestInput) .map { String(format: "%02x", $0) } .joined() return NSString(string: "\(rawUrl)#headers=\(fingerprint)") } func nativeSheetAvatarDataCacheKey( cacheIdentifier: String, data: Data, targetPixelSize: Int ) -> NSString { let fingerprint = SHA256.hash(data: data) .map { String(format: "%02x", $0) } .joined() return NSString( string: "data:\(cacheIdentifier)#sha256=\(fingerprint)#px=\(targetPixelSize)" ) } func nativeSheetImageSessionConfiguration() -> URLSessionConfiguration { let configuration = URLSessionConfiguration.ephemeral configuration.timeoutIntervalForRequest = 10 configuration.timeoutIntervalForResource = 15 configuration.httpMaximumConnectionsPerHost = 4 configuration.requestCachePolicy = .reloadIgnoringLocalCacheData configuration.urlCache = nil configuration.httpCookieStorage = nil configuration.httpShouldSetCookies = false configuration.urlCredentialStorage = nil return configuration } final class NativeSheetImageLoadToken { private let lock = NSLock() private var cancellationHandler: (() -> Void)? private var cancelled = false var isCancelled: Bool { lock.lock() let value = cancelled lock.unlock() return value } func installCancellationHandler(_ handler: @escaping () -> Void) { lock.lock() if cancelled { lock.unlock() handler() return } cancellationHandler = handler lock.unlock() } func cancel() { lock.lock() guard !cancelled else { lock.unlock() return } cancelled = true let handler = cancellationHandler cancellationHandler = nil lock.unlock() handler?() } } private struct NativeSheetHTTPOrigin: Equatable { let scheme: String let host: String let port: Int init?(_ url: URL) { guard let components = URLComponents( url: url, resolvingAgainstBaseURL: false ), let rawScheme = components.scheme?.lowercased(), rawScheme == "http" || rawScheme == "https", let rawHost = components.host?.lowercased(), !rawHost.isEmpty, components.user == nil, components.password == nil else { return nil } scheme = rawScheme host = rawHost port = components.port ?? (rawScheme == "https" ? 443 : 80) } } func nativeSheetRedirectStaysWithinOrigin( originalURL: URL, redirectURL: URL ) -> Bool { guard let original = NativeSheetHTTPOrigin(originalURL), let redirected = NativeSheetHTTPOrigin(redirectURL) else { return false } if original == redirected { return true } // Google's public favicon endpoint always redirects the headerless image // request to a numbered gstatic shard. Keep the general same-origin rule, // but allow this exact HTTPS asset hop so source favicons can render. guard original.scheme == "https", original.host == "www.google.com", original.port == 443, originalURL.path == "/s2/favicons", redirected.scheme == "https", redirected.port == 443, redirectURL.path == "/faviconV2" else { return false } return redirected.host.range( of: #"^t[0-9]+\.gstatic\.com$"#, options: .regularExpression ) != nil } /// Builds an image request without allowing caller-supplied credentials to /// choose their own destination. Headerless HTTP(S) URLs remain valid, while /// nonempty headers are forwarded only for the explicitly trusted origin. func nativeSheetImageRequest( rawUrl: String, headers: [String: String], trustedServerOriginURL: URL? ) -> URLRequest? { guard let url = URL(string: rawUrl), let targetOrigin = NativeSheetHTTPOrigin(url) else { return nil } var request = URLRequest(url: url) guard !headers.isEmpty, let trustedServerOriginURL, let trustedOrigin = NativeSheetHTTPOrigin(trustedServerOriginURL), targetOrigin == trustedOrigin else { return request } for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) } return request } final class NativeSheetRequestAdmission { private let lock = NSLock() private let limit: Int private var outstanding = 0 init(limit: Int) { self.limit = max(1, limit) } func acquire() -> Bool { lock.lock() guard outstanding < limit else { lock.unlock() return false } outstanding += 1 lock.unlock() return true } func release() { lock.lock() outstanding = max(0, outstanding - 1) lock.unlock() } var count: Int { lock.lock() let value = outstanding lock.unlock() return value } } private final class NativeSheetBoundedImageClient: NSObject, URLSessionDataDelegate { static let shared = NativeSheetBoundedImageClient() private static let maximumOutstandingRequests = 24 private final class RequestState { let maxBytes: Int let originalURL: URL let completion: (Data?) -> Void var receivedData = Data() init( maxBytes: Int, originalURL: URL, completion: @escaping (Data?) -> Void ) { self.maxBytes = maxBytes self.originalURL = originalURL self.completion = completion } } private let delegateQueue: OperationQueue = { let queue = OperationQueue() queue.name = "app.cogwheel.conduit.native-sheet-image-network" queue.qualityOfService = .userInitiated queue.maxConcurrentOperationCount = 1 return queue }() private let stateLock = NSLock() private let admission = NativeSheetRequestAdmission( limit: NativeSheetBoundedImageClient.maximumOutstandingRequests ) private var requests: [Int: RequestState] = [:] private lazy var session = URLSession( configuration: nativeSheetImageSessionConfiguration(), delegate: self, delegateQueue: delegateQueue ) private override init() { super.init() } func load( _ request: URLRequest, maxBytes: Int, completion: @escaping (Data?) -> Void ) -> NativeSheetImageLoadToken { let token = NativeSheetImageLoadToken() guard maxBytes > 0, let originalURL = request.url, NativeSheetHTTPOrigin(originalURL) != nil, admission.acquire() else { completion(nil) token.cancel() return token } let task = session.dataTask(with: request) let state = RequestState( maxBytes: maxBytes, originalURL: originalURL, completion: completion ) stateLock.lock() requests[task.taskIdentifier] = state stateLock.unlock() token.installCancellationHandler { [weak self, weak task] in task?.cancel() self?.finish(taskIdentifier: task?.taskIdentifier, data: nil) } task.resume() return token } func urlSession( _ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void ) { guard let state = requestState(for: task.taskIdentifier), let redirectURL = request.url, nativeSheetRedirectStaysWithinOrigin( originalURL: state.originalURL, redirectURL: redirectURL ) else { completionHandler(nil) task.cancel() finish(taskIdentifier: task.taskIdentifier, data: nil) return } completionHandler(request) } func urlSession( _ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void ) { guard let state = requestState(for: dataTask.taskIdentifier) else { completionHandler(.cancel) return } if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { completionHandler(.cancel) finish(taskIdentifier: dataTask.taskIdentifier, data: nil) return } if let mimeType = response.mimeType, !mimeType.lowercased().hasPrefix("image/") { completionHandler(.cancel) finish(taskIdentifier: dataTask.taskIdentifier, data: nil) return } if response.expectedContentLength > Int64(state.maxBytes) { completionHandler(.cancel) finish(taskIdentifier: dataTask.taskIdentifier, data: nil) return } if response.expectedContentLength > 0 { stateLock.lock() state.receivedData.reserveCapacity( min(Int(response.expectedContentLength), state.maxBytes) ) stateLock.unlock() } completionHandler(.allow) } func urlSession( _ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data ) { stateLock.lock() guard let state = requests[dataTask.taskIdentifier] else { stateLock.unlock() dataTask.cancel() return } guard data.count <= state.maxBytes - state.receivedData.count else { stateLock.unlock() dataTask.cancel() finish(taskIdentifier: dataTask.taskIdentifier, data: nil) return } state.receivedData.append(data) stateLock.unlock() } func urlSession( _ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error? ) { stateLock.lock() let state = requests[task.taskIdentifier] let data = error == nil && state?.receivedData.isEmpty == false ? state?.receivedData : nil stateLock.unlock() guard state != nil else { return } finish(taskIdentifier: task.taskIdentifier, data: data) } private func requestState(for taskIdentifier: Int) -> RequestState? { stateLock.lock() let state = requests[taskIdentifier] stateLock.unlock() return state } private func finish(taskIdentifier: Int?, data: Data?) { guard let taskIdentifier else { return } stateLock.lock() let state = requests.removeValue(forKey: taskIdentifier) stateLock.unlock() guard let state else { return } admission.release() state.completion(data) } } enum NativeSheetImageLoader { private final class InFlightRequest { let requestId: UUID var callbacks: [UUID: (NativeSheetImageLoadToken, (UIImage) -> Void)] var networkToken: NativeSheetImageLoadToken? init( requestId: UUID, callbackId: UUID, token: NativeSheetImageLoadToken, completion: @escaping (UIImage) -> Void ) { self.requestId = requestId callbacks = [callbackId: (token, completion)] } } private static let maxEncodedBytes = 2 * 1024 * 1024 private static let cache: NSCache = { let cache = NSCache() cache.countLimit = 128 cache.totalCostLimit = 8 * 1024 * 1024 return cache }() private static let stateLock = NSLock() private static var inFlight: [NSString: InFlightRequest] = [:] private static var avatarHashExecutionObserver: ((Bool) -> Void)? private static let processingQueue = DispatchQueue( label: "app.cogwheel.conduit.native-sheet-images", qos: .userInitiated ) static func setAvatarHashExecutionObserverForTesting( _ observer: ((Bool) -> Void)? ) { stateLock.lock() avatarHashExecutionObserver = observer stateLock.unlock() } @discardableResult static func load( rawUrl: String, headers: [String: String] = [:], trustedServerOriginURL: URL? = nil, targetPixelSize: Int = 96, completion: @escaping (UIImage) -> Void ) -> NativeSheetImageLoadToken { let token = NativeSheetImageLoadToken() if rawUrl.hasPrefix("data:image") { processingQueue.async { guard !token.isCancelled else { return } guard let image = decodeDataImage( rawUrl, targetPixelSize: targetPixelSize ) else { return } deliver(image, token: token, completion: completion) } return token } guard let request = nativeSheetImageRequest( rawUrl: rawUrl, headers: headers, trustedServerOriginURL: trustedServerOriginURL ) else { return token } let forwardedHeaders = request.allHTTPHeaderFields ?? [:] let baseKey = nativeSheetImageCacheKey( rawUrl: rawUrl, headers: forwardedHeaders ) let cacheKey = NSString(string: "\(baseKey)#px=\(targetPixelSize)") if let cached = cache.object(forKey: cacheKey) { deliver(cached, token: token, completion: completion) return token } let callbackId = UUID() let requestId = register( cacheKey: cacheKey, callbackId: callbackId, token: token, completion: completion ) guard let requestId else { return token } let networkToken = NativeSheetBoundedImageClient.shared.load( request, maxBytes: maxEncodedBytes ) { data in processingQueue.async { let image = data.flatMap { downsample(data: $0, targetPixelSize: targetPixelSize) } finish( cacheKey: cacheKey, requestId: requestId, image: image ) } } installNetworkToken( networkToken, cacheKey: cacheKey, requestId: requestId ) return token } @discardableResult static func load( data: Data, cacheIdentifier: String? = nil, targetPixelSize: Int, completion: @escaping (UIImage) -> Void ) -> NativeSheetImageLoadToken { let token = NativeSheetImageLoadToken() guard !data.isEmpty, data.count <= maxEncodedBytes else { return token } processingQueue.async { guard !token.isCancelled else { return } stateLock.lock() let observer = avatarHashExecutionObserver stateLock.unlock() observer?(Thread.isMainThread) let cacheKey = cacheIdentifier.map { nativeSheetAvatarDataCacheKey( cacheIdentifier: $0, data: data, targetPixelSize: targetPixelSize ) } guard !token.isCancelled else { return } if let cacheKey, let cached = cache.object(forKey: cacheKey) { deliver(cached, token: token, completion: completion) return } if let cacheKey { let callbackId = UUID() let requestId = register( cacheKey: cacheKey, callbackId: callbackId, token: token, completion: completion ) guard let requestId else { return } let image = downsample( data: data, targetPixelSize: targetPixelSize ) finish( cacheKey: cacheKey, requestId: requestId, image: image ) return } let image = downsample( data: data, targetPixelSize: targetPixelSize ) if let image { deliver(image, token: token, completion: completion) } } return token } private static func register( cacheKey: NSString, callbackId: UUID, token: NativeSheetImageLoadToken, completion: @escaping (UIImage) -> Void ) -> UUID? { stateLock.lock() let requestId: UUID? if let request = inFlight[cacheKey] { request.callbacks[callbackId] = (token, completion) requestId = nil } else { let newRequestId = UUID() inFlight[cacheKey] = InFlightRequest( requestId: newRequestId, callbackId: callbackId, token: token, completion: completion ) requestId = newRequestId } stateLock.unlock() token.installCancellationHandler { cancelSubscriber(cacheKey: cacheKey, callbackId: callbackId) } return requestId } private static func cancelSubscriber( cacheKey: NSString, callbackId: UUID ) { var networkToken: NativeSheetImageLoadToken? stateLock.lock() if let request = inFlight[cacheKey] { request.callbacks.removeValue(forKey: callbackId) if request.callbacks.isEmpty { inFlight.removeValue(forKey: cacheKey) networkToken = request.networkToken } } stateLock.unlock() networkToken?.cancel() } private static func installNetworkToken( _ token: NativeSheetImageLoadToken, cacheKey: NSString, requestId: UUID ) { stateLock.lock() if let request = inFlight[cacheKey], request.requestId == requestId, !request.callbacks.isEmpty { request.networkToken = token stateLock.unlock() } else { stateLock.unlock() token.cancel() } } private static func finish( cacheKey: NSString, requestId: UUID, image: UIImage? ) { stateLock.lock() let request: InFlightRequest? if inFlight[cacheKey]?.requestId == requestId { request = inFlight.removeValue(forKey: cacheKey) } else { request = nil } stateLock.unlock() guard let request else { return } if let image { let pixelWidth = Int(image.size.width * image.scale) let pixelHeight = Int(image.size.height * image.scale) cache.setObject( image, forKey: cacheKey, cost: max(1, pixelWidth * pixelHeight * 4) ) } guard let image else { return } DispatchQueue.main.async { request.callbacks.values.forEach { token, callback in guard !token.isCancelled else { return } callback(image) } } } /// Narrow test seam for reproducing stale completion and token-install /// races without issuing a real network request. static func beginInFlightRequestForTesting( cacheKey: NSString, completion: @escaping (UIImage) -> Void ) -> (token: NativeSheetImageLoadToken, requestId: UUID?) { let token = NativeSheetImageLoadToken() let requestId = register( cacheKey: cacheKey, callbackId: UUID(), token: token, completion: completion ) return (token, requestId) } static func installNetworkTokenForTesting( _ token: NativeSheetImageLoadToken, cacheKey: NSString, requestId: UUID ) { installNetworkToken( token, cacheKey: cacheKey, requestId: requestId ) } static func finishInFlightRequestForTesting( cacheKey: NSString, requestId: UUID, image: UIImage? ) { finish(cacheKey: cacheKey, requestId: requestId, image: image) } private static func deliver( _ image: UIImage, token: NativeSheetImageLoadToken, completion: @escaping (UIImage) -> Void ) { let callback = { guard !token.isCancelled else { return } completion(image) } if Thread.isMainThread { callback() } else { DispatchQueue.main.async(execute: callback) } } private static func decodeDataImage( _ dataUrl: String, targetPixelSize: Int ) -> UIImage? { guard let commaIndex = dataUrl.firstIndex(of: ",") else { return nil } let payload = dataUrl[dataUrl.index(after: commaIndex)...] // A base64 payload is roughly 4/3 the decoded size. Reject it before // allocating a potentially huge intermediary String/Data pair. guard payload.utf8.count <= (maxEncodedBytes * 4 / 3) + 8 else { return nil } guard let data = Data(base64Encoded: String(payload)), data.count <= maxEncodedBytes else { return nil } return downsample(data: data, targetPixelSize: targetPixelSize) } private static func downsample( data: Data, targetPixelSize: Int ) -> UIImage? { autoreleasepool { guard targetPixelSize > 0, let source = CGImageSourceCreateWithData( data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary ) else { return nil } let options: [CFString: Any] = [ kCGImageSourceCreateThumbnailFromImageAlways: true, kCGImageSourceCreateThumbnailWithTransform: true, kCGImageSourceShouldCacheImmediately: true, kCGImageSourceThumbnailMaxPixelSize: targetPixelSize, ] guard let cgImage = CGImageSourceCreateThumbnailAtIndex( source, 0, options as CFDictionary ) else { return nil } return UIImage(cgImage: cgImage) } } } private final class NativeModelAvatarView: UIView { private let imageView = UIImageView() private let initialsLabel = UILabel() private let symbolView = UIImageView() private var expectedImageUrl: String? private var imageGeneration = UUID() private var imageLoadToken: NativeSheetImageLoadToken? init(side: CGFloat = 32) { super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false widthAnchor.constraint(equalToConstant: side).isActive = true heightAnchor.constraint(equalToConstant: side).isActive = true layer.cornerRadius = 8 layer.cornerCurve = .continuous layer.borderWidth = 1 clipsToBounds = true imageView.translatesAutoresizingMaskIntoConstraints = false imageView.contentMode = .scaleAspectFill imageView.isHidden = true addSubview(imageView) initialsLabel.translatesAutoresizingMaskIntoConstraints = false initialsLabel.font = .preferredFont(forTextStyle: .footnote) initialsLabel.adjustsFontForContentSizeCategory = true initialsLabel.textAlignment = .center addSubview(initialsLabel) symbolView.translatesAutoresizingMaskIntoConstraints = false symbolView.contentMode = .scaleAspectFit symbolView.preferredSymbolConfiguration = UIImage.SymbolConfiguration( pointSize: 16, weight: .medium ) symbolView.isHidden = true addSubview(symbolView) NSLayoutConstraint.activate([ imageView.leadingAnchor.constraint(equalTo: leadingAnchor), imageView.trailingAnchor.constraint(equalTo: trailingAnchor), imageView.topAnchor.constraint(equalTo: topAnchor), imageView.bottomAnchor.constraint(equalTo: bottomAnchor), initialsLabel.leadingAnchor.constraint(equalTo: leadingAnchor), initialsLabel.trailingAnchor.constraint(equalTo: trailingAnchor), initialsLabel.topAnchor.constraint(equalTo: topAnchor), initialsLabel.bottomAnchor.constraint(equalTo: bottomAnchor), symbolView.centerXAnchor.constraint(equalTo: centerXAnchor), symbolView.centerYAnchor.constraint(equalTo: centerYAnchor), symbolView.widthAnchor.constraint(equalToConstant: 18), symbolView.heightAnchor.constraint(equalToConstant: 18), ]) } required init?(coder: NSCoder) { nil } deinit { imageLoadToken?.cancel() } func cancelImageLoad() { imageLoadToken?.cancel() imageLoadToken = nil imageGeneration = UUID() expectedImageUrl = nil } func configure( name: String, avatarUrl: String?, avatarData: Data?, avatarHeaders: [String: String], sfSymbol: String?, usesTemplateImage: Bool = false, avatarCacheIdentifier: String? = nil ) { imageLoadToken?.cancel() imageLoadToken = nil imageGeneration = UUID() let generation = imageGeneration expectedImageUrl = avatarUrl imageView.image = nil imageView.tintColor = nil imageView.contentMode = .scaleAspectFill imageView.isHidden = true let accentColor = nativeAvatarAccentColor(seed: name) backgroundColor = accentColor.withAlphaComponent(0.12) layer.borderColor = accentColor.withAlphaComponent(0.24).cgColor if let sfSymbol, !sfSymbol.isEmpty { symbolView.image = UIImage(systemName: sfSymbol) symbolView.tintColor = accentColor symbolView.isHidden = false initialsLabel.isHidden = true } else { symbolView.isHidden = true initialsLabel.text = name .trimmingCharacters(in: .whitespacesAndNewlines) .prefix(1) .uppercased() initialsLabel.textColor = accentColor initialsLabel.isHidden = false } if let avatarData { imageLoadToken = NativeSheetImageLoader.load( data: avatarData, cacheIdentifier: avatarCacheIdentifier, targetPixelSize: 96 ) { [weak self] image in guard let self, self.imageGeneration == generation else { return } self.imageView.image = usesTemplateImage ? image.withRenderingMode(.alwaysTemplate) : image self.imageView.tintColor = usesTemplateImage ? NativeSheetTheme.shared.foreground : nil self.imageView.contentMode = usesTemplateImage ? .scaleAspectFit : .scaleAspectFill self.imageView.isHidden = false self.initialsLabel.isHidden = true self.symbolView.isHidden = true } return } guard let avatarUrl, !avatarUrl.isEmpty else { return } // Dart builds nonempty avatar headers only for URLs on the active // server origin (see imageUrlIsServerOrigin), so the avatar URL itself // is the origin these credentials were issued for. Cross-origin // redirects still drop the request entirely. imageLoadToken = NativeSheetImageLoader.load( rawUrl: avatarUrl, headers: avatarHeaders, trustedServerOriginURL: URL(string: avatarUrl), targetPixelSize: 96 ) { [weak self] image in guard let self, self.imageGeneration == generation, self.expectedImageUrl == avatarUrl else { return } self.imageView.image = usesTemplateImage ? image.withRenderingMode(.alwaysTemplate) : image self.imageView.tintColor = usesTemplateImage ? NativeSheetTheme.shared.foreground : nil self.imageView.contentMode = usesTemplateImage ? .scaleAspectFit : .scaleAspectFill self.imageView.isHidden = false self.initialsLabel.isHidden = true self.symbolView.isHidden = true } } } private final class NativeTextEditorViewController: UIViewController, UITextViewDelegate { private let configuration: NativeTextEditorConfiguration private let onClose: () -> Void private let onSend: () -> Void private let textView = UITextView() private let placeholderLabel = UILabel() var closeActionId: String { configuration.closeActionId } init( configuration: NativeTextEditorConfiguration, onClose: @escaping () -> Void, onSend: @escaping () -> Void ) { self.configuration = configuration self.onClose = onClose self.onSend = onSend super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { nil } override func viewDidLoad() { super.viewDidLoad() title = configuration.title view.backgroundColor = NativeSheetTheme.shared.background navigationItem.leftBarButtonItem = iconBarButton( systemName: "xmark", action: UIAction { [weak self] _ in self?.closeTapped() } ) let sendButton = UIBarButtonItem( title: configuration.sendLabel, primaryAction: UIAction { [weak self] _ in self?.sendTapped() } ) sendButton.style = .done navigationItem.rightBarButtonItem = sendButton textView.translatesAutoresizingMaskIntoConstraints = false textView.backgroundColor = .clear textView.font = .preferredFont(forTextStyle: .body) textView.adjustsFontForContentSizeCategory = true textView.textColor = NativeSheetTheme.shared.foreground textView.tintColor = NativeSheetTheme.shared.accent textView.text = configuration.initialValue textView.delegate = self textView.keyboardDismissMode = .interactive textView.alwaysBounceVertical = true textView.textContainerInset = UIEdgeInsets(top: 16, left: 0, bottom: 16, right: 0) textView.textContainer.lineFragmentPadding = 0 placeholderLabel.translatesAutoresizingMaskIntoConstraints = false placeholderLabel.text = configuration.placeholder placeholderLabel.font = textView.font placeholderLabel.textColor = .placeholderText placeholderLabel.numberOfLines = 0 placeholderLabel.adjustsFontForContentSizeCategory = true view.addSubview(textView) textView.addSubview(placeholderLabel) NSLayoutConstraint.activate([ textView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), textView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), textView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), textView.bottomAnchor.constraint(equalTo: view.keyboardLayoutGuide.topAnchor), placeholderLabel.leadingAnchor.constraint(equalTo: textView.leadingAnchor), placeholderLabel.trailingAnchor.constraint(equalTo: textView.trailingAnchor), placeholderLabel.topAnchor.constraint(equalTo: textView.topAnchor, constant: textView.textContainerInset.top), ]) refreshActions() } func focusEditor() { DispatchQueue.main.async { [weak self] in self?.textView.becomeFirstResponder() } } func resultPayload(actionId: String) -> [String: Any] { [ "actionId": actionId, "values": [configuration.valueId: textView.text ?? ""], ] } func textViewDidChange(_ textView: UITextView) { refreshActions() } private func refreshActions() { placeholderLabel.isHidden = !textView.text.isEmpty navigationItem.rightBarButtonItem?.isEnabled = !textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } private func closeTapped() { onClose() } private func sendTapped() { guard !textView.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } onSend() } } private final class NativeDatePickerViewController: UIViewController { private let configuration: NativeDatePickerConfiguration private let onConfirm: (Date) -> Void private let onClose: () -> Void private let datePicker = UIDatePicker() init( configuration: NativeDatePickerConfiguration, onConfirm: @escaping (Date) -> Void, onClose: @escaping () -> Void ) { self.configuration = configuration self.onConfirm = onConfirm self.onClose = onClose super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { nil } override func viewDidLoad() { super.viewDidLoad() title = configuration.title view.backgroundColor = NativeSheetTheme.shared.background navigationItem.leftBarButtonItem = iconBarButton( systemName: "xmark", action: UIAction { [weak self] _ in self?.cancelTapped() } ) navigationItem.rightBarButtonItem = iconBarButton( systemName: "checkmark", style: .done, action: UIAction { [weak self] _ in self?.doneTapped() } ) let container = UIView() container.translatesAutoresizingMaskIntoConstraints = false container.backgroundColor = NativeSheetTheme.shared.surface container.layer.cornerRadius = 16 container.layer.cornerCurve = .continuous datePicker.translatesAutoresizingMaskIntoConstraints = false datePicker.datePickerMode = .date datePicker.preferredDatePickerStyle = .wheels datePicker.minimumDate = configuration.firstDate datePicker.maximumDate = configuration.lastDate datePicker.date = min( max(configuration.initialDate, configuration.firstDate), configuration.lastDate ) view.addSubview(container) container.addSubview(datePicker) NSLayoutConstraint.activate([ container.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), container.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), container.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 24), datePicker.leadingAnchor.constraint(equalTo: container.leadingAnchor), datePicker.trailingAnchor.constraint(equalTo: container.trailingAnchor), datePicker.topAnchor.constraint(equalTo: container.topAnchor, constant: 12), datePicker.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -12), ]) } @objc private func cancelTapped() { onClose() } @objc private func doneTapped() { NativeSheetHaptics.success() onConfirm(datePicker.date) } } private func configureNavigationCell( _ cell: UITableViewCell, item: NativeSheetItem, showsDisclosure: Bool = true ) { var content = cell.defaultContentConfiguration() content.text = item.title content.secondaryText = item.kind == "searchablePicker" ? (item.selectedOptionLabel ?? item.subtitle) : item.subtitle if let iconAsset = item.iconAsset { content.image = loadFlutterAssetImage(iconAsset)? .withRenderingMode(.alwaysTemplate) ?? UIImage(systemName: item.sfSymbol) } else { content.image = UIImage(systemName: item.sfSymbol) } NativeSheetSettingsStyle.applyContentStyle(&content) if item.iconAsset != nil { let assetSize = item.iconSize ?? NativeSheetSettingsStyle.iconSize content.imageProperties.maximumSize = CGSize( width: assetSize, height: assetSize ) } if item.destructive { content.textProperties.color = NativeSheetTheme.shared.destructive content.imageProperties.tintColor = NativeSheetTheme.shared.destructive } content.textProperties.font = .preferredFont(forTextStyle: .body) if item.kind == "info" && !showsDisclosure { content.textProperties.numberOfLines = 0 content.textProperties.lineBreakMode = .byWordWrapping content.secondaryTextProperties.numberOfLines = 0 content.secondaryTextProperties.lineBreakMode = .byWordWrapping } cell.contentConfiguration = content applyTextAccessibilityLabel(to: cell, from: content) cell.accessoryType = showsDisclosure ? .disclosureIndicator : .none NativeSheetSettingsStyle.applyCellStyle(cell) } /// VoiceOver otherwise leads with the SF Symbol's generated description /// ("Paint palette", "trash") instead of the row's title. private func applyTextAccessibilityLabel( to cell: UITableViewCell, from content: UIListContentConfiguration ) { cell.accessibilityLabel = [content.text, content.secondaryText] .compactMap { $0 } .filter { !$0.isEmpty } .joined(separator: ", ") } private final class NativeAvatarView: UIView { private let imageView = UIImageView() private let initialsLabel = UILabel() private let imageGeneration = NativeAvatarSelectionGeneration() private var imageLoadToken: NativeSheetImageLoadToken? init(profile: NativeSheetProfile, diameter: CGFloat = 88) { super.init(frame: .zero) translatesAutoresizingMaskIntoConstraints = false widthAnchor.constraint(equalToConstant: diameter).isActive = true heightAnchor.constraint(equalToConstant: diameter).isActive = true layer.cornerRadius = diameter / 2 clipsToBounds = true backgroundColor = NativeSheetTheme.shared.surface initialsLabel.text = profile.initials let fontStyle: UIFont.TextStyle = diameter >= 96 ? .largeTitle : .title2 initialsLabel.font = .preferredFont(forTextStyle: fontStyle) initialsLabel.adjustsFontForContentSizeCategory = true initialsLabel.textColor = NativeSheetTheme.shared.secondaryForeground initialsLabel.textAlignment = .center initialsLabel.translatesAutoresizingMaskIntoConstraints = false addSubview(initialsLabel) imageView.contentMode = .scaleAspectFill imageView.translatesAutoresizingMaskIntoConstraints = false imageView.isHidden = true addSubview(imageView) NSLayoutConstraint.activate([ initialsLabel.leadingAnchor.constraint(equalTo: leadingAnchor), initialsLabel.trailingAnchor.constraint(equalTo: trailingAnchor), initialsLabel.topAnchor.constraint(equalTo: topAnchor), initialsLabel.bottomAnchor.constraint(equalTo: bottomAnchor), imageView.leadingAnchor.constraint(equalTo: leadingAnchor), imageView.trailingAnchor.constraint(equalTo: trailingAnchor), imageView.topAnchor.constraint(equalTo: topAnchor), imageView.bottomAnchor.constraint(equalTo: bottomAnchor), ]) loadImage(profile: profile) } required init?(coder: NSCoder) { nil } deinit { imageLoadToken?.cancel() } func setPickedPreview(_ image: UIImage?) { imageLoadToken?.cancel() imageLoadToken = nil imageGeneration.invalidate() if let image { applyNativeAvatarImage( image, isTemplate: false, to: imageView, initialsLabel: initialsLabel ) } } func showRemovedPlaceholder() { imageLoadToken?.cancel() imageLoadToken = nil imageGeneration.invalidate() let img = UIImage(systemName: "person.crop.circle.fill")? .withRenderingMode(.alwaysTemplate) imageView.image = img imageView.tintColor = NativeSheetTheme.shared.icon imageView.contentMode = .scaleAspectFit imageView.isHidden = false initialsLabel.isHidden = true } private func loadImage(profile: NativeSheetProfile) { imageLoadToken?.cancel() imageLoadToken = nil let generation = imageGeneration.begin() if let avatarData = profile.avatarData { imageLoadToken = NativeSheetImageLoader.load( data: avatarData, targetPixelSize: 408 ) { [weak self] image in guard let self, self.imageGeneration.isCurrent(generation) else { return } applyNativeAvatarImage( image, isTemplate: profile.avatarIsTemplate, to: self.imageView, initialsLabel: self.initialsLabel ) } return } guard let avatarUrl = profile.avatarUrl, !avatarUrl.isEmpty else { return } // Dart builds nonempty avatar headers only for URLs on the active // server origin (see imageUrlIsServerOrigin), so the avatar URL itself // is the origin these credentials were issued for. Cross-origin // redirects still drop the request entirely. imageLoadToken = NativeSheetImageLoader.load( rawUrl: avatarUrl, headers: profile.avatarHeaders, trustedServerOriginURL: URL(string: avatarUrl), targetPixelSize: 408 ) { [weak self] image in guard let self, self.imageGeneration.isCurrent(generation) else { return } applyNativeAvatarImage( image, isTemplate: profile.avatarIsTemplate, to: self.imageView, initialsLabel: self.initialsLabel ) } } }