remindwall / RWPureSwift / Sources / EditSettingsNew / TopLevel / BatterySettings.swift
Back to remindwall — Current RemindWall
import AppTypes
import ComposableArchitecture
import Dao
import Dependencies
import EditSettingsNew_BusSettings
import Foundation
import HomeKitAsync
import SwiftUI
@Reducer
public struct BatterySettingsFeature: Sendable {
@Dependency(\.homeKitAsync) var homeKitAsync
@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; a window limits WHEN.
@Shared(.syncedSetting(BATTERY_WINDOW_SETTING_KEY)) public var window: AlertWindow?
/// Accessories that lie about their batteries (H1.7).
@Shared(.syncedSetting(BATTERY_IGNORED_SETTING_KEY)) public var ignored: BatteryIgnoreList = .empty
/// Everything the house reports, alertable or not - HomeKit battery
/// reporting is vendor-soup, so the browser shows the raw truth.
public var discovered: [BatteryStatus] = []
public var isLoadingBatteries: Bool = false
public var isDetailPresented: Bool = false
/// The one line the settings form shows; everything else lives in
/// the sheet (H1.8 - a 15-accessory browser was eating the form).
public var summary: String {
var parts = ["Below \(thresholdPercent)%", window?.summary ?? "always"]
let ignoredCount = ignored.names.count
if ignoredCount > 0 {
parts.append("\(ignoredCount) ignored")
}
return parts.joined(separator: " · ")
}
public init() {}
}
public enum Action: Equatable {
case onAppear
case setDetailPresented(Bool)
case thresholdChanged(Int)
case windowedToggled(Bool)
case setWindowStart(hour: Int, minute: Int)
case setWindowEnd(hour: Int, minute: Int)
case toggleWeekday(DaysOfWeek)
case refreshBatteries
case ignoreToggled(accessoryName: String)
case _batteriesLoaded([BatteryStatus])
}
enum CancelID { case fetch }
public init() {}
public var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .onAppear:
return .send(.refreshBatteries)
case let .setDetailPresented(isPresented):
state.isDetailPresented = isPresented
return .none
case let .thresholdChanged(percent):
state.$thresholdPercent.withLock { $0 = min(max(percent, 5), 50) }
return .none
case let .windowedToggled(isOn):
state.$window.withLock { $0 = isOn ? .default : nil }
return .none
case let .setWindowStart(hour, minute):
let current = state.window ?? .default
state.$window.withLock { $0 = current.withStart(hour: hour, minute: minute) }
return .none
case let .setWindowEnd(hour, minute):
let current = state.window ?? .default
state.$window.withLock { $0 = current.withEnd(hour: hour, minute: minute) }
return .none
case let .toggleWeekday(day):
let current = state.window ?? .default
var days = current.weekdays
if days.contains(day) { days.remove(day) } else { days.insert(day) }
state.$window.withLock { $0 = current.withWeekdays(days) }
return .none
case let .ignoreToggled(accessoryName):
state.$ignored.withLock { $0 = $0.toggling(accessoryName) }
return .none
case .refreshBatteries:
state.isLoadingBatteries = true
return .run { [homeKitAsync] send in
await send(._batteriesLoaded(homeKitAsync.batteryStatuses()))
}
.cancellable(id: CancelID.fetch, cancelInFlight: true)
case let ._batteriesLoaded(statuses):
state.isLoadingBatteries = false
state.discovered = statuses.sorted {
if $0.levelPercent != $1.levelPercent {
return ($0.levelPercent ?? 0) < ($1.levelPercent ?? 0)
}
return $0.accessoryName < $1.accessoryName
}
return .none
}
}
}
}
public struct BatterySettingsView: View {
@Bindable var store: StoreOf<BatterySettingsFeature>
public init(store: StoreOf<BatterySettingsFeature>) {
self.store = store
}
public var body: some View {
// One row in the form; the details sheet holds everything else.
Button {
store.send(.setDetailPresented(true))
} label: {
HStack {
Text(store.summary)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
.sheet(isPresented: Binding(
get: { store.isDetailPresented },
set: { store.send(.setDetailPresented($0)) }
)) {
detailSheet
}
}
private var detailSheet: some View {
NavigationStack {
Form {
Section("Alert rule") {
Stepper(
"Alert below \(store.thresholdPercent)% (or when the accessory flags itself low)",
value: Binding(
get: { store.thresholdPercent },
set: { store.send(.thresholdChanged($0)) }
),
in: 5...50,
step: 5
)
}
Section("When chips show") {
Toggle("Only during a window", isOn: Binding(
get: { store.window != nil },
set: { store.send(.windowedToggled($0)) }
))
if let window = store.window {
AlertWindowEditorView(
window: window,
onSetStart: { hour, minute in store.send(.setWindowStart(hour: hour, minute: minute)) },
onSetEnd: { hour, minute in store.send(.setWindowEnd(hour: hour, minute: minute)) },
onToggleWeekday: { day in store.send(.toggleWeekday(day)) }
)
}
}
Section {
batteriesFound
}
}
.navigationTitle("Battery Alerts")
.toolbar {
ToolbarItem {
Button("Done") { store.send(.setDetailPresented(false)) }
}
}
.onAppear { store.send(.onAppear) }
}
}
@ViewBuilder
private var batteriesFound: some View {
HStack {
Text("Batteries found")
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
Button {
store.send(.refreshBatteries)
} label: {
if store.isLoadingBatteries {
ProgressView()
} else {
Image(systemName: "arrow.clockwise")
}
}
.buttonStyle(.borderless)
.disabled(store.isLoadingBatteries)
.accessibilityLabel("Refresh batteries")
}
if store.discovered.isEmpty, !store.isLoadingBatteries {
Text("None found yet — is HomeKit access granted on this device?")
.font(.caption)
.foregroundStyle(.secondary)
}
ForEach(store.discovered) { status in
let isIgnored = store.ignored.contains(status.accessoryName)
HStack {
VStack(alignment: .leading) {
Text(status.accessoryName)
Text(isIgnored ? "ignored" : (status.roomName ?? ""))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if status.isLow, !isIgnored {
Text("LOW")
.font(.caption2)
.fontWeight(.bold)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.red)
.foregroundStyle(.white)
.clipShape(Capsule())
}
Text(status.levelPercent.map { "\($0)%" } ?? "?")
.monospacedDigit()
.foregroundStyle(
!isIgnored && status.isAlertable(belowPercent: store.thresholdPercent)
? .red : .secondary
)
Button {
store.send(.ignoreToggled(accessoryName: status.accessoryName))
} label: {
Image(systemName: isIgnored ? "eye" : "eye.slash")
}
.buttonStyle(.borderless)
.accessibilityLabel(isIgnored ? "Stop ignoring \(status.accessoryName)" : "Ignore \(status.accessoryName)")
}
.opacity(isIgnored ? 0.45 : 1.0)
}
}
}