UILocalPlugin.swift 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. //
  2. // UILocal.swift
  3. // App
  4. //
  5. // Created by Charlie on 2025/5/29.
  6. //
  7. import Capacitor
  8. import Foundation
  9. import Speech
  10. func isDarkMode() -> Bool {
  11. if #available(iOS 12.0, *) {
  12. return UITraitCollection.current.userInterfaceStyle == .dark
  13. } else {
  14. return false
  15. }
  16. }
  17. func isOnlyDayDifferentOrSame(date1: Foundation.Date, date2: Date) -> Bool {
  18. let calendar = Calendar.current
  19. let components1 = calendar.dateComponents([.year, .month, .day], from: date1)
  20. let components2 = calendar.dateComponents([.year, .month, .day], from: date2)
  21. return components1.year == components2.year && components1.month == components2.month && (components1.day != components2.day || components1.day == components2.day)
  22. }
  23. class DatePickerView: UIView {
  24. override init(frame: CGRect) {
  25. super.init(frame: frame)
  26. isUserInteractionEnabled = true
  27. }
  28. required init?(coder: NSCoder) {
  29. super.init(coder: coder)
  30. isUserInteractionEnabled = true
  31. }
  32. override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
  33. super.touchesBegan(touches, with: event)
  34. }
  35. }
  36. class DatePickerDialogViewController: UIViewController {
  37. private let datePicker = UIDatePicker()
  38. private let dialogView = DatePickerView()
  39. private var lastDate: Date?
  40. private var initialMonthLabel: UILabel?
  41. private var currentMonthText: String?
  42. var onDateSelected: ((Date?) -> Void)?
  43. override func viewDidLoad() {
  44. super.viewDidLoad()
  45. lastDate = datePicker.date
  46. setupImplView()
  47. DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
  48. self?.settleMonthLabel()
  49. }
  50. }
  51. private func settleMonthLabel() {
  52. initialMonthLabel = findMonthLabel(in: datePicker)
  53. if let label = initialMonthLabel {
  54. currentMonthText = label.text
  55. print("Initial month label: \(currentMonthText ?? "Unknown")")
  56. } else {
  57. print("Month label not found")
  58. }
  59. }
  60. private func findMonthLabel(in view: UIView) -> UILabel? {
  61. for subview in view.subviews {
  62. if let label = subview as? UILabel, (label.text?.contains(" ")) == true {
  63. print(label.text as Any)
  64. return label
  65. }
  66. if let foundLabel = findMonthLabel(in: subview) {
  67. return foundLabel
  68. }
  69. }
  70. return nil
  71. }
  72. private func inCalendarWheelPickerMode(in view: UIView) -> Bool? {
  73. for subview in view.subviews {
  74. if let label = subview as? UILabel, label.text?.contains("July") == true {
  75. print(label.text as Any)
  76. return true
  77. }
  78. let found: Bool? = inCalendarWheelPickerMode(in: subview)
  79. if found == true {
  80. return true
  81. }
  82. }
  83. return false
  84. }
  85. @objc private func confirmDate() {
  86. let label = findMonthLabel(in: datePicker)
  87. if isOnlyDayDifferentOrSame(date1: lastDate!, date2: datePicker.date) || (label != nil && label?.text != currentMonthText && (inCalendarWheelPickerMode(in: datePicker) != true)) {
  88. onDateSelected?(datePicker.date)
  89. dismiss(animated: false, completion: nil)
  90. }
  91. }
  92. @objc private func dismissDialog() {
  93. onDateSelected?(nil)
  94. dismiss(animated: false, completion: nil)
  95. }
  96. override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
  97. super.traitCollectionDidChange(previousTraitCollection)
  98. if #available(iOS 12.0, *) {
  99. if traitCollection.userInterfaceStyle != previousTraitCollection?.userInterfaceStyle {
  100. if traitCollection.userInterfaceStyle == .dark {
  101. print("switch to dark mode")
  102. dialogView.backgroundColor = .black
  103. } else {
  104. print("switch to light mode")
  105. dialogView.backgroundColor = .white
  106. }
  107. }
  108. }
  109. }
  110. func setupImplView() {
  111. datePicker.datePickerMode = .date
  112. datePicker.preferredDatePickerStyle = .inline
  113. datePicker.addTarget(
  114. self, action: #selector(confirmDate), for: .valueChanged)
  115. // Create hosting view controller
  116. let view = self.view!
  117. view.backgroundColor = .black.withAlphaComponent(0.4)
  118. view.isUserInteractionEnabled = true
  119. if isDarkMode() {
  120. dialogView.backgroundColor = .black
  121. } else {
  122. dialogView.backgroundColor = .white
  123. }
  124. dialogView.layer.cornerRadius = 10
  125. dialogView.clipsToBounds = true
  126. view.addSubview(dialogView)
  127. dialogView.translatesAutoresizingMaskIntoConstraints = false
  128. NSLayoutConstraint.activate([
  129. dialogView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
  130. dialogView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
  131. ])
  132. // Add sub views
  133. dialogView.addSubview(datePicker)
  134. // Add date selector and toolbar to the view
  135. datePicker.translatesAutoresizingMaskIntoConstraints = false
  136. NSLayoutConstraint.activate([
  137. datePicker.topAnchor.constraint(equalTo: dialogView.topAnchor),
  138. datePicker.bottomAnchor.constraint(equalTo: dialogView.bottomAnchor, constant: -8),
  139. datePicker.leadingAnchor.constraint(equalTo: dialogView.leadingAnchor, constant: 16),
  140. datePicker.trailingAnchor.constraint(equalTo: dialogView.trailingAnchor, constant: -16),
  141. ])
  142. datePicker.setContentHuggingPriority(.required, for: .horizontal)
  143. datePicker.setContentHuggingPriority(.required, for: .vertical)
  144. datePicker.setContentCompressionResistancePriority(
  145. .required, for: .horizontal)
  146. datePicker.setContentCompressionResistancePriority(
  147. .required, for: .vertical)
  148. }
  149. override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
  150. super.touchesBegan(touches, with: event)
  151. if let touch = touches.first {
  152. let location = touch.location(in: view)
  153. if !dialogView.frame.contains(location) {
  154. dismiss(animated: true, completion: nil)
  155. }
  156. }
  157. }
  158. }
  159. @objc(UILocalPlugin)
  160. public class UILocalPlugin: CAPPlugin, CAPBridgedPlugin {
  161. public let identifier = "UILocalPlugin"
  162. public let jsName = "UILocal"
  163. private var call: CAPPluginCall?
  164. private var selectedDate: Date?
  165. private var datepickerViewController: UIViewController?
  166. private var datepickerDialogView: UIView?
  167. public let pluginMethods: [CAPPluginMethod] = [
  168. CAPPluginMethod(name: "showDatePicker", returnType: CAPPluginReturnPromise),
  169. CAPPluginMethod(name: "transcribeAudio2Text", returnType: CAPPluginReturnPromise)
  170. ]
  171. // TODO: switch to use https://developer.apple.com/documentation/speech/speechanalyzer for iOS 26+
  172. // 语音识别方法
  173. private func recognizeSpeech(from url: URL, completion: @escaping (String?, Error?) -> Void) {
  174. SFSpeechRecognizer.requestAuthorization { authStatus in
  175. guard authStatus == .authorized else {
  176. completion(nil, NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "语音识别权限未授权"]))
  177. return
  178. }
  179. let recognizer = SFSpeechRecognizer(locale: Locale(identifier: "en-US"))
  180. let request = SFSpeechURLRecognitionRequest(url: url)
  181. // Setting up offline speech recognition
  182. recognizer?.supportsOnDeviceRecognition = true
  183. request.shouldReportPartialResults = false
  184. request.requiresOnDeviceRecognition = true
  185. request.taskHint = .dictation
  186. if #available(iOS 16, *) {
  187. request.addsPunctuation = true
  188. }
  189. recognizer?.recognitionTask(with: request) { result, error in
  190. if let result = result {
  191. let transcription = result.bestTranscription.formattedString
  192. completion(transcription, nil)
  193. } else if let error = error {
  194. completion(nil, error)
  195. }
  196. }
  197. }
  198. }
  199. @objc func transcribeAudio2Text(_ call: CAPPluginCall) {
  200. self.call = call
  201. // 接收音频数据 arrayBuffer
  202. guard let audioArray = call.getArray("audioData", NSNumber.self) as? [UInt8] else {
  203. call.reject("无效的音频数据")
  204. return
  205. }
  206. // 将数组转换为 Data
  207. let audioData = Data(audioArray)
  208. // 保存为本地文件
  209. let fileURL = FileManager.default.temporaryDirectory.appendingPathComponent("recordedAudio.m4a")
  210. do {
  211. try audioData.write(to: fileURL)
  212. let fileExists = FileManager.default.fileExists(atPath: fileURL.path)
  213. print("文件是否存在: \(fileExists), 路径: \(fileURL.path)")
  214. if !fileExists {
  215. call.reject("文件保存失败,文件不存在")
  216. return
  217. }
  218. // 调用语音识别
  219. self.recognizeSpeech(from: fileURL) { result, error in
  220. if let result = result {
  221. call.resolve(["transcription": result])
  222. } else if let error = error {
  223. call.reject("语音识别失败: \(error.localizedDescription)")
  224. }
  225. }
  226. } catch {
  227. call.reject("保存文件失败: \(error.localizedDescription)")
  228. }
  229. }
  230. @objc func showDatePicker(_ call: CAPPluginCall) {
  231. self.call = call
  232. DispatchQueue.main.async { [weak self] in
  233. let viewController = DatePickerDialogViewController()
  234. // Set view controller presentation
  235. viewController.modalPresentationStyle = .overFullScreen
  236. viewController.modalTransitionStyle = .crossDissolve
  237. viewController.isModalInPresentation = true // 禁止非按钮交互关闭
  238. viewController.onDateSelected = self?.dateChanged
  239. // Present View Controller
  240. guard let presentingViewController = self?.bridge?.viewController else {
  241. call.reject("Unable to present date picker")
  242. return
  243. }
  244. presentingViewController.present(
  245. viewController, animated: false, completion: nil)
  246. }
  247. }
  248. private func dateChanged(_ date: Date?) {
  249. self.selectedDate = date
  250. self.call?.keepAlive = true // Keep calling until confirmed or canceled
  251. onDateSelected()
  252. }
  253. private func onDateSelected() {
  254. if let date = self.selectedDate {
  255. let formatter = DateFormatter()
  256. formatter.dateFormat = "yyyy-MM-dd"
  257. let dateString = formatter.string(from: date)
  258. let result: PluginCallResultData = ["value": dateString]
  259. self.call?.resolve(result)
  260. } else {
  261. let formatter = DateFormatter()
  262. formatter.dateFormat = "yyyy-MM-dd"
  263. let dateString = formatter.string(from: Date())
  264. let result: PluginCallResultData = ["value": dateString]
  265. self.call?.resolve(result)
  266. }
  267. self.bridge?.viewController?.dismiss(animated: true, completion: nil)
  268. }
  269. override public func load() {
  270. print("🔅 UILocalPlugin loaded")
  271. }
  272. }