remindwall / RWPureSwift / Sources / EditSettingsNew / BusSettings / AlertsSettings.swift
Back to remindwall โ Current RemindWall
import AppTypes
import ComposableArchitecture
import Dao
import Dependencies
import Foundation
import SQLiteData
import SwiftUI
/// One row in the unified Alerts list: a bus stop and a driving route are
/// peers (TR1.4 review decision) - each carries its own window and toggle.
public enum Watch: Equatable, Identifiable, Sendable {
case bus(MonitoredStop)
case drive(MonitoredRoute)
public enum ID: Hashable, Sendable {
case bus(MonitoredStop.ID)
case drive(MonitoredRoute.ID)
}
public var id: ID {
switch self {
case let .bus(stop): .bus(stop.id)
case let .drive(route): .drive(route.id)
}
}
public var label: String {
switch self {
case let .bus(stop): stop.label
case let .drive(route): route.label
}
}
public var enabled: Bool {
switch self {
case let .bus(stop): stop.enabled
case let .drive(route): route.enabled
}
}
public var window: AlertWindow? {
switch self {
case let .bus(stop): stop.window
case let .drive(route): route.window
}
}
public var chipText: String {
switch self {
case let .bus(stop): stop.routeShortName
case .drive: "๐"
}
}
public var whereText: String {
switch self {
case let .bus(stop): stop.stopId
case let .drive(route): "to \(route.destinationName)"
}
}
/// "MTWTF ยท 6:30 AMโ9:00 AM" - the row self-describes when it matters.
public var windowSummary: String {
(window ?? .default).summary
}
}
@Reducer
public struct AlertsSettingsFeature: Sendable {
@Dependency(\.defaultDatabase) var defaultDatabase
@ObservableState
public struct State: Equatable {
@Presents public var destination: Destination.State?
@FetchAll(MonitoredStop.none)
public var stops: [MonitoredStop]
@FetchAll(MonitoredRoute.none)
public var routes: [MonitoredRoute]
public var watches: [Watch] {
stops.map(Watch.bus) + routes.map(Watch.drive)
}
public init() {
self._stops = FetchAll(MonitoredStop.all.order(by: \.sortOrder))
self._routes = FetchAll(MonitoredRoute.all.order(by: \.sortOrder))
}
}
public enum Action {
case addBusTapped
case addDriveTapped
case setupTapped
case watchTapped(Watch.ID)
case watchToggled(Watch.ID, Bool)
case destination(PresentationAction<Destination.Action>)
}
public init() {}
public var body: some ReducerOf<Self> {
Reduce<State, Action> { state, action in
switch action {
case .addBusTapped:
let nextSortOrder = (state.stops.map(\.sortOrder).max() ?? -1) + 1
state.destination = .addBus(AddMonitoredStopFeature.State(sortOrder: nextSortOrder))
return .none
case .addDriveTapped:
let nextSortOrder = (state.routes.map(\.sortOrder).max() ?? -1) + 1
state.destination = .addDrive(AddDriveFeature.State(sortOrder: nextSortOrder))
return .none
case .setupTapped:
state.destination = .setup(AlertsSetupFeature.State())
return .none
case let .watchTapped(id):
guard let watch = state.watches.first(where: { $0.id == id }) else { return .none }
state.destination = .detail(WatchDetailFeature.State(watch: watch))
return .none
case let .watchToggled(id, isOn):
return .run { [stops = state.$stops, routes = state.$routes] _ in
await withErrorReporting {
try await defaultDatabase.write { db in
switch id {
case let .bus(stopId):
try MonitoredStop.find(stopId)
.update { $0.enabled = isOn }
.execute(db)
case let .drive(routeId):
try MonitoredRoute.find(routeId)
.update { $0.enabled = isOn }
.execute(db)
}
}
try await stops.load(MonitoredStop.all.order(by: \.sortOrder))
try await routes.load(MonitoredRoute.all.order(by: \.sortOrder))
}
}
case let .destination(.presented(.addBus(.delegate(.saveStop(draft))))):
return .run { [stops = state.$stops] _ in
await withErrorReporting {
try await defaultDatabase.write { db in
try MonitoredStop.insert { draft }.execute(db)
}
try await stops.load(MonitoredStop.all.order(by: \.sortOrder))
}
}
case let .destination(.presented(.addDrive(.delegate(.saveRoute(draft))))):
return .run { [routes = state.$routes] _ in
await withErrorReporting {
try await defaultDatabase.write { db in
try MonitoredRoute.insert { draft }.execute(db)
}
try await routes.load(MonitoredRoute.all.order(by: \.sortOrder))
}
}
case let .destination(.presented(.detail(.delegate(.removeRequested(id))))):
state.destination = nil
return .run { [stops = state.$stops, routes = state.$routes] _ in
await withErrorReporting {
try await defaultDatabase.write { db in
switch id {
case let .bus(stopId):
try MonitoredStop.find(stopId).delete().execute(db)
case let .drive(routeId):
try MonitoredRoute.find(routeId).delete().execute(db)
}
}
try await stops.load(MonitoredStop.all.order(by: \.sortOrder))
try await routes.load(MonitoredRoute.all.order(by: \.sortOrder))
}
}
case .destination(.presented(.detail(.delegate(.watchChanged)))):
return .run { [stops = state.$stops, routes = state.$routes] _ in
await withErrorReporting {
try await stops.load(MonitoredStop.all.order(by: \.sortOrder))
try await routes.load(MonitoredRoute.all.order(by: \.sortOrder))
}
}
case .destination:
return .none
}
}
.ifLet(\.$destination, action: \.destination) {
Destination.body
}
}
}
extension AlertsSettingsFeature {
@Reducer
public enum Destination {
case addBus(AddMonitoredStopFeature)
case addDrive(AddDriveFeature)
case detail(WatchDetailFeature)
case setup(AlertsSetupFeature)
}
}
extension AlertsSettingsFeature.Destination.State: Equatable {}
public struct AlertsSettingsView: View {
@Bindable var store: StoreOf<AlertsSettingsFeature>
public init(store: StoreOf<AlertsSettingsFeature>) {
self.store = store
}
public var body: some View {
if store.watches.isEmpty {
Text("No alerts yet โ add a bus stop or a drive.")
.foregroundStyle(.secondary)
}
ForEach(store.watches) { watch in
HStack(spacing: 10) {
Text(watch.chipText)
.font(.caption)
.fontWeight(.bold)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.accentColor.opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: 6))
Button {
store.send(.watchTapped(watch.id))
} label: {
VStack(alignment: .leading) {
Text(watch.label)
Text("\(watch.whereText) ยท \(watch.windowSummary)")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
Spacer()
Toggle("", isOn: Binding(
get: { watch.enabled },
set: { store.send(.watchToggled(watch.id, $0)) }
))
.labelsHidden()
.accessibilityLabel("\(watch.label) enabled")
}
.padding(.vertical, 2)
}
Menu {
Button {
store.send(.addBusTapped)
} label: {
Label("Bus arrival", systemImage: "bus")
}
Button {
store.send(.addDriveTapped)
} label: {
Label("Drive time", systemImage: "car")
}
} label: {
Label("Add alert", systemImage: "plus")
}
Button {
store.send(.setupTapped)
} label: {
HStack {
Text("Setup")
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
Color.clear
.frame(height: 0)
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.sheet(item: $store.scope(state: \.$destination, action: \.destination).addBus) { addStore in
AddMonitoredStopView(store: addStore)
}
.sheet(item: $store.scope(state: \.$destination, action: \.destination).addDrive) { addStore in
AddDriveView(store: addStore)
}
.sheet(item: $store.scope(state: \.$destination, action: \.destination).detail) { detailStore in
WatchDetailView(store: detailStore)
}
.sheet(item: $store.scope(state: \.$destination, action: \.destination).setup) { setupStore in
AlertsSetupView(store: setupStore)
}
}
}