MainStateModel.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  1. import Combine
  2. import LoopKitUI
  3. import SwiftMessages
  4. import SwiftUI
  5. import Swinject
  6. extension Main {
  7. final class StateModel: BaseStateModel<Provider> {
  8. @Injected() var alertPermissionsChecker: AlertPermissionsChecker!
  9. @Injected() var broadcaster: Broadcaster!
  10. private(set) var modal: Modal?
  11. @Published var isModalPresented = false
  12. @Published var isSecondaryModalPresented = false
  13. @Published var secondaryModalView: AnyView? = nil
  14. @Persisted(key: "UserNotificationsManager.snoozeUntilDate") private var snoozeUntilDate: Date = .distantPast
  15. private var timers: [TimeInterval: Timer] = [:]
  16. private var formatter: DateComponentsFormatter { // TODO: Remove debug only
  17. let formatter = DateComponentsFormatter()
  18. formatter.allowsFractionalUnits = false
  19. formatter.unitsStyle = .full
  20. return formatter
  21. }
  22. private var dateFormatter: DateFormatter { // TODO: Remove debug only
  23. let formatter = DateFormatter()
  24. formatter.timeStyle = .short
  25. return formatter
  26. }
  27. private func formatInterval(_ interval: TimeInterval) -> String { // TODO: Remove debug only
  28. formatter.string(from: interval)!
  29. }
  30. private func showTriggeredView(
  31. message: MessageContent,
  32. interval: TimeInterval,
  33. config: SwiftMessages.Config,
  34. view: MessageView
  35. ) {
  36. let snoozeFor = formatter.string(from: interval)! // TODO: Remove debug only
  37. let untilDate = Date() + interval
  38. debug(
  39. .default,
  40. "Notification triggered for: \n \(String(describing: view.titleLabel?.text)) \(String(describing: view.bodyLabel?.text)) snoozed for \(snoozeFor) until \(dateFormatter.string(from: untilDate))"
  41. )
  42. view.customConfigureTheme(
  43. colorSchemePreference: colorSchemePreference
  44. )
  45. setupAction(message: message, view: view)
  46. SwiftMessages.show(config: config, view: view)
  47. }
  48. // Add or replace timer for a specific TimeInterval
  49. private func addOrReplaceTriggerTimer(message: MessageContent, config: SwiftMessages.Config, view: MessageView) {
  50. let trigger = message.trigger as! UNTimeIntervalNotificationTrigger
  51. guard trigger.timeInterval > 0 else { return }
  52. let interval = trigger.timeInterval
  53. // let interval = message.content.contains("20") ? TimeInterval(60) :
  54. // TimeInterval(120) // TimeInterval(60) // trigger.timeInterval // TODO: remove 60 secs for test
  55. let snoozeFor = formatter.string(from: interval)! // TODO: Remove debug only
  56. let untilDate = Date() + interval
  57. debug(
  58. .default,
  59. "\(message.title) \(message.content) \(message.type) \(message.subtype) will snooze for \(snoozeFor) until \(dateFormatter.string(from: untilDate)) for view.id \(view.id)"
  60. )
  61. SwiftMessages.hide(id: view.id)
  62. // If a timer already exists for this interval, invalidate it
  63. if let existingTimer = timers[interval] {
  64. existingTimer.invalidate()
  65. }
  66. // Create a new timer with the provided interval
  67. let newTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
  68. self?.showTriggeredView(message: message, interval: interval, config: config, view: view)
  69. self?.timers[interval] = nil
  70. }
  71. timers[interval] = newTimer
  72. }
  73. // Cancel all timers (optional cleanup method)
  74. private func cancelAllTimers() {
  75. timers.values.forEach { $0.invalidate() }
  76. timers.removeAll()
  77. }
  78. private func setupPumpConfig() {
  79. // display the pump configuration immediatly
  80. if let pump = provider.deviceManager.pumpManager,
  81. let bluetooth = provider.bluetoothProvider
  82. {
  83. let view = PumpConfig.PumpSettingsView(
  84. pumpManager: pump,
  85. bluetoothManager: bluetooth,
  86. completionDelegate: self
  87. ).asAny()
  88. router.mainSecondaryModalView.send(view)
  89. }
  90. }
  91. private func setupButton(message _: MessageContent, view: MessageView) {
  92. view.button?.setImage(UIImage(), for: .normal)
  93. view.iconLabel = nil
  94. let buttonImage = UIImage(systemName: "chevron.right")?.withTintColor(.white)
  95. view.button?.setImage(buttonImage, for: .normal)
  96. view.button?.backgroundColor = view.backgroundView.backgroundColor
  97. view.button?.tintColor = view.iconImageView?.tintColor // .foregroundColor
  98. }
  99. private func setupAction(message: MessageContent, view: MessageView) {
  100. switch message.action {
  101. case .snooze:
  102. setupButton(message: message, view: view)
  103. view.buttonTapHandler = { _ in
  104. // Popup Snooze view when user taps on Glucose Notification
  105. SwiftMessages.hide()
  106. self.router.mainModalScreen.send(.snooze)
  107. }
  108. case .pumpConfig:
  109. setupButton(message: message, view: view)
  110. view.buttonTapHandler = { _ in
  111. SwiftMessages.hide()
  112. self.setupPumpConfig()
  113. }
  114. default: // break
  115. view.button?.setImage(UIImage(), for: .normal)
  116. view.buttonTapHandler = { _ in
  117. SwiftMessages.hide()
  118. }
  119. }
  120. }
  121. private func isApnPumpConfigAction(_ message: MessageContent) -> Bool {
  122. if message.type != .error, message.action == .pumpConfig {
  123. setupPumpConfig()
  124. return true
  125. }
  126. return false
  127. }
  128. private func allowNotify(_ message: MessageContent) -> Bool {
  129. if message.type == .error { return true } // .errorPump
  130. switch message.subtype {
  131. case .pump:
  132. guard settingsManager.settings.notificationsPump else { return false }
  133. case .cgm:
  134. guard settingsManager.settings.notificationsCgm else { return false }
  135. case .carb:
  136. guard settingsManager.settings.notificationsCarb else { return false }
  137. case .glucose:
  138. guard settingsManager.settings.glucoseNotificationsAlways else { return false }
  139. case .algorithm:
  140. guard settingsManager.settings.notificationsAlgorithm else { return false }
  141. case .misc:
  142. return true
  143. }
  144. return true
  145. }
  146. private func showAlertMessage(_ message: MessageContent) {
  147. if message.useAPN, !alertPermissionsChecker.notificationsDisabled
  148. {
  149. showAPN(message)
  150. } else {
  151. showSwiftMessage(message)
  152. }
  153. }
  154. private func showAPN(_ message: MessageContent) {
  155. DispatchQueue.main.async {
  156. self.broadcaster.notify(alertMessageNotificationObserver.self, on: .main) {
  157. $0.alertMessageNotification(message)
  158. }
  159. }
  160. }
  161. // Read the color scheme preference from UserDefaults; defaults to system default setting
  162. @AppStorage("colorSchemePreference") private var colorSchemePreference: ColorSchemeOption = .systemDefault
  163. private func showSwiftMessage(_ message: MessageContent) {
  164. // SwiftMessages.pauseBetweenMessages = 1.0
  165. if snoozeUntilDate > Date(), message.action == .snooze {
  166. return
  167. }
  168. var config = SwiftMessages.defaultConfig
  169. let view = MessageView.viewFromNib(layout: .cardView)
  170. // viewRespectsSystemMinimumLayoutMargins = false
  171. view.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
  172. config.prefersStatusBarHidden = true
  173. // Set id so that multiple notifications are not queued while waiting for user response; only the latest will be shown
  174. if message.subtype == .glucose || message.subtype == .carb {
  175. view.id = message.type.rawValue + message.subtype.rawValue
  176. }
  177. let titleContent: String
  178. let iconName = UIApplication.shared.alternateIconName ?? "trioBlack"
  179. let iconImage = UIImage(named: iconName) ?? UIImage()
  180. view.configureContent(
  181. title: "title",
  182. body: NSLocalizedString(message.content, comment: "Info message"),
  183. iconImage: nil,
  184. iconText: nil,
  185. buttonImage: nil,
  186. buttonTitle: nil,
  187. buttonTapHandler: nil
  188. )
  189. view.configureIcon(withSize: CGSize(width: 40, height: 40), contentMode: .scaleAspectFit)
  190. view.iconImageView!.image = iconImage
  191. view.iconImageView?.layer.cornerRadius = 10
  192. view.customConfigureTheme(
  193. colorSchemePreference: colorSchemePreference
  194. )
  195. view.iconImageView?.image = iconImage
  196. switch message.type {
  197. case .info,
  198. .other:
  199. config.duration = .seconds(seconds: 5)//.automatic
  200. titleContent = message.title != "" ? message.title : NSLocalizedString("Info", comment: "Info title")
  201. case .warning:
  202. config.duration = .forever
  203. titleContent = message.title != "" ? message
  204. .title : NSLocalizedString("Warning", comment: "Warning title")
  205. case .error:
  206. config.duration = .forever
  207. titleContent = message.title != "" ? message
  208. .title : NSLocalizedString("Error", comment: "Error title")
  209. }
  210. view.titleLabel?.text = titleContent
  211. config.dimMode = .gray(interactive: true)
  212. setupAction(message: message, view: view)
  213. if message.trigger != nil {
  214. addOrReplaceTriggerTimer(message: message, config: config, view: view)
  215. }
  216. guard message.type == .error || message.action != .pumpConfig, message.trigger == nil, !view.isHidden else { return }
  217. SwiftMessages.show(config: config, view: view)
  218. }
  219. override func subscribe() {
  220. router.mainModalScreen
  221. .map { $0?.modal(resolver: self.resolver!) }
  222. .removeDuplicates { $0?.id == $1?.id }
  223. .receive(on: DispatchQueue.main)
  224. .sink { modal in
  225. self.modal = modal
  226. self.isModalPresented = modal != nil
  227. }
  228. .store(in: &lifetime)
  229. $isModalPresented
  230. .filter { !$0 }
  231. .sink { _ in
  232. self.router.mainModalScreen.send(nil)
  233. }
  234. .store(in: &lifetime)
  235. router.alertMessage
  236. .receive(on: DispatchQueue.main)
  237. .sink { message in
  238. guard !self.isApnPumpConfigAction(message) else { return }
  239. guard self.allowNotify(message) else { return }
  240. // self.queueMessageIfNeeded(message) // TODO: Remove if Batched Info and Throttled APNs are NOT in-scope
  241. self.showAlertMessage(message) // TODO: Call this if Batched Info and Throttled APNs are NOT in-scope
  242. }
  243. .store(in: &lifetime)
  244. router.mainSecondaryModalView
  245. .receive(on: DispatchQueue.main)
  246. .sink { view in
  247. self.secondaryModalView = view
  248. self.isSecondaryModalPresented = view != nil
  249. }
  250. .store(in: &lifetime)
  251. $isSecondaryModalPresented
  252. .removeDuplicates()
  253. .filter { !$0 }
  254. .sink { _ in
  255. self.router.mainSecondaryModalView.send(nil)
  256. }
  257. .store(in: &lifetime)
  258. }
  259. }
  260. }
  261. extension MessageView {
  262. func currentColorScheme() -> ColorScheme {
  263. let userInterfaceStyle = UITraitCollection.current.userInterfaceStyle
  264. return userInterfaceStyle == .dark ? .dark : .light
  265. }
  266. func customConfigureTheme(colorSchemePreference: ColorSchemeOption) {
  267. let defaultSystemColorScheme = currentColorScheme() // UIColor.defaultSystemBackgroundColor
  268. var backgroundColor = UIColor.systemBackground
  269. var foregroundColor = UIColor.white
  270. // Color.gray.opacity(0.1) is used by MainRootView but is translucent
  271. // lightGray is same color as MainRootView background and systemGray6 is a shade darker
  272. // systemGray5 is a shade darker than systemGray6 and gray is a few shades darker
  273. // systemBackground is the same as systemGray6 when iOS is light mode
  274. switch colorSchemePreference {
  275. case .systemDefault:
  276. backgroundColor = defaultSystemColorScheme == .light ? UIColor.systemBackground :
  277. UIColor(Color.black.opacity(0.9))
  278. foregroundColor = UIColor.label
  279. case .dark:
  280. backgroundColor = defaultSystemColorScheme == .light ? UIColor.systemGray5 :
  281. UIColor(Color.black.opacity(0.9))
  282. foregroundColor = defaultSystemColorScheme == .light ? UIColor.black : UIColor.white
  283. case .light:
  284. backgroundColor = defaultSystemColorScheme == .light ? .systemGray6 : UIColor
  285. .gray
  286. foregroundColor = defaultSystemColorScheme == .light ? UIColor.black : UIColor.white
  287. }
  288. iconImageView?.tintColor = foregroundColor
  289. backgroundView.backgroundColor = backgroundColor
  290. titleLabel?.textColor = foregroundColor
  291. bodyLabel?.textColor = foregroundColor
  292. iconImageView?.isHidden = iconImageView?.image == nil
  293. backgroundView.layer.cornerRadius = 25
  294. let adjustedFont = UIFont.systemFont(ofSize: 13.0, weight: .bold)
  295. let preferredTitleFont = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: adjustedFont)
  296. let preferredBodyFont = UIFont.preferredFontforStyle(forTextStyle: .footnote)
  297. // Set the title and body font to the dynamic type sizes
  298. titleLabel?.adjustsFontForContentSizeCategory = true
  299. titleLabel?.font = preferredTitleFont
  300. bodyLabel?.adjustsFontForContentSizeCategory = true
  301. bodyLabel?.font = preferredBodyFont
  302. // Set custom colors for title and body text
  303. titleLabel?.textColor = foregroundColor
  304. bodyLabel?.textColor = foregroundColor
  305. }
  306. }
  307. @available(iOS 16.0, *)
  308. extension Main.StateModel: CompletionDelegate {
  309. func completionNotifyingDidComplete(_: CompletionNotifying) {
  310. // close the window
  311. router.mainSecondaryModalView.send(nil)
  312. }
  313. }
  314. // Extension to convert SwiftUI TextStyle to UIFont
  315. extension UIFont {
  316. static func preferredFontforStyle(forTextStyle: UIFont.TextStyle) -> UIFont {
  317. let uiFontMetrics = UIFontMetrics.default
  318. let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: forTextStyle)
  319. return uiFontMetrics.scaledFont(for: UIFont(descriptor: descriptor, size: 0)) // size: 12
  320. }
  321. }