mirror of
https://github.com/cogwheel0/conduit.git
synced 2026-10-19 09:43:36 +00:00
Some checks failed
CI / Mobile app (analyze + test) (push) Has been cancelled
CI / Dart packages (push) Has been cancelled
CI / Dart packages-1 (push) Has been cancelled
CI / Dart packages-2 (push) Has been cancelled
CI / Dart packages-3 (push) Has been cancelled
CI / Web-safe packages (push) Has been cancelled
CI / Localization (push) Has been cancelled
Split ios/Runner/AppDelegate.swift into one file per bridge and put the bridges behind a host-neutral ConduitBridgeHost protocol, so each bridge can be tested and reused without the Flutter view controller. The boundary check in tool/check_package_boundaries.dart now holds every Swift file in ios/Runner to one side of that seam. No intended behaviour change. Review follow-ups: an expiring streaming background task that keepAlive already replaced no longer reports a suspension, and the Send Image App Intent rejects files that do not resolve to an image type.
6933 lines
250 KiB
Swift
6933 lines
250 KiB
Swift
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<String>()
|
|
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<String>()
|
|
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<String?, Error>) -> Void
|
|
private typealias PendingActionResult = (Result<PlatformNativeSheetActionResult?, Error>) -> 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<Bool, Error>) -> 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<String?, Error>) -> 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<String?, Error>) -> 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<String?, Error>) -> 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<PlatformNativeSheetActionResult?, Error>) -> 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<PlatformNativeSheetActionResult?, Error>) -> 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<String>
|
|
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<String>
|
|
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<String>,
|
|
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<NSString, UIImage> = {
|
|
let cache = NSCache<NSString, UIImage>()
|
|
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
|
|
)
|
|
}
|
|
}
|
|
}
|