From 58f145e87f0934a9cd001ec7705fac7da04e2260 Mon Sep 17 00:00:00 2001 From: Priyonto M Rahman Date: Wed, 27 Dec 2023 17:40:30 +0100 Subject: [PATCH] Enhancements and bug fixes (#1823) * fix: Fix UI glitch after toast is dismissed #1600 (#1816) Increase min screen on time for toast to 1.5 seconds from 1 second. * Add battery level check on FW upgrade from Discover (#1819) Implements battery charge check on Firmware upgrade process on Discover * task: Disable RSSI alert editing for local sensors #1751 (#1820) * fix: View menu not updating after sync from cloud #1632 (#1822) * fix: Improve activity presenter transition #1600 (#1821) --------- Co-authored-by: Rinat Enikeev --- .../Home/Presenter/DashboardPresenter.swift | 1 + .../Presenter/TagSettingsPresenter.swift | 37 ++--- .../View/UI/TagSettingsAlertConfigCell.swift | 2 + .../View/UI/TagSettingsViewController.swift | 17 +- .../ActivityPresenterRuuviLogo.swift | 150 ++++++++++++++---- .../RuuviPresenters/ActivityPresenter.swift | 13 +- .../Error/Alert/ErrorPresenterAlert.swift | 2 +- .../Alert/PermissionPresenterAlert.swift | 2 +- .../Util/RuuviPresenterHelper.swift | 17 ++ .../Util/UIApplication+ViewController.swift | 20 --- .../RuuviFirmware/FirmwareInteractor.swift | 35 ++++ .../RuuviFirmware/FirmwarePresenter.swift | 3 + .../RuuviFirmware/RuuviFirmwareBuilder.swift | 3 + .../RuuviFirmware/SwiftUI/FirmwareView.swift | 11 ++ .../SwiftUI/FirmwareViewModel.swift | 8 + .../RuuviCloudRequestStateObserver.swift | 48 ++++++ 16 files changed, 290 insertions(+), 79 deletions(-) create mode 100644 Common/RuuviPresenters/Sources/RuuviPresenters/Util/RuuviPresenterHelper.swift delete mode 100644 Common/RuuviPresenters/Sources/RuuviPresenters/Util/UIApplication+ViewController.swift create mode 100644 Packages/RuuviCloud/Sources/RuuviCloud/RuuviCloudRequestStateObserver.swift diff --git a/Apps/RuuviStation/Sources/Classes/Presentation/Modules/Dashboard/Home/Presenter/DashboardPresenter.swift b/Apps/RuuviStation/Sources/Classes/Presentation/Modules/Dashboard/Home/Presenter/DashboardPresenter.swift index 058794346..5f0adbf70 100644 --- a/Apps/RuuviStation/Sources/Classes/Presentation/Modules/Dashboard/Home/Presenter/DashboardPresenter.swift +++ b/Apps/RuuviStation/Sources/Classes/Presentation/Modules/Dashboard/Home/Presenter/DashboardPresenter.swift @@ -1541,6 +1541,7 @@ extension DashboardPresenter { using: { [weak self] _ in guard let self = self else { return } DispatchQueue.main.async { + self.view?.dashboardSortingType = self.dashboardSortingType() self.viewModels = self.reorder(self.viewModels) } } diff --git a/Apps/RuuviStation/Sources/Classes/Presentation/Modules/TagSettings/Presenter/TagSettingsPresenter.swift b/Apps/RuuviStation/Sources/Classes/Presentation/Modules/TagSettings/Presenter/TagSettingsPresenter.swift index 8030f7cc4..0b156c2f5 100644 --- a/Apps/RuuviStation/Sources/Classes/Presentation/Modules/TagSettings/Presenter/TagSettingsPresenter.swift +++ b/Apps/RuuviStation/Sources/Classes/Presentation/Modules/TagSettings/Presenter/TagSettingsPresenter.swift @@ -76,7 +76,6 @@ class TagSettingsPresenter: NSObject, TagSettingsModuleInput { private var appDidBecomeActiveToken: NSObjectProtocol? private var alertDidChangeToken: NSObjectProtocol? private var backgroundToken: NSObjectProtocol? - private var cloudRequestStateToken: NSObjectProtocol? private var mutedTillTimer: Timer? private var exportFileUrl: URL? private var previousAdvertisementSequence: Int? @@ -108,8 +107,10 @@ class TagSettingsPresenter: NSObject, TagSettingsModuleInput { appDidBecomeActiveToken?.invalidate() alertDidChangeToken?.invalidate() backgroundToken?.invalidate() - cloudRequestStateToken?.invalidate() timer?.invalidate() + RuuviCloudRequestStateObserverManager + .shared + .stopObserving(for: ruuviTag.macId?.value) NotificationCenter.default.removeObserver(self) } @@ -1143,27 +1144,17 @@ extension TagSettingsPresenter { } private func startObservingCloudRequestState() { - cloudRequestStateToken?.invalidate() - cloudRequestStateToken = nil - - cloudRequestStateToken = NotificationCenter - .default - .addObserver( - forName: .RuuviCloudRequestStateDidChange, - object: nil, - queue: .main, - using: { [weak self] notification in - guard let self, - let userInfo = notification.userInfo, - let macId = userInfo[RuuviCloudRequestStateKey.macId] as? String, - macId == self.ruuviTag.macId?.value, - let state = userInfo[RuuviCloudRequestStateKey.state] as? RuuviCloudRequestStateType - else { - return - } - self.presentActivityIndicator(with: state) - } - ) + guard let macId = ruuviTag.macId?.value else { return } + // Stop if already observing + RuuviCloudRequestStateObserverManager + .shared + .stopObserving(for: macId) + + RuuviCloudRequestStateObserverManager + .shared + .startObserving(for: macId) { [weak self] state in + self?.presentActivityIndicator(with: state) + } } private func reloadMutedTill() { diff --git a/Apps/RuuviStation/Sources/Classes/Presentation/Modules/TagSettings/View/UI/TagSettingsAlertConfigCell.swift b/Apps/RuuviStation/Sources/Classes/Presentation/Modules/TagSettings/View/UI/TagSettingsAlertConfigCell.swift index 7cbefda37..5b0e77c81 100644 --- a/Apps/RuuviStation/Sources/Classes/Presentation/Modules/TagSettings/View/UI/TagSettingsAlertConfigCell.swift +++ b/Apps/RuuviStation/Sources/Classes/Presentation/Modules/TagSettings/View/UI/TagSettingsAlertConfigCell.swift @@ -395,6 +395,8 @@ extension TagSettingsAlertConfigCell { alertLimitSliderView.disable(disable) case .alertRSSI: noticeView.disable(disable) + alertLimitDescriptionView.disable(disable) + alertLimitSliderView.disable(disable) case .alertMovement, .alertConnection: additionalTextView.disable(disable) default: break diff --git a/Apps/RuuviStation/Sources/Classes/Presentation/Modules/TagSettings/View/UI/TagSettingsViewController.swift b/Apps/RuuviStation/Sources/Classes/Presentation/Modules/TagSettings/View/UI/TagSettingsViewController.swift index ba4ae9467..e16381b60 100644 --- a/Apps/RuuviStation/Sources/Classes/Presentation/Modules/TagSettings/View/UI/TagSettingsViewController.swift +++ b/Apps/RuuviStation/Sources/Classes/Presentation/Modules/TagSettings/View/UI/TagSettingsViewController.swift @@ -1243,8 +1243,17 @@ extension TagSettingsViewController { } rssiAlertCell.bind(viewModel.latestMeasurement) { cell, measurement in + let isClaimed = GlobalHelpers.getBool(from: viewModel.isClaimedTag.value) cell.disableEditing( - disable: measurement == nil, + disable: measurement == nil || !isClaimed, + identifier: .alertRSSI + ) + } + + rssiAlertCell.bind(viewModel.isClaimedTag) { [weak self] cell, isClaimed in + let hasMeasurement = GlobalHelpers.getBool(from: self?.hasMeasurement()) + cell.disableEditing( + disable: !hasMeasurement || !GlobalHelpers.getBool(from: isClaimed), identifier: .alertRSSI ) } @@ -1603,7 +1612,8 @@ extension TagSettingsViewController { private func rssiAlertItem() -> TagSettingsItem { let (minRange, maxRange) = rssiMinMaxForSliders() - let disableRssi = !hasMeasurement() + let disableRssi = !hasMeasurement() || + !GlobalHelpers.getBool(from: viewModel?.isClaimedTag.value) let settingItem = TagSettingsItem( createdCell: { [weak self] in self?.rssiAlertCell?.showNoticeView() @@ -3485,7 +3495,8 @@ extension TagSettingsViewController: TagSettingsExpandableSectionHeaderDelegate selectedMaxValue: rssiUpperBound() ) rssiAlertCell.disableEditing( - disable: GlobalHelpers.getBool(from: !hasMeasurement()), + disable: GlobalHelpers.getBool(from: !hasMeasurement()) || + !GlobalHelpers.getBool(from: viewModel?.isClaimedTag.value), identifier: currentSection.identifier ) } diff --git a/Common/RuuviPresenters/Sources/RuuviPresenters/Activity/RuuviLogo/ActivityPresenterRuuviLogo.swift b/Common/RuuviPresenters/Sources/RuuviPresenters/Activity/RuuviLogo/ActivityPresenterRuuviLogo.swift index 01aa45655..d8aec8a35 100644 --- a/Common/RuuviPresenters/Sources/RuuviPresenters/Activity/RuuviLogo/ActivityPresenterRuuviLogo.swift +++ b/Common/RuuviPresenters/Sources/RuuviPresenters/Activity/RuuviLogo/ActivityPresenterRuuviLogo.swift @@ -1,54 +1,146 @@ import UIKit -public final class ActivityPresenterRuuviLogo: ActivityPresenter { - let minAnimationTime: CFTimeInterval = 1.5 - var startTime: CFTimeInterval? - let window = UIWindow(frame: UIScreen.main.bounds) - let activityPresenterViewProvider: ActivityPresenterViewProvider - let activityPresenterViewController: UIViewController - let stateHolder = ActivityPresenterStateHolder() +public final class ActivityPresenterRuuviLogo { + // Minimum duration to keep the activity indicator on the screen + private let minAnimationTime: CFTimeInterval = 1.5 + // Animation duration for presenting and dismissing the activity indicator. + private let animationDuration: CGFloat = 0.5 + // Vertical(top/bottom) padding for activity indicator depending on position. + private let verticalPadding: CGFloat = 8 - weak var appWindow: UIWindow? + private let activityPresenterViewProvider: ActivityPresenterViewProvider + private let activityPresenterViewController: UIViewController + private let stateHolder = ActivityPresenterStateHolder() + + private var activityPresenterPosition: ActivityPresenterPosition = .bottom + private var startTime: CFTimeInterval? public init() { activityPresenterViewProvider = ActivityPresenterViewProvider(stateHolder: stateHolder) activityPresenterViewController = activityPresenterViewProvider.makeViewController() activityPresenterViewController.view.backgroundColor = .clear - window.windowLevel = .normal - activityPresenterViewController.view.translatesAutoresizingMaskIntoConstraints = false - window.rootViewController = activityPresenterViewController } } -public extension ActivityPresenterRuuviLogo { - func setPosition(_ position: ActivityPresenterPosition) { +extension ActivityPresenterRuuviLogo: ActivityPresenter { + public func show( + with state: ActivityPresenterState, + atPosition position: ActivityPresenterPosition + ) { + activityPresenterPosition = position activityPresenterViewProvider.updatePosition(position) - } - - func show(with state: ActivityPresenterState) { startTime = CFAbsoluteTimeGetCurrent() - appWindow = UIWindow.key - window.makeKeyAndVisible() - window.layoutIfNeeded() + + guard let topController = RuuviPresenterHelper.topViewController(), + let toastView = activityPresenterViewController.view else { + return + } + + topController.view.addSubview(toastView) + setupConstraints(for: toastView, in: topController) + animateActivityViewPresentation(toastView, atPosition: position) + activityPresenterViewProvider.updateState(state) } - func update(with state: ActivityPresenterState) { - // Reset start time with state update since we want to show - // latest state message before dismissing the presenter. + public func update(with state: ActivityPresenterState) { startTime = CFAbsoluteTimeGetCurrent() activityPresenterViewProvider.updateState(state) } - func dismiss(immediately: Bool) { + public func dismiss(immediately: Bool) { let executionTime = CFAbsoluteTimeGetCurrent() - (startTime ?? 0) - let additionalWaitTime = immediately ? 0 : - executionTime < minAnimationTime ? (minAnimationTime - executionTime) : 0 - DispatchQueue.main.asyncAfter(deadline: .now() + additionalWaitTime) { [weak self] in - self?.window.isHidden = true - self?.appWindow?.makeKeyAndVisible() - self?.appWindow = nil + let additionalWaitTime = immediately ? 0 : max(minAnimationTime - executionTime, 0) + + guard let toastView = activityPresenterViewController.view else { + return + } + + DispatchQueue.main.asyncAfter( + deadline: .now() + additionalWaitTime + ) { [weak self] in + self?.animateActivityViewDismissal(toastView) + } + } +} + +extension ActivityPresenterRuuviLogo { + private func setupConstraints( + for view: UIView, + in viewController: UIViewController + ) { + view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint( + equalTo: viewController.view.leadingAnchor + ), + view.trailingAnchor.constraint( + equalTo: viewController.view.trailingAnchor + ), + view.topAnchor.constraint( + equalTo: viewController.view.topAnchor + ), + view.bottomAnchor.constraint( + equalTo: viewController.view.bottomAnchor + ), + ]) + } + + private func animateActivityViewPresentation( + _ view: UIView, + atPosition position: ActivityPresenterPosition + ) { + UIView.animate( + withDuration: animationDuration, + delay: 0, + options: [.curveEaseOut] + ) { [weak self] in + guard let self = self else { return } + switch position { + case .top: + view.transform = CGAffineTransform( + translationX: 0, + y: self.safeAreaInsets().top + self.verticalPadding + ) + case .bottom: + view.transform = CGAffineTransform( + translationX: 0, + y: -(self.safeAreaInsets().bottom + self.verticalPadding) + ) + case .center: + view.transform = .identity + } + } + } + + private func animateActivityViewDismissal(_ view: UIView) { + UIView.animate( + withDuration: animationDuration, + delay: 0, + options: [.curveEaseIn] + ) { [weak self] in + guard let self = self else { return } + switch self.activityPresenterPosition { + case .top: + view.transform = CGAffineTransform( + translationX: 0, + y: -(view.frame.height + verticalPadding) + ) + case .bottom: + view.transform = CGAffineTransform( + translationX: 0, + y: view.frame.height + verticalPadding + ) + case .center: + break + } + } completion: { [weak self] _ in + view.removeFromSuperview() self?.activityPresenterViewProvider.updateState(.dismiss) } } + + private func safeAreaInsets() -> UIEdgeInsets { + RuuviPresenterHelper.topViewController()?.view.safeAreaInsets ?? .zero + } } diff --git a/Common/RuuviPresenters/Sources/RuuviPresenters/ActivityPresenter.swift b/Common/RuuviPresenters/Sources/RuuviPresenters/ActivityPresenter.swift index 084b591bd..7b6537e57 100644 --- a/Common/RuuviPresenters/Sources/RuuviPresenters/ActivityPresenter.swift +++ b/Common/RuuviPresenters/Sources/RuuviPresenters/ActivityPresenter.swift @@ -1,13 +1,22 @@ import Foundation public protocol ActivityPresenter { - func setPosition(_ position: ActivityPresenterPosition) - func show(with state: ActivityPresenterState) + func show( + with state: ActivityPresenterState, + atPosition position: ActivityPresenterPosition + ) func update(with state: ActivityPresenterState) func dismiss(immediately: Bool) } public extension ActivityPresenter { + func show( + with state: ActivityPresenterState, + atPosition position: ActivityPresenterPosition = .bottom + ) { + show(with: state, atPosition: position) + } + func dismiss(immediately: Bool = false) { dismiss(immediately: immediately) } diff --git a/Common/RuuviPresenters/Sources/RuuviPresenters/Error/Alert/ErrorPresenterAlert.swift b/Common/RuuviPresenters/Sources/RuuviPresenters/Error/Alert/ErrorPresenterAlert.swift index 61ef977af..ad4bc3f2f 100644 --- a/Common/RuuviPresenters/Sources/RuuviPresenters/Error/Alert/ErrorPresenterAlert.swift +++ b/Common/RuuviPresenters/Sources/RuuviPresenters/Error/Alert/ErrorPresenterAlert.swift @@ -29,7 +29,7 @@ public final class ErrorPresenterAlert: ErrorPresenter { let feedback = UINotificationFeedbackGenerator() feedback.notificationOccurred(.error) feedback.prepare() - UIApplication.shared.topViewController()?.present(alert, animated: true) + RuuviPresenterHelper.topViewController()?.present(alert, animated: true) } } } diff --git a/Common/RuuviPresenters/Sources/RuuviPresenters/Permission/Alert/PermissionPresenterAlert.swift b/Common/RuuviPresenters/Sources/RuuviPresenters/Permission/Alert/PermissionPresenterAlert.swift index 15de129b0..acd00439c 100644 --- a/Common/RuuviPresenters/Sources/RuuviPresenters/Permission/Alert/PermissionPresenterAlert.swift +++ b/Common/RuuviPresenters/Sources/RuuviPresenters/Permission/Alert/PermissionPresenterAlert.swift @@ -25,7 +25,7 @@ public final class PermissionPresenterAlert: PermissionPresenter { } private func presentAlert(with message: String) { - guard let viewController = UIApplication.shared.topViewController() else { return } + guard let viewController = RuuviPresenterHelper.topViewController() else { return } let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert) let cancel = UIAlertAction(title: RuuviLocalization.cancel, style: .cancel, handler: nil) let actionTitle = RuuviLocalization.PermissionPresenter.settings diff --git a/Common/RuuviPresenters/Sources/RuuviPresenters/Util/RuuviPresenterHelper.swift b/Common/RuuviPresenters/Sources/RuuviPresenters/Util/RuuviPresenterHelper.swift new file mode 100644 index 000000000..dae7db3fb --- /dev/null +++ b/Common/RuuviPresenters/Sources/RuuviPresenters/Util/RuuviPresenterHelper.swift @@ -0,0 +1,17 @@ +import UIKit + +struct RuuviPresenterHelper { + public static func topViewController() -> UIViewController? { + if var topController = keyWindow()?.rootViewController { + while let presentedViewController = topController.presentedViewController { + topController = presentedViewController + } + return topController + } + return nil + } + + private static func keyWindow() -> UIWindow? { + return UIApplication.shared.windows.first(where: { $0.isKeyWindow }) + } +} diff --git a/Common/RuuviPresenters/Sources/RuuviPresenters/Util/UIApplication+ViewController.swift b/Common/RuuviPresenters/Sources/RuuviPresenters/Util/UIApplication+ViewController.swift deleted file mode 100644 index dc60647e6..000000000 --- a/Common/RuuviPresenters/Sources/RuuviPresenters/Util/UIApplication+ViewController.swift +++ /dev/null @@ -1,20 +0,0 @@ -import UIKit - -extension UIApplication { - func topViewController(_ base: UIViewController? = nil) -> UIViewController? { - let base = base ?? UIWindow.key?.rootViewController - if let top = (base as? UINavigationController)?.topViewController { - return topViewController(top) - } - if let selected = (base as? UITabBarController)?.selectedViewController { - return topViewController(selected) - } - if let presented = base as? UIAlertController { - return presented.parent - } - if let presented = base?.presentedViewController, !presented.isBeingDismissed { - return topViewController(presented) - } - return base - } -} diff --git a/Modules/RuuviFirmware/Sources/RuuviFirmware/FirmwareInteractor.swift b/Modules/RuuviFirmware/Sources/RuuviFirmware/FirmwareInteractor.swift index 81a29a664..2bc3bda07 100644 --- a/Modules/RuuviFirmware/Sources/RuuviFirmware/FirmwareInteractor.swift +++ b/Modules/RuuviFirmware/Sources/RuuviFirmware/FirmwareInteractor.swift @@ -18,20 +18,55 @@ enum FirmwareError: Error { } final class FirmwareInteractor { + var batteryIsLow = CurrentValueSubject(false) private let background: BTBackground + private let foreground: BTForeground private let firmwareRepository: FirmwareRepository private let ruuviDFU: RuuviDFU + private var ruuviTagObservationToken: ObservationToken? init( background: BTBackground, + foreground: BTForeground, ruuviDFU: RuuviDFU, firmwareRepository: FirmwareRepository ) { self.background = background + self.foreground = foreground self.ruuviDFU = ruuviDFU self.firmwareRepository = firmwareRepository } + deinit { + ruuviTagObservationToken?.invalidate() + } + + func ensureBatteryHasEnoughPower(uuid: String) { + ruuviTagObservationToken?.invalidate() + ruuviTagObservationToken = foreground.observe(self, uuid: uuid) { observer, device in + if let ruuviTag = device.ruuvi?.tag { + self.batteryIsLow.value = observer.batteryNeedsReplacement(ruuviTag: ruuviTag) + } + } + } + + private func batteryNeedsReplacement( + ruuviTag: RuuviTag + ) -> Bool { + if let temperature = ruuviTag.celsius, + let voltage = ruuviTag.volts { + if temperature < 0, temperature >= -20 { + voltage < 2.3 + } else if temperature < -20 { + voltage < 2 + } else { + voltage < 2.5 + } + } else { + false + } + } + func loadLatestGitHubRelease() -> AnyPublisher { let urlString = "https://api.github.com/repos/ruuvi/ruuvi.firmware.c/releases/latest" guard let url = URL(string: urlString) diff --git a/Modules/RuuviFirmware/Sources/RuuviFirmware/FirmwarePresenter.swift b/Modules/RuuviFirmware/Sources/RuuviFirmware/FirmwarePresenter.swift index 7cd8d475a..673318a9b 100644 --- a/Modules/RuuviFirmware/Sources/RuuviFirmware/FirmwarePresenter.swift +++ b/Modules/RuuviFirmware/Sources/RuuviFirmware/FirmwarePresenter.swift @@ -34,6 +34,7 @@ final class FirmwarePresenter: RuuviFirmware { uuid: String, currentFirmware: String?, background: BTBackground, + foreground: BTForeground, ruuviDFU: RuuviDFU, firmwareRepository: FirmwareRepository ) { @@ -41,9 +42,11 @@ final class FirmwarePresenter: RuuviFirmware { self.currentFirmware = currentFirmware interactor = FirmwareInteractor( background: background, + foreground: foreground, ruuviDFU: ruuviDFU, firmwareRepository: firmwareRepository ) + interactor.ensureBatteryHasEnoughPower(uuid: uuid) } func isSafeToDismiss() -> Bool { diff --git a/Modules/RuuviFirmware/Sources/RuuviFirmware/RuuviFirmwareBuilder.swift b/Modules/RuuviFirmware/Sources/RuuviFirmware/RuuviFirmwareBuilder.swift index 870d5a354..c4c63d5fe 100644 --- a/Modules/RuuviFirmware/Sources/RuuviFirmware/RuuviFirmwareBuilder.swift +++ b/Modules/RuuviFirmware/Sources/RuuviFirmware/RuuviFirmwareBuilder.swift @@ -3,6 +3,7 @@ import RuuviDFU public struct RuuviFirmwareDependencies { public var background: BTBackground + public var foreground: BTForeground public var ruuviDFU: RuuviDFU public var firmwareRepository: FirmwareRepository } @@ -19,6 +20,7 @@ public final class RuuviFirmwareBuilder { uuid: uuid, currentFirmware: currentFirmware, background: dependencies.background, + foreground: dependencies.foreground, ruuviDFU: dependencies.ruuviDFU, firmwareRepository: dependencies.firmwareRepository ) @@ -30,6 +32,7 @@ public extension RuuviFirmwareDependencies { static var `default`: Self { RuuviFirmwareDependencies( background: BTKit.background, + foreground: BTKit.foreground, ruuviDFU: RuuviDFUImpl.shared, firmwareRepository: FirmwareRepositoryImpl() ) diff --git a/Modules/RuuviFirmware/Sources/RuuviFirmware/SwiftUI/FirmwareView.swift b/Modules/RuuviFirmware/Sources/RuuviFirmware/SwiftUI/FirmwareView.swift index c01d4bd2a..c8208ac9e 100644 --- a/Modules/RuuviFirmware/Sources/RuuviFirmware/SwiftUI/FirmwareView.swift +++ b/Modules/RuuviFirmware/Sources/RuuviFirmware/SwiftUI/FirmwareView.swift @@ -362,6 +362,9 @@ struct FirmwareView: View { alignment: .top ) .padding(EdgeInsets(top: 0, leading: 20, bottom: 20, trailing: 20)) + .onAppear { + viewModel.ensureBatteryHasEnoughPower(uuid: uuid) + } .eraseToAnyView() case .flashing: VStack(alignment: .center, spacing: 24) { @@ -425,6 +428,14 @@ struct FirmwareView: View { VStack { content } + .alert(isPresented: $viewModel.isBatteryLow) { + Alert( + title: Text(""), + message: Text(texts.lowBatteryWarningMessage), + dismissButton: .cancel(Text(texts.okTitle)) + ) + } + .padding() .accentColor(.red) .navigationBarBackButtonHidden(true) .onAppear { diff --git a/Modules/RuuviFirmware/Sources/RuuviFirmware/SwiftUI/FirmwareViewModel.swift b/Modules/RuuviFirmware/Sources/RuuviFirmware/SwiftUI/FirmwareViewModel.swift index 743063220..0bc14e3f3 100644 --- a/Modules/RuuviFirmware/Sources/RuuviFirmware/SwiftUI/FirmwareViewModel.swift +++ b/Modules/RuuviFirmware/Sources/RuuviFirmware/SwiftUI/FirmwareViewModel.swift @@ -10,6 +10,8 @@ final class FirmwareViewModel: ObservableObject { @Published private(set) var state: State = .idle @Published var downloadProgress: Double = 0 @Published var flashProgress: Double = 0 + @Published var isBatteryLow: Bool = false + var output: FirmwareViewModelOutput? private let input = PassthroughSubject() private let uuid: String @@ -42,6 +44,8 @@ final class FirmwareViewModel: ObservableObject { ) .assign(to: \.state, on: self) .store(in: &bag) + + interactor.batteryIsLow.assign(to: &$isBatteryLow) } func send(event: Event) { @@ -51,6 +55,10 @@ final class FirmwareViewModel: ObservableObject { func finish() { output?.firmwareUpgradeDidFinishSuccessfully() } + + func ensureBatteryHasEnoughPower(uuid: String) { + interactor.ensureBatteryHasEnoughPower(uuid: uuid) + } } // MARK: - Feedbacks diff --git a/Packages/RuuviCloud/Sources/RuuviCloud/RuuviCloudRequestStateObserver.swift b/Packages/RuuviCloud/Sources/RuuviCloud/RuuviCloudRequestStateObserver.swift new file mode 100644 index 000000000..b7e1ab3c5 --- /dev/null +++ b/Packages/RuuviCloud/Sources/RuuviCloud/RuuviCloudRequestStateObserver.swift @@ -0,0 +1,48 @@ +import Foundation + +public class RuuviCloudRequestStateObserverManager { + public static let shared = RuuviCloudRequestStateObserverManager() + private var cloudRequestStateTokens: [String: NSObjectProtocol] = [:] + + private init() {} + + public func startObserving( + for macId: String?, + callback: @escaping (RuuviCloudRequestStateType) -> Void + ) { + // Ensures no duplicate observers for the same macId + guard let macId = macId else { return } + stopObserving(for: macId) + + let token = NotificationCenter.default.addObserver( + forName: .RuuviCloudRequestStateDidChange, + object: nil, + queue: .main, + using: { notification in + guard let userInfo = notification.userInfo, + let observedMacId = userInfo[RuuviCloudRequestStateKey.macId] as? String, + observedMacId == macId, + let state = userInfo[RuuviCloudRequestStateKey.state] as? RuuviCloudRequestStateType else { + return + } + callback(state) + } + ) + + cloudRequestStateTokens[macId] = token + } + + public func stopObserving(for macId: String?) { + guard let macId = macId else { return } + if let token = cloudRequestStateTokens[macId] { + NotificationCenter.default.removeObserver(token) + cloudRequestStateTokens[macId] = nil + } + } + + public func stopAllObservers() { + for (macId, _) in cloudRequestStateTokens { + stopObserving(for: macId) + } + } +}