Christopher Hotchkiss Christopher Hotchkiss
Login

Code

remindwall / RWPureSwift / Sources / EditSettingsNew / TopLevel / Settings.swift

Back to remindwall — Current RemindWall

Download this file

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<TrackeeDetailFeature.State>()
        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<TrackeeDetailFeature>)
        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<Self> {
        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<SettingsFeature>
    
    public init(store: StoreOf<SettingsFeature>) {
        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()
    }
  )
}