Christopher Hotchkiss Christopher Hotchkiss
Login

Code

remindwall / RWPureSwift / Sources / EditSettingsNew / BusSettings / AddMonitoredStop.swift

Back to remindwall — Current RemindWall

Download this file

import ComposableArchitecture
import Dao
import Dependencies
import Foundation
import SwiftUI
import TransitAPI

public struct PugetSoundAgency: Equatable, Hashable, Identifiable, Sendable {
    public let id: String
    public let name: String

    public static let all: [PugetSoundAgency] = [
        .init(id: "1", name: "King County Metro"),
        .init(id: "3", name: "Pierce Transit"),
        .init(id: "19", name: "Intercity Transit"),
        .init(id: "20", name: "Kitsap Transit"),
        .init(id: "23", name: "City of Seattle"),
        .init(id: "29", name: "Community Transit"),
        .init(id: "40", name: "Sound Transit"),
        .init(id: "95", name: "Washington State Ferries"),
        .init(id: "97", name: "Everett Transit"),
    ]
}

@Reducer
public struct AddMonitoredStopFeature: Sendable {
    @Dependency(\.transitAPI) var transitAPI
    @Dependency(\.transitKeyStore) var transitKeyStore
    @Dependency(\.uuid) var uuid
    @Dependency(\.dismiss) var dismiss

    @ObservableState
    public struct State: Equatable {
        public var sortOrder: Int
        public var agencyId: String = PugetSoundAgency.all[0].id
        public var stopCode: String = ""
        public var label: String = ""
        public var selectedRouteId: String?
        public var lookedUpStop: StopInfo?
        public var routeOptions: [RouteInfo] = []
        public var isLooking: Bool = false
        public var errorMessage: String?

        public init(sortOrder: Int) {
            self.sortOrder = sortOrder
        }

        public var canSave: Bool {
            lookedUpStop != nil
                && selectedRouteId != nil
                && !label.trimmingCharacters(in: .whitespaces).isEmpty
        }
    }

    public enum Action: Equatable {
        case agencyChanged(String)
        case stopCodeChanged(String)
        case labelChanged(String)
        case routeChanged(String?)
        case lookupTapped
        case _stopFetched(StopInfo)
        case _lookupFailed(String)
        case _routesFetched([RouteInfo])
        case saveButtonTapped
        case cancelButtonTapped
        case delegate(Delegate)

        @CasePathable
        public enum Delegate: Equatable {
            case saveStop(MonitoredStop.Draft)
        }
    }

    public init() {}

    public var body: some ReducerOf<Self> {
        Reduce<State, Action> { state, action in
            switch action {
            case let .agencyChanged(id):
                state.agencyId = id
                state.lookedUpStop = nil
                state.routeOptions = []
                state.selectedRouteId = nil
                state.errorMessage = nil
                return .none

            case let .stopCodeChanged(code):
                state.stopCode = code
                state.lookedUpStop = nil
                state.routeOptions = []
                state.selectedRouteId = nil
                state.errorMessage = nil
                return .none

            case let .labelChanged(label):
                state.label = label
                return .none

            case let .routeChanged(id):
                state.selectedRouteId = id
                return .none

            case .lookupTapped:
                guard !state.stopCode.isEmpty else { return .none }
                guard let key = transitKeyStore.read() else {
                    state.errorMessage = "No API key configured"
                    return .none
                }
                let composedId = "\(state.agencyId)_\(state.stopCode)"
                state.isLooking = true
                state.errorMessage = nil
                return .run { [transitAPI] send in
                    do {
                        let stop = try await transitAPI.fetchStop(apiKey: key, stopId: composedId)
                        await send(._stopFetched(stop))
                        var routes: [RouteInfo] = []
                        for routeId in stop.routeIds {
                            if let route = try? await transitAPI.fetchRoute(
                                apiKey: key, routeId: routeId
                            ) {
                                routes.append(route)
                            }
                        }
                        await send(._routesFetched(routes))
                    } catch let error as TransitAPIError {
                        await send(._lookupFailed(message(for: error)))
                    } catch {
                        await send(._lookupFailed(error.localizedDescription))
                    }
                }

            case let ._stopFetched(stop):
                state.lookedUpStop = stop
                state.errorMessage = nil
                if state.label.isEmpty { state.label = stop.name }
                return .none

            case let ._routesFetched(routes):
                state.routeOptions = routes
                state.selectedRouteId = routes.first?.routeId
                state.isLooking = false
                return .none

            case let ._lookupFailed(message):
                state.isLooking = false
                state.errorMessage = message
                return .none

            case .saveButtonTapped:
                guard let stop = state.lookedUpStop,
                      let routeId = state.selectedRouteId,
                      let route = state.routeOptions.first(where: { $0.routeId == routeId })
                else {
                    return .none
                }
                let draft = MonitoredStop.Draft(
                    label: state.label.trimmingCharacters(in: .whitespaces),
                    stopId: stop.stopId,
                    routeId: route.routeId,
                    routeShortName: route.shortName,
                    sortOrder: state.sortOrder
                )
                return .run { [dismiss] send in
                    await send(.delegate(.saveStop(draft)))
                    await dismiss()
                }

            case .cancelButtonTapped:
                return .run { [dismiss] _ in
                    await dismiss()
                }

            case .delegate:
                return .none
            }
        }
    }

