| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373 |
- import Combine
- import LoopKitUI
- import SwiftMessages
- import SwiftUI
- import Swinject
- extension Main {
- final class StateModel: BaseStateModel<Provider> {
- @Injected() var alertPermissionsChecker: AlertPermissionsChecker!
- @Injected() var broadcaster: Broadcaster!
- private(set) var modal: Modal?
- @Published var isModalPresented = false
- @Published var isSecondaryModalPresented = false
- @Published var secondaryModalView: AnyView? = nil
- @Persisted(key: "UserNotificationsManager.snoozeUntilDate") private var snoozeUntilDate: Date = .distantPast
- private var timers: [TimeInterval: Timer] = [:]
- private var formatter: DateComponentsFormatter { // TODO: Remove debug only
- let formatter = DateComponentsFormatter()
- formatter.allowsFractionalUnits = false
- formatter.unitsStyle = .full
- return formatter
- }
- private var dateFormatter: DateFormatter { // TODO: Remove debug only
- let formatter = DateFormatter()
- formatter.timeStyle = .short
- return formatter
- }
- private func formatInterval(_ interval: TimeInterval) -> String { // TODO: Remove debug only
- formatter.string(from: interval)!
- }
- private func showTriggeredView(
- message: MessageContent,
- interval: TimeInterval,
- config: SwiftMessages.Config,
- view: MessageView
- ) {
- let snoozeFor = formatter.string(from: interval)! // TODO: Remove debug only
- let untilDate = Date() + interval
- debug(
- .default,
- "Notification triggered for: \n \(String(describing: view.titleLabel?.text)) \(String(describing: view.bodyLabel?.text)) snoozed for \(snoozeFor) until \(dateFormatter.string(from: untilDate))"
- )
- view.customConfigureTheme(
- colorSchemePreference: colorSchemePreference
- )
- setupAction(message: message, view: view)
- SwiftMessages.show(config: config, view: view)
- }
- // Add or replace timer for a specific TimeInterval
- private func addOrReplaceTriggerTimer(message: MessageContent, config: SwiftMessages.Config, view: MessageView) {
- let trigger = message.trigger as! UNTimeIntervalNotificationTrigger
- guard trigger.timeInterval > 0 else { return }
- let interval = trigger.timeInterval
- // let interval = message.content.contains("20") ? TimeInterval(60) :
- // TimeInterval(120) // TimeInterval(60) // trigger.timeInterval // TODO: remove 60 secs for test
- let snoozeFor = formatter.string(from: interval)! // TODO: Remove debug only
- let untilDate = Date() + interval
- debug(
- .default,
- "\(message.title) \(message.content) \(message.type) \(message.subtype) will snooze for \(snoozeFor) until \(dateFormatter.string(from: untilDate)) for view.id \(view.id)"
- )
- SwiftMessages.hide(id: view.id)
- // If a timer already exists for this interval, invalidate it
- if let existingTimer = timers[interval] {
- existingTimer.invalidate()
- }
- // Create a new timer with the provided interval
- let newTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
- self?.showTriggeredView(message: message, interval: interval, config: config, view: view)
- self?.timers[interval] = nil
- }
- timers[interval] = newTimer
- }
- // Cancel all timers (optional cleanup method)
- private func cancelAllTimers() {
- timers.values.forEach { $0.invalidate() }
- timers.removeAll()
- }
- private func setupPumpConfig() {
- // display the pump configuration immediatly
- if let pump = provider.deviceManager.pumpManager,
- let bluetooth = provider.bluetoothProvider
- {
- let view = PumpConfig.PumpSettingsView(
- pumpManager: pump,
- bluetoothManager: bluetooth,
- completionDelegate: self
- ).asAny()
- router.mainSecondaryModalView.send(view)
- }
- }
- private func setupButton(message _: MessageContent, view: MessageView) {
- view.button?.setImage(UIImage(), for: .normal)
- view.iconLabel = nil
- let buttonImage = UIImage(systemName: "chevron.right")?.withTintColor(.white)
- view.button?.setImage(buttonImage, for: .normal)
- view.button?.backgroundColor = view.backgroundView.backgroundColor
- view.button?.tintColor = view.iconImageView?.tintColor // .foregroundColor
- }
- private func setupAction(message: MessageContent, view: MessageView) {
- switch message.action {
- case .snooze:
- setupButton(message: message, view: view)
- view.buttonTapHandler = { _ in
- // Popup Snooze view when user taps on Glucose Notification
- SwiftMessages.hide()
- self.router.mainModalScreen.send(.snooze)
- }
- case .pumpConfig:
- setupButton(message: message, view: view)
- view.buttonTapHandler = { _ in
- SwiftMessages.hide()
- self.setupPumpConfig()
- }
- default: // break
- view.button?.setImage(UIImage(), for: .normal)
- view.buttonTapHandler = { _ in
- SwiftMessages.hide()
- }
- }
- }
- private func isApnPumpConfigAction(_ message: MessageContent) -> Bool {
- if message.type != .error, message.action == .pumpConfig {
- setupPumpConfig()
- return true
- }
- return false
- }
- private func allowNotify(_ message: MessageContent) -> Bool {
- if message.type == .error { return true } // .errorPump
- switch message.subtype {
- case .pump:
- guard settingsManager.settings.notificationsPump else { return false }
- case .cgm:
- guard settingsManager.settings.notificationsCgm else { return false }
- case .carb:
- guard settingsManager.settings.notificationsCarb else { return false }
- case .glucose:
- guard settingsManager.settings.glucoseNotificationsAlways else { return false }
- case .algorithm:
- guard settingsManager.settings.notificationsAlgorithm else { return false }
- case .misc:
- return true
- }
- return true
- }
- private func showAlertMessage(_ message: MessageContent) {
- if message.useAPN, !alertPermissionsChecker.notificationsDisabled
- {
- showAPN(message)
- } else {
- showSwiftMessage(message)
- }
- }
- private func showAPN(_ message: MessageContent) {
- DispatchQueue.main.async {
- self.broadcaster.notify(alertMessageNotificationObserver.self, on: .main) {
- $0.alertMessageNotification(message)
- }
- }
- }
- // Read the color scheme preference from UserDefaults; defaults to system default setting
- @AppStorage("colorSchemePreference") private var colorSchemePreference: ColorSchemeOption = .systemDefault
- private func showSwiftMessage(_ message: MessageContent) {
- // SwiftMessages.pauseBetweenMessages = 1.0
- if snoozeUntilDate > Date(), message.action == .snooze {
- return
- }
- var config = SwiftMessages.defaultConfig
- let view = MessageView.viewFromNib(layout: .cardView)
- // viewRespectsSystemMinimumLayoutMargins = false
- view.layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0)
- config.prefersStatusBarHidden = true
- // Set id so that multiple notifications are not queued while waiting for user response; only the latest will be shown
- if message.subtype == .glucose || message.subtype == .carb {
- view.id = message.type.rawValue + message.subtype.rawValue
- }
- let titleContent: String
- let iconName = UIApplication.shared.alternateIconName ?? "trioBlack"
- let iconImage = UIImage(named: iconName) ?? UIImage()
- view.configureContent(
- title: "title",
- body: NSLocalizedString(message.content, comment: "Info message"),
- iconImage: nil,
- iconText: nil,
- buttonImage: nil,
- buttonTitle: nil,
- buttonTapHandler: nil
- )
- view.configureIcon(withSize: CGSize(width: 40, height: 40), contentMode: .scaleAspectFit)
- view.iconImageView!.image = iconImage
- view.iconImageView?.layer.cornerRadius = 10
- view.customConfigureTheme(
- colorSchemePreference: colorSchemePreference
- )
- view.iconImageView?.image = iconImage
- switch message.type {
- case .info,
- .other:
- config.duration = .seconds(seconds: 5)//.automatic
- titleContent = message.title != "" ? message.title : NSLocalizedString("Info", comment: "Info title")
- case .warning:
- config.duration = .forever
- titleContent = message.title != "" ? message
- .title : NSLocalizedString("Warning", comment: "Warning title")
- case .error:
- config.duration = .forever
- titleContent = message.title != "" ? message
- .title : NSLocalizedString("Error", comment: "Error title")
- }
- view.titleLabel?.text = titleContent
- config.dimMode = .gray(interactive: true)
- setupAction(message: message, view: view)
- if message.trigger != nil {
- addOrReplaceTriggerTimer(message: message, config: config, view: view)
- }
- guard message.type == .error || message.action != .pumpConfig, message.trigger == nil, !view.isHidden else { return }
- SwiftMessages.show(config: config, view: view)
- }
- override func subscribe() {
- router.mainModalScreen
- .map { $0?.modal(resolver: self.resolver!) }
- .removeDuplicates { $0?.id == $1?.id }
- .receive(on: DispatchQueue.main)
- .sink { modal in
- self.modal = modal
- self.isModalPresented = modal != nil
- }
- .store(in: &lifetime)
- $isModalPresented
- .filter { !$0 }
- .sink { _ in
- self.router.mainModalScreen.send(nil)
- }
- .store(in: &lifetime)
- router.alertMessage
- .receive(on: DispatchQueue.main)
- .sink { message in
- guard !self.isApnPumpConfigAction(message) else { return }
- guard self.allowNotify(message) else { return }
- // self.queueMessageIfNeeded(message) // TODO: Remove if Batched Info and Throttled APNs are NOT in-scope
- self.showAlertMessage(message) // TODO: Call this if Batched Info and Throttled APNs are NOT in-scope
- }
- .store(in: &lifetime)
- router.mainSecondaryModalView
- .receive(on: DispatchQueue.main)
- .sink { view in
- self.secondaryModalView = view
- self.isSecondaryModalPresented = view != nil
- }
- .store(in: &lifetime)
- $isSecondaryModalPresented
- .removeDuplicates()
- .filter { !$0 }
- .sink { _ in
- self.router.mainSecondaryModalView.send(nil)
- }
- .store(in: &lifetime)
- }
- }
- }
- extension MessageView {
- func currentColorScheme() -> ColorScheme {
- let userInterfaceStyle = UITraitCollection.current.userInterfaceStyle
- return userInterfaceStyle == .dark ? .dark : .light
- }
- func customConfigureTheme(colorSchemePreference: ColorSchemeOption) {
- let defaultSystemColorScheme = currentColorScheme() // UIColor.defaultSystemBackgroundColor
- var backgroundColor = UIColor.systemBackground
- var foregroundColor = UIColor.white
- // Color.gray.opacity(0.1) is used by MainRootView but is translucent
- // lightGray is same color as MainRootView background and systemGray6 is a shade darker
- // systemGray5 is a shade darker than systemGray6 and gray is a few shades darker
- // systemBackground is the same as systemGray6 when iOS is light mode
- switch colorSchemePreference {
- case .systemDefault:
- backgroundColor = defaultSystemColorScheme == .light ? UIColor.systemBackground :
- UIColor(Color.black.opacity(0.9))
- foregroundColor = UIColor.label
- case .dark:
- backgroundColor = defaultSystemColorScheme == .light ? UIColor.systemGray5 :
- UIColor(Color.black.opacity(0.9))
- foregroundColor = defaultSystemColorScheme == .light ? UIColor.black : UIColor.white
- case .light:
- backgroundColor = defaultSystemColorScheme == .light ? .systemGray6 : UIColor
- .gray
- foregroundColor = defaultSystemColorScheme == .light ? UIColor.black : UIColor.white
- }
- iconImageView?.tintColor = foregroundColor
- backgroundView.backgroundColor = backgroundColor
- titleLabel?.textColor = foregroundColor
- bodyLabel?.textColor = foregroundColor
- iconImageView?.isHidden = iconImageView?.image == nil
- backgroundView.layer.cornerRadius = 25
- let adjustedFont = UIFont.systemFont(ofSize: 13.0, weight: .bold)
- let preferredTitleFont = UIFontMetrics(forTextStyle: .footnote).scaledFont(for: adjustedFont)
- let preferredBodyFont = UIFont.preferredFontforStyle(forTextStyle: .footnote)
- // Set the title and body font to the dynamic type sizes
- titleLabel?.adjustsFontForContentSizeCategory = true
- titleLabel?.font = preferredTitleFont
- bodyLabel?.adjustsFontForContentSizeCategory = true
- bodyLabel?.font = preferredBodyFont
- // Set custom colors for title and body text
- titleLabel?.textColor = foregroundColor
- bodyLabel?.textColor = foregroundColor
- }
- }
- @available(iOS 16.0, *)
- extension Main.StateModel: CompletionDelegate {
- func completionNotifyingDidComplete(_: CompletionNotifying) {
- // close the window
- router.mainSecondaryModalView.send(nil)
- }
- }
- // Extension to convert SwiftUI TextStyle to UIFont
- extension UIFont {
- static func preferredFontforStyle(forTextStyle: UIFont.TextStyle) -> UIFont {
- let uiFontMetrics = UIFontMetrics.default
- let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: forTextStyle)
- return uiFontMetrics.scaledFont(for: UIFont(descriptor: descriptor, size: 0)) // size: 12
- }
- }
|