Christopher Hotchkiss Christopher Hotchkiss
Login

Code

remindwall / RWPureSwift / Sources / Dashboard / BatteryAlerts.swift

Back to remindwall — Current RemindWall

Download this file

import AppTypes
import ComposableArchitecture
import Dao
import Dependencies
import Foundation
import HomeKitAsync

@Reducer
public struct BatteryAlertsFeature: Sendable {
    @Dependency(\.homeKitAsync) var homeKitAsync
    @Dependency(\.continuousClock) var clock
    @Dependency(\.date.now) var now
    @Dependency(\.calendar) var calendar

    /// Batteries move over days - 30 minutes, not seconds.
    static let refreshInterval = Duration.seconds(60 * 30)
    /// The window edge needs to land within a minute, not within a poll.
    static let windowCheckInterval = Duration.seconds(60)

    @ObservableState
    public struct State: Equatable {
        @Shared(.syncedSetting(BATTERY_ALERTS_ENABLED_SETTING_KEY)) public var enabled: Bool = false
        @Shared(.syncedSetting(BATTERY_THRESHOLD_SETTING_KEY)) public var thresholdPercent: Int = 20
        /// nil = chips show whenever a battery is low (H1.6 review decision).
        @Shared(.syncedSetting(BATTERY_WINDOW_SETTING_KEY)) public var window: AlertWindow?
        /// Accessories that lie about their batteries never chip (H1.7).
        @Shared(.syncedSetting(BATTERY_IGNORED_SETTING_KEY)) public var ignored: BatteryIgnoreList = .empty

        public var statuses: [BatteryStatus] = []
        /// Evaluated on the fast windowTick; nil window is always-in.
        public var isInWindow: Bool = true

        /// The Tier 2 chips: only alertable batteries surface, lowest level
        /// first so the most urgent chore reads first.
        public var ambientChips: [AmbientChip] {
            guard enabled, isInWindow else { return [] }
            return statuses
                .filter {
                    !ignored.contains($0.accessoryName)
                        && $0.isAlertable(belowPercent: thresholdPercent)
                }
                .sorted {
                    if $0.levelPercent != $1.levelPercent {
                        return ($0.levelPercent ?? 0) < ($1.levelPercent ?? 0)
                    }
                    return $0.accessoryName < $1.accessoryName
                }
                .map { status in
                    AmbientChip(
                        id: "battery-\(status.id.uuidString)",
                        systemImage: "battery.25percent",
                        text: chipText(for: status)
                    )
                }
        }

        public init() {}
    }

    public enum Action: Equatable {
        case startMonitoring
        case tick
        case windowTick
        case _statusesLoaded([BatteryStatus])
    }

    enum CancelID { case pollLoop, windowLoop, fetch }

    public init() {}

    public var body: some Reducer<State, Action> {
        Reduce { state, action in
            switch action {
            case .startMonitoring:
                return .merge(
                    .run { send in
                        await send(.tick)
                        for await _ in self.clock.timer(interval: Self.refreshInterval) {
                            await send(.tick)
                        }
                    }
                    .cancellable(id: CancelID.pollLoop, cancelInFlight: true),
                    .run { send in
                        await send(.windowTick)
                        for await _ in self.clock.timer(interval: Self.windowCheckInterval) {
                            await send(.windowTick)
                        }
                    }
                    .cancellable(id: CancelID.windowLoop, cancelInFlight: true)
                )

            case .windowTick:
                state.isInWindow = state.window
                    .map { $0.isInWindow(date: now, calendar: calendar) } ?? true
                return .none

            case .tick:
                guard state.enabled else {
                    return .send(._statusesLoaded([]))
                }
                // cancelInFlight: a HomeKit load that never resolves (the
                // H1.1 sandbox question) must not pile up a task per tick.
                return .run { [homeKitAsync] send in
                    await send(._statusesLoaded(homeKitAsync.batteryStatuses()))
                }
                .cancellable(id: CancelID.fetch, cancelInFlight: true)

            case let ._statusesLoaded(statuses):
                state.statuses = statuses
                return .none
            }
        }
    }
}

private func chipText(for status: BatteryStatus) -> String {
    if let level = status.levelPercent {
        return "\(status.accessoryName) · \(level)%"
    }
    return "\(status.accessoryName) · low"
}