    private func message(for error: TransitAPIError) -> String {
        switch error {
        case .unauthorized: return "API key was rejected — check it in Settings."
        case .notFound: return "Stop not found. Check the agency and stop code."
        case .rateLimited: return "Too many requests. Try again in a moment."
        case .invalidResponse: return "Unexpected response from the server."
        case let .network(detail): return "Network error: \(detail)"
        case let .decoding(detail): return "Could not parse response: \(detail)"
        }
    }
}

public struct AddMonitoredStopView: View {
    @Bindable var store: StoreOf<AddMonitoredStopFeature>

    public init(store: StoreOf<AddMonitoredStopFeature>) {
        self.store = store
    }

    public var body: some View {
        NavigationStack {
            Form {
                Section("Agency") {
                    Picker("Agency", selection: Binding(
                        get: { store.agencyId },
                        set: { store.send(.agencyChanged($0)) }
                    )) {
                        ForEach(PugetSoundAgency.all) { agency in
                            Text(agency.name).tag(agency.id)
                        }
                    }
                }

                Section("Stop") {
                    HStack {
                        TextField("Stop code (from sign)", text: Binding(
                            get: { store.stopCode },
                            set: { store.send(.stopCodeChanged($0)) }
                        ))
                        #if !os(macOS)
                        .keyboardType(.numberPad)
                        #endif
                        Button("Look up") {
                            store.send(.lookupTapped)
                        }
                        .disabled(store.stopCode.isEmpty || store.isLooking)
                    }

                    if store.isLooking {
                        HStack {
                            ProgressView()
                            Text("Looking up…")
                        }
                    }

                    if let stop = store.lookedUpStop {
                        Label(stop.name, systemImage: "mappin.circle")
                            .font(.callout)
                            .foregroundStyle(.secondary)
                    }

                    if let error = store.errorMessage {
                        Label(error, systemImage: "exclamationmark.triangle")
                            .foregroundStyle(.red)
                            .font(.caption)
                    }
                }

                if !store.routeOptions.isEmpty {
                    Section("Route") {
                        Picker("Route", selection: Binding(
                            get: { store.selectedRouteId },
                            set: { store.send(.routeChanged($0)) }
                        )) {
                            ForEach(store.routeOptions, id: \.routeId) { route in
                                Text("\(route.shortName) — \(route.longName)")
                                    .tag(route.routeId as String?)
                            }
                        }
                    }
                }

                if store.lookedUpStop != nil {
                    Section("Label") {
                        TextField("Display name", text: Binding(
                            get: { store.label },
                            set: { store.send(.labelChanged($0)) }
                        ))
                    }
                }
            }
            .navigationTitle("Add Stop")
            #if !os(macOS)
            .navigationBarTitleDisplayMode(.inline)
            #endif
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { store.send(.cancelButtonTapped) }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") { store.send(.saveButtonTapped) }
                        .disabled(!store.canSave)
                }
            }
        }
    }
}