import AppTypes import ComposableArchitecture import EditSettingsNew_BusSettings import EditSettingsNew_Trackees import ScreenControl import SwiftUI private enum TerminateAppKey: DependencyKey { static let liveValue: @Sendable () -> Void = { exit(0) } static let testValue: @Sendable () -> Void = { } } extension DependencyValues { var terminateApp: @Sendable () -> Void { get { self[TerminateAppKey.self] } set { self[TerminateAppKey.self] = newValue } } } @Reducer public struct SettingsFeature { @Dependency(\.continuousClock) var clock @Dependency(\.fireAndForget) var fireAndForget @Dependency(\.screenControl) var screenControl @Dependency(\.terminateApp) var terminateApp @ObservableState public struct State: Equatable { public var trackeesState = TrackeesFeature.State() public var albumPickerState = AlbumPickerFeature.State() public var calendarPickerState = CalendarPickerFeature.State() public var screenOffSettingState = ScreenOffSettingFeature.State() public var alertsState = AlertsSettingsFeature.State() public var path = StackState() public var isBrightnessControlAvailable: Bool = true public var batterySettingsState = BatterySettingsFeature.State() public init(){} } public enum Action { case albumPicker(AlbumPickerFeature.Action) case batteryAlertsToggled(Bool) case batterySettings(BatterySettingsFeature.Action) case alerts(AlertsSettingsFeature.Action) case calendarPicker(CalendarPickerFeature.Action) case delegate(Delegate) case screenOffSetting(ScreenOffSettingFeature.Action) case trackees(TrackeesFeature.Action) case path(StackActionOf) case _brightnessCheckCompleted(Bool) case calendarToggled(Bool) case onAppear case onDisappear case quitApplication case screenOffToggled(Bool) case slideshowToggled(Bool) case startSlideshow @CasePathable public enum Delegate: Equatable { case startSlideshow } } enum CancelID { case availabilityLoop } public init() {} public var body: some ReducerOf { Scope(state: \.albumPickerState, action: \.albumPicker) { AlbumPickerFeature() } Scope(state: \.batterySettingsState, action: \.batterySettings) { BatterySettingsFeature() } Scope(state: \.alertsState, action: \.alerts) { AlertsSettingsFeature() } Scope(state: \.calendarPickerState, action: \.calendarPicker) { CalendarPickerFeature() } Scope(state: \.screenOffSettingState, action: \.screenOffSetting) { ScreenOffSettingFeature() } Scope(state: \.trackeesState, action: \.trackees) { TrackeesFeature() } Reduce { state, action in switch action { case let .batteryAlertsToggled(isOn): state.batterySettingsState.$enabled.withLock { $0 = isOn } return .none case let .calendarToggled(isOn): if isOn { state.calendarPickerState.$selectedCalendar.withLock { $0 = CalendarId("") } } else { state.calendarPickerState.$selectedCalendar.withLock { $0 = nil } } return .none case .onAppear: // Poll while settings is visible so the warning clears live as // the operator fixes the daemon; cancelled on disappear (the // dashboard runs 24/7 - a leaked poll would run forever). return .run { [screenControl, clock] send in await send(._brightnessCheckCompleted(screenControl.isAvailable())) for await _ in clock.timer(interval: .seconds(15)) { await send(._brightnessCheckCompleted(screenControl.isAvailable())) } } .cancellable(id: CancelID.availabilityLoop, cancelInFlight: true) case .onDisappear: return .cancel(id: CancelID.availabilityLoop) case let ._brightnessCheckCompleted(available): state.isBrightnessControlAvailable = available return .none case .quitApplication: return .run { [fireAndForget, terminateApp] _ in await fireAndForget { terminateApp() } } case let .screenOffToggled(isOn): if isOn { state.screenOffSettingState.$schedule.withLock { $0 = .default } } else { state.screenOffSettingState.$schedule.withLock { $0 = nil } } return .none case let .slideshowToggled(isOn): if isOn { state.albumPickerState.$selectedAlbum.withLock { $0 = AlbumLocalId("") } } else { state.albumPickerState.$selectedAlbum.withLock { $0 = nil } } return .none case .startSlideshow: return .send(.delegate(.startSlideshow)) case .delegate: return .none case let .path(.element(id: id, action: .delegate(.confirmDeletion))): guard let detailState = state.path[id: id] else { return .none } return .send(.trackees(.deleteTrackee(detailState.trackee.id))) case .alerts, .batterySettings, .trackees, .albumPicker, .calendarPicker, .screenOffSetting, .path: return .none } }.forEach(\.path, action: \.path) { TrackeeDetailFeature() } } } public struct SettingsView: View { @Bindable var store: StoreOf public init(store: StoreOf) { self.store = store } public var body: some View { NavigationStack(path: $store.scope(state: \.path, action: \.path)) { Form{ Section { if store.albumPickerState.selectedAlbum != nil { AlbumPickerView(store: store.scope(state: \.albumPickerState, action: \.albumPicker)) } } header: { Toggle("Slideshow", isOn: Binding( get: { store.albumPickerState.selectedAlbum != nil }, set: { store.send(.slideshowToggled($0)) } )) } Section { if store.calendarPickerState.selectedCalendar != nil { CalendarPickerView(store: store.scope(state: \.calendarPickerState, action: \.calendarPicker)) } } header: { Toggle("Calendar Reminders", isOn: Binding( get: { store.calendarPickerState.selectedCalendar != nil }, set: { store.send(.calendarToggled($0)) } )) } Section { if store.screenOffSettingState.schedule != nil { ScreenOffSettingView( store: store.scope(state: \.screenOffSettingState, action: \.screenOffSetting) ) } #if targetEnvironment(macCatalyst) if !store.isBrightnessControlAvailable { HStack { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.yellow) Text("Screen control daemon (ddcd) unreachable — brightness and panel power are disabled. Setup: scripts/kiosk/INSTALL.md in the repo") .font(.caption) .foregroundStyle(.secondary) } } #endif } header: { Toggle("Screen Off", isOn: Binding( get: { store.screenOffSettingState.schedule != nil }, set: { store.send(.screenOffToggled($0)) } )) } Section { AlertsSettingsView( store: store.scope(state: \.alertsState, action: \.alerts) ) } header: { Text("Alerts") } Section { if store.batterySettingsState.enabled { BatterySettingsView( store: store.scope(state: \.batterySettingsState, action: \.batterySettings) ) } } header: { Toggle("Battery Alerts", isOn: Binding( get: { store.batterySettingsState.enabled }, set: { store.send(.batteryAlertsToggled($0)) } )) } Section { TrackeesView(store: store.scope(state: \.trackeesState, action: \.trackees), isEmbedded: true) } header: { HStack { Text("Trackees") Spacer() Button { store.send(.trackees(.addButtonTapped)) } label: { Image(systemName: "plus") } } } Section { let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Unknown" let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "Unknown" LabeledContent("Version") { Text("\(version) (\(build))") } } #if targetEnvironment(macCatalyst) Section { Button("Quit Application", role: .destructive) { store.send(.quitApplication) } } #endif } .onAppear { store.send(.onAppear) } .onDisappear { store.send(.onDisappear) } .navigationTitle("Settings") .toolbar { ToolbarItem { Button("Start Slideshow") { store.send(.startSlideshow) } } } } destination: { store in TrackeeDetailView(store: store) } } } #Preview { let _ = prepareDependencies { $0.defaultDatabase = try! $0.appDatabase() } SettingsView( store: Store( initialState: SettingsFeature.State() ) { SettingsFeature() } ) }