Establishes version control for the personal Logseq DB fork before starting the server-client architecture migration and domain features described in docs/agent-guide and the approved implementation plan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
673 lines
23 KiB
Swift
673 lines
23 KiB
Swift
//
|
|
// UILocal.swift
|
|
// App
|
|
//
|
|
// Created by Charlie on 2025/5/29.
|
|
//
|
|
|
|
import Capacitor
|
|
import Foundation
|
|
import Speech
|
|
import NaturalLanguage
|
|
import Drops
|
|
|
|
func isDarkMode() -> Bool {
|
|
return UITraitCollection.current.userInterfaceStyle == .dark
|
|
}
|
|
|
|
func isOnlyDayDifferentOrSame(date1: Foundation.Date, date2: Date) -> Bool {
|
|
let calendar = Calendar.current
|
|
let components1 = calendar.dateComponents([.year, .month, .day], from: date1)
|
|
let components2 = calendar.dateComponents([.year, .month, .day], from: date2)
|
|
|
|
return components1.year == components2.year && components1.month == components2.month && (components1.day != components2.day || components1.day == components2.day)
|
|
}
|
|
|
|
class DatePickerView: UIView {
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
isUserInteractionEnabled = true
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
super.init(coder: coder)
|
|
isUserInteractionEnabled = true
|
|
}
|
|
|
|
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
super.touchesBegan(touches, with: event)
|
|
}
|
|
}
|
|
|
|
class DatePickerDialogViewController: UIViewController {
|
|
private let datePicker = UIDatePicker()
|
|
private let dialogView = DatePickerView()
|
|
|
|
private var lastDate: Date?
|
|
private var initialMonthLabel: UILabel?
|
|
private var currentMonthText: String?
|
|
private var didFinish = false
|
|
|
|
var onDateSelected: ((Date?) -> Void)?
|
|
|
|
override func viewDidLoad() {
|
|
super.viewDidLoad()
|
|
lastDate = datePicker.date
|
|
setupImplView()
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
|
self?.settleMonthLabel()
|
|
}
|
|
}
|
|
|
|
private func settleMonthLabel() {
|
|
initialMonthLabel = findMonthLabel(in: datePicker)
|
|
if let label = initialMonthLabel {
|
|
currentMonthText = label.text
|
|
print("Initial month label: \(currentMonthText ?? "Unknown")")
|
|
} else {
|
|
print("Month label not found")
|
|
}
|
|
}
|
|
|
|
private func findMonthLabel(in view: UIView) -> UILabel? {
|
|
for subview in view.subviews {
|
|
if let label = subview as? UILabel, (label.text?.contains(" ")) == true {
|
|
print(label.text as Any)
|
|
return label
|
|
}
|
|
if let foundLabel = findMonthLabel(in: subview) {
|
|
return foundLabel
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func inCalendarWheelPickerMode(in view: UIView) -> Bool? {
|
|
for subview in view.subviews {
|
|
if let label = subview as? UILabel, label.text?.contains("July") == true {
|
|
print(label.text as Any)
|
|
return true
|
|
}
|
|
|
|
let found: Bool? = inCalendarWheelPickerMode(in: subview)
|
|
|
|
if found == true {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
|
|
private func finish(_ date: Date?) {
|
|
guard !didFinish else { return }
|
|
didFinish = true
|
|
onDateSelected?(date)
|
|
dismiss(animated: false, completion: nil)
|
|
}
|
|
|
|
@objc private func confirmDate() {
|
|
let label = findMonthLabel(in: datePicker)
|
|
if isOnlyDayDifferentOrSame(date1: lastDate!, date2: datePicker.date) || (label != nil && label?.text != currentMonthText && (inCalendarWheelPickerMode(in: datePicker) != true)) {
|
|
finish(datePicker.date)
|
|
}
|
|
}
|
|
|
|
@objc private func dismissDialog() {
|
|
finish(nil)
|
|
}
|
|
|
|
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
|
|
super.traitCollectionDidChange(previousTraitCollection)
|
|
|
|
if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle {
|
|
dialogView.backgroundColor = .logseqBackground
|
|
}
|
|
}
|
|
|
|
func setupImplView() {
|
|
datePicker.datePickerMode = .date
|
|
datePicker.preferredDatePickerStyle = .inline
|
|
datePicker.addTarget(
|
|
self, action: #selector(confirmDate), for: .valueChanged)
|
|
|
|
// Create hosting view controller
|
|
let view = self.view!
|
|
|
|
view.backgroundColor = .logseqBackground.withAlphaComponent(0.4)
|
|
view.isUserInteractionEnabled = true
|
|
|
|
dialogView.backgroundColor = .logseqBackground
|
|
|
|
dialogView.layer.cornerRadius = 14
|
|
dialogView.clipsToBounds = false // IMPORTANT: allow shadow
|
|
|
|
dialogView.layer.shadowColor = UIColor.black.cgColor
|
|
dialogView.layer.shadowOpacity = isDarkMode() ? 0.4 : 0.15
|
|
dialogView.layer.shadowOffset = CGSize(width: 0, height: 6)
|
|
dialogView.layer.shadowRadius = 16
|
|
dialogView.layer.masksToBounds = false
|
|
|
|
view.addSubview(dialogView)
|
|
|
|
dialogView.translatesAutoresizingMaskIntoConstraints = false
|
|
NSLayoutConstraint.activate([
|
|
dialogView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
|
dialogView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
|
])
|
|
|
|
// Add sub views
|
|
dialogView.addSubview(datePicker)
|
|
|
|
// Add date selector and toolbar to the view
|
|
datePicker.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
NSLayoutConstraint.activate([
|
|
datePicker.topAnchor.constraint(equalTo: dialogView.topAnchor),
|
|
datePicker.bottomAnchor.constraint(equalTo: dialogView.bottomAnchor, constant: -8),
|
|
datePicker.leadingAnchor.constraint(equalTo: dialogView.leadingAnchor, constant: 16),
|
|
datePicker.trailingAnchor.constraint(equalTo: dialogView.trailingAnchor, constant: -16),
|
|
])
|
|
|
|
datePicker.setContentHuggingPriority(.required, for: .horizontal)
|
|
datePicker.setContentHuggingPriority(.required, for: .vertical)
|
|
datePicker.setContentCompressionResistancePriority(
|
|
.required, for: .horizontal)
|
|
datePicker.setContentCompressionResistancePriority(
|
|
.required, for: .vertical)
|
|
}
|
|
|
|
|
|
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
|
super.touchesBegan(touches, with: event)
|
|
|
|
if let touch = touches.first {
|
|
let location = touch.location(in: view)
|
|
if !dialogView.frame.contains(location) {
|
|
finish(nil)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc(UILocalPlugin)
|
|
public class UILocalPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
|
|
public let identifier = "UILocalPlugin"
|
|
public let jsName = "UILocal"
|
|
public static let navigationNotification = Notification.Name("UILocalPluginNavigation")
|
|
public static let routeChangeNotification = Notification.Name("UILocalPluginRouteChanged")
|
|
private static var navigationReady = false
|
|
private static var pendingRoutes: [[String: Any]] = []
|
|
private static var pendingPaths: [(path: String, push: Bool)] = []
|
|
private var navigationObserver: NSObjectProtocol?
|
|
|
|
private var call: CAPPluginCall?
|
|
private var datepickerViewController: UIViewController?
|
|
private var datepickerDialogView: UIView?
|
|
|
|
public let pluginMethods: [CAPPluginMethod] = [
|
|
CAPPluginMethod(name: "showDatePicker", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "transcribeAudio2Text", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "routeDidChange", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "alert", returnType: CAPPluginReturnPromise),
|
|
CAPPluginMethod(name: "hideAlert", returnType: CAPPluginReturnPromise)
|
|
]
|
|
|
|
@objc func alert(_ call: CAPPluginCall) {
|
|
guard let title = call.getString("title") ?? call.getString("message") else {
|
|
call.reject("title is required")
|
|
return
|
|
}
|
|
|
|
let subtitle = call.getString("subtitle") ?? call.getString("description")
|
|
let type = call.getString("type")?.lowercased()
|
|
let iconName = call.getString("icon")
|
|
let iconColorHex = call.getString("iconColor") ?? call.getString("tintColor")
|
|
let position = (call.getString("position")?.lowercased() == "bottom") ? Drop.Position.bottom : Drop.Position.top
|
|
let durationSeconds = call.getDouble("duration")
|
|
let accessibilityMessage = call.getString("accessibility")
|
|
|
|
let drop = Drop(
|
|
title: title,
|
|
titleNumberOfLines: 0,
|
|
subtitle: subtitle,
|
|
subtitleNumberOfLines: 0,
|
|
icon: buildIcon(type: type, iconName: iconName, hexColor: iconColorHex),
|
|
action: nil,
|
|
position: position,
|
|
duration: durationSeconds.flatMap { Drop.Duration.seconds($0) } ?? .recommended,
|
|
accessibility: accessibilityMessage.map { Drop.Accessibility(message: $0) }
|
|
)
|
|
|
|
Drops.show(drop)
|
|
call.resolve()
|
|
}
|
|
|
|
@objc func hideAlert(_ call: CAPPluginCall) {
|
|
Drops.hideAll()
|
|
call.resolve()
|
|
}
|
|
|
|
private func buildIcon(type: String?, iconName: String?, hexColor: String?) -> UIImage? {
|
|
let tint = color(fromHex: hexColor) ?? color(for: type)
|
|
|
|
if let iconName, let image = UIImage(systemName: iconName) {
|
|
guard let tint else { return image }
|
|
return image.withTintColor(tint, renderingMode: .alwaysOriginal)
|
|
}
|
|
|
|
guard let type else { return nil }
|
|
|
|
let symbolName: String
|
|
switch type {
|
|
case "success":
|
|
symbolName = "checkmark.circle.fill"
|
|
case "warning":
|
|
symbolName = "exclamationmark.triangle.fill"
|
|
case "error", "danger":
|
|
symbolName = "xmark.octagon.fill"
|
|
default:
|
|
symbolName = "info.circle.fill"
|
|
}
|
|
|
|
if let tint {
|
|
return UIImage(systemName: symbolName)?
|
|
.withTintColor(tint, renderingMode: .alwaysOriginal)
|
|
}
|
|
|
|
return UIImage(systemName: symbolName)
|
|
}
|
|
|
|
private func color(for type: String?) -> UIColor? {
|
|
guard let type else { return nil }
|
|
switch type {
|
|
case "success":
|
|
return .systemGreen
|
|
case "warning":
|
|
return .systemOrange
|
|
case "error", "danger":
|
|
return .systemRed
|
|
default:
|
|
return .systemBlue
|
|
}
|
|
}
|
|
|
|
private func color(fromHex hexString: String?) -> UIColor? {
|
|
guard var hex = hexString?
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.uppercased(),
|
|
!hex.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
if hex.hasPrefix("#") {
|
|
hex.removeFirst()
|
|
}
|
|
|
|
var rgbValue: UInt64 = 0
|
|
guard Scanner(string: hex).scanHexInt64(&rgbValue) else { return nil }
|
|
|
|
switch hex.count {
|
|
case 6:
|
|
return UIColor(
|
|
red: CGFloat((rgbValue & 0xFF0000) >> 16) / 255.0,
|
|
green: CGFloat((rgbValue & 0x00FF00) >> 8) / 255.0,
|
|
blue: CGFloat(rgbValue & 0x0000FF) / 255.0,
|
|
alpha: 1.0
|
|
)
|
|
case 8:
|
|
return UIColor(
|
|
red: CGFloat((rgbValue & 0xFF000000) >> 24) / 255.0,
|
|
green: CGFloat((rgbValue & 0x00FF0000) >> 16) / 255.0,
|
|
blue: CGFloat((rgbValue & 0x0000FF00) >> 8) / 255.0,
|
|
alpha: CGFloat(rgbValue & 0x000000FF) / 255.0
|
|
)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
@available(iOS 26.0, *)
|
|
func recognizeWithAutoLocale(from file: URL,
|
|
completion: @escaping (String?, Error?) -> Void) {
|
|
Task {
|
|
do {
|
|
// ---------- STEP 1: Gather candidate locales ----------
|
|
let preferred = Array(Locale.preferredLanguages.prefix(3))
|
|
var candidateIDs = preferred
|
|
if !candidateIDs.contains(where: { $0.hasPrefix("en") }) {
|
|
candidateIDs.append("en-US")
|
|
}
|
|
if !candidateIDs.contains(where: { $0.hasPrefix("zh") }) {
|
|
candidateIDs.append("zh-CN")
|
|
}
|
|
|
|
// ---------- STEP 2: Probe candidates in parallel ----------
|
|
var results: [(Locale, String)] = []
|
|
|
|
await withTaskGroup(of: (Locale, String).self) { group in
|
|
for id in candidateIDs {
|
|
let candidate = Locale(identifier: id)
|
|
if let supported = await SpeechTranscriber.supportedLocale(equivalentTo: candidate) {
|
|
group.addTask {
|
|
let text = (try? await self.quickSampleTranscription(file: file, locale: supported)) ?? ""
|
|
return (supported, text)
|
|
}
|
|
}
|
|
}
|
|
for await (locale, text) in group {
|
|
results.append((locale, text))
|
|
}
|
|
}
|
|
|
|
// ---------- STEP 3: Score results ----------
|
|
var bestLocale: Locale = Locale(identifier: "en-US")
|
|
var bestScore = Int.min
|
|
for (locale, text) in results {
|
|
let score = scoreTranscript(text, locale: locale)
|
|
print("📊 Candidate: \(locale.identifier), score: \(score), text: \(text)")
|
|
if score > bestScore {
|
|
bestScore = score
|
|
bestLocale = locale
|
|
}
|
|
}
|
|
|
|
print("🎙 Running full transcription with locale: \(bestLocale.identifier)")
|
|
|
|
// ---------- STEP 4: Full transcription ----------
|
|
let transcriber = SpeechTranscriber(locale: bestLocale, preset: .transcription)
|
|
|
|
if let req = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) {
|
|
try await req.downloadAndInstall()
|
|
print("✅ Model installed for \(bestLocale.identifier)")
|
|
}
|
|
|
|
let collectFullTask = Task { () -> String in
|
|
var full = ""
|
|
do {
|
|
for try await r in transcriber.results {
|
|
full += String(r.text.characters) + " "
|
|
}
|
|
} catch {}
|
|
return full
|
|
}
|
|
|
|
let analyzer = SpeechAnalyzer(modules: [transcriber])
|
|
let audio = try AVAudioFile(forReading: file)
|
|
if let last = try await analyzer.analyzeSequence(from: audio) {
|
|
try await analyzer.finalizeAndFinish(through: last)
|
|
} else {
|
|
try await analyzer.cancelAndFinishNow()
|
|
}
|
|
|
|
let finalText = (await collectFullTask.value)
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
completion(finalText.isEmpty ? nil : finalText, nil)
|
|
|
|
} catch {
|
|
completion(nil, error)
|
|
}
|
|
}
|
|
}
|
|
|
|
@available(iOS 26.0, *)
|
|
private func quickSampleTranscription(file: URL, locale: Locale) async throws -> String {
|
|
let transcriber = SpeechTranscriber(locale: locale, preset: .transcription)
|
|
|
|
// Install models if needed (you could cache this across runs)
|
|
if let req = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) {
|
|
try await req.downloadAndInstall()
|
|
}
|
|
|
|
var sample = ""
|
|
var count = 0
|
|
let analyzer = SpeechAnalyzer(modules: [transcriber])
|
|
|
|
let collectTask = Task { () -> String in
|
|
do {
|
|
for try await r in transcriber.results {
|
|
sample += String(r.text.characters) + " "
|
|
count += 1
|
|
if count >= 3 {
|
|
// ✅ Early exit: stop once we have enough
|
|
try? await analyzer.cancelAndFinishNow()
|
|
break
|
|
}
|
|
}
|
|
} catch {}
|
|
return sample
|
|
}
|
|
|
|
let audioFile = try AVAudioFile(forReading: file)
|
|
if let last = try await analyzer.analyzeSequence(from: audioFile) {
|
|
try await analyzer.finalizeAndFinish(through: last)
|
|
} else {
|
|
try await analyzer.cancelAndFinishNow()
|
|
}
|
|
|
|
return await collectTask.value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
private func scoreTranscript(_ text: String, locale: Locale) -> Int {
|
|
// Normalize: keep only letters/digits/scripts (ignore punctuation)
|
|
let normalized = text.unicodeScalars.filter {
|
|
CharacterSet.letters.contains($0) ||
|
|
CharacterSet.decimalDigits.contains($0) ||
|
|
CharacterSet(charactersIn: "\u{4E00}"..."\u{9FFF}").contains($0) || // Han
|
|
CharacterSet(charactersIn: "\u{3040}"..."\u{30FF}").contains($0) || // Kana
|
|
CharacterSet(charactersIn: "\u{AC00}"..."\u{D7AF}").contains($0) || // Hangul
|
|
CharacterSet(charactersIn: "\u{0400}"..."\u{04FF}").contains($0) || // Cyrillic
|
|
CharacterSet(charactersIn: "\u{0600}"..."\u{06FF}").contains($0) || // Arabic
|
|
CharacterSet(charactersIn: "\u{0590}"..."\u{05FF}").contains($0) || // Hebrew
|
|
CharacterSet(charactersIn: "\u{0900}"..."\u{097F}").contains($0) // Devanagari
|
|
}
|
|
let coreText = String(String.UnicodeScalarView(normalized))
|
|
var score = coreText.count
|
|
|
|
// Detect script presence
|
|
let hasHan = coreText.range(of: #"\p{Han}"#, options: .regularExpression) != nil
|
|
let hasKana = coreText.range(of: #"\u3040-\u30FF"#, options: .regularExpression) != nil
|
|
let hasHangul = coreText.range(of: #"\uAC00-\uD7AF"#, options: .regularExpression) != nil
|
|
let hasCyrillic = coreText.range(of: #"\u0400-\u04FF"#, options: .regularExpression) != nil
|
|
let hasArabic = coreText.range(of: #"\u0600-\u06FF"#, options: .regularExpression) != nil
|
|
let hasHebrew = coreText.range(of: #"\u0590-\u05FF"#, options: .regularExpression) != nil
|
|
let hasDevanag = coreText.range(of: #"\u0900-\u097F"#, options: .regularExpression) != nil
|
|
|
|
// Latin ratio detection
|
|
let latinLetters = coreText.filter { $0.isASCII && $0.isLetter }.count
|
|
let latinRatio = coreText.isEmpty ? 0.0 : Double(latinLetters) / Double(coreText.count)
|
|
|
|
if latinRatio > 0.7 {
|
|
if locale.identifier.hasPrefix("en") {
|
|
score += 500
|
|
} else if locale.identifier.hasPrefix("zh")
|
|
|| locale.identifier.hasPrefix("ja")
|
|
|| locale.identifier.hasPrefix("ko") {
|
|
score -= 500
|
|
}
|
|
}
|
|
|
|
if hasHan { score += locale.identifier.hasPrefix("zh") || locale.identifier.hasPrefix("ja") ? 1000 : -500 }
|
|
if hasKana { score += locale.identifier.hasPrefix("ja") ? 1000 : -500 }
|
|
if hasHangul { score += locale.identifier.hasPrefix("ko") ? 1000 : -500 }
|
|
if hasCyrillic { score += locale.identifier.hasPrefix("ru") ? 1000 : -500 }
|
|
if hasArabic { score += locale.identifier.hasPrefix("ar") ? 1000 : -500 }
|
|
if hasHebrew { score += locale.identifier.hasPrefix("he") ? 1000 : -500 }
|
|
if hasDevanag { score += locale.identifier.hasPrefix("hi") ? 1000 : -500 }
|
|
|
|
// Bias toward user-preferred languages
|
|
if Locale.preferredLanguages.contains(where: { locale.identifier.hasPrefix($0.prefix(2)) }) {
|
|
score += 200
|
|
}
|
|
|
|
return score
|
|
}
|
|
|
|
|
|
@available(iOS 26.0, *)
|
|
@objc func transcribeAudio2Text(_ call: CAPPluginCall) {
|
|
self.call = call
|
|
|
|
// audio arrayBuffer
|
|
guard let audioArray = call.getArray("audioData", NSNumber.self) as? [UInt8] else {
|
|
call.reject("invalid audioData")
|
|
return
|
|
}
|
|
|
|
let audioData = Data(audioArray)
|
|
|
|
let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("recordedAudio.m4a")
|
|
|
|
do {
|
|
try audioData.write(to: fileURL)
|
|
|
|
let fileExists = FileManager.default.fileExists(atPath: fileURL.path)
|
|
|
|
print("file exists: \(fileExists), path: \(fileURL.path)")
|
|
if !fileExists {
|
|
call.reject("file save failed: file doesn't exist")
|
|
return
|
|
}
|
|
|
|
self.recognizeWithAutoLocale(from: fileURL) { result, error in
|
|
if let result = result {
|
|
call.resolve(["transcription": result])
|
|
} else if let error = error {
|
|
call.reject("failed to transcribe: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
} catch {
|
|
call.reject("failed to transcribe: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
|
|
@objc func showDatePicker(_ call: CAPPluginCall) {
|
|
self.call = call
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
let viewController = DatePickerDialogViewController()
|
|
|
|
// Set view controller presentation
|
|
viewController.modalPresentationStyle = .overFullScreen
|
|
viewController.modalTransitionStyle = .crossDissolve
|
|
viewController.isModalInPresentation = true // 禁止非按钮交互关闭
|
|
|
|
viewController.onDateSelected = self?.dateChanged
|
|
|
|
// Present View Controller
|
|
guard let presentingViewController = self?.bridge?.viewController else {
|
|
call.reject("Unable to present date picker")
|
|
return
|
|
}
|
|
|
|
presentingViewController.present(
|
|
viewController, animated: false, completion: nil)
|
|
}
|
|
}
|
|
|
|
private func dateChanged(_ date: Date?) {
|
|
guard let call = self.call else { return }
|
|
self.call = nil
|
|
|
|
if let date {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "yyyy-MM-dd"
|
|
let dateString = formatter.string(from: date)
|
|
let result: PluginCallResultData = ["value": dateString]
|
|
call.resolve(result)
|
|
} else {
|
|
call.resolve([:])
|
|
}
|
|
}
|
|
|
|
override public func load() {
|
|
super.load()
|
|
print("🔅 UILocalPlugin loaded")
|
|
navigationObserver = NotificationCenter.default.addObserver(
|
|
forName: UILocalPlugin.navigationNotification,
|
|
object: nil,
|
|
queue: OperationQueue.main
|
|
) { [weak self] notification in
|
|
guard let strongSelf = self else { return }
|
|
|
|
if let route = notification.userInfo?["route"] as? [String: Any] {
|
|
strongSelf.notifyListeners(
|
|
"nativeNavigation",
|
|
data: ["route": route]
|
|
)
|
|
return
|
|
}
|
|
|
|
if let path = notification.userInfo?["path"] as? String {
|
|
var data: [String: Any] = ["path": path]
|
|
if let push = notification.userInfo?["push"] as? Bool {
|
|
data["push"] = push
|
|
}
|
|
strongSelf.notifyListeners(
|
|
"nativeNavigation",
|
|
data: data
|
|
)
|
|
}
|
|
}
|
|
UILocalPlugin.navigationReady = true
|
|
|
|
// Flush any pending navigation requests that arrived before the plugin loaded.
|
|
UILocalPlugin.pendingRoutes.forEach { route in
|
|
NotificationCenter.default.post(
|
|
name: UILocalPlugin.navigationNotification,
|
|
object: nil,
|
|
userInfo: ["route": route]
|
|
)
|
|
}
|
|
UILocalPlugin.pendingRoutes.removeAll()
|
|
|
|
UILocalPlugin.pendingPaths.forEach { payload in
|
|
NotificationCenter.default.post(
|
|
name: UILocalPlugin.navigationNotification,
|
|
object: nil,
|
|
userInfo: ["path": payload.path, "push": payload.push]
|
|
)
|
|
}
|
|
UILocalPlugin.pendingPaths.removeAll()
|
|
}
|
|
|
|
deinit {
|
|
if let navigationObserver {
|
|
NotificationCenter.default.removeObserver(navigationObserver)
|
|
}
|
|
}
|
|
|
|
@objc func routeDidChange(_ call: CAPPluginCall) {
|
|
let navigationType = call.getString("navigationType") ?? "push"
|
|
let push = call.getBool("push") ?? (navigationType == "push")
|
|
let path = call.getString("path") ?? "/"
|
|
|
|
// ✅ read stack from JS, default to "home" only if missing
|
|
let stack = call.getString("stack") ?? "home"
|
|
|
|
#if DEBUG
|
|
print("📬 UILocal.routeDidChange call from JS")
|
|
print(" navigationType=\(navigationType) push=\(push) stack=\(stack) path=\(path)")
|
|
#endif
|
|
|
|
DispatchQueue.main.async {
|
|
NotificationCenter.default.post(
|
|
name: UILocalPlugin.routeChangeNotification,
|
|
object: nil,
|
|
userInfo: [
|
|
"navigationType": navigationType,
|
|
"push": push,
|
|
"stack": stack, // 👈 forward it
|
|
"path": path
|
|
]
|
|
)
|
|
}
|
|
|
|
call.resolve()
|
|
}
|
|
}
|