Skip to content

Commit 767af25

Browse files
committed
feat(app): search page
1 parent b8d854a commit 767af25

20 files changed

+710
-80
lines changed

apps/mobile/metro-now/common/components/route-label-view/route-name.view.swift

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,18 @@ struct RouteNameIconView: View {
99

1010
var body: some View {
1111
Text(label.uppercased())
12-
.font(.system(size: 12, weight: .bold, design: .monospaced))
12+
.font(.system(size: 12))
13+
.fontWeight(.bold)
14+
.fontDesign(.default)
1315
.foregroundStyle(.white)
1416
.fixedSize(horizontal: true, vertical: true)
15-
.frame(width: 26, height: 26)
17+
.padding(.horizontal, 4)
18+
.frame(minWidth: 26)
19+
.frame(height: 26)
1620
.background(Rectangle().fill(background))
17-
.clipShape(.rect(cornerRadius: 6))
21+
.clipShape(
22+
.rect(cornerRadius: 6)
23+
)
1824
}
1925
}
2026

apps/mobile/metro-now/common/utils/color.utils.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ extension Color {
1616
public extension Color {
1717
enum pragueMetro {
1818
public static let a = Color.green
19-
public static let b = Color(hex: 0xFFA305)
19+
public static let b = Color(hex: 0xFFCA38)
2020
public static let c = Color.red
2121
}
2222
}

apps/mobile/metro-now/common/utils/find-closest-stop.swift

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,7 @@ func findClosestStop(to location: CLLocation, stops: [ApiStop]) -> ApiStop? {
99
var closestDistance: CLLocationDistance?
1010

1111
for stop in stops {
12-
let stopLocation = CLLocation(latitude: stop.avgLatitude, longitude: stop.avgLongitude)
13-
14-
let distance = location.distance(from: stopLocation)
12+
let distance = getStopDistance(location, stop)
1513

1614
guard closestDistance != nil else {
1715
closestStop = stop
@@ -27,3 +25,15 @@ func findClosestStop(to location: CLLocation, stops: [ApiStop]) -> ApiStop? {
2725

2826
return closestStop
2927
}
28+
29+
func getStopDistance(
30+
_ location: CLLocation,
31+
_ stop: ApiStop
32+
) -> CLLocationDistance {
33+
location.distance(
34+
from: CLLocation(
35+
latitude: stop.avgLatitude,
36+
longitude: stop.avgLongitude
37+
)
38+
)
39+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// metro-now
2+
// https://github.com/krystxf/metro-now
3+
4+
func isMetro(_ routeName: String) -> Bool {
5+
["A", "B", "C"].contains(where: { $0 == routeName })
6+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
// metro-now
2+
// https://github.com/krystxf/metro-now
3+
4+
import Alamofire
5+
6+
func fetchData<T: Decodable>(_ req: DataRequest, ofType _: T.Type) async throws -> T {
7+
try await withCheckedThrowingContinuation { continuation in
8+
req.responseDecodable(of: T.self) { response in
9+
switch response.result {
10+
case let .success(data):
11+
continuation.resume(returning: data)
12+
case let .failure(error):
13+
continuation.resume(throwing: error)
14+
}
15+
}
16+
}
17+
}

apps/mobile/metro-now/metro-now.xcodeproj/project.pbxproj

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,15 @@
5656
"components/countdown-view/countdown.utils.swift",
5757
"components/route-label-view/get-color-by-route-name.utils.swift",
5858
"components/route-label-view/route-type.enum.swift",
59-
const/endpoint.const.swift,
59+
const/api.const.swift,
6060
const/github.const.swift,
6161
"const/review-url.const.swift",
6262
"types/api-types.swift",
6363
utils/array.utils.swift,
64+
utils/color.utils.swift,
6465
"utils/get-platform-label.swift",
66+
"utils/is-metro.utils.swift",
67+
utils/network.utils.swift,
6568
utils/station.utils.swift,
6669
);
6770
target = 2D7FEC762CE96F300073FF5B /* metro-nowTests */;
@@ -74,13 +77,16 @@
7477
"components/route-label-view/get-color-by-route-name.utils.swift",
7578
"components/route-label-view/route-name.view.swift",
7679
"components/route-label-view/route-type.enum.swift",
77-
const/endpoint.const.swift,
80+
const/api.const.swift,
7881
const/github.const.swift,
7982
"const/review-url.const.swift",
8083
"types/api-types.swift",
8184
utils/array.utils.swift,
85+
utils/color.utils.swift,
8286
"utils/find-closest-stop.swift",
8387
"utils/get-platform-label.swift",
88+
"utils/is-metro.utils.swift",
89+
utils/network.utils.swift,
8490
utils/station.utils.swift,
8591
);
8692
target = 2D001BB72CC8099C00C6B4F8 /* metro-now Watch App */;
@@ -467,7 +473,7 @@
467473
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
468474
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
469475
CODE_SIGN_STYLE = Automatic;
470-
CURRENT_PROJECT_VERSION = 3;
476+
CURRENT_PROJECT_VERSION = 6;
471477
DEVELOPMENT_ASSET_PATHS = "\"metro-now Watch App/Preview Content\"";
472478
DEVELOPMENT_TEAM = R6WU5ABNG2;
473479
ENABLE_PREVIEWS = YES;
@@ -481,7 +487,7 @@
481487
"$(inherited)",
482488
"@executable_path/Frameworks",
483489
);
484-
MARKETING_VERSION = 0.3.3;
490+
MARKETING_VERSION = 0.3.4;
485491
PRODUCT_BUNDLE_IDENTIFIER = "com.krystof.metro-now.watchkitapp";
486492
PRODUCT_NAME = "$(TARGET_NAME)";
487493
SDKROOT = watchos;
@@ -499,7 +505,7 @@
499505
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
500506
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
501507
CODE_SIGN_STYLE = Automatic;
502-
CURRENT_PROJECT_VERSION = 3;
508+
CURRENT_PROJECT_VERSION = 6;
503509
DEVELOPMENT_ASSET_PATHS = "\"metro-now Watch App/Preview Content\"";
504510
DEVELOPMENT_TEAM = R6WU5ABNG2;
505511
ENABLE_PREVIEWS = YES;
@@ -513,7 +519,7 @@
513519
"$(inherited)",
514520
"@executable_path/Frameworks",
515521
);
516-
MARKETING_VERSION = 0.3.3;
522+
MARKETING_VERSION = 0.3.4;
517523
PRODUCT_BUNDLE_IDENTIFIER = "com.krystof.metro-now.watchkitapp";
518524
PRODUCT_NAME = "$(TARGET_NAME)";
519525
SDKROOT = watchos;
@@ -532,7 +538,7 @@
532538
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
533539
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
534540
CODE_SIGN_STYLE = Automatic;
535-
CURRENT_PROJECT_VERSION = 3;
541+
CURRENT_PROJECT_VERSION = 6;
536542
DEVELOPMENT_ASSET_PATHS = "\"metro-now/Preview Content\"";
537543
DEVELOPMENT_TEAM = R6WU5ABNG2;
538544
ENABLE_PREVIEWS = YES;
@@ -550,7 +556,7 @@
550556
"$(inherited)",
551557
"@executable_path/Frameworks",
552558
);
553-
MARKETING_VERSION = 0.3.3;
559+
MARKETING_VERSION = 0.3.4;
554560
PRODUCT_BUNDLE_IDENTIFIER = "com.krystof.metro-now";
555561
PRODUCT_NAME = "$(TARGET_NAME)";
556562
SDKROOT = iphoneos;
@@ -566,7 +572,7 @@
566572
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
567573
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
568574
CODE_SIGN_STYLE = Automatic;
569-
CURRENT_PROJECT_VERSION = 3;
575+
CURRENT_PROJECT_VERSION = 6;
570576
DEVELOPMENT_ASSET_PATHS = "\"metro-now/Preview Content\"";
571577
DEVELOPMENT_TEAM = R6WU5ABNG2;
572578
ENABLE_PREVIEWS = YES;
@@ -584,7 +590,7 @@
584590
"$(inherited)",
585591
"@executable_path/Frameworks",
586592
);
587-
MARKETING_VERSION = 0.3.3;
593+
MARKETING_VERSION = 0.3.4;
588594
PRODUCT_BUNDLE_IDENTIFIER = "com.krystof.metro-now";
589595
PRODUCT_NAME = "$(TARGET_NAME)";
590596
SDKROOT = iphoneos;
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"info": {
3-
"author": "xcode",
4-
"version": 1
5-
}
2+
"info" : {
3+
"author" : "xcode",
4+
"version" : 1
5+
}
66
}

