From 36f505b0e317a3021cd1be6d439763b4482a58c8 Mon Sep 17 00:00:00 2001 From: Krystof Date: Fri, 15 Nov 2024 19:16:31 +0100 Subject: [PATCH] feat(ios): bus departures --- .../common/components/route-name.view.swift | 120 ++++++++++---- .../utils/get-color-by-route-name.swift | 39 +++-- .../pages/platform/platform-detail.view.swift | 6 +- .../metro-now/metro-now/ContentView.swift | 113 ++++--------- ...eparture-list-item-placeholder.view.swift} | 49 ++++-- .../departure-list-item.view.swift} | 0 .../metro-departures.view.swift} | 69 +++++++- .../non-metro/non-metro-departures.view.swift | 156 ++++++++++++++++++ 8 files changed, 409 insertions(+), 143 deletions(-) rename apps/mobile/metro-now/metro-now/{pages/closest-stop/closest-stop-page-list-item-placeholder.view.swift => components/departure-list-item/departure-list-item-placeholder.view.swift} (53%) rename apps/mobile/metro-now/metro-now/{pages/closest-stop/closest-stop-page-list-item.view.swift => components/departure-list-item/departure-list-item.view.swift} (100%) rename apps/mobile/metro-now/metro-now/pages/closest-stop/{closest-metro-stop-section.view.swift => metro/metro-departures.view.swift} (50%) create mode 100644 apps/mobile/metro-now/metro-now/pages/closest-stop/non-metro/non-metro-departures.view.swift diff --git a/apps/mobile/metro-now/common/components/route-name.view.swift b/apps/mobile/metro-now/common/components/route-name.view.swift index 74570d87..79e82b42 100644 --- a/apps/mobile/metro-now/common/components/route-name.view.swift +++ b/apps/mobile/metro-now/common/components/route-name.view.swift @@ -9,7 +9,7 @@ struct RouteNameIconView: View { var body: some View { Text(label.uppercased()) - .font(.system(size: 12, weight: .bold)) + .font(.system(size: 12, weight: .bold, design: .monospaced)) .foregroundStyle(.white) .fixedSize(horizontal: true, vertical: true) .frame(width: 26, height: 26) @@ -19,38 +19,88 @@ struct RouteNameIconView: View { } #Preview { - RouteNameIconView( - label: "a", - background: .green - ) - - RouteNameIconView( - label: "b", - background: .yellow - ) - - RouteNameIconView( - label: "c", - background: .red - ) - - RouteNameIconView( - label: "28", - background: .purple - ) - - RouteNameIconView( - label: "99", - background: .black - ) - - RouteNameIconView( - label: "149", - background: .blue - ) - - RouteNameIconView( - label: "912", - background: .black - ) + HStack { + RouteNameIconView( + label: "a", + background: getColorByRouteName("a") + ) + + RouteNameIconView( + label: "b", + background: getColorByRouteName("b") + ) + + RouteNameIconView( + label: "c", + background: getColorByRouteName("c") + ) + } + + HStack { + RouteNameIconView( + label: "2", + background: getColorByRouteName("28") + ) + RouteNameIconView( + label: "23", + background: getColorByRouteName("28") + ) + RouteNameIconView( + label: "28", + background: getColorByRouteName("28") + ) + RouteNameIconView( + label: "99", + background: getColorByRouteName("99") + ) + } + + HStack { + RouteNameIconView( + label: "149", + background: getColorByRouteName("149") + ) + + RouteNameIconView( + label: "912", + background: getColorByRouteName("912") + ) + } + + HStack { + RouteNameIconView( + label: "P2", + background: getColorByRouteName("P2") + ) + RouteNameIconView( + label: "P4", + background: getColorByRouteName("P2") + ) + RouteNameIconView( + label: "P6", + background: getColorByRouteName("P2") + ) + } + + HStack { + RouteNameIconView( + label: "S9", + background: getColorByRouteName("S49") + ) + RouteNameIconView( + label: "S88", + background: getColorByRouteName("S49") + ) + RouteNameIconView( + label: "R19", + background: getColorByRouteName("S49") + ) + } + + HStack { + RouteNameIconView( + label: "LD", + background: getColorByRouteName("LD") + ) + } } diff --git a/apps/mobile/metro-now/common/utils/get-color-by-route-name.swift b/apps/mobile/metro-now/common/utils/get-color-by-route-name.swift index b4188889..01763bfd 100644 --- a/apps/mobile/metro-now/common/utils/get-color-by-route-name.swift +++ b/apps/mobile/metro-now/common/utils/get-color-by-route-name.swift @@ -5,14 +5,22 @@ import SwiftUI private let FALLBACK_COLOR: Color = .black +private let METRO_A_COLOR: Color = .green +private let METRO_B_COLOR: Color = .yellow +private let METRO_C_COLOR: Color = .red + +private let NIGHT_COLOR: Color = .black +private let BUS_COLOR: Color = .blue +private let TRAM_COLOR: Color = .indigo +private let FERRY_COLOR: Color = .cyan +private let FUNICULAR_COLOR: Color = .brown +private let TRAIN_COLOR: Color = .gray + func getColorByRouteName(_ metroLine: MetroLine?) -> Color { switch metroLine { - case .A: - .green - case .B: - .yellow - case .C: - .red + case .A: METRO_A_COLOR + case .B: METRO_B_COLOR + case .C: METRO_C_COLOR default: FALLBACK_COLOR } } @@ -25,18 +33,18 @@ func getColorByRouteName(_ routeNumber: Int?) -> Color { // tram if routeNumber < 100 { if routeNumber >= 90 { - return .black + return NIGHT_COLOR } - return .purple + return TRAM_COLOR } // bus if routeNumber >= 900 { - return .black + return NIGHT_COLOR } - return .blue + return BUS_COLOR } func getColorByRouteName(_ routeName: String?) -> Color { @@ -46,18 +54,23 @@ func getColorByRouteName(_ routeName: String?) -> Color { if let routeNumber = Int(routeName) { return getColorByRouteName(routeNumber) - } else if let metroLine = MetroLine(rawValue: routeName) { + } else if let metroLine = MetroLine(rawValue: routeName.uppercased()) { return getColorByRouteName(metroLine) } + // train + if routeName.hasPrefix("S") || routeName.hasPrefix("R") { + return TRAIN_COLOR + } + // ferry if routeName.hasPrefix("P") { - return Color.blue + return FERRY_COLOR } // funicular if routeName.hasPrefix("LD") { - return Color.blue + return FUNICULAR_COLOR } return FALLBACK_COLOR diff --git a/apps/mobile/metro-now/metro-now Watch App/pages/platform/platform-detail.view.swift b/apps/mobile/metro-now/metro-now Watch App/pages/platform/platform-detail.view.swift index af322ce4..0e9dc0bc 100644 --- a/apps/mobile/metro-now/metro-now Watch App/pages/platform/platform-detail.view.swift +++ b/apps/mobile/metro-now/metro-now Watch App/pages/platform/platform-detail.view.swift @@ -78,7 +78,11 @@ struct PlatformDetailView: View { func getDepartures() { NetworkManager.shared - .getDepartures(stopIds: [], platformIds: [platformId]) { result in + .getDepartures( + includeVehicle: .METRO, + excludeMetro: false, + stopIds: [], platformIds: [platformId] + ) { result in DispatchQueue.main.async { switch result { case let .success(departures): diff --git a/apps/mobile/metro-now/metro-now/ContentView.swift b/apps/mobile/metro-now/metro-now/ContentView.swift index e05d8ec6..10102a1a 100644 --- a/apps/mobile/metro-now/metro-now/ContentView.swift +++ b/apps/mobile/metro-now/metro-now/ContentView.swift @@ -4,75 +4,48 @@ import CoreLocation import SwiftUI + struct ContentView: View { - @StateObject private var locationManager = LocationManager() - @State var metroStops: [ApiStop]? = nil @State var allStops: [ApiStop]? = nil - @State var departures: [ApiDeparture]? = nil - private let timer = Timer.publish(every: 2, on: .main, in: .common).autoconnect() + @State var metroStops: [ApiStop]? = nil var body: some View { - NavigationStack { - List { - if let location = locationManager.location, - let metroStops, - let closestMetroStop = findClosestStop(to: location, stops: metroStops) - { - Section(header: Text("Metro")) { - ClosestMetroStopSectionView( - closestStop: closestMetroStop, - departures: departures - ) - .navigationTitle(closestMetroStop.name) + NavigationStack { + if let metroStops { + List { + Section(header: Text("Metro")) { + MetroDeparturesView(stops: metroStops) + } + + + if let allStops { + NonMetroDeparturesView(stops: allStops) + } } +// .navigationTitle( +// findClosestStop( +// to: location, +// stops: metroStops +// )?.name ?? +// "Loading..." +// ) } else { ProgressView() } } - } - .onAppear { - getAllMetroStops() - getAllStops() - } - .onReceive(timer) { _ in - getAllMetroStops() - getAllStops() - getStopDepartures() - } - } - - func findClosestStop(to location: CLLocation, stops: [ApiStop]) -> ApiStop? { - var closestStop: ApiStop? - var closestDistance: CLLocationDistance? - - for stop in stops { - let stopLocation = CLLocation(latitude: stop.avgLatitude, longitude: stop.avgLongitude) - - let distance = location.distance(from: stopLocation) - - guard closestDistance != nil else { - closestStop = stop - closestDistance = distance - continue - } - - if distance < closestDistance! { - closestStop = stop - closestDistance = distance + .onAppear { + getMetroStops() + getAllStops() } - } - - return closestStop + } - func getAllMetroStops() { - NetworkManager.shared.getMetroStops { result in + func getAllStops() { + NetworkManager.shared.getStops(metroOnly: false) { result in DispatchQueue.main.async { switch result { case let .success(stops): - - metroStops = stops - + allStops = stops case let .failure(error): print(error.localizedDescription) } @@ -80,44 +53,18 @@ struct ContentView: View { } } - func getAllStops() { - NetworkManager.shared.getAllStops { result in + func getMetroStops() { + NetworkManager.shared.getStops(metroOnly: true) { result in DispatchQueue.main.async { switch result { case let .success(stops): - - allStops = stops - + metroStops = stops case let .failure(error): print(error.localizedDescription) } } } } - - func getStopDepartures() { - guard - let location = locationManager.location, - let metroStops, - let closestStop = findClosestStop(to: location, stops: metroStops) - else { - return - } - - NetworkManager.shared - .getDepartures(stopIds: [closestStop.id], platformIds: []) { result in - DispatchQueue.main.async { - switch result { - case let .success(departures): - - self.departures = departures - - case let .failure(error): - print(error.localizedDescription) - } - } - } - } } #Preview { diff --git a/apps/mobile/metro-now/metro-now/pages/closest-stop/closest-stop-page-list-item-placeholder.view.swift b/apps/mobile/metro-now/metro-now/components/departure-list-item/departure-list-item-placeholder.view.swift similarity index 53% rename from apps/mobile/metro-now/metro-now/pages/closest-stop/closest-stop-page-list-item-placeholder.view.swift rename to apps/mobile/metro-now/metro-now/components/departure-list-item/departure-list-item-placeholder.view.swift index 09987464..dc07f69e 100644 --- a/apps/mobile/metro-now/metro-now/pages/closest-stop/closest-stop-page-list-item-placeholder.view.swift +++ b/apps/mobile/metro-now/metro-now/components/departure-list-item/departure-list-item-placeholder.view.swift @@ -4,19 +4,48 @@ import SwiftUI struct ClosestStopPageListItemPlaceholderView: View { - let routeLabel: String + let routeLabel: String? let routeLabelBackground: Color var body: some View { - ClosestStopPageListItemView( - routeLabel: routeLabel, - routeLabelBackground: routeLabelBackground, - headsign: "Loading...", - departure: .now + 10 * 60, - nextHeadsign: "Loading..", - nextDeparture: .now + 15 * 60 - ) - .redacted(reason: .placeholder) + HStack( + alignment: .top, + spacing: 8 + ) { + if let routeLabel { + RouteNameIconView( + label: routeLabel, + background: routeLabelBackground + ) + } else { + RouteNameIconView( + label: "X", + background: routeLabelBackground + ) + .redacted(reason: .placeholder) + } + + VStack(alignment: .trailing, spacing: 4) { + HStack { + Text("Loading...") + Spacer() + CountdownView(targetDate: .now + 10 * 60) + } + + HStack { + Spacer() + CountdownView( + targetDate: .now + 15 * 60 + ) { + "Also in \($0)" + } + } + .foregroundStyle(.secondary) + .font(.footnote) + } + .fontWeight(.semibold) + .redacted(reason: .placeholder) + } } } diff --git a/apps/mobile/metro-now/metro-now/pages/closest-stop/closest-stop-page-list-item.view.swift b/apps/mobile/metro-now/metro-now/components/departure-list-item/departure-list-item.view.swift similarity index 100% rename from apps/mobile/metro-now/metro-now/pages/closest-stop/closest-stop-page-list-item.view.swift rename to apps/mobile/metro-now/metro-now/components/departure-list-item/departure-list-item.view.swift diff --git a/apps/mobile/metro-now/metro-now/pages/closest-stop/closest-metro-stop-section.view.swift b/apps/mobile/metro-now/metro-now/pages/closest-stop/metro/metro-departures.view.swift similarity index 50% rename from apps/mobile/metro-now/metro-now/pages/closest-stop/closest-metro-stop-section.view.swift rename to apps/mobile/metro-now/metro-now/pages/closest-stop/metro/metro-departures.view.swift index e4320416..58b7b612 100644 --- a/apps/mobile/metro-now/metro-now/pages/closest-stop/closest-metro-stop-section.view.swift +++ b/apps/mobile/metro-now/metro-now/pages/closest-stop/metro/metro-departures.view.swift @@ -1,6 +1,7 @@ // metro-now // https://github.com/krystxf/metro-now +import CoreLocation import SwiftUI struct ClosestMetroStopSectionView: View { @@ -9,7 +10,9 @@ struct ClosestMetroStopSectionView: View { var body: some View { ForEach(closestStop.platforms, id: \.id) { platform in - let platformDepartures: [ApiDeparture]? = departures?.filter { $0.platformId == platform.id } + let platformDepartures: [ApiDeparture]? = departures?.filter { + $0.platformId == platform.id + } if platform.routes.count == 0 { EmptyView() @@ -57,3 +60,67 @@ struct PlatformDeparturesView: View { } } } + +struct MetroDeparturesView: View { + @StateObject private var locationManager = LocationManager() + let stops: [ApiStop] + + init(stops: [ApiStop]) { + self.stops = stops + } + + @State var departures: [ApiDeparture]? = nil + private let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() + + var body: some View { + if let location = locationManager.location { + if let closestMetroStop = findClosestStop(to: location, stops: stops) { + ClosestMetroStopSectionView( + closestStop: closestMetroStop, + departures: departures + ) + .navigationTitle(closestMetroStop.name) + } else { + ProgressView() + } + + EmptyView() + .onAppear { + getStopDepartures() + } + .onReceive(timer) { _ in + getStopDepartures() + } + } + else { + EmptyView() + } + } + + func getStopDepartures() { + guard let location = locationManager.location else { + return + } + + guard let closestStop = findClosestStop(to: location, stops: stops) else { + return + } + + NetworkManager.shared + .getDepartures( + includeVehicle: .METRO, + excludeMetro: false, + stopIds: [closestStop.id], + platformIds: [] + ) { result in + DispatchQueue.main.async { + switch result { + case let .success(departures): + self.departures = departures + case let .failure(error): + print(error.localizedDescription) + } + } + } + } +} diff --git a/apps/mobile/metro-now/metro-now/pages/closest-stop/non-metro/non-metro-departures.view.swift b/apps/mobile/metro-now/metro-now/pages/closest-stop/non-metro/non-metro-departures.view.swift new file mode 100644 index 00000000..4e783c17 --- /dev/null +++ b/apps/mobile/metro-now/metro-now/pages/closest-stop/non-metro/non-metro-departures.view.swift @@ -0,0 +1,156 @@ +// metro-now +// https://github.com/krystxf/metro-now + +import CoreLocation +import SwiftUI + +struct PlatformSectionListView: View { + let departures: [[ApiDeparture]] + + var body: some View { + ForEach(departures, id: \.first?.id) { deps in + let departure = deps.count > 0 ? deps[0] : nil + let nextDeparture = deps.count > 1 ? deps[1] : nil + + if let departure { + ClosestStopPageListItemView( + routeLabel: departure.route, + routeLabelBackground: getColorByRouteName(departure.route), + headsign: departure.headsign, + departure: departure.departure.predicted, + nextHeadsign: nextDeparture?.headsign, + nextDeparture: nextDeparture?.departure.predicted + ) + } else { + Text("Loading") + } + } + } +} + +struct PlatformSectionListPlaceholderView: View { + let routes: [ApiRoute] + let maxItems: Int = 3 + + var body: some View { + ForEach(routes.prefix(maxItems), id: \.id) { route in + ClosestStopPageListItemPlaceholderView( + routeLabel: route.name, + routeLabelBackground: getColorByRouteName(route.name) + ) + } + } +} + +struct PlatformSectionView: View { + let platform: ApiPlatform + let departures: [[ApiDeparture]]? + + init(platform: ApiPlatform, departures: [ApiDeparture]?) { + self.platform = platform + + guard let departures else { + self.departures = nil + return + } + + let filteredDepartures = departures + .filter { + platform.id == $0.platformId + } + + let departuresByRoute = Dictionary( + grouping: filteredDepartures, + by: { $0.route } + ) + + self.departures = Array( + departuresByRoute + .map(\.value) + .sorted(by: { + $0.first!.departure.predicted < $1.first!.departure.predicted + } + ) + ) + } + + var body: some View { + if departures == nil || departures!.count > 0 { + Section(header: Text(getPlatformLabel(platform))) { + if let departures { + PlatformSectionListView(departures: departures) + } else { + PlatformSectionListPlaceholderView(routes: platform.routes) + .redacted(reason: .placeholder) + } + } + } else { + EmptyView() + } + } +} + +struct NonMetroDeparturesView: View { + @StateObject private var locationManager = LocationManager() + let stops: [ApiStop] + + @State var departures: [ApiDeparture]? = nil + private let timer = Timer.publish(every: 5, on: .main, in: .common).autoconnect() + + var body: some View { + if let location = locationManager.location, let closestStop = findClosestStop( + to: location, + stops: stops + ) { + let platforms = closestStop.platforms.filter { !$0.isMetro }.sorted( + by: { getPlatformLabel($0) < getPlatformLabel($1) } + ) + + ForEach(platforms, id: \.id) { platform in + PlatformSectionView( + platform: platform, + departures: departures + ) + } + + } else { + ProgressView() + } + + EmptyView() + .onAppear { + getStopDepartures() + } + .onReceive(timer) { _ in + getStopDepartures() + } + } + + func getStopDepartures() { + guard let location = locationManager.location else { + return + } + + + let closestStop = findClosestStop(to: location, stops: stops)! + + NetworkManager.shared + .getDepartures( + includeVehicle: .ALL, + excludeMetro: true, + stopIds: [closestStop.id], + platformIds: [] + ) { result in + DispatchQueue.main.async { + switch result { + case let .success(departures): + + self.departures = departures + + case let .failure(error): + print(error.localizedDescription) + } + } + } + } +}