MainStateModel.swift 14 KB

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