apps/mobile/metro-now/metro-now/ContentView.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import SwiftUI
55

66
struct ContentView: View {
77
@StateObject private var networkMonitor = NetworkMonitor()
8+
@StateObject private var locationModel = LocationViewModel()
89
@State private var showNoInternetBanner = false
910

1011
@AppStorage(
1112
AppStorageKeys.hasSeenWelcomeScreen.rawValue
1213
) var hasSeenWelcomeScreen = false
14+
@StateObject var stopsViewModel = StopsViewModel()
1315
@State private var showWelcomeScreen: Bool = false
1416
@State private var showSearchScreen: Bool = false
1517

@@ -40,7 +42,10 @@ struct ContentView: View {
4042
showSearchScreen = false
4143
}
4244
) {
43-
SearchPageView()
45+
SearchPageView(
46+
location: locationModel.location
47+
)
48+
.environmentObject(stopsViewModel)
4449
.presentationDetents([.large])
4550
}
4651
.sheet(
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// metro-now
2+
// https://github.com/krystxf/metro-now
3+
4+
import Alamofire
5+
import CoreLocation
6+
import Foundation
7+
8+
class LocationViewModel: NSObject, ObservableObject, CLLocationManagerDelegate {
9+
private let locationManager = CLLocationManager()
10+
11+
// Published property to store user's current location
12+
@Published var location: CLLocation?
13+
14+
// Published property to handle location access status
15+
@Published var authorizationStatus: CLAuthorizationStatus?
16+
17+
override init() {
18+
super.init()
19+
20+
locationManager.delegate = self
21+
locationManager.desiredAccuracy = kCLLocationAccuracyBest
22+
locationManager.requestWhenInUseAuthorization()
23+
locationManager.startUpdatingLocation()
24+
25+
// Capture the initial authorization status
26+
authorizationStatus = locationManager.authorizationStatus
27+
}
28+
29+
// CLLocationManagerDelegate method: called when location updates
30+
func locationManager(_: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
31+
// Take the most recent location
32+
guard let latestLocation = locations.last else { return }
33+
DispatchQueue.main.async {
34+
self.location = latestLocation
35+
}
36+
}
37+
38+
// CLLocationManagerDelegate method: called when authorization status changes
39+
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
40+
DispatchQueue.main.async {
41+
self.authorizationStatus = manager.authorizationStatus
42+
}
43+
44+
// Handle location updates based on the new authorization status
45+
switch manager.authorizationStatus {
46+
case .authorizedWhenInUse, .authorizedAlways:
47+
locationManager.startUpdatingLocation()
48+
case .denied, .restricted:
49+
locationManager.stopUpdatingLocation()
50+
default:
51+
break
52+
}
53+
}
54+
}
55+
56+
class StopsViewModel: NSObject, ObservableObject {
57+
@Published var stops: [ApiStop]?
58+
59+
func getClosestStop(_ location: CLLocation) -> ApiStop? {
60+
guard let stops else {
61+
return nil
62+
}
63+
64+
return findClosestStop(
65+
to: location,
66+
stops: stops
67+
)
68+
}
69+
70+
private var refreshTimer: Timer?
71+
72+
override init() {
73+
super.init()
74+
75+
Task(priority: .high) {
76+
await self.updateStops()
77+
}
78+
79+
startPeriodicRefresh()
80+
}
81+
82+
@MainActor
83+
private func updateStops() async {
84+
stops = await fetchStops()
85+
}
86+
87+
private func startPeriodicRefresh() {
88+
stopPeriodicRefresh() // Stop any existing timer to avoid duplication.
89+
90+
refreshTimer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
91+
92+
guard let self else {
93+
return
94+
}
95+
96+
Task(priority: .low) {
97+
await self.updateStops()
98+
}
99+
}
100+
}
101+
102+
deinit {
103+
stopPeriodicRefresh()
104+
}
105+
106+
private func stopPeriodicRefresh() {
107+
refreshTimer?.invalidate()
108+
refreshTimer = nil
109+
}
110+
111+
private func fetchStops(metroOnly: Bool = false) async -> [ApiStop]? {
112+
let req = AF.request(
113+
"\(API_URL)/v1/stop/all",
114+
method: .get,
115+
parameters: ["metroOnly": String(metroOnly)]
116+
)
117+
118+
return try? await fetchData(req, ofType: [ApiStop].self)
119+
}
120+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// metro-now
2+
// https://github.com/krystxf/metro-now
3+
4+
import SwiftUI
5+
6+
struct SearchPageAllStopsListView: View {
7+
let stops: [ApiStop]
8+
9+
var transferStops: [ApiStop] {
10+
stops.filter { stop in
11+
stop.platforms.count > 2
12+
}
13+
}
14+
15+
var body: some View {
16+
Section(header: Text("Transfer stations")) {
17+
ForEach(transferStops, id: \.id) { stop in
18+
SearchPageItemView(
19+
label: stop.name,
20+
routeNames: stop.platforms
21+
.flatMap(\.routes)
22+
.map(\.name)
23+
)
24+
}
25+
}
26+
27+
ForEach(["A", "B", "C"], id: \.self) { metroLine in
28+
let metroLineStops = stops.filter { stop in
29+
stop.platforms.flatMap(\.routes).contains(where: { $0.name == metroLine })
30+
}
31+
32+
Section(header: Text(metroLine)) {
33+
ForEach(metroLineStops, id: \.id) { stop in
34+
let routes = stop.platforms.flatMap(\.routes)
35+
36+
SearchPageItemView(
37+
label: stop.name,
38+
routeNames: routes.map(\.name)
39+
)
40+
}
41+
}
42+
}
43+
}
44+
}

0 commit comments

Comments
 (0)