From 080e23b1d61cd0b77450572b3cf9d3cc31b907fc Mon Sep 17 00:00:00 2001 From: Jayson Hahn <46629787+JaysonHahn@users.noreply.github.com> Date: Wed, 25 Sep 2024 17:47:50 -0400 Subject: [PATCH 01/13] Initial Networking --- Podfile | 1 - Podfile.lock | 2 +- TCAT.xcodeproj/project.pbxproj | 48 ++++++- TCAT/Base/AppDelegate.swift | 43 ++++--- .../InformationViewController.swift | 2 +- .../RouteDetail+ContentViewController.swift | 24 ++-- ...outeOptionsViewController+Extensions.swift | 3 +- .../RouteOptionsViewController.swift | 21 +-- TCAT/Models/APIResponse.swift | 14 ++ TCAT/{Network => Services}/Endpoints.swift | 33 ----- TCAT/Services/Network/APIErrorHandler.swift | 48 +++++++ TCAT/Services/Network/ApiEndpoint.swift | 78 ++++++++++++ TCAT/{ => Services}/Network/Models.swift | 6 - TCAT/Services/Network/NetworkManager.swift | 34 +++++ TCAT/Services/Network/NetworkSession.swift | 53 ++++++++ .../{ => Services}/Network/Reachability.swift | 0 .../Network/ReachabilityManager.swift | 0 TCAT/Services/Providers/TransitProvider.swift | 120 ++++++++++++++++++ TCAT/Services/Providers/TransitService.swift | 97 ++++++++++++++ TCAT/Supporting/Constants.swift | 3 - TCAT/Utils/Extensions+App.swift | 23 ++-- 21 files changed, 546 insertions(+), 107 deletions(-) create mode 100644 TCAT/Models/APIResponse.swift rename TCAT/{Network => Services}/Endpoints.swift (70%) create mode 100644 TCAT/Services/Network/APIErrorHandler.swift create mode 100644 TCAT/Services/Network/ApiEndpoint.swift rename TCAT/{ => Services}/Network/Models.swift (94%) create mode 100644 TCAT/Services/Network/NetworkManager.swift create mode 100644 TCAT/Services/Network/NetworkSession.swift rename TCAT/{ => Services}/Network/Reachability.swift (100%) rename TCAT/{ => Services}/Network/ReachabilityManager.swift (100%) create mode 100644 TCAT/Services/Providers/TransitProvider.swift create mode 100644 TCAT/Services/Providers/TransitService.swift diff --git a/Podfile b/Podfile index 7c2b6f10..2efefa72 100644 --- a/Podfile +++ b/Podfile @@ -31,7 +31,6 @@ target 'TCAT' do pod 'Pulley', '~> 2.7' pod 'Presentation', :git=> 'https://github.com/cuappdev/Presentation.git' pod 'SnapKit', '~> 5.0' - pod 'WhatsNewKit', '~> 1.1' # Other pod 'SwiftLint' diff --git a/Podfile.lock b/Podfile.lock index f5a62843..00d7c526 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -236,4 +236,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: a3b80dd04ea30998a17c032f2730e21ee8517238 -COCOAPODS: 1.15.2 +COCOAPODS: 1.15.0 diff --git a/TCAT.xcodeproj/project.pbxproj b/TCAT.xcodeproj/project.pbxproj index 7dfabf64..5aa17d9a 100644 --- a/TCAT.xcodeproj/project.pbxproj +++ b/TCAT.xcodeproj/project.pbxproj @@ -127,6 +127,13 @@ BF74AC1E1F945D8E00AFD4E4 /* GoogleMaps.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF74AC1C1F945D8E00AFD4E4 /* GoogleMaps.framework */; }; D4756EA223986CB500FE7F0D /* ReachabilityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4756EA123986CB500FE7F0D /* ReachabilityManager.swift */; }; DD3D9C211F94297100B164D4 /* Reachability.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3D9C201F94297100B164D4 /* Reachability.swift */; }; + FDE68D1E2C97E24900024A69 /* NetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D1D2C97E24900024A69 /* NetworkManager.swift */; }; + FDE68D202C97EBBE00024A69 /* APIErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D1F2C97EBBE00024A69 /* APIErrorHandler.swift */; }; + FDE68D222C97EF6200024A69 /* ApiEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D212C97EF6200024A69 /* ApiEndpoint.swift */; }; + FDE68D242C97F32B00024A69 /* NetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D232C97F32B00024A69 /* NetworkSession.swift */; }; + FDE68D262C97FC0D00024A69 /* TransitService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D252C97FC0D00024A69 /* TransitService.swift */; }; + FDE68D282C97FC4600024A69 /* TransitProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D272C97FC4600024A69 /* TransitProvider.swift */; }; + FDE68D2C2C9897E100024A69 /* APIResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D2B2C9897E100024A69 /* APIResponse.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -272,6 +279,13 @@ D4756EA123986CB500FE7F0D /* ReachabilityManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReachabilityManager.swift; sourceTree = ""; }; DD3D9C201F94297100B164D4 /* Reachability.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Reachability.swift; sourceTree = ""; }; FD69AF2A2B89212F00970C7E /* ci_post_clone.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = ci_post_clone.sh; sourceTree = ""; }; + FDE68D1D2C97E24900024A69 /* NetworkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkManager.swift; sourceTree = ""; }; + FDE68D1F2C97EBBE00024A69 /* APIErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIErrorHandler.swift; sourceTree = ""; }; + FDE68D212C97EF6200024A69 /* ApiEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiEndpoint.swift; sourceTree = ""; }; + FDE68D232C97F32B00024A69 /* NetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSession.swift; sourceTree = ""; }; + FDE68D252C97FC0D00024A69 /* TransitService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitService.swift; sourceTree = ""; }; + FDE68D272C97FC4600024A69 /* TransitProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitProvider.swift; sourceTree = ""; }; + FDE68D2B2C9897E100024A69 /* APIResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIResponse.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -305,10 +319,13 @@ 2292486621B891790004279C /* Network */ = { isa = PBXGroup; children = ( - BF250D7E222FB12300E7F271 /* Endpoints.swift */, + FDE68D212C97EF6200024A69 /* ApiEndpoint.swift */, + FDE68D1F2C97EBBE00024A69 /* APIErrorHandler.swift */, 22948BFB221B75C5003FC43F /* Models.swift */, D4756EA123986CB500FE7F0D /* ReachabilityManager.swift */, DD3D9C201F94297100B164D4 /* Reachability.swift */, + FDE68D1D2C97E24900024A69 /* NetworkManager.swift */, + FDE68D232C97F32B00024A69 /* NetworkSession.swift */, ); path = Network; sourceTree = ""; @@ -392,6 +409,7 @@ 2E9416AE2BC61731003DEB44 /* ServiceAlert.swift */, 2E9416B42BC61731003DEB44 /* WalkPath.swift */, 2E9416AF2BC61731003DEB44 /* Waypoint.swift */, + FDE68D2B2C9897E100024A69 /* APIResponse.swift */, ); path = Models; sourceTree = ""; @@ -582,7 +600,7 @@ 2E9416822BC6168C003DEB44 /* Controllers */, 2E94165E2BC60A3B003DEB44 /* Ecosystem */, 2E9416AB2BC616DE003DEB44 /* Models */, - 2292486621B891790004279C /* Network */, + FDE68D292C988CDB00024A69 /* Services */, 2E9416C72BC61763003DEB44 /* Supporting */, 2E9416E02BC618E6003DEB44 /* Utils */, 2E9416FD2BC61CAE003DEB44 /* Views */, @@ -608,6 +626,25 @@ path = ci_scripts; sourceTree = ""; }; + FDE68D292C988CDB00024A69 /* Services */ = { + isa = PBXGroup; + children = ( + BF250D7E222FB12300E7F271 /* Endpoints.swift */, + 2292486621B891790004279C /* Network */, + FDE68D2A2C98933900024A69 /* Providers */, + ); + path = Services; + sourceTree = ""; + }; + FDE68D2A2C98933900024A69 /* Providers */ = { + isa = PBXGroup; + children = ( + FDE68D272C97FC4600024A69 /* TransitProvider.swift */, + FDE68D252C97FC0D00024A69 /* TransitService.swift */, + ); + path = Providers; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -819,7 +856,9 @@ 2E9416C12BC61731003DEB44 /* WalkPath.swift in Sources */, 2E9416BC2BC61731003DEB44 /* Waypoint.swift in Sources */, 2E9417202BC61CF1003DEB44 /* WalkWithDistanceIcon.swift in Sources */, + FDE68D282C97FC4600024A69 /* TransitProvider.swift in Sources */, 2E94169B2BC616B9003DEB44 /* StopPickerViewController.swift in Sources */, + FDE68D1E2C97E24900024A69 /* NetworkManager.swift in Sources */, 2E9417162BC61CF1003DEB44 /* SearchBarView.swift in Sources */, 2E9FFA882BC673240051793C /* Amenity.graphql.swift in Sources */, 2E9FFA852BC673240051793C /* AmenityType.graphql.swift in Sources */, @@ -851,6 +890,7 @@ 2E9FFA8A2BC673240051793C /* Facility.graphql.swift in Sources */, 2E9416B92BC61731003DEB44 /* PlaceCoordinates.swift in Sources */, 2E9417142BC61CF1003DEB44 /* RouteLine.swift in Sources */, + FDE68D242C97F32B00024A69 /* NetworkSession.swift in Sources */, 2E9FFA8C2BC673240051793C /* OpenHours.graphql.swift in Sources */, 2E9417182BC61CF1003DEB44 /* RouteDiagramSegment.swift in Sources */, 2E9416C32BC61731003DEB44 /* SearchManager.swift in Sources */, @@ -858,6 +898,7 @@ D4756EA223986CB500FE7F0D /* ReachabilityManager.swift in Sources */, 2E9FFA832BC673240051793C /* OpenHoursFields.graphql.swift in Sources */, 2E9416972BC616B9003DEB44 /* RouteDetail+ContentViewController.swift in Sources */, + FDE68D262C97FC0D00024A69 /* TransitService.swift in Sources */, 2E9416F62BC61984003DEB44 /* Time.swift in Sources */, 2E9FFA8D2BC673240051793C /* Query.graphql.swift in Sources */, 2E9416792BC61679003DEB44 /* AddFavoritesCollectionViewCell.swift in Sources */, @@ -869,6 +910,7 @@ 2E9416F12BC61984003DEB44 /* Shared.swift in Sources */, 2E9FFA892BC673240051793C /* Capacity.graphql.swift in Sources */, 2E9416BB2BC61731003DEB44 /* ServiceAlert.swift in Sources */, + FDE68D222C97EF6200024A69 /* ApiEndpoint.swift in Sources */, 2EC1F5162BC66CBA001D9F66 /* Publishers.swift in Sources */, 2E94169E2BC616B9003DEB44 /* SearchResultsViewController.swift in Sources */, 2E9FFA822BC673240051793C /* GymFields.graphql.swift in Sources */, @@ -882,6 +924,7 @@ 2E9416F52BC61984003DEB44 /* Extensions+App.swift in Sources */, 2E9416782BC61679003DEB44 /* LargeDetailTableViewCell.swift in Sources */, 2E94169F2BC616B9003DEB44 /* HomeOptionsCardViewController.swift in Sources */, + FDE68D2C2C9897E100024A69 /* APIResponse.swift in Sources */, 2E94169A2BC616B9003DEB44 /* CustomNavigationController.swift in Sources */, 2E9416DE2BC618DA003DEB44 /* Constants.swift in Sources */, 2E9FFA862BC673240051793C /* CourtType.graphql.swift in Sources */, @@ -900,6 +943,7 @@ 2E9FFA8B2BC673240051793C /* Gym.graphql.swift in Sources */, 2E9FFA8E2BC673240051793C /* SchemaConfiguration.swift in Sources */, 2E9416EF2BC61984003DEB44 /* EventPayload.swift in Sources */, + FDE68D202C97EBBE00024A69 /* APIErrorHandler.swift in Sources */, 22948BFD221B75C5003FC43F /* Models.swift in Sources */, 2E94167A2BC61679003DEB44 /* GeneralTableViewCell.swift in Sources */, 2E9417222BC61CF1003DEB44 /* BusLocationView.swift in Sources */, diff --git a/TCAT/Base/AppDelegate.swift b/TCAT/Base/AppDelegate.swift index fc137710..6dbfcb9d 100755 --- a/TCAT/Base/AppDelegate.swift +++ b/TCAT/Base/AppDelegate.swift @@ -75,16 +75,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ) let rootVC = showOnboarding ? OnboardingViewController(initialViewing: true) : parentHomeViewController let navigationController = showOnboarding ? OnboardingNavigationController(rootViewController: rootVC) : - CustomNavigationController(rootViewController: rootVC) + CustomNavigationController(rootViewController: rootVC) // Setup networking for AppDevAnnouncements // TODO: Set up announcements once it's done -// AnnouncementNetworking.setupConfig( -// scheme: TransitEnvironment.announcementsScheme, -// host: TransitEnvironment.announcementsHost, -// commonPath: TransitEnvironment.announcementsCommonPath, -// announcementPath: TransitEnvironment.announcementsPath -// ) + // AnnouncementNetworking.setupConfig( + // scheme: TransitEnvironment.announcementsScheme, + // host: TransitEnvironment.announcementsHost, + // commonPath: TransitEnvironment.announcementsCommonPath, + // announcementPath: TransitEnvironment.announcementsPath + // ) // Initalize window without storyboard self.window = UIWindow(frame: UIScreen.main.bounds) @@ -103,7 +103,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { /// Creates and sets a unique identifier. If the device identifier changes, updates it. func setupUniqueIdentifier() { if let uid = UIDevice.current.identifierForVendor?.uuidString, - uid != sharedUserDefaults?.string(forKey: Constants.UserDefaults.uid) { + uid != sharedUserDefaults?.string(forKey: Constants.UserDefaults.uid) { sharedUserDefaults?.set(uid, forKey: Constants.UserDefaults.uid) } } @@ -111,9 +111,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func handleShortcut(item: UIApplicationShortcutItem) { if let shortcutData = item.userInfo as? [String: Data] { guard let place = shortcutData["place"], - let destination = try? decoder.decode(Place.self, from: place) else { - print("[AppDelegate] Unable to access shortcutData['place']") - return + let destination = try? decoder.decode(Place.self, from: place) else { + print("[AppDelegate] Unable to access shortcutData['place']") + return } let optionsVC = RouteOptionsViewController(searchTo: destination) if let navController = window?.rootViewController as? CustomNavigationController { @@ -128,7 +128,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return networking(Endpoint.getAllStops()).decode() } - /// Get all bus stops and store in userDefaults + /// Get all bus stops and store in userDefaults func getBusStops() { getAllStops().observe { [weak self] result in guard let self = self else { return } @@ -174,19 +174,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate { if url.absoluteString.contains("getRoutes") { // siri URL scheme var latitude: CLLocationDegrees? var longitude: CLLocationDegrees? - var stopName: String? + var destination: String? + + if let lat = items?.filter({ $0.name == "lat" }).first?.value, + let long = items?.filter({ $0.name == "long" }).first?.value, + let dest = items?.filter({ $0.name == "stopName" }).first?.value ?? + items?.filter({ $0.name == "destinationName" }).first?.value { - if - let lat = items?.filter({ $0.name == "lat" }).first?.value, - let long = items?.filter({ $0.name == "long" }).first?.value, - let stop = items?.filter({ $0.name == "stopName" }).first?.value { latitude = Double(lat) longitude = Double(long) - stopName = stop + destination = dest } - if let latitude = latitude, let longitude = longitude, let stopName = stopName { - let place = Place(name: stopName, type: .busStop, latitude: latitude, longitude: longitude) + + + if let latitude = latitude, let longitude = longitude, let destination = destination { + let place = Place(name: destination, type: .busStop, latitude: latitude, longitude: longitude) let optionsVC = RouteOptionsViewController(searchTo: place) navigationController.pushViewController(optionsVC, animated: false) return true diff --git a/TCAT/Controllers/InformationViewController.swift b/TCAT/Controllers/InformationViewController.swift index 47d0701a..c27bede4 100644 --- a/TCAT/Controllers/InformationViewController.swift +++ b/TCAT/Controllers/InformationViewController.swift @@ -183,7 +183,7 @@ class InformationViewController: UIViewController { guard let URL = URL(string: url) else { return } - + if inApp { let safariViewController = SFSafariViewController(url: URL) UIApplication.shared.windows.filter {$0.isKeyWindow}.first?.presentInApp(safariViewController) diff --git a/TCAT/Controllers/RouteDetail+ContentViewController.swift b/TCAT/Controllers/RouteDetail+ContentViewController.swift index 4bf4f602..954321f7 100755 --- a/TCAT/Controllers/RouteDetail+ContentViewController.swift +++ b/TCAT/Controllers/RouteDetail+ContentViewController.swift @@ -27,6 +27,7 @@ class RouteDetailContentViewController: UIViewController { var buses = [GMSMarker]() var currentLocation: CLLocationCoordinate2D? var directions: [Direction] = [] + var endDestination: Place! /// Number of seconds to wait before auto-refreshing live tracking network call call, timed with live indicator var liveTrackingNetworkRefreshRate: Double = LiveIndicator.interval * 1.0 var liveTrackingNetworkTimer: Timer? @@ -49,12 +50,17 @@ class RouteDetailContentViewController: UIViewController { /// Initalize RouteDetailViewController. Be sure to send a valid route, otherwise /// dummy data will be used. The directions parameter have logical assumptions, /// such as ArriveDirection always comes after DepartDirection. - init(route: Route, currentLocation: CLLocationCoordinate2D?, routeOptionsCell: RouteTableViewCell?) { + init(route: Route, endDestination: Place, currentLocation: CLLocationCoordinate2D?, routeOptionsCell: RouteTableViewCell?) { super.init(nibName: nil, bundle: nil) self.routeOptionsCell = routeOptionsCell + self.endDestination = endDestination initializeRoute(route, currentLocation) } - + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + override func viewDidLoad() { super.viewDidLoad() @@ -320,7 +326,7 @@ class RouteDetailContentViewController: UIViewController { // MARK: - Share Function @objc func shareRoute() { - presentShareSheet(from: view, for: route, with: routeOptionsCell?.getImage()) + presentShareSheet(from: view, for: endDestination, with: routeOptionsCell?.getImage()) } func calculatePlacement(position: CLLocationCoordinate2D, view: UIView) -> CLLocationCoordinate2D? { @@ -473,11 +479,11 @@ class RouteDetailContentViewController: UIViewController { return drawerDisplayController } - required convenience init(coder aDecoder: NSCoder) { - guard let route = aDecoder.decodeObject(forKey: "route") as? Route - else { fatalError("init(coder:) has not been implemented") } - - self.init(route: route, currentLocation: nil, routeOptionsCell: nil) - } +// required convenience init(coder aDecoder: NSCoder) { +// guard let route = aDecoder.decodeObject(forKey: "route") as? Route +// else { fatalError("init(coder:) has not been implemented") } +// +// self.init(route: route, endDestination: , currentLocation: nil, routeOptionsCell: nil) +// } } diff --git a/TCAT/Controllers/RouteOptionsViewController+Extensions.swift b/TCAT/Controllers/RouteOptionsViewController+Extensions.swift index 8acd57b7..08c66e6d 100644 --- a/TCAT/Controllers/RouteOptionsViewController+Extensions.swift +++ b/TCAT/Controllers/RouteOptionsViewController+Extensions.swift @@ -19,7 +19,7 @@ extension RouteOptionsViewController: UIViewControllerPreviewingDelegate { if let indexPath = routeResults.indexPathForRow(at: point), let cell = routeResults.cellForRow(at: indexPath) { let route = routes[indexPath.section][indexPath.row] - presentShareSheet(from: view, for: route, with: cell.getImage()) + presentShareSheet(from: view, for: searchTo, with: cell.getImage()) } } } @@ -283,7 +283,6 @@ extension RouteOptionsViewController: UITableViewDelegate { let payload = RouteResultsCellTappedEventPayload() TransitAnalytics.shared.log(payload) let routeId = routes[indexPath.section][indexPath.row].routeId - routeSelected(routeId: routeId) navigationController?.pushViewController(routeDetailViewController, animated: true) } } diff --git a/TCAT/Controllers/RouteOptionsViewController.swift b/TCAT/Controllers/RouteOptionsViewController.swift index 2ca2dfa9..c6112d99 100755 --- a/TCAT/Controllers/RouteOptionsViewController.swift +++ b/TCAT/Controllers/RouteOptionsViewController.swift @@ -507,26 +507,6 @@ class RouteOptionsViewController: UIViewController { } } - func routeSelected(routeId: String) { - networking(Endpoint.routeSelected(routeId: routeId)).observe { [weak self] result in - guard let self = self else { return } - DispatchQueue.main.async { - switch result { - case .value: - self.printClass(context: "\(#function)", message: "success") - case .error(let error): - self.printClass(context: "\(#function) error", message: error.localizedDescription) - let payload = NetworkErrorPayload( - location: "\(self) Get Route Selected", - type: "\((error as NSError).domain)", - description: error.localizedDescription - ) - TransitAnalytics.shared.log(payload) - } - } - } - } - private func getRoutesTrips() { // For each route in each route array inside of the 'routes' array, get its // tripId and stopId to create trip array for request to get all delays. @@ -691,6 +671,7 @@ class RouteOptionsViewController: UIViewController { let contentViewController = RouteDetailContentViewController( route: route, + endDestination: searchTo, currentLocation: routeDetailCurrentLocation, routeOptionsCell: routeOptionsCell ) diff --git a/TCAT/Models/APIResponse.swift b/TCAT/Models/APIResponse.swift new file mode 100644 index 00000000..dcb534d1 --- /dev/null +++ b/TCAT/Models/APIResponse.swift @@ -0,0 +1,14 @@ +// +// Response.swift +// TCAT +// +// Created by Jayson Hahn on 9/16/24. +// Copyright © 2024 Cornell AppDev. All rights reserved. +// + +import Foundation + +struct APIResponse: Decodable { + var success: Bool + var data: T +} diff --git a/TCAT/Network/Endpoints.swift b/TCAT/Services/Endpoints.swift similarity index 70% rename from TCAT/Network/Endpoints.swift rename to TCAT/Services/Endpoints.swift index 4ad5a1a8..df7fa92e 100755 --- a/TCAT/Network/Endpoints.swift +++ b/TCAT/Services/Endpoints.swift @@ -46,26 +46,6 @@ extension Endpoint { return Endpoint(path: "/api/v2"+Constants.Endpoints.getRoutes, body: body, useCommonPath: false) } - static func getMultiRoutes( - startCoord: CLLocationCoordinate2D, - time: Date, - endCoords: [String], - endPlaceNames: [String] - ) -> Endpoint { - let body = MultiRoutesBody( - start: "\(startCoord.latitude),\(startCoord.longitude)", - time: time.timeIntervalSince1970, - end: endCoords, - destinationNames: endPlaceNames - ) - return Endpoint(path: Constants.Endpoints.multiRoute, body: body) - } - - static func getPlaceIDCoordinates(placeID: String) -> Endpoint { - let body = PlaceIDCoordinatesBody(placeID: placeID) - return Endpoint(path: Constants.Endpoints.placeIDCoordinates, body: body) - } - static func getAppleSearchResults(searchText: String) -> Endpoint { let body = SearchResultsBody(query: searchText) return Endpoint(path: Constants.Endpoints.appleSearch, body: body) @@ -76,14 +56,6 @@ extension Endpoint { return Endpoint(path: Constants.Endpoints.applePlaces, body: body) } - static func routeSelected(routeId: String) -> Endpoint { - // Add unique identifier to request - let uid = sharedUserDefaults?.string(forKey: Constants.UserDefaults.uid) - - let body = RouteSelectedBody(routeId: routeId, uid: uid) - return Endpoint(path: Constants.Endpoints.routeSelected, body: body) - } - static func getBusLocations(_ directions: [Direction]) -> Endpoint { let departDirections = directions.filter { $0.type == .depart && $0.tripIdentifiers != nil } @@ -111,9 +83,4 @@ extension Endpoint { return Endpoint(path: Constants.Endpoints.delays, body: body) } - static func getDelayUrl(tripId: String, stopId: String) -> String { - let path = "delay" - return "\(String(describing: Endpoint.config.host))\(path)?stopID=\(stopId)&tripID=\(tripId)" - } - } diff --git a/TCAT/Services/Network/APIErrorHandler.swift b/TCAT/Services/Network/APIErrorHandler.swift new file mode 100644 index 00000000..50163300 --- /dev/null +++ b/TCAT/Services/Network/APIErrorHandler.swift @@ -0,0 +1,48 @@ +// +// APIErrorHandler.swift +// TCAT +// +// Created by Jayson Hahn on 9/16/24. +// Copyright © 2024 Cornell AppDev. All rights reserved. +// + +import Foundation + +struct ApiError: Codable { + let code: String? + let message: String? + let errorItems: [String: String]? +} + +enum APIErrorHandler: Error { + case customApiError(ApiError) + case requestFailed + case normalError(Error) + case emptyErrorWithStatusCode(String) + + var errorDescription: String? { + switch self { + case .customApiError(let apiError): + var errorItems: String? + if let errorItemsDict = apiError.errorItems { + errorItems = "" + errorItemsDict.forEach { key, value in + errorItems?.append(key) + errorItems?.append(" ") + errorItems?.append(value) + errorItems?.append("\n") + } + } + if errorItems == nil && apiError.code == nil && apiError.message == nil { + errorItems = "Internal error!" + } + return String(format: "%@ %@ \n %@", apiError.code ?? "", apiError.message ?? "", errorItems ?? "") + case .requestFailed: + return "request failed" + case .normalError(let error): + return error.localizedDescription + case .emptyErrorWithStatusCode(let status): + return status + } + } +} diff --git a/TCAT/Services/Network/ApiEndpoint.swift b/TCAT/Services/Network/ApiEndpoint.swift new file mode 100644 index 00000000..968625dc --- /dev/null +++ b/TCAT/Services/Network/ApiEndpoint.swift @@ -0,0 +1,78 @@ +// +// ApiEndpoint.swift +// TCAT +// +// Created by Jayson Hahn on 9/16/24. +// Copyright © 2024 Cornell AppDev. All rights reserved. +// + +import Foundation + +enum APIHTTPMethod: String { + case GET + case POST + case PUT + case DELETE + case PATCH +} + +protocol ApiEndpoint { + var baseURLString: String { get } + var apiPath: String { get } + var apiVersion: String { get } + var separatorPath: String? { get } + var path: String { get } + var headers: [String: String]? { get } + var queryParams: [URLQueryItem]? { get } + var params: [String: Any]? { get } + var method: APIHTTPMethod { get } + var customDataBody: Data? { get } +} + +extension ApiEndpoint { + var makeRequest: URLRequest { + var urlComponents = URLComponents(string: baseURLString) + var longPath = "/" + longPath.append(apiPath) + longPath.append("/") + longPath.append(apiVersion) + if let separatorPath = separatorPath { + longPath.append("/") + longPath.append(separatorPath) + } + longPath.append("/") + longPath.append(path) + urlComponents?.path = longPath + + if let queryParams = queryParams { + urlComponents?.queryItems = [URLQueryItem]() + for queryParam in queryParams { + urlComponents?.queryItems?.append(URLQueryItem(name: queryParam.name, value: queryParam.value)) + } + } + + guard let url = urlComponents?.url else { return URLRequest(url: URL(string: baseURLString)!) } + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + + if let headers = headers { + for header in headers { + request.addValue(header.value, forHTTPHeaderField: header.key) + } + } + + if let params = params { + let jsonData = try? JSONSerialization.data(withJSONObject: params) + request.httpBody = jsonData + } + + if let customDataBody = customDataBody { + request.httpBody = customDataBody + } + +// #if DEBUG +// debugLog(request.cURL(pretty: true)) +// #endif + return request + } +} diff --git a/TCAT/Network/Models.swift b/TCAT/Services/Network/Models.swift similarity index 94% rename from TCAT/Network/Models.swift rename to TCAT/Services/Network/Models.swift index c701dc6a..295a0e8d 100644 --- a/TCAT/Network/Models.swift +++ b/TCAT/Services/Network/Models.swift @@ -86,9 +86,3 @@ internal struct Delay: Codable { let tripID: String let delay: Int? } - -// Response -struct Response: Codable { - var success: Bool - var data: T -} diff --git a/TCAT/Services/Network/NetworkManager.swift b/TCAT/Services/Network/NetworkManager.swift new file mode 100644 index 00000000..70ab14c9 --- /dev/null +++ b/TCAT/Services/Network/NetworkManager.swift @@ -0,0 +1,34 @@ +// +// NetworkManager.swift +// TCAT +// +// Created by Jayson Hahn on 9/15/24. +// Copyright © 2024 Cornell AppDev. All rights reserved. +// + +import Foundation +import Combine + +class NetworkManager { + + let session: NetworkSession + + init(session: NetworkSession = URLSession.shared) { + self.session = session + } + + func performRequest( + _ request: URLRequest, + decodingType: T.Type + ) -> AnyPublisher< + T, + APIErrorHandler + > where T: Decodable { + return session.publisher(request, decodingType: decodingType) + .mapError { error -> APIErrorHandler in + return error + } + .eraseToAnyPublisher() + } + +} diff --git a/TCAT/Services/Network/NetworkSession.swift b/TCAT/Services/Network/NetworkSession.swift new file mode 100644 index 00000000..c2e463cf --- /dev/null +++ b/TCAT/Services/Network/NetworkSession.swift @@ -0,0 +1,53 @@ +// +// NetworkSession.swift +// TCAT +// +// Created by Jayson Hahn on 9/16/24. +// Copyright © 2024 Cornell AppDev. All rights reserved. +// + +import Foundation +import Combine + +protocol NetworkSession: AnyObject { + func publisher(_ request: URLRequest, decodingType: T.Type) -> AnyPublisher where T: Decodable +} + +extension URLSession: NetworkSession { + func publisher(_ request: URLRequest, decodingType: T.Type) -> AnyPublisher where T: Decodable { + + return dataTaskPublisher(for: request) + .tryMap({ result in + guard let httpResponse = result.response as? HTTPURLResponse else { + throw APIErrorHandler.requestFailed + } + + if (200..<300) ~= httpResponse.statusCode { + return result.data + } else { + if let error = try? JSONDecoder().decode(ApiError.self, from: result.data) { + throw APIErrorHandler.customApiError(error) + } else { + throw APIErrorHandler.emptyErrorWithStatusCode(httpResponse.statusCode.description) + } + } + }) + .decode(type: APIResponse.self, decoder: JSONDecoder()) + .tryMap { response in + // FIXME: Fix backend error handler + if !response.success { + throw APIErrorHandler.customApiError(ApiError(code: "500", message: "Internal server error", errorItems: nil)) + } + return response.data + } + .mapError({ error -> APIErrorHandler in + if let error = error as? APIErrorHandler { + return error + } + return APIErrorHandler.normalError(error) + }) + .eraseToAnyPublisher() + } +} + + diff --git a/TCAT/Network/Reachability.swift b/TCAT/Services/Network/Reachability.swift similarity index 100% rename from TCAT/Network/Reachability.swift rename to TCAT/Services/Network/Reachability.swift diff --git a/TCAT/Network/ReachabilityManager.swift b/TCAT/Services/Network/ReachabilityManager.swift similarity index 100% rename from TCAT/Network/ReachabilityManager.swift rename to TCAT/Services/Network/ReachabilityManager.swift diff --git a/TCAT/Services/Providers/TransitProvider.swift b/TCAT/Services/Providers/TransitProvider.swift new file mode 100644 index 00000000..a027c9b4 --- /dev/null +++ b/TCAT/Services/Providers/TransitProvider.swift @@ -0,0 +1,120 @@ +// +// Providers.swift +// TCAT +// +// Created by Jayson Hahn on 9/16/24. +// Copyright © 2024 Cornell AppDev. All rights reserved. +// + +import Foundation + +enum TransitProvider { + case alerts + case allDelays(TripBody) + case allStops + case applePlaces(ApplePlacesBody) + case appleSearch(SearchResultsBody) + case busLocations(GetBusLocationsBody) + case delay(GetDelayBody) + case routes(GetRoutesBody) +} + +extension TransitProvider: ApiEndpoint { + + var baseURLString: String { + return TransitEnvironment.transitURL + } + + var apiPath: String { + return "api" + } + + var apiVersion: String { + switch self { + case .routes: + return "v2" + default: + return "v3" + } + } + + var separatorPath: String? { + switch self { + default: + return "" + } + } + + var path: String { + switch self { + case .alerts: + return Constants.Endpoints.alerts + case .allDelays: + return Constants.Endpoints.delays + case .allStops: + return Constants.Endpoints.allStops + case .applePlaces: + return Constants.Endpoints.applePlaces + case .appleSearch: + return Constants.Endpoints.appleSearch + case .busLocations: + return Constants.Endpoints.busLocations + case .delay: + return Constants.Endpoints.delay + case .routes: + return Constants.Endpoints.getRoutes + } + } + + var headers: [String: String]? { + switch self { + default: + return ["Content-Type": "application/json"] + } + } + + var queryParams: [URLQueryItem]? { + switch self { + case .delay(let getDelayBody): + return getDelayBody.toQueryItems() + default: + return nil + } + } + + var params: [String: Any]? { + switch self { + default: + return nil + } + } + + var method: APIHTTPMethod { + switch self { + case .alerts, .allStops: + return .GET + default: + return .PUT + } + } + + var customDataBody: Data? { + switch self { + case .allDelays(let tripBody): + return try? JSONEncoder().encode(tripBody) + case .applePlaces(let applePlacesBody): + return try? JSONEncoder().encode(applePlacesBody) + case .appleSearch(let searchResultsBody): + return try? JSONEncoder().encode(searchResultsBody) + case .busLocations(let getBusLocationsBody): + return try? JSONEncoder().encode(getBusLocationsBody) + case .delay(let getDelayBody): + return try? JSONEncoder().encode(getDelayBody) + case .routes(let getRoutesBody): + return try? JSONEncoder().encode(getRoutesBody) + default: + return nil + } + } + +} diff --git a/TCAT/Services/Providers/TransitService.swift b/TCAT/Services/Providers/TransitService.swift new file mode 100644 index 00000000..9e160178 --- /dev/null +++ b/TCAT/Services/Providers/TransitService.swift @@ -0,0 +1,97 @@ +// +// Services.swift +// TCAT +// +// Created by Jayson Hahn on 9/16/24. +// Copyright © 2024 Cornell AppDev. All rights reserved. +// + +import Foundation +import Combine + +protocol TransitServiceProtocol: AnyObject { + func getAllStops() -> AnyPublisher<[Place], APIErrorHandler> + func getAlerts() -> AnyPublisher<[ServiceAlert], APIErrorHandler> + func getRoutes(start: Place, end: Place, time: Date, type: SearchType) -> AnyPublisher + func getAppleSearchResults(searchText: String) -> AnyPublisher + func updateApplePlacesCache(searchText: String, places: [Place]) -> AnyPublisher + func getBusLocations(_ directions: [Direction]) -> AnyPublisher + func getDelay(tripID: String, stopID: String) -> AnyPublisher + func getAllDelays(trips: [Trip]) -> AnyPublisher +} + +class TransitService: TransitServiceProtocol { + + private let networkManager: NetworkManager + + init(networkManager: NetworkManager) { + self.networkManager = networkManager + } + + func getAllStops() -> AnyPublisher<[Place], APIErrorHandler> { + let request = TransitProvider.allStops.makeRequest + return networkManager.performRequest(request, decodingType: [Place].self) + } + + func getAlerts() -> AnyPublisher<[ServiceAlert], APIErrorHandler> { + let request = TransitProvider.alerts.makeRequest + return networkManager.performRequest(request, decodingType: [ServiceAlert].self) + } + + func getRoutes(start: Place, end: Place, time: Date, type: SearchType) -> AnyPublisher { + let uid = sharedUserDefaults?.string(forKey: Constants.UserDefaults.uid) + let body = GetRoutesBody( + arriveBy: type == .arriveBy, + end: "\(end.latitude),\(end.longitude)", + start: "\(start.latitude),\(start.longitude)", + time: time.timeIntervalSince1970, + destinationName: end.name, + originName: start.name, + uid: uid + ) + let request = TransitProvider.routes(body).makeRequest + return networkManager.performRequest(request, decodingType: RouteSectionsObject.self) + } + + func getAppleSearchResults(searchText: String) -> AnyPublisher { + let request = TransitProvider.appleSearch(searchText).makeRequest + return networkManager.performRequest(request, decodingType: AppleSearchResponse.self) + } + + func updateApplePlacesCache(searchText: String, places: [Place]) -> AnyPublisher { + let body = ApplePlacesBody(query: searchText, places: places) + let request = TransitProvider.applePlaces(body).makeRequest + return networkManager.performRequest(request, decodingType: Bool.self) + } + + func getBusLocations(_ directions: [Direction]) -> AnyPublisher { + let departDirections = directions.filter { $0.type == .depart && $0.tripIdentifiers != nil } + + let locationsInfo = departDirections.map { direction -> BusLocationsInfo in + // The id of the location, or bus stop, the bus needs to get to + let stopID = direction.stops.first?.id ?? "-1" + return BusLocationsInfo( + stopID: stopID, + routeID: String(direction.routeNumber), + tripIdentifiers: direction.tripIdentifiers! + ) + } + + let body = GetBusLocationsBody(data: locationsInfo) + let request = TransitProvider.applePlaces(body).makeRequest + return networkManager.performRequest(request, decodingType: BusLocation.self) + } + + func getDelay(tripID: String, stopID: String) -> AnyPublisher { + let body = GetDelayBody(stopID: stopID, tripID: tripID) + let request = TransitProvider.delay(body).makeRequest + return networkManager.performRequest(request, decodingType: Int?.self) + } + + func getAllDelays(trips: [Trip]) -> AnyPublisher { + let body = TripBody(data: trips) + let request = TransitProvider.delay(body).makeRequest + return networkManager.performRequest(request, decodingType: Delay.self) + } + +} diff --git a/TCAT/Supporting/Constants.swift b/TCAT/Supporting/Constants.swift index f186c2fa..56033122 100644 --- a/TCAT/Supporting/Constants.swift +++ b/TCAT/Supporting/Constants.swift @@ -169,9 +169,6 @@ struct Constants { static let delay = "/delay" static let delays = "/delays" static let getRoutes = "/route" - static let multiRoute = "/multiroute" - static let placeIDCoordinates = "/placeIDCoordinates" - static let routeSelected = "/routeSelected" } struct Footers { diff --git a/TCAT/Utils/Extensions+App.swift b/TCAT/Utils/Extensions+App.swift index 76543ba4..0780c3eb 100755 --- a/TCAT/Utils/Extensions+App.swift +++ b/TCAT/Utils/Extensions+App.swift @@ -230,17 +230,22 @@ extension Array where Element: Comparable { } /// Present a share sheet for a route in any context. -func presentShareSheet(from view: UIView, for route: Route, with image: UIImage? = nil) { - - let shareText = route.summaryDescription - let promotionalText = "Download Ithaca Transit on the App Store! \(Constants.App.appStoreLink)" +func presentShareSheet( + from view: UIView, + for destination: Place, + with image: UIImage? = nil +) { + + let lat: Double = destination.latitude + let long: Double = destination.longitude + let thirdParamName: String = ( + destination.type == .busStop + ) ? "stopName" : "destinationName" + let destination = destination.name + + let promotionalText = "ithaca-transit://getRoutes?lat=\(lat)&long=\(long)&\(thirdParamName)=\(destination)" var activityItems: [Any] = [promotionalText] - if let shareImage = image { - activityItems.insert(shareImage, at: 0) - } else { - activityItems.insert(shareText, at: 0) - } let activityVC = UIActivityViewController(activityItems: activityItems, applicationActivities: nil) activityVC.excludedActivityTypes = [.print, .assignToContact, .openInIBooks, .addToReadingList] From f77140270aa3ef0127bb4523a5ff13c0aab26342 Mon Sep 17 00:00:00 2001 From: Jayson Hahn <46629787+JaysonHahn@users.noreply.github.com> Date: Wed, 23 Oct 2024 22:34:32 -0400 Subject: [PATCH 02/13] Finish network refactor --- Podfile | 1 - Podfile.lock | 14 +- TCAT.xcodeproj/project.pbxproj | 48 +-- TCAT/Base/AppDelegate.swift | 98 ++--- TCAT/Cells/RouteTableViewCell.swift | 2 - .../CustomNavigationController.swift | 28 +- .../FavoritesTableViewController.swift | 51 ++- TCAT/Controllers/HomeMapViewController.swift | 1 - ...OptionsCardViewController+Extensions.swift | 14 +- .../HomeOptionsCardViewController.swift | 60 ++-- .../RouteDetail+ContentViewController.swift | 51 ++- .../RouteDetail+DrawerViewController.swift | 107 ++---- .../RouteOptionsViewController.swift | 169 ++++----- .../SearchResultsViewController.swift | 45 ++- .../ServiceAlertsViewController.swift | 36 +- .../StopPickerViewController.swift | 76 ++-- TCAT/Models/APIResponse.swift | 14 - TCAT/Models/SearchManager.swift | 212 +++++------ TCAT/Services/Endpoints.swift | 86 ----- TCAT/Services/Network/APIErrorHandler.swift | 48 --- TCAT/Services/Network/ApiEndpoint.swift | 15 +- TCAT/Services/Network/ApiErrorHandler.swift | 54 +++ TCAT/Services/Network/NetworkManager.swift | 71 +++- TCAT/Services/Network/NetworkMonitor.swift | 65 ++++ TCAT/Services/Network/NetworkSession.swift | 53 --- TCAT/Services/Network/Reachability.swift | 334 ------------------ .../Network/ReachabilityManager.swift | 57 --- .../{Models.swift => RequestModels.swift} | 5 + TCAT/Services/Providers/TransitService.swift | 97 ----- .../TransitProvider.swift | 2 +- TCAT/Services/Transit/TransitService.swift | 157 ++++++++ TCAT/Utils/Extensions+App.swift | 9 +- 32 files changed, 769 insertions(+), 1311 deletions(-) delete mode 100644 TCAT/Models/APIResponse.swift delete mode 100755 TCAT/Services/Endpoints.swift delete mode 100644 TCAT/Services/Network/APIErrorHandler.swift create mode 100644 TCAT/Services/Network/ApiErrorHandler.swift create mode 100644 TCAT/Services/Network/NetworkMonitor.swift delete mode 100644 TCAT/Services/Network/NetworkSession.swift delete mode 100755 TCAT/Services/Network/Reachability.swift delete mode 100644 TCAT/Services/Network/ReachabilityManager.swift rename TCAT/Services/Network/{Models.swift => RequestModels.swift} (95%) delete mode 100644 TCAT/Services/Providers/TransitService.swift rename TCAT/Services/{Providers => Transit}/TransitProvider.swift (99%) create mode 100644 TCAT/Services/Transit/TransitService.swift diff --git a/Podfile b/Podfile index 2efefa72..27576c35 100644 --- a/Podfile +++ b/Podfile @@ -15,7 +15,6 @@ target 'TCAT' do # Networking + Data pod 'Apollo', '~> 1.9.3' pod 'SwiftyJSON', '~> 5.0' - pod 'FutureNova', :git => 'https://github.com/cuappdev/ios-networking.git' pod 'Wormholy', :configurations => ['Debug'] # Analytics diff --git a/Podfile.lock b/Podfile.lock index 00d7c526..5899a464 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -59,7 +59,6 @@ PODS: - GoogleUtilities/Environment (~> 7.10) - nanopb (< 2.30911.0, >= 2.30908.0) - PromisesSwift (~> 2.1) - - FutureNova (0.1.6) - GoogleAppMeasurement (10.24.0): - GoogleAppMeasurement/AdIdSupport (= 10.24.0) - GoogleUtilities/AppDelegateSwizzler (~> 7.11) @@ -134,7 +133,6 @@ PODS: - SnapKit (5.0.1) - SwiftLint (0.54.0) - SwiftyJSON (5.0.1) - - WhatsNewKit (1.3.7) - Wormholy (1.7.0) - Zip (1.1.0) @@ -143,7 +141,6 @@ DEPENDENCIES: - DZNEmptyDataSet (from `https://github.com/cuappdev/DZNEmptyDataSet.git`) - Firebase - FirebaseCrashlytics - - FutureNova (from `https://github.com/cuappdev/ios-networking.git`) - GoogleMaps - NotificationBannerSwift (~> 3.0.0) - Presentation (from `https://github.com/cuappdev/Presentation.git`) @@ -151,7 +148,6 @@ DEPENDENCIES: - SnapKit (~> 5.0) - SwiftLint - SwiftyJSON (~> 5.0) - - WhatsNewKit (~> 1.1) - Wormholy - Zip (~> 1.1) @@ -180,15 +176,12 @@ SPEC REPOS: - SnapKit - SwiftLint - SwiftyJSON - - WhatsNewKit - Wormholy - Zip EXTERNAL SOURCES: DZNEmptyDataSet: :git: https://github.com/cuappdev/DZNEmptyDataSet.git - FutureNova: - :git: https://github.com/cuappdev/ios-networking.git Presentation: :git: https://github.com/cuappdev/Presentation.git @@ -196,9 +189,6 @@ CHECKOUT OPTIONS: DZNEmptyDataSet: :commit: a4a007e7ade7d9711f067f4d6510085fa1d92629 :git: https://github.com/cuappdev/DZNEmptyDataSet.git - FutureNova: - :commit: db0540d78bd5bfb67f39945bbaf0fd3f2fbf56b5 - :git: https://github.com/cuappdev/ios-networking.git Presentation: :commit: b53eb453d2e1520e724cfac5e3e444e730ffe985 :git: https://github.com/cuappdev/Presentation.git @@ -215,7 +205,6 @@ SPEC CHECKSUMS: FirebaseInstallations: 8f581fca6478a50705d2bd2abd66d306e0f5736e FirebaseRemoteConfigInterop: 6c349a466490aeace3ce9c091c86be1730711634 FirebaseSessions: 2651b464e241c93fd44112f995d5ab663c970487 - FutureNova: 95f9aa352b2c250253b96fdf380754afcc87c7f3 GoogleAppMeasurement: f3abf08495ef2cba7829f15318c373b8d9226491 GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a GoogleMaps: 8939898920281c649150e0af74aa291c60f2e77d @@ -230,10 +219,9 @@ SPEC CHECKSUMS: SnapKit: 97b92857e3df3a0c71833cce143274bf6ef8e5eb SwiftLint: c1de071d9d08c8aba837545f6254315bc900e211 SwiftyJSON: 2f33a42c6fbc52764d96f13368585094bfd8aa5e - WhatsNewKit: c87028c4059dccd113495422801914cc53f6aab0 Wormholy: ab1c8c2f02f58587a0941deb0088555ffbf039a1 Zip: 8877eede3dda76bcac281225c20e71c25270774c -PODFILE CHECKSUM: a3b80dd04ea30998a17c032f2730e21ee8517238 +PODFILE CHECKSUM: 12c25e103a1201d074276cbcc1e62972bb0cb57f COCOAPODS: 1.15.0 diff --git a/TCAT.xcodeproj/project.pbxproj b/TCAT.xcodeproj/project.pbxproj index 5aa17d9a..a365fadb 100644 --- a/TCAT.xcodeproj/project.pbxproj +++ b/TCAT.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - 22948BFD221B75C5003FC43F /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22948BFB221B75C5003FC43F /* Models.swift */; }; + 22948BFD221B75C5003FC43F /* RequestModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22948BFB221B75C5003FC43F /* RequestModels.swift */; }; 28EA3E17A0C473892F5506EC /* Pods_TCAT.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 542B073726DFD1EE044EA97F /* Pods_TCAT.framework */; }; 2E70434E2BB75E10003AC1D6 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 2E70434D2BB75E10003AC1D6 /* PrivacyInfo.xcprivacy */; }; 2E9416602BC60A59003DEB44 /* UpliftQueries.graphql in Resources */ = {isa = PBXBuildFile; fileRef = 2E94165F2BC60A59003DEB44 /* UpliftQueries.graphql */; }; @@ -121,19 +121,15 @@ 2EC1F5142BC66A19001D9F66 /* ApolloNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC1F5132BC66A19001D9F66 /* ApolloNetwork.swift */; }; 2EC1F5162BC66CBA001D9F66 /* Publishers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC1F5152BC66CBA001D9F66 /* Publishers.swift */; }; 449A7C801D80D0E80019300C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 449A7C7F1D80D0E80019300C /* Assets.xcassets */; }; - BF250D7F222FB12400E7F271 /* Endpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF250D7E222FB12300E7F271 /* Endpoints.swift */; }; BF74AC1A1F945D7D00AFD4E4 /* GoogleMapsBase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF74AC191F945D7D00AFD4E4 /* GoogleMapsBase.framework */; }; BF74AC1D1F945D8E00AFD4E4 /* GoogleMapsCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF74AC1B1F945D8E00AFD4E4 /* GoogleMapsCore.framework */; }; BF74AC1E1F945D8E00AFD4E4 /* GoogleMaps.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF74AC1C1F945D8E00AFD4E4 /* GoogleMaps.framework */; }; - D4756EA223986CB500FE7F0D /* ReachabilityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4756EA123986CB500FE7F0D /* ReachabilityManager.swift */; }; - DD3D9C211F94297100B164D4 /* Reachability.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3D9C201F94297100B164D4 /* Reachability.swift */; }; + FDA3439F2CB6DF5800608A1A /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA3439E2CB6DF4D00608A1A /* NetworkMonitor.swift */; }; FDE68D1E2C97E24900024A69 /* NetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D1D2C97E24900024A69 /* NetworkManager.swift */; }; - FDE68D202C97EBBE00024A69 /* APIErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D1F2C97EBBE00024A69 /* APIErrorHandler.swift */; }; + FDE68D202C97EBBE00024A69 /* ApiErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D1F2C97EBBE00024A69 /* ApiErrorHandler.swift */; }; FDE68D222C97EF6200024A69 /* ApiEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D212C97EF6200024A69 /* ApiEndpoint.swift */; }; - FDE68D242C97F32B00024A69 /* NetworkSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D232C97F32B00024A69 /* NetworkSession.swift */; }; FDE68D262C97FC0D00024A69 /* TransitService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D252C97FC0D00024A69 /* TransitService.swift */; }; FDE68D282C97FC4600024A69 /* TransitProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D272C97FC4600024A69 /* TransitProvider.swift */; }; - FDE68D2C2C9897E100024A69 /* APIResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D2B2C9897E100024A69 /* APIResponse.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -150,7 +146,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 22948BFB221B75C5003FC43F /* Models.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + 22948BFB221B75C5003FC43F /* RequestModels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RequestModels.swift; sourceTree = ""; }; 2E70434D2BB75E10003AC1D6 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 2E94165F2BC60A59003DEB44 /* UpliftQueries.graphql */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = UpliftQueries.graphql; sourceTree = ""; }; 2E9416672BC615DF003DEB44 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -272,20 +268,16 @@ 7C562FAA4261465E07ACE741 /* Pods-TCAT.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TCAT.debug.xcconfig"; path = "Target Support Files/Pods-TCAT/Pods-TCAT.debug.xcconfig"; sourceTree = ""; }; 7E14AEC02177E846006A344D /* IntentsUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IntentsUI.framework; path = System/Library/Frameworks/IntentsUI.framework; sourceTree = SDKROOT; }; 7EEF189C21B39C6200343FFD /* NotificationCenter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NotificationCenter.framework; path = System/Library/Frameworks/NotificationCenter.framework; sourceTree = SDKROOT; }; - BF250D7E222FB12300E7F271 /* Endpoints.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Endpoints.swift; sourceTree = ""; }; BF74AC191F945D7D00AFD4E4 /* GoogleMapsBase.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GoogleMapsBase.framework; path = Pods/GoogleMaps/Base/Frameworks/GoogleMapsBase.framework; sourceTree = ""; }; BF74AC1B1F945D8E00AFD4E4 /* GoogleMapsCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GoogleMapsCore.framework; path = Pods/GoogleMaps/Maps/Frameworks/GoogleMapsCore.framework; sourceTree = ""; }; BF74AC1C1F945D8E00AFD4E4 /* GoogleMaps.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GoogleMaps.framework; path = Pods/GoogleMaps/Maps/Frameworks/GoogleMaps.framework; sourceTree = ""; }; - D4756EA123986CB500FE7F0D /* ReachabilityManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReachabilityManager.swift; sourceTree = ""; }; - DD3D9C201F94297100B164D4 /* Reachability.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Reachability.swift; sourceTree = ""; }; FD69AF2A2B89212F00970C7E /* ci_post_clone.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = ci_post_clone.sh; sourceTree = ""; }; + FDA3439E2CB6DF4D00608A1A /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; FDE68D1D2C97E24900024A69 /* NetworkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkManager.swift; sourceTree = ""; }; - FDE68D1F2C97EBBE00024A69 /* APIErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIErrorHandler.swift; sourceTree = ""; }; + FDE68D1F2C97EBBE00024A69 /* ApiErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiErrorHandler.swift; sourceTree = ""; }; FDE68D212C97EF6200024A69 /* ApiEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiEndpoint.swift; sourceTree = ""; }; - FDE68D232C97F32B00024A69 /* NetworkSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkSession.swift; sourceTree = ""; }; FDE68D252C97FC0D00024A69 /* TransitService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitService.swift; sourceTree = ""; }; FDE68D272C97FC4600024A69 /* TransitProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitProvider.swift; sourceTree = ""; }; - FDE68D2B2C9897E100024A69 /* APIResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIResponse.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -320,12 +312,10 @@ isa = PBXGroup; children = ( FDE68D212C97EF6200024A69 /* ApiEndpoint.swift */, - FDE68D1F2C97EBBE00024A69 /* APIErrorHandler.swift */, - 22948BFB221B75C5003FC43F /* Models.swift */, - D4756EA123986CB500FE7F0D /* ReachabilityManager.swift */, - DD3D9C201F94297100B164D4 /* Reachability.swift */, + FDE68D1F2C97EBBE00024A69 /* ApiErrorHandler.swift */, FDE68D1D2C97E24900024A69 /* NetworkManager.swift */, - FDE68D232C97F32B00024A69 /* NetworkSession.swift */, + FDA3439E2CB6DF4D00608A1A /* NetworkMonitor.swift */, + 22948BFB221B75C5003FC43F /* RequestModels.swift */, ); path = Network; sourceTree = ""; @@ -386,8 +376,8 @@ 2E9416832BC616B9003DEB44 /* RouteDetailViewController.swift */, 2E94168E2BC616B9003DEB44 /* RouteOptionsViewController.swift */, 2E9416892BC616B9003DEB44 /* RouteOptionsViewController+Extensions.swift */, - 2E94168B2BC616B9003DEB44 /* SearchResultsViewController.swift */, 2E94168F2BC616B9003DEB44 /* ServiceAlertsViewController.swift */, + 2E94168B2BC616B9003DEB44 /* SearchResultsViewController.swift */, 2E9416882BC616B9003DEB44 /* StopPickerViewController.swift */, ); path = Controllers; @@ -409,7 +399,6 @@ 2E9416AE2BC61731003DEB44 /* ServiceAlert.swift */, 2E9416B42BC61731003DEB44 /* WalkPath.swift */, 2E9416AF2BC61731003DEB44 /* Waypoint.swift */, - FDE68D2B2C9897E100024A69 /* APIResponse.swift */, ); path = Models; sourceTree = ""; @@ -629,20 +618,19 @@ FDE68D292C988CDB00024A69 /* Services */ = { isa = PBXGroup; children = ( - BF250D7E222FB12300E7F271 /* Endpoints.swift */, 2292486621B891790004279C /* Network */, - FDE68D2A2C98933900024A69 /* Providers */, + FDE68D2A2C98933900024A69 /* Transit */, ); path = Services; sourceTree = ""; }; - FDE68D2A2C98933900024A69 /* Providers */ = { + FDE68D2A2C98933900024A69 /* Transit */ = { isa = PBXGroup; children = ( FDE68D272C97FC4600024A69 /* TransitProvider.swift */, FDE68D252C97FC0D00024A69 /* TransitService.swift */, ); - path = Providers; + path = Transit; sourceTree = ""; }; /* End PBXGroup section */ @@ -884,18 +872,14 @@ 2E9416802BC61679003DEB44 /* RouteTableViewCell.swift in Sources */, 2E94171B2BC61CF1003DEB44 /* LiveIndicator.swift in Sources */, 2E94167C2BC61679003DEB44 /* SmallDetailTableViewCell.swift in Sources */, - DD3D9C211F94297100B164D4 /* Reachability.swift in Sources */, - BF250D7F222FB12400E7F271 /* Endpoints.swift in Sources */, 2E9416A32BC616B9003DEB44 /* HomeMapViewController.swift in Sources */, 2E9FFA8A2BC673240051793C /* Facility.graphql.swift in Sources */, 2E9416B92BC61731003DEB44 /* PlaceCoordinates.swift in Sources */, 2E9417142BC61CF1003DEB44 /* RouteLine.swift in Sources */, - FDE68D242C97F32B00024A69 /* NetworkSession.swift in Sources */, 2E9FFA8C2BC673240051793C /* OpenHours.graphql.swift in Sources */, 2E9417182BC61CF1003DEB44 /* RouteDiagramSegment.swift in Sources */, 2E9416C32BC61731003DEB44 /* SearchManager.swift in Sources */, 2E9417212BC61CF1003DEB44 /* NotificationBannerView.swift in Sources */, - D4756EA223986CB500FE7F0D /* ReachabilityManager.swift in Sources */, 2E9FFA832BC673240051793C /* OpenHoursFields.graphql.swift in Sources */, 2E9416972BC616B9003DEB44 /* RouteDetail+ContentViewController.swift in Sources */, FDE68D262C97FC0D00024A69 /* TransitService.swift in Sources */, @@ -904,6 +888,7 @@ 2E9416792BC61679003DEB44 /* AddFavoritesCollectionViewCell.swift in Sources */, 2E9FFA812BC673240051793C /* FacilityFields.graphql.swift in Sources */, 2E94171F2BC61CF1003DEB44 /* BusIcon.swift in Sources */, + FDA3439F2CB6DF5800608A1A /* NetworkMonitor.swift in Sources */, 2E9417242BC61CF1003DEB44 /* DetailIconView.swift in Sources */, 2E94171A2BC61CF1003DEB44 /* SummaryView.swift in Sources */, 2E9416992BC616B9003DEB44 /* RouteDetailContentViewController+Extensions.swift in Sources */, @@ -924,7 +909,6 @@ 2E9416F52BC61984003DEB44 /* Extensions+App.swift in Sources */, 2E9416782BC61679003DEB44 /* LargeDetailTableViewCell.swift in Sources */, 2E94169F2BC616B9003DEB44 /* HomeOptionsCardViewController.swift in Sources */, - FDE68D2C2C9897E100024A69 /* APIResponse.swift in Sources */, 2E94169A2BC616B9003DEB44 /* CustomNavigationController.swift in Sources */, 2E9416DE2BC618DA003DEB44 /* Constants.swift in Sources */, 2E9FFA862BC673240051793C /* CourtType.graphql.swift in Sources */, @@ -943,8 +927,8 @@ 2E9FFA8B2BC673240051793C /* Gym.graphql.swift in Sources */, 2E9FFA8E2BC673240051793C /* SchemaConfiguration.swift in Sources */, 2E9416EF2BC61984003DEB44 /* EventPayload.swift in Sources */, - FDE68D202C97EBBE00024A69 /* APIErrorHandler.swift in Sources */, - 22948BFD221B75C5003FC43F /* Models.swift in Sources */, + FDE68D202C97EBBE00024A69 /* ApiErrorHandler.swift in Sources */, + 22948BFD221B75C5003FC43F /* RequestModels.swift in Sources */, 2E94167A2BC61679003DEB44 /* GeneralTableViewCell.swift in Sources */, 2E9417222BC61CF1003DEB44 /* BusLocationView.swift in Sources */, 2E9416C42BC61731003DEB44 /* Section.swift in Sources */, diff --git a/TCAT/Base/AppDelegate.swift b/TCAT/Base/AppDelegate.swift index 6dbfcb9d..7b0b8726 100755 --- a/TCAT/Base/AppDelegate.swift +++ b/TCAT/Base/AppDelegate.swift @@ -6,8 +6,8 @@ // Copyright © 2016 cuappdev. All rights reserved. // +import Combine import Firebase -import FutureNova import GoogleMaps import Intents import SafariServices @@ -22,18 +22,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? private let encoder = JSONEncoder() + private let transitService: TransitServiceProtocol = TransitService.shared + private let userDataInits: [(key: String, defaultValue: Any)] = [ (key: Constants.UserDefaults.onboardingShown, defaultValue: false), (key: Constants.UserDefaults.recentSearch, defaultValue: [Any]()), (key: Constants.UserDefaults.favorites, defaultValue: [Any]()) ] - private let networking: Networking = URLSession.shared.request func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Set up networking - Endpoint.setupEndpointConfig() - // Set Up Google Services FirebaseApp.configure() @@ -47,17 +45,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { TransitAnalytics.shared.log(payload) setupUniqueIdentifier() - for (key, defaultValue) in userDataInits { - if userDefaults.value(forKey: key) == nil { - if key == Constants.UserDefaults.favorites && sharedUserDefaults?.value(forKey: key) == nil { - sharedUserDefaults?.set(defaultValue, forKey: key) - } else { - userDefaults.set(defaultValue, forKey: key) - } - } else if key == Constants.UserDefaults.favorites && sharedUserDefaults?.value(forKey: key) == nil { - sharedUserDefaults?.set(userDefaults.value(forKey: key), forKey: key) - } - } + // Initialize UserDefaults values if needed + initializeUserDefaults() // Track number of app opens for Store Review prompt StoreReviewHelper.incrementAppOpenedCount() @@ -65,9 +54,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Debug - Always Show Onboarding // userDefaults.set(false, forKey: Constants.UserDefaults.onboardingShown) - getBusStops() - - // Initalize first view based on context + // Initialize first view based on context let showOnboarding = !userDefaults.bool(forKey: Constants.UserDefaults.onboardingShown) let parentHomeViewController = ParentHomeMapViewController( contentViewController: HomeMapViewController(), @@ -100,18 +87,33 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // MARK: - Helper Functions + /// Initializes the UserDefaults values if not present + private func initializeUserDefaults() { + for (key, defaultValue) in userDataInits { + if userDefaults.value(forKey: key) == nil { + if key == Constants.UserDefaults.favorites && sharedUserDefaults?.value(forKey: key) == nil { + sharedUserDefaults?.set(defaultValue, forKey: key) + } else { + userDefaults.set(defaultValue, forKey: key) + } + } else if key == Constants.UserDefaults.favorites && sharedUserDefaults?.value(forKey: key) == nil { + sharedUserDefaults?.set(userDefaults.value(forKey: key), forKey: key) + } + } + } + /// Creates and sets a unique identifier. If the device identifier changes, updates it. - func setupUniqueIdentifier() { + private func setupUniqueIdentifier() { if let uid = UIDevice.current.identifierForVendor?.uuidString, uid != sharedUserDefaults?.string(forKey: Constants.UserDefaults.uid) { sharedUserDefaults?.set(uid, forKey: Constants.UserDefaults.uid) } } - func handleShortcut(item: UIApplicationShortcutItem) { + private func handleShortcut(item: UIApplicationShortcutItem) { if let shortcutData = item.userInfo as? [String: Data] { guard let place = shortcutData["place"], - let destination = try? decoder.decode(Place.self, from: place) else { + let destination = try? JSONDecoder().decode(Place.self, from: place) else { print("[AppDelegate] Unable to access shortcutData['place']") return } @@ -124,44 +126,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - private func getAllStops() -> Future> { - return networking(Endpoint.getAllStops()).decode() - } - - /// Get all bus stops and store in userDefaults - func getBusStops() { - getAllStops().observe { [weak self] result in - guard let self = self else { return } - DispatchQueue.main.async { - switch result { - case .value(let response): - if response.data.isEmpty { self.handleGetAllStopsError() } else { - let encodedObject = try? JSONEncoder().encode(response.data) - userDefaults.set(encodedObject, forKey: Constants.UserDefaults.allBusStops) - } - case .error(let error): - print("getBusStops error:", error.localizedDescription) - self.handleGetAllStopsError() - } - } - } - } - - /// Present an alert indicating bus stops weren't fetched. - func handleGetAllStopsError() { - let title = "Couldn't Fetch Bus Stops" - let message = "The app will continue trying on launch. You can continue to use the app as normal." - let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) - alertController.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil)) - UIApplication.shared.keyWindow?.presentInApp(alertController) - } - /// Open the app when opened via URL scheme func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { // URLs for testing // BusStop: ithaca-transit://getRoutes?lat=42.442558&long=-76.485336&stopName=Collegetown - // PlaceResult: ithaca-transit://getRoutes?lat=42.44707979999999&long=-76.4885196&destinationName=Hans%20Bethe%20House + // PlaceResult: ithaca-transit://getRoutes?lat=42.4440892&long=-76.4847823&destinationName=Hollister%Hall&destinationType=applePlace let rootVC = HomeMapViewController() let navigationController = CustomNavigationController(rootViewController: rootVC) @@ -170,8 +140,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { self.window?.makeKeyAndVisible() let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems + var placeType: PlaceType = .busStop - if url.absoluteString.contains("getRoutes") { // siri URL scheme + if url.absoluteString.contains("getRoutes") { var latitude: CLLocationDegrees? var longitude: CLLocationDegrees? var destination: String? @@ -179,17 +150,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate { if let lat = items?.filter({ $0.name == "lat" }).first?.value, let long = items?.filter({ $0.name == "long" }).first?.value, let dest = items?.filter({ $0.name == "stopName" }).first?.value ?? - items?.filter({ $0.name == "destinationName" }).first?.value { + items?.filter({ $0.name == "destinationName" }).first?.value, + let destType = items?.filter({ $0.name == "destinationType" }).first?.value { latitude = Double(lat) longitude = Double(long) - destination = dest - } - + destination = dest.split(separator: "%").joined(separator: " ") + if destType == "applePlace" { + placeType = .applePlace + } + } if let latitude = latitude, let longitude = longitude, let destination = destination { - let place = Place(name: destination, type: .busStop, latitude: latitude, longitude: longitude) + let place = Place(name: destination, type: placeType, latitude: latitude, longitude: longitude) let optionsVC = RouteOptionsViewController(searchTo: place) navigationController.pushViewController(optionsVC, animated: false) return true @@ -203,7 +177,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { extension UIWindow { - /// Find the visible view controller in the root navigation controller and present passed in view controlelr. + /// Find the visible view controller in the root navigation controller and present passed in view controller. func presentInApp(_ viewController: UIViewController) { (rootViewController as? UINavigationController)?.visibleViewController?.present(viewController, animated: true) } diff --git a/TCAT/Cells/RouteTableViewCell.swift b/TCAT/Cells/RouteTableViewCell.swift index 78ed935d..23ac4a62 100755 --- a/TCAT/Cells/RouteTableViewCell.swift +++ b/TCAT/Cells/RouteTableViewCell.swift @@ -6,7 +6,6 @@ // Copyright © 2017 cuappdev. All rights reserved. // -import FutureNova import SwiftyJSON import UIKit @@ -25,7 +24,6 @@ class RouteTableViewCell: UITableViewCell { // MARK: - Data vars private let containerViewLayoutInsets = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 12) - private let networking: Networking = URLSession.shared.request // MARK: - Init diff --git a/TCAT/Controllers/CustomNavigationController.swift b/TCAT/Controllers/CustomNavigationController.swift index 0bbe3155..6fc073c7 100644 --- a/TCAT/Controllers/CustomNavigationController.swift +++ b/TCAT/Controllers/CustomNavigationController.swift @@ -31,19 +31,10 @@ class CustomNavigationController: UINavigationController, UINavigationController super.init(rootViewController: rootViewController) view.backgroundColor = Colors.white customizeAppearance() + } - ReachabilityManager.shared.addListener(self) { [weak self] connection in - guard let self = self else { return } - switch connection { - case .wifi, .cellular: - self.banner.dismiss() - case .none: - self.banner.show(queuePosition: .front, on: self) - self.banner.autoDismiss = false - self.banner.isUserInteractionEnabled = false - } - self.setNeedsStatusBarAppearanceUpdate() - } + deinit { + NotificationCenter.default.removeObserver(self, name: .reachabilityChanged, object: nil) } override open var childForStatusBarStyle: UIViewController? { @@ -73,6 +64,8 @@ class CustomNavigationController: UINavigationController, UINavigationController let payload = ScreenshotTakenPayload(location: "\(type(of: currentViewController))") TransitAnalytics.shared.log(payload) } + + NotificationCenter.default.addObserver(self, selector: #selector(handleReachabilityChange), name: .reachabilityChanged, object: nil) } override func viewWillDisappear(_ animated: Bool) { @@ -139,6 +132,17 @@ class CustomNavigationController: UINavigationController, UINavigationController _ = popViewController(animated: true) } + @objc func handleReachabilityChange() { + if NetworkMonitor.shared.isReachable { + self.banner.dismiss() + } else { + self.banner.show(queuePosition: .front, on: self) + self.banner.autoDismiss = false + self.banner.isUserInteractionEnabled = false + } + self.setNeedsStatusBarAppearanceUpdate() + } + // MARK: - UINavigationController Functions override func pushViewController(_ viewController: UIViewController, animated: Bool) { diff --git a/TCAT/Controllers/FavoritesTableViewController.swift b/TCAT/Controllers/FavoritesTableViewController.swift index 0ade6198..f015c7e9 100644 --- a/TCAT/Controllers/FavoritesTableViewController.swift +++ b/TCAT/Controllers/FavoritesTableViewController.swift @@ -8,15 +8,14 @@ import UIKit import DZNEmptyDataSet -import FutureNova +import Combine class FavoritesTableViewController: UIViewController { private var searchBar = UISearchBar() private var tableView: UITableView! - private var timer: Timer? - private let networking: Networking = URLSession.shared.request + private var currentSearchCancellable: AnyCancellable? private var resultsSection = Section.searchResults(items: []) { didSet { tableView.reloadData() @@ -158,35 +157,31 @@ extension FavoritesTableViewController: DZNEmptyDataSetSource { // MARK: - Search extension FavoritesTableViewController: UISearchBarDelegate { func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { - timer?.invalidate() - timer = Timer.scheduledTimer( - timeInterval: 0.2, - target: self, - selector: #selector(getPlaces), - userInfo: ["searchText": searchText], - repeats: false - ) + startSearch(for: searchText) } - /// Get Search Results - @objc func getPlaces(timer: Timer) { - if let userInfo = timer.userInfo as? [String: String], - let searchText = userInfo["searchText"], - !searchText.isEmpty { - SearchManager.shared.performLookup(for: searchText) { [weak self] (searchResults, error) in + private func startSearch(for searchText: String) { + currentSearchCancellable?.cancel() + + currentSearchCancellable = SearchManager.shared.search(for: searchText) + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] result in guard let self = self else { return } - DispatchQueue.main.async { - if let error = error { - self.printClass(context: "SearchManager lookup error", message: error.localizedDescription) - self.resultsSection = Section.recentSearches(items: []) - return - } - self.resultsSection = Section.searchResults(items: searchResults) + + switch result { + case .success(let searchResults): + self.updateSearchResults(with: searchResults) + + case .failure(let error): + print("[FavoritesTableViewController] Search failed: \(error.errorDescription)") } - } - } else { - resultsSection = Section.searchResults(items: []) - } + }) + } + + // Update UI with the new search results + private func updateSearchResults(with searchResults: [Place]) { + self.resultsSection = Section.searchResults(items: searchResults) + self.tableView.reloadData() } } diff --git a/TCAT/Controllers/HomeMapViewController.swift b/TCAT/Controllers/HomeMapViewController.swift index 23e24a17..ce1f15e3 100644 --- a/TCAT/Controllers/HomeMapViewController.swift +++ b/TCAT/Controllers/HomeMapViewController.swift @@ -7,7 +7,6 @@ // import CoreLocation -import FutureNova import GoogleMaps import SnapKit import UIKit diff --git a/TCAT/Controllers/HomeOptionsCardViewController+Extensions.swift b/TCAT/Controllers/HomeOptionsCardViewController+Extensions.swift index 8b2248d0..d247a71b 100644 --- a/TCAT/Controllers/HomeOptionsCardViewController+Extensions.swift +++ b/TCAT/Controllers/HomeOptionsCardViewController+Extensions.swift @@ -60,14 +60,12 @@ extension HomeOptionsCardViewController: UISearchBarDelegate { func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { searchBar.returnKeyType = searchText.isEmpty ? .default : .search searchBar.setShowsCancelButton(true, animated: true) - timer?.invalidate() - timer = Timer.scheduledTimer( - timeInterval: 0.2, - target: self, - selector: #selector(getPlaces), - userInfo: ["searchText": searchText], - repeats: false - ) + + guard !searchText.isEmpty else { + updateSections() + return + } + startSearch(for: searchText) } func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { diff --git a/TCAT/Controllers/HomeOptionsCardViewController.swift b/TCAT/Controllers/HomeOptionsCardViewController.swift index 0ebba13a..f7626286 100644 --- a/TCAT/Controllers/HomeOptionsCardViewController.swift +++ b/TCAT/Controllers/HomeOptionsCardViewController.swift @@ -6,8 +6,8 @@ // Copyright © 2019 cuappdev. All rights reserved. // +import Combine import CoreLocation -import FutureNova import GoogleMaps import SnapKit import UIKit @@ -28,12 +28,12 @@ class HomeOptionsCardViewController: UIViewController { var searchBar: UISearchBar! var tableView: UITableView! - private let networking: Networking = URLSession.shared.request private var searchResultsSection: Section! var currentLocation: CLLocation? { return delegate?.getCurrentLocation() } - var timer: Timer? +// var timer: Timer? var isNetworkDown = false + private var currentSearchCancellable: AnyCancellable? private let infoButtonAnimationDuration = 0.1 private var keyboardHeight: CGFloat = 0 private let maxFavoritesCount = 2 @@ -118,7 +118,7 @@ class HomeOptionsCardViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - addReachabilityListener() + NotificationCenter.default.addObserver(self, selector: #selector(handleReachabilityChange), name: .reachabilityChanged, object: nil) setupTableView() setupInfoButton() @@ -128,21 +128,17 @@ class HomeOptionsCardViewController: UIViewController { updatePlaces() } - private func addReachabilityListener() { - ReachabilityManager.shared.addListener(self) { [weak self] connection in - guard let self = self else { return } - - switch connection { - case .none: - self.isNetworkDown = true - self.searchBar.isUserInteractionEnabled = false - self.sections = [] - case .cellular, .wifi: - self.isNetworkDown = false - self.updateSections() - self.searchBar.isUserInteractionEnabled = true - } + @objc func handleReachabilityChange() { + if NetworkMonitor.shared.isReachable { + self.isNetworkDown = false + self.updateSections() + self.searchBar.isUserInteractionEnabled = true + } else { + self.isNetworkDown = true + self.searchBar.isUserInteractionEnabled = false + self.sections = [] } + self.setNeedsStatusBarAppearanceUpdate() } private func setupTableView() { @@ -300,27 +296,25 @@ class HomeOptionsCardViewController: UIViewController { } // MARK: - Get Search Results - /// Get Search Results - @objc func getPlaces(timer: Timer) { - if let userInfo = timer.userInfo as? [String: String], - let searchText = userInfo["searchText"], - !searchText.isEmpty { - SearchManager.shared.performLookup(for: searchText) { [weak self] (searchResults, error) in + internal func startSearch(for searchText: String) { + currentSearchCancellable?.cancel() + + currentSearchCancellable = SearchManager.shared.search(for: searchText) + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] result in guard let self = self else { return } - if let error = error { - self.printClass(context: "SearchManager lookup error", message: error.localizedDescription) - return - } - DispatchQueue.main.async { + + switch result { + case .success(let searchResults): self.searchResultsSection = Section.searchResults(items: searchResults) self.tableView.contentOffset = .zero self.sections = [self.searchResultsSection] + + case .failure(let error): + print("Search error: \(error.errorDescription)") } - } - } else { - updateSections() - } + }) } // MARK: - Keyboard diff --git a/TCAT/Controllers/RouteDetail+ContentViewController.swift b/TCAT/Controllers/RouteDetail+ContentViewController.swift index 954321f7..018c15ab 100755 --- a/TCAT/Controllers/RouteDetail+ContentViewController.swift +++ b/TCAT/Controllers/RouteDetail+ContentViewController.swift @@ -6,8 +6,8 @@ // Copyright © 2017 cuappdev. All rights reserved. // +import Combine import CoreLocation -import FutureNova import GoogleMaps import MapKit import NotificationBannerSwift @@ -25,6 +25,7 @@ class RouteDetailContentViewController: UIViewController { var bounds = GMSCoordinateBounds() var busIndicators = [GMSMarker]() var buses = [GMSMarker]() + private var cancellables = Set() var currentLocation: CLLocationCoordinate2D? var directions: [Direction] = [] var endDestination: Place! @@ -33,7 +34,6 @@ class RouteDetailContentViewController: UIViewController { var liveTrackingNetworkTimer: Timer? private var locationManager = CLLocationManager() var mapView: GMSMapView! - private let networking: Networking = URLSession.shared.request private var paths: [Path] = [] private var route: Route! private var routeOptionsCell: RouteTableViewCell? @@ -168,21 +168,14 @@ class RouteDetailContentViewController: UIViewController { // MARK: - Network Calls - private func busLocations(_ directions: [Direction]) -> Future> { - return networking(Endpoint.getBusLocations(directions)).decode() - } - /// Fetch live-tracking information for the first direction's bus route. - /// Handles connection issues with banners. Animated indicators. + /// Handles connection issues with banners. Animated indicators. @objc func getBusLocations() { - // swiftlint:disable:next reduce_boolean - let directionsAreValid = route.directions.reduce(true) { result, direction in - if direction.type == .depart { - return result && direction.routeNumber > 0 && direction.tripIdentifiers != nil - } else { - return true - } + // Check if directions are valid for live tracking + let directionsAreValid = route.directions.allSatisfy { direction in + direction.type != .depart || (direction.routeNumber > 0 && direction.tripIdentifiers != nil) } + if !directionsAreValid { printClass(context: "\(#function)", message: "Directions are not valid") let payload = NetworkErrorPayload( @@ -194,17 +187,12 @@ class RouteDetailContentViewController: UIViewController { return } - busLocations(route.directions).observe { [weak self] result in - guard let self = self else { return } - DispatchQueue.main.async { - switch result { - case .value(let response): - if response.data.isEmpty { - // Reset banner in case transitioned from Error to Online - No Bus Locations - self.hideBanner() - } - self.parseBusLocationsData(data: response.data) - case .error(let error): + // Fetch bus locations using the TransitService + TransitService.shared.getBusLocations(route.directions) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + if case .failure(let error) = completion { self.printClass(context: "\(#function) error", message: error.localizedDescription) if let banner = self.banner, !banner.isDisplaying { self.showBanner(Constants.Banner.cannotConnectLive, status: .danger) @@ -216,9 +204,16 @@ class RouteDetailContentViewController: UIViewController { ) TransitAnalytics.shared.log(payload) } - } - } - // Bounce any visible indicators + }, receiveValue: { [weak self] busLocations in + guard let self = self else { return } + if busLocations.isEmpty { + // Reset banner in case of transition from Error to Online - No Bus Locations + self.hideBanner() + } + self.parseBusLocationsData(data: busLocations) + }) + .store(in: &cancellables) + bounceIndicators() } diff --git a/TCAT/Controllers/RouteDetail+DrawerViewController.swift b/TCAT/Controllers/RouteDetail+DrawerViewController.swift index 0c192f42..8c005b66 100644 --- a/TCAT/Controllers/RouteDetail+DrawerViewController.swift +++ b/TCAT/Controllers/RouteDetail+DrawerViewController.swift @@ -6,7 +6,7 @@ // Copyright © 2017 cuappdev. All rights reserved. // -import FutureNova +import Combine import Pulley import SwiftyJSON import UIKit @@ -49,6 +49,7 @@ class RouteDetailDrawerViewController: UIViewController { var summaryView: SummaryView! let tableView = UITableView(frame: .zero, style: .grouped) + private var cancellables = Set() var currentPulleyPosition: PulleyPosition? var directionsAndVisibleStops: [RouteDetailItem] = [] var expandedDirections: Set = [] @@ -57,9 +58,7 @@ class RouteDetailDrawerViewController: UIViewController { /// Number of seconds to wait before auto-refreshing bus delay network call. private var busDelayNetworkRefreshRate: Double = 10 - private var busDelayNetworkTimer: Timer? private let chevronFlipDurationTime = 0.25 - private let networking: Networking = URLSession.shared.request private let route: Route // MARK: - Initalization @@ -89,29 +88,14 @@ class RouteDetailDrawerViewController: UIViewController { if let drawer = self.parent as? RouteDetailViewController { drawer.initialDrawerPosition = .partiallyRevealed } - + getDelays() setupConstraints() } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - // Bus Delay Network Timer - busDelayNetworkTimer?.invalidate() - busDelayNetworkTimer = Timer.scheduledTimer( - timeInterval: busDelayNetworkRefreshRate, - target: self, - selector: #selector(getDelays), - userInfo: nil, - repeats: true - ) - busDelayNetworkTimer?.fire() - - } - override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - busDelayNetworkTimer?.invalidate() + cancellables.forEach { $0.cancel() } + cancellables.removeAll() } private func setupSummaryView() { @@ -194,59 +178,26 @@ class RouteDetailDrawerViewController: UIViewController { } /// Fetch delay information and update table view cells. - @objc private func getDelays() { - + private func getDelays() { // First depart direction(s) guard let delayDirection = route.getFirstDepartRawDirection() else { return // Use rawDirection (preserves first stop metadata) } let directions = directionsAndVisibleStops.compactMap { $0.getDirection() } + guard let firstDepartDirection = directions.first(where: { $0.type == .depart }) else { return } - let firstDepartDirection = directions.first(where: { $0.type == .depart })! - + // Reset delays for directions directions.forEach { $0.delay = nil } + // Check if tripId and stopId are available if let tripId = delayDirection.tripIdentifiers?.first, - let stopId = delayDirection.stops.first?.id { - - getDelay(tripId: tripId, stopId: stopId).observe(with: { [weak self] result in - guard let self = self else { return } - DispatchQueue.main.async { - switch result { - case .value(let response): - if response.success { - - delayDirection.delay = response.data - firstDepartDirection.delay = response.data - - // Update delay variable of other ensuing directions - directions.filter { - let isAfter = directions.firstIndex( - of: firstDepartDirection - )! < directions.firstIndex(of: $0)! - return isAfter && $0.type != .depart - } - .forEach { direction in - if direction.delay != nil { - direction.delay! += delayDirection.delay ?? 0 - } else { - direction.delay = delayDirection.delay - } - } - - self.tableView.reloadData() - self.summaryView.updateTimes(for: self.route) - } else { - self.printClass(context: "\(#function) success", message: "false") - let payload = NetworkErrorPayload( - location: "\(self) Get Delay", - type: "Response Failure", - description: "Response Failure" - ) - TransitAnalytics.shared.log(payload) - } - case .error(let error): + let stopId = delayDirection.stops.first?.id { + TransitService.shared.getDelay(tripID: tripId, stopID: stopId, refreshInterval: busDelayNetworkRefreshRate) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + if case .failure(let error) = completion { self.printClass(context: "\(#function) error", message: error.localizedDescription) let payload = NetworkErrorPayload( location: "\(self) Get Delay", @@ -255,13 +206,29 @@ class RouteDetailDrawerViewController: UIViewController { ) TransitAnalytics.shared.log(payload) } - } - }) - } - } + }, receiveValue: { [weak self] delay in + guard let self = self else { return } + + delayDirection.delay = delay + firstDepartDirection.delay = delay - private func getDelay(tripId: String, stopId: String) -> Future> { - return networking(Endpoint.getDelay(tripID: tripId, stopID: stopId)).decode() + directions.filter { + let isAfter = directions.firstIndex(of: firstDepartDirection)! < directions.firstIndex(of: $0)! + return isAfter && $0.type != .depart + } + .forEach { direction in + if let currentDelay = direction.delay { + direction.delay = currentDelay + (delay ?? 0) + } else { + direction.delay = delay + } + } + + self.tableView.reloadData() + self.summaryView.updateTimes(for: self.route) + }) + .store(in: &cancellables) + } } func getFirstDirection() -> Direction? { diff --git a/TCAT/Controllers/RouteOptionsViewController.swift b/TCAT/Controllers/RouteOptionsViewController.swift index c6112d99..3c75fb39 100755 --- a/TCAT/Controllers/RouteOptionsViewController.swift +++ b/TCAT/Controllers/RouteOptionsViewController.swift @@ -6,9 +6,9 @@ // Copyright © 2017 cuappdev. All rights reserved. // +import Combine import CoreLocation import DZNEmptyDataSet -import FutureNova import Intents import NotificationBannerSwift import Pulley @@ -38,6 +38,8 @@ class RouteOptionsViewController: UIViewController { let routeSelection = RouteSelectionView() var searchBarView = SearchBarView() + private var busDelaysNetworkRefreshRate: Double = 5.0 + private var cancellables = Set() var cellUserInteraction = true var currentLocation: CLLocationCoordinate2D? var lastRouteRefreshDate = Date() @@ -57,11 +59,9 @@ class RouteOptionsViewController: UIViewController { private let estimatedRowHeight: CGFloat = 115 private let mediumTapticGenerator = UIImpactFeedbackGenerator(style: .medium) - private let networking: Networking = URLSession.shared.request private let routeResultsTitle: String = Constants.Titles.routeResults - /// Timer to retrieve route delays and update route cells - private var routeTimer: Timer? + /// Timer to retrieve route update route cells private var updateTimer: Timer? /// Dictionary to map route id to delay @@ -100,7 +100,7 @@ class RouteOptionsViewController: UIViewController { title = Constants.Titles.routeOptions - addReachabilityListener() + NotificationCenter.default.addObserver(self, selector: #selector(setUserInteraction), name: .reachabilityChanged, object: nil) setupRouteSelection(destination: searchTo) setupSearchBar() @@ -117,14 +117,7 @@ class RouteOptionsViewController: UIViewController { } searchForRoutes() - - routeTimer = Timer.scheduledTimer( - timeInterval: 5.0, - target: self, - selector: #selector(updateAllRoutesLiveTracking(sender:)), - userInfo: nil, - repeats: true - ) + updateAllRoutesLiveTracking() updateTimer = Timer.scheduledTimer( timeInterval: 20.0, target: self, @@ -151,7 +144,10 @@ class RouteOptionsViewController: UIViewController { // Remove banner banner?.dismiss() banner = nil - routeTimer?.invalidate() + + cancellables.forEach { $0.cancel() } + cancellables.removeAll() + updateTimer?.invalidate() // Remove notification observer // swiftlint:disable:next notification_center_detachment @@ -162,12 +158,6 @@ class RouteOptionsViewController: UIViewController { return banner != nil ? .lightContent : .default } - private func addReachabilityListener() { - ReachabilityManager.shared.addListener(self) { [weak self] connection in - self?.setUserInteraction(to: connection != .none) - } - } - private func setupRouteSelection(destination: Place?) { routeSelection.configure( delegate: self, @@ -361,51 +351,46 @@ class RouteOptionsViewController: UIViewController { routeResults.reloadData() } - private func getAllDelays(trips: [Trip]) -> Future> { - return networking(Endpoint.getAllDelays(trips: trips)).decode() - } - - @objc func updateAllRoutesLiveTracking(sender: Timer) { - getAllDelays(trips: trips).observe(with: { result in - DispatchQueue.main.async { - switch result { - case .value(let delaysResponse): - if !delaysResponse.success { return } - let allDelays = delaysResponse.data - for delayResponse in allDelays { - let tripRoute = self.tripDictionary[delayResponse.tripID] - guard let route = tripRoute, - let routeId = tripRoute?.routeId, - let direction = route.getFirstDepartRawDirection(), - let delay = delayResponse.delay else { - continue - } + private func updateAllRoutesLiveTracking() { + TransitService.shared.getAllDelays(trips: trips, refreshInterval: busDelaysNetworkRefreshRate) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { [weak self] completion in + guard let self = self else { return } + if case .failure(let error) = completion { + let payload = NetworkErrorPayload( + location: "\(self) Get All Delays", + type: "\((error as NSError).domain)", + description: error.localizedDescription + ) + TransitAnalytics.shared.log(payload) + self.printClass(context: "\(#function) error", message: error.localizedDescription) + } + }, receiveValue: { [weak self] delays in + guard let self = self else { return } + + for delayResponse in delays { + if let route = self.tripDictionary[delayResponse.tripID], + let direction = route.getFirstDepartRawDirection(), + let delay = delayResponse.delay { + + let routeId = route.routeId + let departTime = direction.startTime let delayedDepartTime = departTime.addingTimeInterval(TimeInterval(delay)) - var delayState: DelayState! - let isLateDelay = Time.compare( - date1: delayedDepartTime, - date2: departTime - ) == .orderedDescending - if isLateDelay { - delayState = DelayState.late(date: delayedDepartTime) + + let delayState: DelayState + if delayedDepartTime > departTime { + delayState = .late(date: delayedDepartTime) } else { - delayState = DelayState.onTime(date: departTime) + delayState = .onTime(date: departTime) } + self.delayDictionary[routeId] = delayState route.getFirstDepartRawDirection()?.delay = delay } - case .error(let error): - self.printClass(context: "\(#function) error", message: error.localizedDescription) - let payload = NetworkErrorPayload( - location: "\(self) Get All Delays", - type: "\((error as NSError).domain)", - description: error.localizedDescription - ) - TransitAnalytics.shared.log(payload) } - } - }) + }) + .store(in: &cancellables) } @objc private func refreshRoutesAndTime() { @@ -494,19 +479,6 @@ class RouteOptionsViewController: UIViewController { } } - private func getRoutes( - start: Place, - end: Place, - time: Date, - type: SearchType - ) -> Future>? { - if let endpoint = Endpoint.getRoutes(start: start, end: end, time: time, type: type) { - return networking(endpoint).decode() - } else { - return nil - } - } - private func getRoutesTrips() { // For each route in each route array inside of the 'routes' array, get its // tripId and stopId to create trip array for request to get all delays. @@ -526,36 +498,38 @@ class RouteOptionsViewController: UIViewController { } private func processRequest(start: Place, end: Place, time: Date, type: SearchType) { - if let result = getRoutes(start: start, end: end, time: time, type: type) { - result.observe(with: { [weak self] result in + TransitService.shared.getRoutes(start: start, end: end, time: time, type: type) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { [weak self] completion in guard let self = self else { return } - DispatchQueue.main.async { - switch result { - case .value(let response): - - // Parse sections of routes - [response.data.fromStop, response.data.boardingSoon, response.data.walking] - .forEach { routeSection in - routeSection.forEach { (route) in - route.formatDirections(start: self.searchFrom?.name, end: self.searchTo.name) - } - // Allow for custom display in search results for fromStop. - // We want to display a [] if a bus stop is the origin and doesn't exist - if !routeSection.isEmpty || self.searchFrom?.type == .busStop { - self.routes.append(routeSection) - } - - } - self.getRoutesTrips() - self.requestDidFinish(perform: [.hideBanner]) - case .error(let error): - self.processRequestError(error: error) + switch completion { + case .failure(let error): + self.processRequestError(error: error) + case .finished: + break + } + }, receiveValue: { [weak self] response in + guard let self = self else { return } + + // Parse sections of routes + [response.fromStop, response.boardingSoon, response.walking].forEach { routeSection in + routeSection.forEach { route in + route.formatDirections(start: self.searchFrom?.name, end: self.searchTo.name) + } + // Add routes to results + if !routeSection.isEmpty || self.searchFrom?.type == .busStop { + self.routes.append(routeSection) } - let payload = DestinationSearchedEventPayload(destination: end.name) - TransitAnalytics.shared.log(payload) } + + self.getRoutesTrips() + self.requestDidFinish(perform: [.hideBanner]) + + // Log analytics + let payload = DestinationSearchedEventPayload(destination: end.name) + TransitAnalytics.shared.log(payload) }) - } + .store(in: &cancellables) } private func processRequestError(error: Error) { @@ -630,7 +604,8 @@ class RouteOptionsViewController: UIViewController { routeResults.reloadData() } - func setUserInteraction(to userInteraction: Bool) { + @objc func setUserInteraction() { + var userInteraction = NetworkMonitor.shared.isReachable cellUserInteraction = userInteraction for cell in routeResults.visibleCells { diff --git a/TCAT/Controllers/SearchResultsViewController.swift b/TCAT/Controllers/SearchResultsViewController.swift index 73633253..13e4e581 100755 --- a/TCAT/Controllers/SearchResultsViewController.swift +++ b/TCAT/Controllers/SearchResultsViewController.swift @@ -6,9 +6,9 @@ // Copyright © 2017 cuappdev. All rights reserved. // +import Combine import CoreLocation import DZNEmptyDataSet -import FutureNova import MapKit import SwiftyJSON import UIKit @@ -30,11 +30,11 @@ class SearchResultsViewController: UIViewController { private weak var destinationDelegate: DestinationDelegate? private weak var searchBarCancelDelegate: SearchBarCancelDelegate? + private var currentSearchCancellable: AnyCancellable? private var favorites: [Place] = [] private var favoritesSection: Section! private var initialTableViewIndexMinY: CGFloat! private let locationManager = CLLocationManager() - private let networking: Networking = URLSession.shared.request private var recentLocations: [Place] = [] private var recentSearchesSection: Section! private var returningFromAllStopsBusStop: Place? @@ -152,23 +152,22 @@ class SearchResultsViewController: UIViewController { }) } - @objc private func getPlaces(timer: Timer) { - if let userInfo = timer.userInfo as? [String: String], - let searchText = userInfo["searchText"], - !searchText.isEmpty { - SearchManager.shared.performLookup(for: searchText) { [weak self] (searchResults, error) in + private func startSearch(for searchText: String) { + currentSearchCancellable?.cancel() + + currentSearchCancellable = SearchManager.shared.search(for: searchText) + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] result in guard let self = self else { return } - if let error = error { - self.printClass(context: "SearchManager lookup error", message: error.localizedDescription) - return - } - DispatchQueue.main.async { + + switch result { + case .success(let searchResults): self.updateSearchResultsSection(with: searchResults) + + case .failure(let error): + self.printClass(context: "SearchManager lookup error", message: error.localizedDescription) } - } - } else { - createDefaultSections() - } + }) } } @@ -311,14 +310,12 @@ extension SearchResultsViewController: UISearchBarDelegate, UISearchResultsUpdat } func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { - timer?.invalidate() - timer = Timer.scheduledTimer( - timeInterval: 0.75, - target: self, - selector: #selector(getPlaces), - userInfo: ["searchText": searchText], - repeats: false - ) + // Start the search as the text changes + guard !searchText.isEmpty else { + createDefaultSections() + return + } + startSearch(for: searchText) } } diff --git a/TCAT/Controllers/ServiceAlertsViewController.swift b/TCAT/Controllers/ServiceAlertsViewController.swift index b2e5877b..0a79268a 100644 --- a/TCAT/Controllers/ServiceAlertsViewController.swift +++ b/TCAT/Controllers/ServiceAlertsViewController.swift @@ -6,8 +6,8 @@ // Copyright © 2018 cuappdev. All rights reserved. // +import Combine import DZNEmptyDataSet -import FutureNova import SnapKit import UIKit @@ -15,10 +15,10 @@ class ServiceAlertsViewController: UIViewController { private let tableView = UITableView(frame: .zero, style: .grouped) + private var cancellables = Set() private var isLoading: Bool { return loadingIndicator != nil } private var loadingIndicator: LoadingIndicator? private var networkError: Bool = false - private let networking: Networking = URLSession.shared.request private var priorities = [Int]() private var alerts = [Int: [ServiceAlert]]() { @@ -92,22 +92,16 @@ class ServiceAlertsViewController: UIViewController { } } - private func getAlerts() -> Future> { - return networking(Endpoint.getAlerts()).decode() - } - + /// Fetches service alerts using TransitService and updates the table view. private func getServiceAlerts() { - getAlerts().observe(with: { [weak self] result in - guard let self = self else { return } - DispatchQueue.main.async { - switch result { - case .value(let response): - if response.success { - self.removeLoadingIndicator() - self.networkError = false - self.alerts = self.sortedAlerts(alertsList: response.data) - } - case .error(let error): + setUpLoadingIndicator() + + TransitService.shared.getAlerts() + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + guard let self = self else { return } + switch completion { + case .failure(let error): self.removeLoadingIndicator() self.networkError = true self.alerts = [:] @@ -118,9 +112,15 @@ class ServiceAlertsViewController: UIViewController { description: error.localizedDescription ) TransitAnalytics.shared.log(payload) + case .finished: + break } + } receiveValue: { [weak self] alerts in + self?.removeLoadingIndicator() + self?.networkError = false + self?.alerts = self?.sortedAlerts(alertsList: alerts) ?? [:] } - }) + .store(in: &cancellables) } private func sortedAlerts(alertsList: [ServiceAlert]) -> [Int: [ServiceAlert]] { diff --git a/TCAT/Controllers/StopPickerViewController.swift b/TCAT/Controllers/StopPickerViewController.swift index dc1618c3..30925d35 100644 --- a/TCAT/Controllers/StopPickerViewController.swift +++ b/TCAT/Controllers/StopPickerViewController.swift @@ -6,12 +6,13 @@ // Copyright © 2017 cuappdev. All rights reserved. // +import Combine import DZNEmptyDataSet -import FutureNova import UIKit class StopPickerViewController: UIViewController { + private var cancellables = Set() private let tableView = UITableView() private typealias Section = (title: String, places: [Place]) private var sections: [Section] = [] @@ -29,7 +30,7 @@ class StopPickerViewController: UIViewController { title = Constants.Titles.allStops setupTableView() - refreshStops() + getAllStops() } private func setupTableView() { @@ -59,49 +60,42 @@ class StopPickerViewController: UIViewController { } } - // MARK: - Refresh stops - - private func getStopsFromServer() -> Future> { - return URLSession.shared.request(endpoint: Endpoint.getAllStops()).decode() - } - - /// Get all bus stops from the server, update UserDefaults, and refresh the table - private func refreshStops() { + // MARK: - Get all stops + /// Get all bus stops from the server + func getAllStops() { setUpLoadingIndicator() - if let busStopsData = userDefaults.data(forKey: Constants.UserDefaults.allBusStops), - let busStops = try? decoder.decode([Place].self, from: busStopsData) { - loadingIndicator?.removeFromSuperview() - loadingIndicator = nil - sections = tableSections(for: busStops) - tableView.reloadData() - } else { - getStopsFromServer().observe { [weak self] result in + TransitService.shared.getAllStops() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { [weak self] completion in guard let self = self else { return } - - switch result { - case .value(let response): - guard !response.data.isEmpty else { return } // ensure the response has stops - - do { - // note: response.data is [Place], not Data - let stopsData = try JSONEncoder().encode(response.data) - userDefaults.set(stopsData, forKey: Constants.UserDefaults.allBusStops) - self.sections = self.tableSections(for: response.data) - } catch { - self.logRefreshError(error) - } - case .error(let error): - self.logRefreshError(error) + self.loadingIndicator?.removeFromSuperview() + self.loadingIndicator = nil + + switch completion { + case .failure(let error): + handleGetAllStopsError() + case .finished: + break } + }, receiveValue: { [weak self] response in + guard let self = self else { return } + guard !response.isEmpty else { return } - DispatchQueue.main.async { - self.loadingIndicator?.removeFromSuperview() - self.loadingIndicator = nil - self.tableView.reloadData() - } - } - } + self.sections = self.tableSections(for: response) + self.tableView.reloadData() + }) + .store(in: &cancellables) + } + + // ToDo: Ask whats better when unable to get stop + /// Handle error when bus stops aren't fetched successfully + private func handleGetAllStopsError() { + let title = "Couldn't Fetch Bus Stops" + let message = "The app will continue trying on launch. You can continue to use the app as normal." + let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil)) + UIApplication.shared.keyWindow?.presentInApp(alertController) } /// Sorts `busStops` into table `Section`s in alphabetical order. @@ -178,7 +172,7 @@ extension StopPickerViewController: DZNEmptyDataSetDelegate { func emptyDataSet(_ scrollView: UIScrollView, didTap didTapButton: UIButton) { setUpLoadingIndicator() - refreshStops() + getAllStops() } } diff --git a/TCAT/Models/APIResponse.swift b/TCAT/Models/APIResponse.swift deleted file mode 100644 index dcb534d1..00000000 --- a/TCAT/Models/APIResponse.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// Response.swift -// TCAT -// -// Created by Jayson Hahn on 9/16/24. -// Copyright © 2024 Cornell AppDev. All rights reserved. -// - -import Foundation - -struct APIResponse: Decodable { - var success: Bool - var data: T -} diff --git a/TCAT/Models/SearchManager.swift b/TCAT/Models/SearchManager.swift index 71e98fbf..1a9b2c94 100644 --- a/TCAT/Models/SearchManager.swift +++ b/TCAT/Models/SearchManager.swift @@ -6,153 +6,119 @@ // Copyright © 2019 cuappdev. All rights reserved. // -import FutureNova +import Combine +import Foundation import MapKit -struct SearchManagerError: Swift.Error { - let description: String -} - class SearchManager: NSObject { - typealias SearchManagerCallback = (_ searchResults: [Place], _ error: Error?) -> Void - + // MARK: - Public Properties static let shared = SearchManager() - // MARK: - Private vars - private var callback: SearchManagerCallback? + // MARK: - Private Properties private var busStops = [Place]() - private let networking: Networking = URLSession.shared.request - private let searchCompleter = MKLocalSearchCompleter() - private var searchResults = [MKLocalSearchCompletion]() - - private let gshLat = 42.442558 - private let gshLong = -76.485336 + private var cancellables = Set() + private var searchQuerySubject = PassthroughSubject() + private var lastSearchQuery: String? + private var searchPublisher = PassthroughSubject, Never>() + // MARK: - Initializer override private init() { super.init() - searchCompleter.delegate = self - if let searchRadius = CLLocationDistance(exactly: Constants.Map.searchRadius) { - let center = CLLocationCoordinate2D( - latitude: Constants.Map.startingLat, - longitude: Constants.Map.startingLong - ) - searchCompleter.region = MKCoordinateRegion( - center: center, - latitudinalMeters: searchRadius, - longitudinalMeters: searchRadius - ) - } + setUpSearchSubscription() } - private func sortLocations(_ s1: Place, _ s2: Place) -> Bool { - let s1Check = pow((s1.latitude-(self.gshLat)),2.0) + pow((s1.longitude-(self.gshLong)),2.0) - - let s2Check = pow((s2.latitude-(self.gshLat)),2.0) + pow((s2.longitude-(self.gshLong)),2.0) - return s1Check < s2Check + + // MARK: - Public Search Method + func search(for query: String) -> AnyPublisher, Never> { + searchQuerySubject.send(query) + return searchPublisher.eraseToAnyPublisher() } - - func performLookup(for query: String, completionHandler: @escaping SearchManagerCallback) { - getAppleSearchResults(searchText: query).observe { [weak self] result in - guard let self = self else { - completionHandler([], SearchManagerError(description: "[SearchManager] self is nil")) - return + // MARK: - Private Methods + private func setUpSearchSubscription() { + searchQuerySubject + .removeDuplicates() + .debounce(for: .milliseconds(750), scheduler: DispatchQueue.main) + .flatMap { [weak self] searchText -> AnyPublisher in + guard let self = self, !searchText.isEmpty else { + return Fail(error: ApiErrorHandler.noSearchResultsFound).eraseToAnyPublisher() + } + self.lastSearchQuery = searchText + return TransitService.shared.getAppleSearchResults(searchText: searchText) } - DispatchQueue.main.async { - switch result { - case .value(let response): - let busStops = response.data.busStops - // If the list of Apple Places for this query already exists in - // server cache, no further work is needed - if let applePlaces = response.data.applePlaces { - let updatedApplePlaces = applePlaces.sorted(by: self.sortLocations) - - let searchResults = updatedApplePlaces + busStops - completionHandler(searchResults, nil) - } else { - // Otherwise, we need to perform the Apple Places lookup locally - // and only display results after this lookup is done - self.busStops = busStops - self.callback = completionHandler - self.searchCompleter.queryFragment = query - } - case .error(let error): - completionHandler([], error) + .sink(receiveCompletion: { completion in + switch completion { + case .failure(let error): + self.searchPublisher.send(.failure(error)) + case .finished: + break } + }, receiveValue: { [weak self] response in + self?.processSearchResults(response: response) + }) + .store(in: &cancellables) + } + + private func processSearchResults(response: AppleSearchResponse) { + busStops = response.busStops + + if let applePlaces = response.applePlaces, !applePlaces.isEmpty { + let combinedResults = applePlaces + busStops + self.searchPublisher.send(.success(combinedResults)) + } else { + if let lastQuery = lastSearchQuery { + performLocalSearch(with: lastQuery) + } else { + self.searchPublisher.send(.failure(.noSearchResultsFound)) } } } - private func getAppleSearchResults(searchText: String) -> Future> { - return networking(Endpoint.getAppleSearchResults(searchText: searchText)).decode() - } + private func performLocalSearch(with query: String) { + guard !query.isEmpty else { + self.searchPublisher.send(.failure(.noSearchResultsFound)) + return + } -} + let searchRequest = MKLocalSearch.Request() + searchRequest.naturalLanguageQuery = query + let localSearch = MKLocalSearch(request: searchRequest) -extension SearchManager: MKLocalSearchCompleterDelegate { - - func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) { - // Get list of ApplePlaces for this search query, i.e. completer.queryFragment - let query = completer.queryFragment - var places = [Place]() - let dispatchGroup = DispatchGroup() - searchResults = completer.results - searchResults.forEach { completion in - let searchRequest = MKLocalSearch.Request(completion: completion) - let search = MKLocalSearch(request: searchRequest) - dispatchGroup.enter() - search.start(completionHandler: { (response, error) in - if let error = error { - print("[SearchManager] Apple Places search result error: \(error)") - dispatchGroup.leave() - return - } - if let mapItem = response?.mapItems.first, - let name = mapItem.name, - let address = mapItem.placemark.thoroughfare, - let city = mapItem.placemark.locality, - let state = mapItem.placemark.administrativeArea, - let country = mapItem.placemark.country { - let lat = mapItem.placemark.coordinate.latitude - let long = mapItem.placemark.coordinate.longitude - let description = [address, city, state, country].joined(separator: ", ") - let place = Place( - name: name, - type: .applePlace, - latitude: lat, - longitude: long, - placeDescription: description - ) - places.append(place) - } - dispatchGroup.leave() - }) - } - dispatchGroup.notify(queue: .main) { - let searchResults = places + self.busStops - self.callback?(searchResults, nil) - - self.busStops = [] - self.callback = nil - - // Update server cache of Apple Places for this search query - self.updateApplePlacesCache(searchText: query, places: places).observe { [weak self] result in - guard self != nil else { return } - switch result { - case .value(let response): - print("[SearchManager] Succeeded in updating apple places cache: \(response.data)") - default: break - } + localSearch.start { [weak self] response, error in + guard let self = self else { return } + + if let error = error { + self.searchPublisher.send(.failure(.normalError(error))) + return } - } - } - func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) { - print("[SearchManager] MKLocalSearch failed for error: \(error)") - } + let places = self.extractPlaces(from: response) - private func updateApplePlacesCache(searchText: String, places: [Place]) -> Future> { - return networking(Endpoint.updateApplePlacesCache(searchText: searchText, places: places)).decode() + if places.isEmpty { + self.searchPublisher.send(.failure(.noSearchResultsFound)) + } else { + let combinedResults = places + self.busStops + self.searchPublisher.send(.success(combinedResults)) + } + } } + private func extractPlaces(from response: MKLocalSearch.Response?) -> [Place] { + return response?.mapItems.compactMap { mapItem -> Place? in + guard let name = mapItem.name, + let address = mapItem.placemark.thoroughfare, + let city = mapItem.placemark.locality, + let state = mapItem.placemark.administrativeArea, + let country = mapItem.placemark.country else { return nil } + + let description = [address, city, state, country].joined(separator: ", ") + return Place( + name: name, + type: .applePlace, + latitude: mapItem.placemark.coordinate.latitude, + longitude: mapItem.placemark.coordinate.longitude, + placeDescription: description + ) + } ?? [] + } } diff --git a/TCAT/Services/Endpoints.swift b/TCAT/Services/Endpoints.swift deleted file mode 100755 index df7fa92e..00000000 --- a/TCAT/Services/Endpoints.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// Network+Endpoints.swift -// TCAT -// -// Created by Austin Astorga on 4/6/17. -// Copyright © 2017 cuappdev. All rights reserved. -// - -import CoreLocation -import Foundation -import FutureNova - -extension Endpoint { - - static func setupEndpointConfig() { - Endpoint.config.scheme = "https" - Endpoint.config.host = TransitEnvironment.transitURL.replacingOccurrences(of: "https://", with: "") - Endpoint.config.commonPath = "/api/v3" - } - - static func getAllStops() -> Endpoint { - return Endpoint(path: Constants.Endpoints.allStops) - } - - static func getAlerts() -> Endpoint { - return Endpoint(path: Constants.Endpoints.alerts) - } - - static func getRoutes( - start: Place, - end: Place, - time: Date, - type: SearchType - ) -> Endpoint? { - let uid = sharedUserDefaults?.string(forKey: Constants.UserDefaults.uid) - let body = GetRoutesBody( - arriveBy: type == .arriveBy, - end: "\(end.latitude),\(end.longitude)", - start: "\(start.latitude),\(start.longitude)", - time: time.timeIntervalSince1970, - destinationName: end.name, - originName: start.name, - uid: uid - ) - // MARK: - Temporary fix for Boom - return Endpoint(path: "/api/v2"+Constants.Endpoints.getRoutes, body: body, useCommonPath: false) - } - - static func getAppleSearchResults(searchText: String) -> Endpoint { - let body = SearchResultsBody(query: searchText) - return Endpoint(path: Constants.Endpoints.appleSearch, body: body) - } - - static func updateApplePlacesCache(searchText: String, places: [Place]) -> Endpoint { - let body = ApplePlacesBody(query: searchText, places: places) - return Endpoint(path: Constants.Endpoints.applePlaces, body: body) - } - - static func getBusLocations(_ directions: [Direction]) -> Endpoint { - let departDirections = directions.filter { $0.type == .depart && $0.tripIdentifiers != nil } - - let locationsInfo = departDirections.map { direction -> BusLocationsInfo in - // The id of the location, or bus stop, the bus needs to get to - let stopID = direction.stops.first?.id ?? "-1" - return BusLocationsInfo( - stopID: stopID, - routeID: String(direction.routeNumber), - tripIdentifiers: direction.tripIdentifiers! - ) - } - - let body = GetBusLocationsBody(data: locationsInfo) - return Endpoint(path: Constants.Endpoints.busLocations, body: body) - } - - static func getDelay(tripID: String, stopID: String) -> Endpoint { - let queryItems = GetDelayBody(stopID: stopID, tripID: tripID).toQueryItems() - return Endpoint(path: Constants.Endpoints.delay, queryItems: queryItems) - } - - static func getAllDelays(trips: [Trip]) -> Endpoint { - let body = TripBody(data: trips) - return Endpoint(path: Constants.Endpoints.delays, body: body) - } - -} diff --git a/TCAT/Services/Network/APIErrorHandler.swift b/TCAT/Services/Network/APIErrorHandler.swift deleted file mode 100644 index 50163300..00000000 --- a/TCAT/Services/Network/APIErrorHandler.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// APIErrorHandler.swift -// TCAT -// -// Created by Jayson Hahn on 9/16/24. -// Copyright © 2024 Cornell AppDev. All rights reserved. -// - -import Foundation - -struct ApiError: Codable { - let code: String? - let message: String? - let errorItems: [String: String]? -} - -enum APIErrorHandler: Error { - case customApiError(ApiError) - case requestFailed - case normalError(Error) - case emptyErrorWithStatusCode(String) - - var errorDescription: String? { - switch self { - case .customApiError(let apiError): - var errorItems: String? - if let errorItemsDict = apiError.errorItems { - errorItems = "" - errorItemsDict.forEach { key, value in - errorItems?.append(key) - errorItems?.append(" ") - errorItems?.append(value) - errorItems?.append("\n") - } - } - if errorItems == nil && apiError.code == nil && apiError.message == nil { - errorItems = "Internal error!" - } - return String(format: "%@ %@ \n %@", apiError.code ?? "", apiError.message ?? "", errorItems ?? "") - case .requestFailed: - return "request failed" - case .normalError(let error): - return error.localizedDescription - case .emptyErrorWithStatusCode(let status): - return status - } - } -} diff --git a/TCAT/Services/Network/ApiEndpoint.swift b/TCAT/Services/Network/ApiEndpoint.swift index 968625dc..6ec7dfe4 100644 --- a/TCAT/Services/Network/ApiEndpoint.swift +++ b/TCAT/Services/Network/ApiEndpoint.swift @@ -43,36 +43,33 @@ extension ApiEndpoint { longPath.append("/") longPath.append(path) urlComponents?.path = longPath - + if let queryParams = queryParams { urlComponents?.queryItems = [URLQueryItem]() for queryParam in queryParams { urlComponents?.queryItems?.append(URLQueryItem(name: queryParam.name, value: queryParam.value)) } } - + guard let url = urlComponents?.url else { return URLRequest(url: URL(string: baseURLString)!) } var request = URLRequest(url: url) request.httpMethod = method.rawValue - + if let headers = headers { for header in headers { request.addValue(header.value, forHTTPHeaderField: header.key) } } - + if let params = params { let jsonData = try? JSONSerialization.data(withJSONObject: params) request.httpBody = jsonData } - + if let customDataBody = customDataBody { request.httpBody = customDataBody } - -// #if DEBUG -// debugLog(request.cURL(pretty: true)) -// #endif + return request } } diff --git a/TCAT/Services/Network/ApiErrorHandler.swift b/TCAT/Services/Network/ApiErrorHandler.swift new file mode 100644 index 00000000..b7f3c19d --- /dev/null +++ b/TCAT/Services/Network/ApiErrorHandler.swift @@ -0,0 +1,54 @@ +// +// ApiErrorHandler.swift +// TCAT +// +// Created by Jayson Hahn on 9/16/24. +// Copyright © 2024 Cornell AppDev. All rights reserved. +// + +import Foundation + +struct ApiError: Codable { + let code: String? + let message: String? +} + +enum ApiErrorHandler: Error { + case customApiError(ApiError) + case requestFailed + case normalError(Error) + case emptyErrorWithStatusCode(String) + case noSearchResultsFound + + var errorDescription: String { + switch self { + case .customApiError(let apiError): + var errorComponents = [String]() + + if let code = apiError.code, !code.isEmpty { + errorComponents.append("Code: \(code)") + } + if let message = apiError.message, !message.isEmpty { + errorComponents.append("Message: \(message)") + } + + if errorComponents.isEmpty { + return "Internal error!" + } + + return errorComponents.joined(separator: "\n") + + case .requestFailed: + return "Request failed" + + case .normalError(let error): + return error.localizedDescription + + case .emptyErrorWithStatusCode(let status): + return "Empty response with status code: \(status)" + + case .noSearchResultsFound: + return "No search results found" + } + } +} diff --git a/TCAT/Services/Network/NetworkManager.swift b/TCAT/Services/Network/NetworkManager.swift index 70ab14c9..6f8e5145 100644 --- a/TCAT/Services/Network/NetworkManager.swift +++ b/TCAT/Services/Network/NetworkManager.swift @@ -9,26 +9,65 @@ import Foundation import Combine -class NetworkManager { - - let session: NetworkSession +protocol NetworkService { + func request(_ request: URLRequest, decodingType: T.Type) -> AnyPublisher +} - init(session: NetworkSession = URLSession.shared) { +class NetworkManager: NetworkService { + + private let session: URLSession + + init(session: URLSession = .shared) { self.session = session } - - func performRequest( - _ request: URLRequest, - decodingType: T.Type - ) -> AnyPublisher< - T, - APIErrorHandler - > where T: Decodable { - return session.publisher(request, decodingType: decodingType) - .mapError { error -> APIErrorHandler in - return error + + func request(_ request: URLRequest, decodingType: T.Type) -> AnyPublisher { + return session.dataTaskPublisher(for: request) + .tryMap { result in + try self.handleResponse(result) + } + .decode(type: APIResponse.self, decoder: JSONDecoder()) + .tryMap { response in + try self.validateAPIResponse(response) + } + .mapError { error in + self.mapToAPIError(error) } .eraseToAnyPublisher() } - + + // Handles HTTP response and decodes or throws an appropriate error + private func handleResponse(_ result: URLSession.DataTaskPublisher.Output) throws -> Data { + guard let httpResponse = result.response as? HTTPURLResponse else { + throw ApiErrorHandler.requestFailed + } + if (200..<300).contains(httpResponse.statusCode) { + return result.data + } else { + // Attempt to decode error message from server + if let apiError = try? JSONDecoder().decode(ApiError.self, from: result.data) { + throw ApiErrorHandler.customApiError(apiError) + } else { + throw ApiErrorHandler.emptyErrorWithStatusCode(httpResponse.statusCode.description) + } + } + } + + // Validate API response and handle future error cases + private func validateAPIResponse(_ response: APIResponse) throws -> T { + guard response.success else { + // This is a placeholder error handler. Update as needed. When backend sends more error codes. + throw ApiErrorHandler.customApiError(ApiError(code: "500", message: "Internal server error")) + } + + return response.data + } + + // Map Combine errors to custom APIErrorHandler types + private func mapToAPIError(_ error: Error) -> ApiErrorHandler { + if let apiError = error as? ApiErrorHandler { + return apiError + } + return ApiErrorHandler.normalError(error) + } } diff --git a/TCAT/Services/Network/NetworkMonitor.swift b/TCAT/Services/Network/NetworkMonitor.swift new file mode 100644 index 00000000..e39a91e5 --- /dev/null +++ b/TCAT/Services/Network/NetworkMonitor.swift @@ -0,0 +1,65 @@ +// +// NetworkMonitor.swift +// TCAT +// +// Created by Jayson Hahn on 10/9/24. +// Copyright © 2024 Cornell AppDev. All rights reserved. +// + +import Network +import Foundation + +final class NetworkMonitor { + + static let shared = NetworkMonitor() + + private let monitor = NWPathMonitor() + private var status: NWPath.Status = .requiresConnection + + public var isCellular: Bool = false + public var isReachable: Bool { status == .satisfied } + + // Optional handlers for reachability changes + public var whenReachable: (() -> Void)? + public var whenUnreachable: (() -> Void)? + + private init() {} + + public func startMonitoring() { + monitor.pathUpdateHandler = { [weak self] path in + self?.status = path.status + self?.isCellular = path.isExpensive + + // Notify handlers and observers based on connection status + if path.status == .satisfied { + print("Connected to the network.") + self?.whenReachable?() + NotificationCenter.default.post(name: .reachabilityChanged, object: self) + } else { + print("No network connection.") + self?.whenUnreachable?() + NotificationCenter.default.post(name: .reachabilityChanged, object: self) + } + + if path.usesInterfaceType(.wifi) { + print("We're connected over Wifi!") + } else if path.usesInterfaceType(.cellular) { + print("We're connected over Cellular!") + } else { + print("We're connected over other network!") + } + } + + let queue = DispatchQueue.global(qos: .background) + monitor.start(queue: queue) + } + + // MARK: - Stop Monitoring + public func stopMonitoring() { + monitor.cancel() + } +} + +extension Notification.Name { + static let reachabilityChanged = Notification.Name("reachabilityChanged") +} diff --git a/TCAT/Services/Network/NetworkSession.swift b/TCAT/Services/Network/NetworkSession.swift deleted file mode 100644 index c2e463cf..00000000 --- a/TCAT/Services/Network/NetworkSession.swift +++ /dev/null @@ -1,53 +0,0 @@ -// -// NetworkSession.swift -// TCAT -// -// Created by Jayson Hahn on 9/16/24. -// Copyright © 2024 Cornell AppDev. All rights reserved. -// - -import Foundation -import Combine - -protocol NetworkSession: AnyObject { - func publisher(_ request: URLRequest, decodingType: T.Type) -> AnyPublisher where T: Decodable -} - -extension URLSession: NetworkSession { - func publisher(_ request: URLRequest, decodingType: T.Type) -> AnyPublisher where T: Decodable { - - return dataTaskPublisher(for: request) - .tryMap({ result in - guard let httpResponse = result.response as? HTTPURLResponse else { - throw APIErrorHandler.requestFailed - } - - if (200..<300) ~= httpResponse.statusCode { - return result.data - } else { - if let error = try? JSONDecoder().decode(ApiError.self, from: result.data) { - throw APIErrorHandler.customApiError(error) - } else { - throw APIErrorHandler.emptyErrorWithStatusCode(httpResponse.statusCode.description) - } - } - }) - .decode(type: APIResponse.self, decoder: JSONDecoder()) - .tryMap { response in - // FIXME: Fix backend error handler - if !response.success { - throw APIErrorHandler.customApiError(ApiError(code: "500", message: "Internal server error", errorItems: nil)) - } - return response.data - } - .mapError({ error -> APIErrorHandler in - if let error = error as? APIErrorHandler { - return error - } - return APIErrorHandler.normalError(error) - }) - .eraseToAnyPublisher() - } -} - - diff --git a/TCAT/Services/Network/Reachability.swift b/TCAT/Services/Network/Reachability.swift deleted file mode 100755 index 05b8a2ea..00000000 --- a/TCAT/Services/Network/Reachability.swift +++ /dev/null @@ -1,334 +0,0 @@ -/* -Copyright (c) 2014, Ashley Mills -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this -list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, -this list of conditions and the following disclaimer in the documentation -and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE -LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGE. -*/ - -import SystemConfiguration -import Foundation - -enum ReachabilityError: Swift.Error { - case FailedToCreateWithAddress(sockaddr_in) - case FailedToCreateWithHostname(String) - case UnableToSetCallback - case UnableToSetDispatchQueue -} - -@available(*, unavailable, renamed: "Notification.Name.reachabilityChanged") -public let ReachabilityChangedNotification = NSNotification.Name("ReachabilityChangedNotification") - -extension Notification.Name { - public static let reachabilityChanged = Notification.Name("reachabilityChanged") -} - -func callback(reachability: SCNetworkReachability, flags: SCNetworkReachabilityFlags, info: UnsafeMutableRawPointer?) { - - guard let info = info else { return } - - let reachability = Unmanaged.fromOpaque(info).takeUnretainedValue() - reachability.reachabilityChanged() -} - -public class Reachability { - - public typealias NetworkReachable = (Reachability) -> Void - public typealias NetworkUnreachable = (Reachability) -> Void - - @available(*, unavailable, renamed: "Conection") - public enum NetworkStatus: CustomStringConvertible { - case notReachable, reachableViaWiFi, reachableViaWWAN - public var description: String { - switch self { - case .reachableViaWWAN: return "Cellular" - case .reachableViaWiFi: return "WiFi" - case .notReachable: return "No Connection" - } - } - } - - public enum Connection: CustomStringConvertible { - case none, wifi, cellular - public var description: String { - switch self { - case .cellular: return "Cellular" - case .wifi: return "WiFi" - case .none: return "No Connection" - } - } - } - - public var whenReachable: NetworkReachable? - public var whenUnreachable: NetworkUnreachable? - - @available(*, deprecated, renamed: "allowsCellularConnection") - public let reachableOnWWAN: Bool = true - - /// Set to `false` to force Reachability.connection to .none when on cellular connection (default value `true`) - public var allowsCellularConnection: Bool - - // The notification center on which "reachability changed" events are being posted - public var notificationCenter: NotificationCenter = NotificationCenter.default - - @available(*, deprecated, renamed: "connection.description") - public var currentReachabilityString: String { - return "\(connection)" - } - - @available(*, unavailable, renamed: "connection") - public var currentReachabilityStatus: Connection { - return connection - } - - public var connection: Connection { - - guard isReachableFlagSet else { return .none } - - // If we're reachable, but not on an iOS device (i.e. simulator), we must be on WiFi - guard isRunningOnDevice else { return .wifi } - - var connection = Connection.none - - if !isConnectionRequiredFlagSet { - connection = .wifi - } - - if isConnectionOnTrafficOrDemandFlagSet { - if !isInterventionRequiredFlagSet { - connection = .wifi - } - } - - if isOnWWANFlagSet { - if !allowsCellularConnection { - connection = .none - } else { - connection = .cellular - } - } - - return connection - } - - fileprivate var previousFlags: SCNetworkReachabilityFlags? - - fileprivate var isRunningOnDevice: Bool = { - #if targetEnvironment(simulator) - return false - #else - return true - #endif - }() - - fileprivate var notifierRunning = false - fileprivate let reachabilityRef: SCNetworkReachability - - fileprivate let reachabilitySerialQueue = DispatchQueue(label: "uk.co.ashleymills.reachability") - - public required init(reachabilityRef: SCNetworkReachability) { - allowsCellularConnection = true - self.reachabilityRef = reachabilityRef - } - - public convenience init?(hostname: String) { - - guard let ref = SCNetworkReachabilityCreateWithName(nil, hostname) else { return nil } - - self.init(reachabilityRef: ref) - } - - public convenience init?() { - - var zeroAddress = sockaddr() - zeroAddress.sa_len = UInt8(MemoryLayout.size) - zeroAddress.sa_family = sa_family_t(AF_INET) - - guard let ref = SCNetworkReachabilityCreateWithAddress(nil, &zeroAddress) else { return nil } - - self.init(reachabilityRef: ref) - } - - deinit { - stopNotifier() - } -} - -public extension Reachability { - - // MARK: - *** Notifier methods *** - func startNotifier() throws { - - guard !notifierRunning else { return } - - var context = SCNetworkReachabilityContext( - version: 0, - info: nil, - retain: nil, - release: nil, - copyDescription: nil - ) - context.info = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) - if !SCNetworkReachabilitySetCallback(reachabilityRef, callback, &context) { - stopNotifier() - throw ReachabilityError.UnableToSetCallback - } - - if !SCNetworkReachabilitySetDispatchQueue(reachabilityRef, reachabilitySerialQueue) { - stopNotifier() - throw ReachabilityError.UnableToSetDispatchQueue - } - - // Perform an initial check - reachabilitySerialQueue.async { - self.reachabilityChanged() - } - - notifierRunning = true - } - - func stopNotifier() { - defer { notifierRunning = false } - - SCNetworkReachabilitySetCallback(reachabilityRef, nil, nil) - SCNetworkReachabilitySetDispatchQueue(reachabilityRef, nil) - } - - // MARK: - *** Connection test methods *** - @available(*, deprecated, message: "Please use `connection != .none`") - var isReachable: Bool { - - guard isReachableFlagSet else { return false } - - if isConnectionRequiredAndTransientFlagSet { - return false - } - - if isRunningOnDevice { - if isOnWWANFlagSet && !reachableOnWWAN { - // We don't want to connect when on cellular connection - return false - } - } - - return true - } - - @available(*, deprecated, message: "Please use `connection == .cellular`") - var isReachableViaWWAN: Bool { - // Check we're not on the simulator, we're REACHABLE and check we're on WWAN - return isRunningOnDevice && isReachableFlagSet && isOnWWANFlagSet - } - - @available(*, deprecated, message: "Please use `connection == .wifi`") - var isReachableViaWiFi: Bool { - - // Check we're reachable - guard isReachableFlagSet else { return false } - - // If reachable we're reachable, but not on an iOS device (i.e. simulator), we must be on WiFi - guard isRunningOnDevice else { return true } - - // Check we're NOT on WWAN - return !isOnWWANFlagSet - } - - var description: String { - - let W = isRunningOnDevice ? (isOnWWANFlagSet ? "W" : "-") : "X" - let R = isReachableFlagSet ? "R" : "-" - let c = isConnectionRequiredFlagSet ? "c" : "-" - let t = isTransientConnectionFlagSet ? "t" : "-" - let i = isInterventionRequiredFlagSet ? "i" : "-" - let C = isConnectionOnTrafficFlagSet ? "C" : "-" - let D = isConnectionOnDemandFlagSet ? "D" : "-" - let l = isLocalAddressFlagSet ? "l" : "-" - let d = isDirectFlagSet ? "d" : "-" - - return "\(W)\(R) \(c)\(t)\(i)\(C)\(D)\(l)\(d)" - } -} - -fileprivate extension Reachability { - - func reachabilityChanged() { - guard previousFlags != flags else { return } - - let block = connection != .none ? whenReachable : whenUnreachable - - DispatchQueue.main.async { - block?(self) - self.notificationCenter.post(name: .reachabilityChanged, object: self) - } - - previousFlags = flags - } - - var isOnWWANFlagSet: Bool { - #if os(iOS) - return flags.contains(.isWWAN) - #else - return false - #endif - } - var isReachableFlagSet: Bool { - return flags.contains(.reachable) - } - var isConnectionRequiredFlagSet: Bool { - return flags.contains(.connectionRequired) - } - var isInterventionRequiredFlagSet: Bool { - return flags.contains(.interventionRequired) - } - var isConnectionOnTrafficFlagSet: Bool { - return flags.contains(.connectionOnTraffic) - } - var isConnectionOnDemandFlagSet: Bool { - return flags.contains(.connectionOnDemand) - } - var isConnectionOnTrafficOrDemandFlagSet: Bool { - return !flags.isDisjoint(with: ([.connectionOnTraffic, .connectionOnDemand])) - } - var isTransientConnectionFlagSet: Bool { - return flags.contains(.transientConnection) - } - var isLocalAddressFlagSet: Bool { - return flags.contains(.isLocalAddress) - } - var isDirectFlagSet: Bool { - return flags.contains(.isDirect) - } - var isConnectionRequiredAndTransientFlagSet: Bool { - return flags.intersection( - [.connectionRequired, .transientConnection] - ) == [.connectionRequired, .transientConnection] - } - - var flags: SCNetworkReachabilityFlags { - var flags = SCNetworkReachabilityFlags() - if SCNetworkReachabilityGetFlags(reachabilityRef, &flags) { - return flags - } else { - return SCNetworkReachabilityFlags() - } - } -} diff --git a/TCAT/Services/Network/ReachabilityManager.swift b/TCAT/Services/Network/ReachabilityManager.swift deleted file mode 100644 index 853a788a..00000000 --- a/TCAT/Services/Network/ReachabilityManager.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// ReachabilityManager.swift -// TCAT -// -// Created by Daniel Vebman on 11/6/19. -// Copyright © 2019 cuappdev. All rights reserved. -// - -import Foundation - -class ReachabilityManager: NSObject { - - static let shared: ReachabilityManager = ReachabilityManager() - - private let reachability = Reachability() - private var listeners: [Pair] = [] - - typealias Listener = AnyObject - typealias Closure = (Reachability.Connection) -> Void - - private struct Pair { - weak var listener: Listener? - var closure: Closure - } - - override private init() { - super.init() - - do { - try reachability?.startNotifier() - } catch { - print("[ReachabilityManager] init: Could not start reachability notifier.") - } - - NotificationCenter.default.addObserver( - self, - selector: #selector(reachabilityChanged(_:)), - name: .reachabilityChanged, - object: reachability - ) - } - - /// Adds a listener to reachability updates. - /// Reminder: Be sure to begin the closure with `[weak self]`. - func addListener(_ listener: Listener, _ closure: @escaping Closure) { - listeners.append(Pair(listener: listener, closure: closure)) - } - - @objc func reachabilityChanged(_ notification: Notification) { - guard let reachability = reachability else { return } - listeners = listeners.filter { pair -> Bool in - pair.closure(reachability.connection) // call the closures - return pair.listener != nil // remove closures for deinitialized listeners - } - } - -} diff --git a/TCAT/Services/Network/Models.swift b/TCAT/Services/Network/RequestModels.swift similarity index 95% rename from TCAT/Services/Network/Models.swift rename to TCAT/Services/Network/RequestModels.swift index 295a0e8d..3c536643 100644 --- a/TCAT/Services/Network/Models.swift +++ b/TCAT/Services/Network/RequestModels.swift @@ -86,3 +86,8 @@ internal struct Delay: Codable { let tripID: String let delay: Int? } + +struct APIResponse: Decodable { + var success: Bool + var data: T +} diff --git a/TCAT/Services/Providers/TransitService.swift b/TCAT/Services/Providers/TransitService.swift deleted file mode 100644 index 9e160178..00000000 --- a/TCAT/Services/Providers/TransitService.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// Services.swift -// TCAT -// -// Created by Jayson Hahn on 9/16/24. -// Copyright © 2024 Cornell AppDev. All rights reserved. -// - -import Foundation -import Combine - -protocol TransitServiceProtocol: AnyObject { - func getAllStops() -> AnyPublisher<[Place], APIErrorHandler> - func getAlerts() -> AnyPublisher<[ServiceAlert], APIErrorHandler> - func getRoutes(start: Place, end: Place, time: Date, type: SearchType) -> AnyPublisher - func getAppleSearchResults(searchText: String) -> AnyPublisher - func updateApplePlacesCache(searchText: String, places: [Place]) -> AnyPublisher - func getBusLocations(_ directions: [Direction]) -> AnyPublisher - func getDelay(tripID: String, stopID: String) -> AnyPublisher - func getAllDelays(trips: [Trip]) -> AnyPublisher -} - -class TransitService: TransitServiceProtocol { - - private let networkManager: NetworkManager - - init(networkManager: NetworkManager) { - self.networkManager = networkManager - } - - func getAllStops() -> AnyPublisher<[Place], APIErrorHandler> { - let request = TransitProvider.allStops.makeRequest - return networkManager.performRequest(request, decodingType: [Place].self) - } - - func getAlerts() -> AnyPublisher<[ServiceAlert], APIErrorHandler> { - let request = TransitProvider.alerts.makeRequest - return networkManager.performRequest(request, decodingType: [ServiceAlert].self) - } - - func getRoutes(start: Place, end: Place, time: Date, type: SearchType) -> AnyPublisher { - let uid = sharedUserDefaults?.string(forKey: Constants.UserDefaults.uid) - let body = GetRoutesBody( - arriveBy: type == .arriveBy, - end: "\(end.latitude),\(end.longitude)", - start: "\(start.latitude),\(start.longitude)", - time: time.timeIntervalSince1970, - destinationName: end.name, - originName: start.name, - uid: uid - ) - let request = TransitProvider.routes(body).makeRequest - return networkManager.performRequest(request, decodingType: RouteSectionsObject.self) - } - - func getAppleSearchResults(searchText: String) -> AnyPublisher { - let request = TransitProvider.appleSearch(searchText).makeRequest - return networkManager.performRequest(request, decodingType: AppleSearchResponse.self) - } - - func updateApplePlacesCache(searchText: String, places: [Place]) -> AnyPublisher { - let body = ApplePlacesBody(query: searchText, places: places) - let request = TransitProvider.applePlaces(body).makeRequest - return networkManager.performRequest(request, decodingType: Bool.self) - } - - func getBusLocations(_ directions: [Direction]) -> AnyPublisher { - let departDirections = directions.filter { $0.type == .depart && $0.tripIdentifiers != nil } - - let locationsInfo = departDirections.map { direction -> BusLocationsInfo in - // The id of the location, or bus stop, the bus needs to get to - let stopID = direction.stops.first?.id ?? "-1" - return BusLocationsInfo( - stopID: stopID, - routeID: String(direction.routeNumber), - tripIdentifiers: direction.tripIdentifiers! - ) - } - - let body = GetBusLocationsBody(data: locationsInfo) - let request = TransitProvider.applePlaces(body).makeRequest - return networkManager.performRequest(request, decodingType: BusLocation.self) - } - - func getDelay(tripID: String, stopID: String) -> AnyPublisher { - let body = GetDelayBody(stopID: stopID, tripID: tripID) - let request = TransitProvider.delay(body).makeRequest - return networkManager.performRequest(request, decodingType: Int?.self) - } - - func getAllDelays(trips: [Trip]) -> AnyPublisher { - let body = TripBody(data: trips) - let request = TransitProvider.delay(body).makeRequest - return networkManager.performRequest(request, decodingType: Delay.self) - } - -} diff --git a/TCAT/Services/Providers/TransitProvider.swift b/TCAT/Services/Transit/TransitProvider.swift similarity index 99% rename from TCAT/Services/Providers/TransitProvider.swift rename to TCAT/Services/Transit/TransitProvider.swift index a027c9b4..cfb1267d 100644 --- a/TCAT/Services/Providers/TransitProvider.swift +++ b/TCAT/Services/Transit/TransitProvider.swift @@ -94,7 +94,7 @@ extension TransitProvider: ApiEndpoint { case .alerts, .allStops: return .GET default: - return .PUT + return .POST } } diff --git a/TCAT/Services/Transit/TransitService.swift b/TCAT/Services/Transit/TransitService.swift new file mode 100644 index 00000000..06ef84fd --- /dev/null +++ b/TCAT/Services/Transit/TransitService.swift @@ -0,0 +1,157 @@ +// +// Services.swift +// TCAT +// +// Created by Jayson Hahn on 9/16/24. +// Copyright © 2024 Cornell AppDev. All rights reserved. +// + +import Foundation +import Combine + +protocol TransitServiceProtocol: AnyObject { + func getAllDelays(trips: [Trip], refreshInterval: TimeInterval) -> AnyPublisher<[Delay], ApiErrorHandler> + func getAllStops() -> AnyPublisher<[Place], ApiErrorHandler> + func getAlerts() -> AnyPublisher<[ServiceAlert], ApiErrorHandler> + func getAppleSearchResults(searchText: String) -> AnyPublisher + func getBusLocations( + _ directions: [Direction], + refreshInterval: TimeInterval + ) -> AnyPublisher< + [BusLocation], + ApiErrorHandler + > + func getDelay(tripID: String, stopID: String, refreshInterval: TimeInterval) -> AnyPublisher + func getRoutes( + start: Place, + end: Place, + time: Date, + type: SearchType + ) -> AnyPublisher< + RouteSectionsObject, + ApiErrorHandler + > + func updateApplePlacesCache(searchText: String, places: [Place]) -> AnyPublisher + +} + +// MARK: - TransitService Implementation + +class TransitService: TransitServiceProtocol { + + // Singleton instance + static var shared = TransitService(networkManager: NetworkManager()) + + // Network manager instance + private let networkManager: NetworkManager + + // Initializer + init(networkManager: NetworkManager) { + self.networkManager = networkManager + } + + func getAllDelays(trips: [Trip], refreshInterval: TimeInterval = 10.0) -> AnyPublisher<[Delay], ApiErrorHandler> { + let body = TripBody(data: trips) + let request = TransitProvider.allDelays(body).makeRequest + + return Timer.publish(every: refreshInterval, on: .main, in: .default) + .autoconnect() + .flatMap { _ in + self.networkManager.request(request, decodingType: [Delay].self) + } + .eraseToAnyPublisher() + } + + func getAllStops() -> AnyPublisher<[Place], ApiErrorHandler> { + let request = TransitProvider.allStops.makeRequest + return networkManager.request(request, decodingType: [Place].self) + } + + func getAlerts() -> AnyPublisher<[ServiceAlert], ApiErrorHandler> { + let request = TransitProvider.alerts.makeRequest + return networkManager.request(request, decodingType: [ServiceAlert].self) + } + + func getAppleSearchResults(searchText: String) -> AnyPublisher { + let body = SearchResultsBody(query: searchText) + let request = TransitProvider.appleSearch(body).makeRequest + return networkManager.request(request, decodingType: AppleSearchResponse.self) + } + + func getBusLocations( + _ directions: [Direction], + refreshInterval: TimeInterval = 5.0 + ) -> AnyPublisher< + [BusLocation], + ApiErrorHandler + > { + let departDirections = directions.filter { $0.type == .depart && $0.tripIdentifiers != nil } + + let locationsInfo = departDirections.map { direction -> BusLocationsInfo in + let stopID = direction.stops.first?.id ?? "-1" + return BusLocationsInfo( + stopID: stopID, + routeID: String(direction.routeNumber), + tripIdentifiers: direction.tripIdentifiers! + ) + } + + let body = GetBusLocationsBody(data: locationsInfo) + let request = TransitProvider.busLocations(body).makeRequest + + return Timer.publish(every: refreshInterval, on: .main, in: .default) + .autoconnect() + .flatMap { _ in + self.networkManager.request(request, decodingType: [BusLocation].self) + } + .eraseToAnyPublisher() + } + + func getDelay( + tripID: String, + stopID: String, + refreshInterval: TimeInterval = 10.0 + ) -> AnyPublisher< + Int?, + ApiErrorHandler + > { + let body = GetDelayBody(stopID: stopID, tripID: tripID) + let request = TransitProvider.delay(body).makeRequest + + return Timer.publish(every: refreshInterval, on: .main, in: .default) + .autoconnect() + .flatMap { _ in + self.networkManager.request(request, decodingType: Int?.self) + } + .eraseToAnyPublisher() + } + + func getRoutes( + start: Place, + end: Place, + time: Date, + type: SearchType + ) -> AnyPublisher< + RouteSectionsObject, + ApiErrorHandler + > { + let uid = sharedUserDefaults?.string(forKey: Constants.UserDefaults.uid) + let body = GetRoutesBody( + arriveBy: type == .arriveBy, + end: "\(end.latitude),\(end.longitude)", + start: "\(start.latitude),\(start.longitude)", + time: time.timeIntervalSince1970, + destinationName: end.name, + originName: start.name, + uid: uid + ) + let request = TransitProvider.routes(body).makeRequest + return networkManager.request(request, decodingType: RouteSectionsObject.self) + } + + func updateApplePlacesCache(searchText: String, places: [Place]) -> AnyPublisher { + let body = ApplePlacesBody(query: searchText, places: places) + let request = TransitProvider.applePlaces(body).makeRequest + return networkManager.request(request, decodingType: Bool.self) + } +} diff --git a/TCAT/Utils/Extensions+App.swift b/TCAT/Utils/Extensions+App.swift index 0780c3eb..37ce7e0a 100755 --- a/TCAT/Utils/Extensions+App.swift +++ b/TCAT/Utils/Extensions+App.swift @@ -241,9 +241,12 @@ func presentShareSheet( let thirdParamName: String = ( destination.type == .busStop ) ? "stopName" : "destinationName" - let destination = destination.name - - let promotionalText = "ithaca-transit://getRoutes?lat=\(lat)&long=\(long)&\(thirdParamName)=\(destination)" + let destType = ( + destination.type == .busStop + ) ? "busStop" : "applePlace" + let dest = destination.name + let formattedDestination = dest.split(separator: " ").joined(separator: "%") + let promotionalText = "ithaca-transit://getRoutes?lat=\(lat)&long=\(long)&\(thirdParamName)=\(formattedDestination)&destinationType=\(destType)" var activityItems: [Any] = [promotionalText] From 7260bf280521e74fab819716c9961d10b6c8b1b9 Mon Sep 17 00:00:00 2001 From: Jayson Hahn <46629787+JaysonHahn@users.noreply.github.com> Date: Sat, 2 Nov 2024 16:44:03 -0400 Subject: [PATCH 03/13] Fix code styling --- TCAT/Base/AppDelegate.swift | 42 ++--------- TCAT/Cells/GeneralTableViewCell.swift | 2 + .../NotificationToggleTableViewCell.swift | 2 + .../CustomNavigationController.swift | 9 ++- .../FavoritesTableViewController.swift | 4 +- ...OptionsCardViewController+Extensions.swift | 25 +++++-- .../HomeOptionsCardViewController.swift | 14 ++-- .../InformationViewController.swift | 2 +- .../ParentHomeViewController.swift | 12 ---- .../RouteDetail+ContentViewController.swift | 53 ++++++++------ .../RouteDetail+DrawerViewController.swift | 8 ++- ...etailDrawerViewController+Extensions.swift | 7 ++ ...outeOptionsViewController+Extensions.swift | 17 +++-- .../RouteOptionsViewController.swift | 29 +++++--- .../SearchResultsViewController.swift | 16 +++-- .../ServiceAlertsViewController.swift | 5 ++ .../StopPickerViewController.swift | 11 +-- TCAT/Models/Direction.swift | 3 + TCAT/Models/SearchManager.swift | 8 ++- TCAT/Models/Section.swift | 36 +++++++--- TCAT/Models/Waypoint.swift | 6 ++ TCAT/Services/Network/ApiEndpoint.swift | 46 ++++++++++-- TCAT/Services/Network/ApiErrorHandler.swift | 15 +++- TCAT/Services/Network/NetworkManager.swift | 24 ++++--- TCAT/Services/Network/NetworkMonitor.swift | 14 +++- TCAT/Services/Transit/TransitProvider.swift | 28 ++++++++ TCAT/Services/Transit/TransitService.swift | 72 +++++++++++++------ TCAT/Utils/Extensions+App.swift | 9 ++- TCAT/Utils/Extensions+Shared.swift | 27 +++++++ TCAT/Utils/JSONFileManager.swift | 1 + TCAT/Utils/SearchTableViewHelpers.swift | 20 ++---- TCAT/Utils/Shared.swift | 3 - TCAT/Utils/StoreReviewHelper.swift | 1 + TCAT/Utils/Styles.swift | 30 +++++--- TCAT/Views/BusIcon.swift | 30 ++++++-- TCAT/Views/Circle.swift | 2 + TCAT/Views/DatePickerView.swift | 5 +- TCAT/Views/HeaderView.swift | 1 + TCAT/Views/NotificationBannerView.swift | 4 ++ 39 files changed, 444 insertions(+), 199 deletions(-) diff --git a/TCAT/Base/AppDelegate.swift b/TCAT/Base/AppDelegate.swift index 7b0b8726..33c29968 100755 --- a/TCAT/Base/AppDelegate.swift +++ b/TCAT/Base/AppDelegate.swift @@ -43,10 +43,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Log basic information let payload = AppLaunchedPayload() TransitAnalytics.shared.log(payload) - setupUniqueIdentifier() + + // Initialize uid in UserDefaults values if needed + userDefaults.setupUniqueIdentifier() // Initialize UserDefaults values if needed - initializeUserDefaults() + userDefaults.initialize(with: userDataInits) // Track number of app opens for Store Review prompt StoreReviewHelper.incrementAppOpenedCount() @@ -64,15 +66,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { let navigationController = showOnboarding ? OnboardingNavigationController(rootViewController: rootVC) : CustomNavigationController(rootViewController: rootVC) - // Setup networking for AppDevAnnouncements - // TODO: Set up announcements once it's done - // AnnouncementNetworking.setupConfig( - // scheme: TransitEnvironment.announcementsScheme, - // host: TransitEnvironment.announcementsHost, - // commonPath: TransitEnvironment.announcementsCommonPath, - // announcementPath: TransitEnvironment.announcementsPath - // ) - // Initalize window without storyboard self.window = UIWindow(frame: UIScreen.main.bounds) self.window?.rootViewController = navigationController @@ -87,29 +80,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // MARK: - Helper Functions - /// Initializes the UserDefaults values if not present - private func initializeUserDefaults() { - for (key, defaultValue) in userDataInits { - if userDefaults.value(forKey: key) == nil { - if key == Constants.UserDefaults.favorites && sharedUserDefaults?.value(forKey: key) == nil { - sharedUserDefaults?.set(defaultValue, forKey: key) - } else { - userDefaults.set(defaultValue, forKey: key) - } - } else if key == Constants.UserDefaults.favorites && sharedUserDefaults?.value(forKey: key) == nil { - sharedUserDefaults?.set(userDefaults.value(forKey: key), forKey: key) - } - } - } - - /// Creates and sets a unique identifier. If the device identifier changes, updates it. - private func setupUniqueIdentifier() { - if let uid = UIDevice.current.identifierForVendor?.uuidString, - uid != sharedUserDefaults?.string(forKey: Constants.UserDefaults.uid) { - sharedUserDefaults?.set(uid, forKey: Constants.UserDefaults.uid) - } - } - private func handleShortcut(item: UIApplicationShortcutItem) { if let shortcutData = item.userInfo as? [String: Data] { guard let place = shortcutData["place"], @@ -130,7 +100,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { // URLs for testing - // BusStop: ithaca-transit://getRoutes?lat=42.442558&long=-76.485336&stopName=Collegetown + // BusStop: ithaca-transit://getRoutes?lat=42.442558&long=-76.485336&stopName=Collegetown&destinationType=busStop // PlaceResult: ithaca-transit://getRoutes?lat=42.4440892&long=-76.4847823&destinationName=Hollister%Hall&destinationType=applePlace let rootVC = HomeMapViewController() @@ -162,7 +132,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } - if let latitude = latitude, let longitude = longitude, let destination = destination { + if let latitude, let longitude, let destination { let place = Place(name: destination, type: placeType, latitude: latitude, longitude: longitude) let optionsVC = RouteOptionsViewController(searchTo: place) navigationController.pushViewController(optionsVC, animated: false) diff --git a/TCAT/Cells/GeneralTableViewCell.swift b/TCAT/Cells/GeneralTableViewCell.swift index 3d446c18..f714012e 100644 --- a/TCAT/Cells/GeneralTableViewCell.swift +++ b/TCAT/Cells/GeneralTableViewCell.swift @@ -49,9 +49,11 @@ class GeneralTableViewCell: UITableViewCell { case .seeAllStops: titleLabel.text = Constants.General.seeAllStops iconView.image = #imageLiteral(resourceName: "list") + case .currentLocation: titleLabel.text = Constants.General.currentLocation iconView.image = #imageLiteral(resourceName: "location") + default: break } } diff --git a/TCAT/Cells/NotificationToggleTableViewCell.swift b/TCAT/Cells/NotificationToggleTableViewCell.swift index 2f095972..78642c88 100644 --- a/TCAT/Cells/NotificationToggleTableViewCell.swift +++ b/TCAT/Cells/NotificationToggleTableViewCell.swift @@ -91,8 +91,10 @@ class NotificationToggleTableViewCell: UITableViewCell { switch type { case .beforeBoarding: delegate?.displayNotificationBanner(type: .beforeBoardingConfirmation) + case .delay: delegate?.displayNotificationBanner(type: .delayConfirmation) + default: break } } diff --git a/TCAT/Controllers/CustomNavigationController.swift b/TCAT/Controllers/CustomNavigationController.swift index 6fc073c7..da000260 100644 --- a/TCAT/Controllers/CustomNavigationController.swift +++ b/TCAT/Controllers/CustomNavigationController.swift @@ -65,7 +65,14 @@ class CustomNavigationController: UINavigationController, UINavigationController TransitAnalytics.shared.log(payload) } - NotificationCenter.default.addObserver(self, selector: #selector(handleReachabilityChange), name: .reachabilityChanged, object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector( + handleReachabilityChange + ), + name: .reachabilityChanged, + object: nil + ) } override func viewWillDisappear(_ animated: Bool) { diff --git a/TCAT/Controllers/FavoritesTableViewController.swift b/TCAT/Controllers/FavoritesTableViewController.swift index f015c7e9..0ca88a05 100644 --- a/TCAT/Controllers/FavoritesTableViewController.swift +++ b/TCAT/Controllers/FavoritesTableViewController.swift @@ -165,7 +165,7 @@ extension FavoritesTableViewController: UISearchBarDelegate { currentSearchCancellable = SearchManager.shared.search(for: searchText) .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] result in + .sink { [weak self] result in guard let self = self else { return } switch result { @@ -175,7 +175,7 @@ extension FavoritesTableViewController: UISearchBarDelegate { case .failure(let error): print("[FavoritesTableViewController] Search failed: \(error.errorDescription)") } - }) + } } // Update UI with the new search results diff --git a/TCAT/Controllers/HomeOptionsCardViewController+Extensions.swift b/TCAT/Controllers/HomeOptionsCardViewController+Extensions.swift index d247a71b..37ad26fb 100644 --- a/TCAT/Controllers/HomeOptionsCardViewController+Extensions.swift +++ b/TCAT/Controllers/HomeOptionsCardViewController+Extensions.swift @@ -65,6 +65,7 @@ extension HomeOptionsCardViewController: UISearchBarDelegate { updateSections() return } + startSearch(for: searchText) } @@ -120,10 +121,17 @@ extension HomeOptionsCardViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { switch sections[section] { - case .seeAllStops: return 1 - case .recentSearches: return recentLocations.count - case .searchResults: return sections[section].getItems().count - default: return 0 + case .seeAllStops: + return 1 + + case .recentSearches: + return recentLocations.count + + case .searchResults: + return sections[section].getItems().count + + default: + return 0 } } @@ -137,6 +145,7 @@ extension HomeOptionsCardViewController: UITableViewDataSource { ) as? GeneralTableViewCell else { return UITableViewCell() } cell.configure(for: .seeAllStops) return cell + default: // Recent searches, etc. guard let cell = tableView.dequeueReusableCell( withIdentifier: Constants.Cells.placeIdentifier @@ -174,8 +183,10 @@ extension HomeOptionsCardViewController: UITableViewDelegate { switch sections[section] { case .recentSearches: return headerHeight + case .seeAllStops: return HeaderView.separatorViewHeight + default: return 0 } @@ -190,10 +201,13 @@ extension HomeOptionsCardViewController: UITableViewDelegate { separatorVisible: true, delegate: self ) + case .seeAllStops: return HeaderView(separatorVisible: true) + case .searchResults: return nil + default: return nil } @@ -207,6 +221,7 @@ extension HomeOptionsCardViewController: UITableViewDelegate { switch section { case .recentSearches: return .delete + default: return .none } @@ -223,6 +238,7 @@ extension HomeOptionsCardViewController: UITableViewDelegate { let place = sections[indexPath.section].getItems()[indexPath.row] recentLocations = Global.shared.deleteRecent(recent: place, allRecents: recentLocations) updateSections() + default: break } } @@ -237,6 +253,7 @@ extension HomeOptionsCardViewController: UITableViewDelegate { self.navigationController?.pushViewController(optionsVC, animated: true) } navigationController?.pushViewController(stopPickerVC, animated: true) + default: if let searchText = searchBar.text { let payload = SearchResultSelectedPayload( diff --git a/TCAT/Controllers/HomeOptionsCardViewController.swift b/TCAT/Controllers/HomeOptionsCardViewController.swift index f7626286..292b112c 100644 --- a/TCAT/Controllers/HomeOptionsCardViewController.swift +++ b/TCAT/Controllers/HomeOptionsCardViewController.swift @@ -30,7 +30,6 @@ class HomeOptionsCardViewController: UIViewController { private var searchResultsSection: Section! var currentLocation: CLLocation? { return delegate?.getCurrentLocation() } -// var timer: Timer? var isNetworkDown = false private var currentSearchCancellable: AnyCancellable? @@ -130,14 +129,13 @@ class HomeOptionsCardViewController: UIViewController { @objc func handleReachabilityChange() { if NetworkMonitor.shared.isReachable { - self.isNetworkDown = false self.updateSections() - self.searchBar.isUserInteractionEnabled = true } else { - self.isNetworkDown = true - self.searchBar.isUserInteractionEnabled = false self.sections = [] } + + self.isNetworkDown = !NetworkMonitor.shared.isReachable + self.searchBar.isUserInteractionEnabled = NetworkMonitor.shared.isReachable self.setNeedsStatusBarAppearanceUpdate() } @@ -241,8 +239,10 @@ class HomeOptionsCardViewController: UIViewController { switch section { case .recentSearches: return headerHeight + tableViewRowHeight * CGFloat(section.getItems().count) + result + case .seeAllStops: return HeaderView.separatorViewHeight + tableViewRowHeight + result + default: return tableViewRowHeight * CGFloat(section.getItems().count) + result } @@ -302,7 +302,7 @@ class HomeOptionsCardViewController: UIViewController { currentSearchCancellable = SearchManager.shared.search(for: searchText) .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] result in + .sink { [weak self] result in guard let self = self else { return } switch result { @@ -314,7 +314,7 @@ class HomeOptionsCardViewController: UIViewController { case .failure(let error): print("Search error: \(error.errorDescription)") } - }) + } } // MARK: - Keyboard diff --git a/TCAT/Controllers/InformationViewController.swift b/TCAT/Controllers/InformationViewController.swift index c27bede4..47d0701a 100644 --- a/TCAT/Controllers/InformationViewController.swift +++ b/TCAT/Controllers/InformationViewController.swift @@ -183,7 +183,7 @@ class InformationViewController: UIViewController { guard let URL = URL(string: url) else { return } - + if inApp { let safariViewController = SFSafariViewController(url: URL) UIApplication.shared.windows.filter {$0.isKeyWindow}.first?.presentInApp(safariViewController) diff --git a/TCAT/Controllers/ParentHomeViewController.swift b/TCAT/Controllers/ParentHomeViewController.swift index ce1f486c..83f02fed 100644 --- a/TCAT/Controllers/ParentHomeViewController.swift +++ b/TCAT/Controllers/ParentHomeViewController.swift @@ -11,18 +11,6 @@ import UIKit class ParentHomeMapViewController: PulleyViewController { - override func viewDidLoad() { - super.viewDidLoad() - - // Present announcement if there are any new ones to present - // TODO: Set up announcements once it's done -// presentAnnouncement { presented in -// if presented { -// TransitAnalytics.shared.log(AnnouncementPresentedPayload()) -// } -// } - } - required init(contentViewController: UIViewController, drawerViewController: UIViewController) { super.init(contentViewController: contentViewController, drawerViewController: drawerViewController) } diff --git a/TCAT/Controllers/RouteDetail+ContentViewController.swift b/TCAT/Controllers/RouteDetail+ContentViewController.swift index 018c15ab..34667d1b 100755 --- a/TCAT/Controllers/RouteDetail+ContentViewController.swift +++ b/TCAT/Controllers/RouteDetail+ContentViewController.swift @@ -28,7 +28,7 @@ class RouteDetailContentViewController: UIViewController { private var cancellables = Set() var currentLocation: CLLocationCoordinate2D? var directions: [Direction] = [] - var endDestination: Place! + var endDestination: Place /// Number of seconds to wait before auto-refreshing live tracking network call call, timed with live indicator var liveTrackingNetworkRefreshRate: Double = LiveIndicator.interval * 1.0 var liveTrackingNetworkTimer: Timer? @@ -51,16 +51,16 @@ class RouteDetailContentViewController: UIViewController { /// dummy data will be used. The directions parameter have logical assumptions, /// such as ArriveDirection always comes after DepartDirection. init(route: Route, endDestination: Place, currentLocation: CLLocationCoordinate2D?, routeOptionsCell: RouteTableViewCell?) { - super.init(nibName: nil, bundle: nil) self.routeOptionsCell = routeOptionsCell self.endDestination = endDestination + super.init(nibName: nil, bundle: nil) initializeRoute(route, currentLocation) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { super.viewDidLoad() @@ -190,8 +190,9 @@ class RouteDetailContentViewController: UIViewController { // Fetch bus locations using the TransitService TransitService.shared.getBusLocations(route.directions) .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { [weak self] completion in + .sink { [weak self] completion in guard let self = self else { return } + if case .failure(let error) = completion { self.printClass(context: "\(#function) error", message: error.localizedDescription) if let banner = self.banner, !banner.isDisplaying { @@ -204,14 +205,16 @@ class RouteDetailContentViewController: UIViewController { ) TransitAnalytics.shared.log(payload) } - }, receiveValue: { [weak self] busLocations in + } receiveValue: { [weak self] busLocations in guard let self = self else { return } + if busLocations.isEmpty { // Reset banner in case of transition from Error to Online - No Bus Locations self.hideBanner() } + self.parseBusLocationsData(data: busLocations) - }) + } .store(in: &cancellables) bounceIndicators() @@ -224,6 +227,7 @@ class RouteDetailContentViewController: UIViewController { if !self.noDataRouteList.contains(busLocation.routeNumber) { self.noDataRouteList.append(busLocation.routeNumber) } + case .invalidData: if let previouslyUnavailableRoute = self.noDataRouteList.firstIndex(of: busLocation.routeNumber) { self.noDataRouteList.remove(at: previouslyUnavailableRoute) @@ -442,14 +446,28 @@ class RouteDetailContentViewController: UIViewController { func setIndex(of marker: GMSMarker, with waypointType: WaypointType) { marker.zIndex = { switch waypointType { - case .bus: return 1 - case .walk: return 1 - case .origin: return 3 - case .destination: return 3 - case .stop: return 1 - case .walking: return 0 + case .bus: + return 1 + + case .walk: + return 1 + + case .origin: + return 3 + + case .destination: + return 3 + + case .stop: + return 1 + + case .walking: + return 0 + // For live bus icon / indicators - case .bussing: return 999 // large constant to place above other elements + case .bussing: + return 999 // large constant to place above other elements + default: return 0 } }() @@ -474,11 +492,4 @@ class RouteDetailContentViewController: UIViewController { return drawerDisplayController } -// required convenience init(coder aDecoder: NSCoder) { -// guard let route = aDecoder.decodeObject(forKey: "route") as? Route -// else { fatalError("init(coder:) has not been implemented") } -// -// self.init(route: route, endDestination: , currentLocation: nil, routeOptionsCell: nil) -// } - } diff --git a/TCAT/Controllers/RouteDetail+DrawerViewController.swift b/TCAT/Controllers/RouteDetail+DrawerViewController.swift index 8c005b66..12bcddd1 100644 --- a/TCAT/Controllers/RouteDetail+DrawerViewController.swift +++ b/TCAT/Controllers/RouteDetail+DrawerViewController.swift @@ -179,6 +179,7 @@ class RouteDetailDrawerViewController: UIViewController { /// Fetch delay information and update table view cells. private func getDelays() { + // First depart direction(s) guard let delayDirection = route.getFirstDepartRawDirection() else { return // Use rawDirection (preserves first stop metadata) @@ -195,8 +196,9 @@ class RouteDetailDrawerViewController: UIViewController { let stopId = delayDirection.stops.first?.id { TransitService.shared.getDelay(tripID: tripId, stopID: stopId, refreshInterval: busDelayNetworkRefreshRate) .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { [weak self] completion in + .sink { [weak self] completion in guard let self = self else { return } + if case .failure(let error) = completion { self.printClass(context: "\(#function) error", message: error.localizedDescription) let payload = NetworkErrorPayload( @@ -206,7 +208,7 @@ class RouteDetailDrawerViewController: UIViewController { ) TransitAnalytics.shared.log(payload) } - }, receiveValue: { [weak self] delay in + } receiveValue: { [weak self] delay in guard let self = self else { return } delayDirection.delay = delay @@ -226,7 +228,7 @@ class RouteDetailDrawerViewController: UIViewController { self.tableView.reloadData() self.summaryView.updateTimes(for: self.route) - }) + } .store(in: &cancellables) } } diff --git a/TCAT/Controllers/RouteDetailDrawerViewController+Extensions.swift b/TCAT/Controllers/RouteDetailDrawerViewController+Extensions.swift index c160ac94..9747b33e 100644 --- a/TCAT/Controllers/RouteDetailDrawerViewController+Extensions.swift +++ b/TCAT/Controllers/RouteDetailDrawerViewController+Extensions.swift @@ -24,8 +24,10 @@ extension RouteDetailDrawerViewController: UIGestureRecognizerDelegate { } else { drawer.setDrawerPosition(position: .open, animated: true) } + case .open: drawer.setDrawerPosition(position: .collapsed, animated: true) + default: break } } @@ -127,6 +129,7 @@ extension RouteDetailDrawerViewController: PulleyDrawerViewControllerDelegate { } else { contentViewController.centerMapOnOverview(drawerPreviewing: drawerPosition == .partiallyRevealed) } + default: break } } @@ -171,6 +174,7 @@ extension RouteDetailDrawerViewController: UITableViewDataSource { else { return UITableViewCell() } cell.configure(for: busStop.name) return cell + case .direction(let direction): switch direction.type { case .walk, .arrive: @@ -184,6 +188,7 @@ extension RouteDetailDrawerViewController: UITableViewDataSource { isLastStep: indexPath.row == section.items.count - 1 ) return cell + default: guard let cell = tableView.dequeueReusableCell( withIdentifier: Constants.Cells.largeDetailCellIdentifier @@ -196,6 +201,7 @@ extension RouteDetailDrawerViewController: UITableViewDataSource { ) return cell } + case .notificationType(let type): guard let cell = tableView.dequeueReusableCell( withIdentifier: Constants.Cells.notificationToggleCellIdentifier @@ -224,6 +230,7 @@ extension RouteDetailDrawerViewController: UITableViewDelegate { } else { return RouteDetailCellSize.smallHeight } + case .notification: return notificationCellHeight } } diff --git a/TCAT/Controllers/RouteOptionsViewController+Extensions.swift b/TCAT/Controllers/RouteOptionsViewController+Extensions.swift index 08c66e6d..21a277a5 100644 --- a/TCAT/Controllers/RouteOptionsViewController+Extensions.swift +++ b/TCAT/Controllers/RouteOptionsViewController+Extensions.swift @@ -72,6 +72,7 @@ extension RouteOptionsViewController: DestinationDelegate { switch searchType { case .from: searchFrom = place + case .to: searchTo = place } @@ -114,9 +115,14 @@ extension RouteOptionsViewController: DatePickerViewDelegate { routeSelection.setDatepickerTitle(withDate: date, withSearchTimeType: searchTimeType) var buttonTapped = "" switch searchType { - case .arriveBy: buttonTapped = "Arrive By Tapped" - case .leaveAt: buttonTapped = "Leave At Tapped" - case .leaveNow: buttonTapped = "Leave Now Tapped" + case .arriveBy: + buttonTapped = "Arrive By Tapped" + + case .leaveAt: + buttonTapped = "Leave At Tapped" + + case .leaveNow: + buttonTapped = "Leave Now Tapped" } dismissDatePicker() @@ -297,7 +303,10 @@ extension RouteOptionsViewController: UITableViewDelegate { } else { return Constants.TableHeaders.boardingSoonFromNearby } - case 2: return Constants.TableHeaders.walking + + case 2: + return Constants.TableHeaders.walking + default: return nil } } diff --git a/TCAT/Controllers/RouteOptionsViewController.swift b/TCAT/Controllers/RouteOptionsViewController.swift index 3c75fb39..5b017036 100755 --- a/TCAT/Controllers/RouteOptionsViewController.swift +++ b/TCAT/Controllers/RouteOptionsViewController.swift @@ -296,6 +296,7 @@ class RouteOptionsViewController: UIViewController { searchBarText = startingDestinationName } placeholder = Constants.General.fromSearchBarPlaceholder + case .to: let endingDestinationName = searchTo.name if endingDestinationName != Constants.General.currentLocation { @@ -354,8 +355,9 @@ class RouteOptionsViewController: UIViewController { private func updateAllRoutesLiveTracking() { TransitService.shared.getAllDelays(trips: trips, refreshInterval: busDelaysNetworkRefreshRate) .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { [weak self] completion in + .sink { [weak self] completion in guard let self = self else { return } + if case .failure(let error) = completion { let payload = NetworkErrorPayload( location: "\(self) Get All Delays", @@ -365,7 +367,7 @@ class RouteOptionsViewController: UIViewController { TransitAnalytics.shared.log(payload) self.printClass(context: "\(#function) error", message: error.localizedDescription) } - }, receiveValue: { [weak self] delays in + } receiveValue: { [weak self] delays in guard let self = self else { return } for delayResponse in delays { @@ -379,17 +381,17 @@ class RouteOptionsViewController: UIViewController { let delayedDepartTime = departTime.addingTimeInterval(TimeInterval(delay)) let delayState: DelayState - if delayedDepartTime > departTime { - delayState = .late(date: delayedDepartTime) - } else { - delayState = .onTime(date: departTime) - } + delayState = delayedDepartTime > departTime ? .late( + date: delayedDepartTime + ) : .late( + date: departTime + ) self.delayDictionary[routeId] = delayState route.getFirstDepartRawDirection()?.delay = delay } } - }) + } .store(in: &cancellables) } @@ -427,6 +429,7 @@ class RouteOptionsViewController: UIViewController { switch searchType { case .from: routeSelection.updateSearchBarTitles(from: searchFrom.name) + case .to: routeSelection.updateSearchBarTitles(to: searchTo.name) } @@ -500,15 +503,17 @@ class RouteOptionsViewController: UIViewController { private func processRequest(start: Place, end: Place, time: Date, type: SearchType) { TransitService.shared.getRoutes(start: start, end: end, time: time, type: type) .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { [weak self] completion in + .sink { [weak self] completion in guard let self = self else { return } + switch completion { case .failure(let error): self.processRequestError(error: error) + case .finished: break } - }, receiveValue: { [weak self] response in + } receiveValue: { [weak self] response in guard let self = self else { return } // Parse sections of routes @@ -528,7 +533,7 @@ class RouteOptionsViewController: UIViewController { // Log analytics let payload = DestinationSearchedEventPayload(destination: end.name) TransitAnalytics.shared.log(payload) - }) + } .store(in: &cancellables) } @@ -581,6 +586,7 @@ class RouteOptionsViewController: UIViewController { let action = UIAlertAction(title: actionTitle, style: .cancel, handler: nil) alertController.addAction(action) present(alertController, animated: true, completion: nil) + case .showError(bannerInfo: let bannerInfo, payload: let payload): banner = StatusBarNotificationBanner(title: bannerInfo.title, style: bannerInfo.style) banner?.autoDismiss = false @@ -591,6 +597,7 @@ class RouteOptionsViewController: UIViewController { ) TransitAnalytics.shared.log(payload) + case .hideBanner: banner?.dismiss() banner = nil diff --git a/TCAT/Controllers/SearchResultsViewController.swift b/TCAT/Controllers/SearchResultsViewController.swift index 13e4e581..98927ef7 100755 --- a/TCAT/Controllers/SearchResultsViewController.swift +++ b/TCAT/Controllers/SearchResultsViewController.swift @@ -157,7 +157,7 @@ class SearchResultsViewController: UIViewController { currentSearchCancellable = SearchManager.shared.search(for: searchText) .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] result in + .sink { [weak self] result in guard let self = self else { return } switch result { @@ -167,7 +167,7 @@ class SearchResultsViewController: UIViewController { case .failure(let error): self.printClass(context: "SearchManager lookup error", message: error.localizedDescription) } - }) + } } } @@ -183,6 +183,7 @@ extension SearchResultsViewController: UITableViewDataSource { switch sections[section] { case .recentSearches: return recentLocations.count + default: return sections[section].getItems().count } @@ -196,6 +197,7 @@ extension SearchResultsViewController: UITableViewDataSource { ) as? GeneralTableViewCell else { return UITableViewCell() } cell.configure(for: sections[indexPath.section]) return cell + default: guard let cell = tableView.dequeueReusableCell( withIdentifier: Constants.Cells.placeIdentifier @@ -216,8 +218,10 @@ extension SearchResultsViewController: UITableViewDelegate { switch sections[section] { case .recentSearches: header = HeaderView(labelText: Constants.TableHeaders.recentSearches, buttonType: .clear) + case .seeAllStops, .searchResults: return nil + default: break } @@ -231,8 +235,11 @@ extension SearchResultsViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { switch sections[section] { - case .recentSearches: return 50 - default: return 24 + case .recentSearches: + return 50 + + default: + return 24 } } @@ -315,6 +322,7 @@ extension SearchResultsViewController: UISearchBarDelegate, UISearchResultsUpdat createDefaultSections() return } + startSearch(for: searchText) } diff --git a/TCAT/Controllers/ServiceAlertsViewController.swift b/TCAT/Controllers/ServiceAlertsViewController.swift index 0a79268a..b20d08b4 100644 --- a/TCAT/Controllers/ServiceAlertsViewController.swift +++ b/TCAT/Controllers/ServiceAlertsViewController.swift @@ -100,6 +100,7 @@ class ServiceAlertsViewController: UIViewController { .receive(on: DispatchQueue.main) .sink { [weak self] completion in guard let self = self else { return } + switch completion { case .failure(let error): self.removeLoadingIndicator() @@ -112,6 +113,7 @@ class ServiceAlertsViewController: UIViewController { description: error.localizedDescription ) TransitAnalytics.shared.log(payload) + case .finished: break } @@ -195,10 +197,13 @@ extension ServiceAlertsViewController: UITableViewDelegate { switch priorities[section] { case 0: return HeaderView(labelText: Constants.TableHeaders.highPriority) + case 1: return HeaderView(labelText: Constants.TableHeaders.mediumPriority) + case 2: return HeaderView(labelText: Constants.TableHeaders.lowPriority) + default: return HeaderView(labelText: Constants.TableHeaders.noPriority) } diff --git a/TCAT/Controllers/StopPickerViewController.swift b/TCAT/Controllers/StopPickerViewController.swift index 30925d35..5e18c40d 100644 --- a/TCAT/Controllers/StopPickerViewController.swift +++ b/TCAT/Controllers/StopPickerViewController.swift @@ -67,24 +67,27 @@ class StopPickerViewController: UIViewController { TransitService.shared.getAllStops() .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { [weak self] completion in + .sink { [weak self] completion in guard let self = self else { return } + self.loadingIndicator?.removeFromSuperview() self.loadingIndicator = nil switch completion { - case .failure(let error): + case .failure: handleGetAllStopsError() + case .finished: break } - }, receiveValue: { [weak self] response in + } receiveValue: { [weak self] response in guard let self = self else { return } + guard !response.isEmpty else { return } self.sections = self.tableSections(for: response) self.tableView.reloadData() - }) + } .store(in: &cancellables) } diff --git a/TCAT/Models/Direction.swift b/TCAT/Models/Direction.swift index 2d07cf62..bcbf4c9f 100755 --- a/TCAT/Models/Direction.swift +++ b/TCAT/Models/Direction.swift @@ -184,10 +184,13 @@ class Direction: NSObject, NSCopying, Codable { switch type { case .depart: return "at \(name)" + case .arrive: return "Get off at \(name)" + case .walk: return "Walk to \(name)" + case .transfer: return "at \(name). Stay on bus." } diff --git a/TCAT/Models/SearchManager.swift b/TCAT/Models/SearchManager.swift index 1a9b2c94..f65ffa66 100644 --- a/TCAT/Models/SearchManager.swift +++ b/TCAT/Models/SearchManager.swift @@ -43,19 +43,21 @@ class SearchManager: NSObject { guard let self = self, !searchText.isEmpty else { return Fail(error: ApiErrorHandler.noSearchResultsFound).eraseToAnyPublisher() } + self.lastSearchQuery = searchText return TransitService.shared.getAppleSearchResults(searchText: searchText) } - .sink(receiveCompletion: { completion in + .sink { completion in switch completion { case .failure(let error): self.searchPublisher.send(.failure(error)) + case .finished: break } - }, receiveValue: { [weak self] response in + } receiveValue: { [weak self] response in self?.processSearchResults(response: response) - }) + } .store(in: &cancellables) } diff --git a/TCAT/Models/Section.swift b/TCAT/Models/Section.swift index bd73d1cb..22cd21f0 100644 --- a/TCAT/Models/Section.swift +++ b/TCAT/Models/Section.swift @@ -17,9 +17,12 @@ enum Section { private func getVal() -> Any? { switch self { - case .seeAllStops: return nil + case .seeAllStops: + return nil + case .currentLocation(let location): return location + case .recentSearches(let items), .searchResults(let items): return items @@ -28,26 +31,34 @@ enum Section { var isEmpty: Bool { switch self { - case .currentLocation, .seeAllStops: return false - case .recentSearches(let items), - .searchResults(let items): return items.isEmpty + case .currentLocation, .seeAllStops: + return false + + case .recentSearches(let items), .searchResults(let items): + return items.isEmpty } } func getItems() -> [Place] { switch self { - case .seeAllStops: return [] - case .currentLocation(let currLocation): return [currLocation] - case .recentSearches(let items), - .searchResults(let items): return items + case .seeAllStops: + return [] + + case .currentLocation(let currLocation): + return [currLocation] + + case .recentSearches(let items), .searchResults(let items): + return items } } func getItem(at index: Int) -> Place? { switch self { - case .currentLocation, .seeAllStops: return nil - case .recentSearches(let items), - .searchResults(let items): return items[optional: index] + case .currentLocation, .seeAllStops: + return nil + + case .recentSearches(let items), .searchResults(let items): + return items[optional: index] } } @@ -65,11 +76,14 @@ extension Section: Equatable { switch (lhs, rhs) { case (.seeAllStops, .seeAllStops): return true + case (.currentLocation(let locA), .currentLocation(let locB)): return locA == locB + case (.searchResults(let itemsA), .searchResults(let itemsB)), (.recentSearches(let itemsA), .recentSearches(let itemsB)): return itemsA == itemsB + default: return false } } diff --git a/TCAT/Models/Waypoint.swift b/TCAT/Models/Waypoint.swift index a7424995..2a57d394 100755 --- a/TCAT/Models/Waypoint.swift +++ b/TCAT/Models/Waypoint.swift @@ -55,16 +55,20 @@ class Waypoint: NSObject { switch wpType { case .origin: self.iconView = Circle(size: .large, style: .solid, color: isStop ? Colors.tcatBlue : Colors.metadataIcon) + case .destination: self.iconView = Circle( size: .large, style: .bordered, color: isStop ? Colors.tcatBlue : Colors.metadataIcon ) + case .bus: self.iconView = Circle(size: .small, style: .solid, color: Colors.tcatBlue) + case .walk: self.iconView = Circle(size: .small, style: .solid, color: Colors.metadataIcon) + case .none, .stop, .walking, .bussing: self.iconView = UIView() } @@ -126,8 +130,10 @@ class Waypoint: NSObject { switch wpType { case .destination: iconView.layer.borderColor = color.cgColor + case .origin, .stop, .bus, .walk, .bussing, .walking: iconView.backgroundColor = color + case .none: break } diff --git a/TCAT/Services/Network/ApiEndpoint.swift b/TCAT/Services/Network/ApiEndpoint.swift index 6ec7dfe4..ea7a0787 100644 --- a/TCAT/Services/Network/ApiEndpoint.swift +++ b/TCAT/Services/Network/ApiEndpoint.swift @@ -8,6 +8,15 @@ import Foundation +/** + An enumeration representing the HTTP methods that can be used in API requests. + + - GET: Represents the HTTP GET method. + - POST: Represents the HTTP POST method. + - PUT: Represents the HTTP PUT method. + - DELETE: Represents the HTTP DELETE method. + - PATCH: Represents the HTTP PATCH method. + */ enum APIHTTPMethod: String { case GET case POST @@ -16,6 +25,24 @@ enum APIHTTPMethod: String { case PATCH } +/** + A protocol defining the requirements for an API endpoint. + + Properties: + - `baseURLString`: The base URL string for the API. + - `apiPath`: The path for the API. + - `apiVersion`: The version of the API. + - `separatorPath`: An optional separator path for the API. + - `path`: The specific path for the endpoint. + - `headers`: An optional dictionary of headers to include in the request. + - `queryParams`: An optional array of URL query items to include in the request. + - `params`: An optional dictionary of parameters to include in the request body. + - `method`: The HTTP method to use for the request. + - `customDataBody`: An optional custom data body to include in the request. + + Methods: + - `makeRequest`: A computed property that constructs and returns a `URLRequest` based on the endpoint's properties. + */ protocol ApiEndpoint { var baseURLString: String { get } var apiPath: String { get } @@ -29,6 +56,11 @@ protocol ApiEndpoint { var customDataBody: Data? { get } } +/** + An extension of the `ApiEndpoint` protocol that provides a default implementation for creating a `URLRequest`. + + The `makeRequest` computed property constructs a `URLRequest` using the endpoint's properties, including the base URL, path, query parameters, headers, and body parameters. + */ extension ApiEndpoint { var makeRequest: URLRequest { var urlComponents = URLComponents(string: baseURLString) @@ -40,36 +72,38 @@ extension ApiEndpoint { longPath.append("/") longPath.append(separatorPath) } + longPath.append("/") longPath.append(path) urlComponents?.path = longPath - + if let queryParams = queryParams { urlComponents?.queryItems = [URLQueryItem]() for queryParam in queryParams { urlComponents?.queryItems?.append(URLQueryItem(name: queryParam.name, value: queryParam.value)) } } - + guard let url = urlComponents?.url else { return URLRequest(url: URL(string: baseURLString)!) } + var request = URLRequest(url: url) request.httpMethod = method.rawValue - + if let headers = headers { for header in headers { request.addValue(header.value, forHTTPHeaderField: header.key) } } - + if let params = params { let jsonData = try? JSONSerialization.data(withJSONObject: params) request.httpBody = jsonData } - + if let customDataBody = customDataBody { request.httpBody = customDataBody } - + return request } } diff --git a/TCAT/Services/Network/ApiErrorHandler.swift b/TCAT/Services/Network/ApiErrorHandler.swift index b7f3c19d..12edbc1b 100644 --- a/TCAT/Services/Network/ApiErrorHandler.swift +++ b/TCAT/Services/Network/ApiErrorHandler.swift @@ -8,18 +8,30 @@ import Foundation +/// Represents an API error with optional code and message. struct ApiError: Codable { let code: String? let message: String? } -enum ApiErrorHandler: Error { +/// Enum to handle various API errors and provide localized error descriptions. +enum ApiErrorHandler: LocalizedError { + /// Custom API error with associated `ApiError` object. case customApiError(ApiError) + + /// Error indicating that the request failed. case requestFailed + + /// Normal error with associated `Error` object. case normalError(Error) + + /// Error indicating an empty response with a specific status code. case emptyErrorWithStatusCode(String) + + /// Error indicating that no search results were found. case noSearchResultsFound + /// Provides a localized description for each error case. var errorDescription: String { switch self { case .customApiError(let apiError): @@ -28,6 +40,7 @@ enum ApiErrorHandler: Error { if let code = apiError.code, !code.isEmpty { errorComponents.append("Code: \(code)") } + if let message = apiError.message, !message.isEmpty { errorComponents.append("Message: \(message)") } diff --git a/TCAT/Services/Network/NetworkManager.swift b/TCAT/Services/Network/NetworkManager.swift index 6f8e5145..ea394821 100644 --- a/TCAT/Services/Network/NetworkManager.swift +++ b/TCAT/Services/Network/NetworkManager.swift @@ -10,17 +10,23 @@ import Foundation import Combine protocol NetworkService { + /// Sends a network request and decodes the response into the specified type. + /// + /// - Parameters: + /// - request: The `URLRequest` to be sent. + /// - decodingType: The type to decode the response into. Must conform to `Decodable`. + /// - Returns: A publisher that emits the decoded object of type `T` or an `ApiErrorHandler` on failure. func request(_ request: URLRequest, decodingType: T.Type) -> AnyPublisher } class NetworkManager: NetworkService { - + private let session: URLSession - + init(session: URLSession = .shared) { self.session = session } - + func request(_ request: URLRequest, decodingType: T.Type) -> AnyPublisher { return session.dataTaskPublisher(for: request) .tryMap { result in @@ -35,12 +41,13 @@ class NetworkManager: NetworkService { } .eraseToAnyPublisher() } - + // Handles HTTP response and decodes or throws an appropriate error private func handleResponse(_ result: URLSession.DataTaskPublisher.Output) throws -> Data { guard let httpResponse = result.response as? HTTPURLResponse else { throw ApiErrorHandler.requestFailed } + if (200..<300).contains(httpResponse.statusCode) { return result.data } else { @@ -52,22 +59,23 @@ class NetworkManager: NetworkService { } } } - + // Validate API response and handle future error cases private func validateAPIResponse(_ response: APIResponse) throws -> T { guard response.success else { - // This is a placeholder error handler. Update as needed. When backend sends more error codes. + // TODO: Update when backend sends more error codes throw ApiErrorHandler.customApiError(ApiError(code: "500", message: "Internal server error")) } - + return response.data } - + // Map Combine errors to custom APIErrorHandler types private func mapToAPIError(_ error: Error) -> ApiErrorHandler { if let apiError = error as? ApiErrorHandler { return apiError } + return ApiErrorHandler.normalError(error) } } diff --git a/TCAT/Services/Network/NetworkMonitor.swift b/TCAT/Services/Network/NetworkMonitor.swift index e39a91e5..0309d0fc 100644 --- a/TCAT/Services/Network/NetworkMonitor.swift +++ b/TCAT/Services/Network/NetworkMonitor.swift @@ -9,18 +9,27 @@ import Network import Foundation +/// A singleton class that monitors the network status using `NWPathMonitor`. final class NetworkMonitor { + /// The shared instance of `NetworkMonitor`. static let shared = NetworkMonitor() + /// A network path monitor that observes changes in network status. + /// This instance is used to monitor the network connectivity status of the device. private let monitor = NWPathMonitor() private var status: NWPath.Status = .requiresConnection + /// Indicates whether the current connection is cellular. public var isCellular: Bool = false + + /// Indicates whether the network is reachable. public var isReachable: Bool { status == .satisfied } - // Optional handlers for reachability changes + /// Optional handler that gets called when the network becomes reachable. public var whenReachable: (() -> Void)? + + /// Optional handler that gets called when the network becomes unreachable. public var whenUnreachable: (() -> Void)? private init() {} @@ -54,12 +63,13 @@ final class NetworkMonitor { monitor.start(queue: queue) } - // MARK: - Stop Monitoring + /// Stops monitoring the network status. public func stopMonitoring() { monitor.cancel() } } extension Notification.Name { + /// Notification name for reachability changes. static let reachabilityChanged = Notification.Name("reachabilityChanged") } diff --git a/TCAT/Services/Transit/TransitProvider.swift b/TCAT/Services/Transit/TransitProvider.swift index cfb1267d..fe923549 100644 --- a/TCAT/Services/Transit/TransitProvider.swift +++ b/TCAT/Services/Transit/TransitProvider.swift @@ -8,6 +8,7 @@ import Foundation +/// Enum representing various transit providers and their associated API endpoints. enum TransitProvider { case alerts case allDelays(TripBody) @@ -19,25 +20,31 @@ enum TransitProvider { case routes(GetRoutesBody) } +/// Extension to conform `TransitProvider` to `ApiEndpoint` protocol. extension TransitProvider: ApiEndpoint { + /// Base URL string for the transit API. var baseURLString: String { return TransitEnvironment.transitURL } + /// API path for the transit endpoints. var apiPath: String { return "api" } + /// API version for the transit endpoints. var apiVersion: String { switch self { case .routes: return "v2" + default: return "v3" } } + /// Separator path for the transit endpoints. var separatorPath: String? { switch self { default: @@ -45,27 +52,36 @@ extension TransitProvider: ApiEndpoint { } } + /// Specific path for each transit endpoint. var path: String { switch self { case .alerts: return Constants.Endpoints.alerts + case .allDelays: return Constants.Endpoints.delays + case .allStops: return Constants.Endpoints.allStops + case .applePlaces: return Constants.Endpoints.applePlaces + case .appleSearch: return Constants.Endpoints.appleSearch + case .busLocations: return Constants.Endpoints.busLocations + case .delay: return Constants.Endpoints.delay + case .routes: return Constants.Endpoints.getRoutes } } + /// Headers for the transit API requests. var headers: [String: String]? { switch self { default: @@ -73,15 +89,18 @@ extension TransitProvider: ApiEndpoint { } } + /// Query parameters for the transit API requests. var queryParams: [URLQueryItem]? { switch self { case .delay(let getDelayBody): return getDelayBody.toQueryItems() + default: return nil } } + /// Parameters for the transit API requests. var params: [String: Any]? { switch self { default: @@ -89,29 +108,38 @@ extension TransitProvider: ApiEndpoint { } } + /// HTTP method for the transit API requests. var method: APIHTTPMethod { switch self { case .alerts, .allStops: return .GET + default: return .POST } } + /// Custom data body for the transit API requests. var customDataBody: Data? { switch self { case .allDelays(let tripBody): return try? JSONEncoder().encode(tripBody) + case .applePlaces(let applePlacesBody): return try? JSONEncoder().encode(applePlacesBody) + case .appleSearch(let searchResultsBody): return try? JSONEncoder().encode(searchResultsBody) + case .busLocations(let getBusLocationsBody): return try? JSONEncoder().encode(getBusLocationsBody) + case .delay(let getDelayBody): return try? JSONEncoder().encode(getDelayBody) + case .routes(let getRoutesBody): return try? JSONEncoder().encode(getRoutesBody) + default: return nil } diff --git a/TCAT/Services/Transit/TransitService.swift b/TCAT/Services/Transit/TransitService.swift index 06ef84fd..17f55ab2 100644 --- a/TCAT/Services/Transit/TransitService.swift +++ b/TCAT/Services/Transit/TransitService.swift @@ -9,40 +9,68 @@ import Foundation import Combine +/// Protocol defining the methods for accessing transit-related services, including fetching delays, stops, alerts, and more. protocol TransitServiceProtocol: AnyObject { + + /// Retrieves delay information for the specified trips, refreshing at regular intervals. + /// - Parameters: + /// - trips: An array of `Trip` objects representing the trips for which delay data is required. + /// - refreshInterval: The time interval (in seconds) between data refreshes. + /// - Returns: A publisher that emits an array of `Delay` objects on success, or an `ApiErrorHandler` on failure. func getAllDelays(trips: [Trip], refreshInterval: TimeInterval) -> AnyPublisher<[Delay], ApiErrorHandler> + + /// Retrieves all transit stops available. + /// - Returns: A publisher that emits an array of `Place` objects representing stops, or an `ApiErrorHandler` on failure. func getAllStops() -> AnyPublisher<[Place], ApiErrorHandler> + + /// Fetches active service alerts for transit services. + /// - Returns: A publisher that emits an array of `ServiceAlert` objects, or an `ApiErrorHandler` if unable to retrieve alerts. func getAlerts() -> AnyPublisher<[ServiceAlert], ApiErrorHandler> + + /// Searches for Apple places based on the provided text query. + /// - Parameter searchText: The text used to query Apple's location services. + /// - Returns: A publisher that emits an `AppleSearchResponse` object containing the results or an `ApiErrorHandler` on failure. func getAppleSearchResults(searchText: String) -> AnyPublisher - func getBusLocations( - _ directions: [Direction], - refreshInterval: TimeInterval - ) -> AnyPublisher< - [BusLocation], - ApiErrorHandler - > + + /// Retrieves real-time bus locations for the specified directions, refreshing at a defined interval. + /// - Parameters: + /// - directions: An array of `Direction` objects to track bus locations. + /// - refreshInterval: The time interval (in seconds) between data refreshes. Default is 5.0 seconds. + /// - Returns: A publisher emitting an array of `BusLocation` objects or an `ApiErrorHandler`. + func getBusLocations(_ directions: [Direction], refreshInterval: TimeInterval) -> AnyPublisher<[BusLocation], ApiErrorHandler> + + /// Retrieves the delay time for a specific trip and stop at set intervals. + /// - Parameters: + /// - tripID: Unique identifier of the trip. + /// - stopID: Unique identifier of the stop. + /// - refreshInterval: Time interval (in seconds) for data refreshes. Default is 10.0 seconds. + /// - Returns: A publisher emitting an optional `Int` delay (in seconds), or an `ApiErrorHandler` if retrieval fails. func getDelay(tripID: String, stopID: String, refreshInterval: TimeInterval) -> AnyPublisher - func getRoutes( - start: Place, - end: Place, - time: Date, - type: SearchType - ) -> AnyPublisher< - RouteSectionsObject, - ApiErrorHandler - > - func updateApplePlacesCache(searchText: String, places: [Place]) -> AnyPublisher + /// Finds available transit routes between the specified start and end locations for a given time. + /// - Parameters: + /// - start: The starting `Place` for the route. + /// - end: The destination `Place` for the route. + /// - time: The desired time of travel. + /// - type: Specifies whether the time is for arrival or departure. + /// - Returns: A publisher emitting a `RouteSectionsObject` with route details or an `ApiErrorHandler` on error. + func getRoutes(start: Place, end: Place, time: Date, type: SearchType) -> AnyPublisher + + /// Updates the local cache of Apple places based on the search text and provided locations. + /// - Parameters: + /// - searchText: The query text used for retrieving places. + /// - places: Array of `Place` objects to cache. + /// - Returns: A publisher emitting `true` if successful, or an `ApiErrorHandler` if the update fails. + func updateApplePlacesCache(searchText: String, places: [Place]) -> AnyPublisher } -// MARK: - TransitService Implementation - +/// Service implementing `TransitServiceProtocol` to fetch and manage transit-related data. class TransitService: TransitServiceProtocol { // Singleton instance static var shared = TransitService(networkManager: NetworkManager()) - // Network manager instance + /// Manages network requests for transit services. private let networkManager: NetworkManager // Initializer @@ -50,6 +78,8 @@ class TransitService: TransitServiceProtocol { self.networkManager = networkManager } + // MARK: - Protocol Methods + func getAllDelays(trips: [Trip], refreshInterval: TimeInterval = 10.0) -> AnyPublisher<[Delay], ApiErrorHandler> { let body = TripBody(data: trips) let request = TransitProvider.allDelays(body).makeRequest @@ -135,7 +165,7 @@ class TransitService: TransitServiceProtocol { RouteSectionsObject, ApiErrorHandler > { - let uid = sharedUserDefaults?.string(forKey: Constants.UserDefaults.uid) + let uid = userDefaults.string(forKey: Constants.UserDefaults.uid) let body = GetRoutesBody( arriveBy: type == .arriveBy, end: "\(end.latitude),\(end.longitude)", diff --git a/TCAT/Utils/Extensions+App.swift b/TCAT/Utils/Extensions+App.swift index 37ce7e0a..d28e86c9 100755 --- a/TCAT/Utils/Extensions+App.swift +++ b/TCAT/Utils/Extensions+App.swift @@ -235,7 +235,7 @@ func presentShareSheet( for destination: Place, with image: UIImage? = nil ) { - + let lat: Double = destination.latitude let long: Double = destination.longitude let thirdParamName: String = ( @@ -276,8 +276,11 @@ infix operator ???: NilCoalescingPrecedence public func ??? (optional: T?, defaultValue: @autoclosure () -> String) -> String { switch optional { - case let value?: return String(describing: value) - case nil: return defaultValue() + case let value?: + return String(describing: value) + + case nil: + return defaultValue() } } diff --git a/TCAT/Utils/Extensions+Shared.swift b/TCAT/Utils/Extensions+Shared.swift index eec7e14b..3b436690 100644 --- a/TCAT/Utils/Extensions+Shared.swift +++ b/TCAT/Utils/Extensions+Shared.swift @@ -245,3 +245,30 @@ extension NSObject { } } + +extension UserDefaults { + + /// Initializes user defaults with default values if they don't exist + func initialize(with defaults: [(key: String, defaultValue: Any)]) { + for (key, defaultValue) in defaults where !hasValue(forKey: key) { + set(defaultValue, forKey: key) + } + } + + /// Creates and sets a unique identifier. If the device identifier changes, updates it. + func setupUniqueIdentifier() { + guard let uid = UIDevice.current.identifierForVendor?.uuidString else { + return + } + + if uid != self.string(forKey: Constants.UserDefaults.uid) { + self.set(uid, forKey: Constants.UserDefaults.uid) + } + } + + /// Checks if a value exists for a given key + private func hasValue(forKey key: String) -> Bool { + return object(forKey: key) != nil + } + +} diff --git a/TCAT/Utils/JSONFileManager.swift b/TCAT/Utils/JSONFileManager.swift index a2c144db..ae85f6e2 100644 --- a/TCAT/Utils/JSONFileManager.swift +++ b/TCAT/Utils/JSONFileManager.swift @@ -18,6 +18,7 @@ enum JSONType { switch self { case .routeJSON: return "routeJSON" + case .delayJSON: return "delayJSON" } diff --git a/TCAT/Utils/SearchTableViewHelpers.swift b/TCAT/Utils/SearchTableViewHelpers.swift index 03b4674b..2d495c12 100755 --- a/TCAT/Utils/SearchTableViewHelpers.swift +++ b/TCAT/Utils/SearchTableViewHelpers.swift @@ -17,16 +17,8 @@ class Global { static let shared = Global() func retrievePlaces(for key: String) -> [Place] { - if key == Constants.UserDefaults.favorites { - if let storedPlaces = sharedUserDefaults?.value(forKey: key) as? Data, - let favorites = try? decoder.decode([Place].self, from: storedPlaces) { - return favorites - } - - } else if - let storedPlaces = userDefaults.value(forKey: key) as? Data, - let places = try? decoder.decode([Place].self, from: storedPlaces) - { + if let storedPlaces = userDefaults.value(forKey: key) as? Data, + let places = try? decoder.decode([Place].self, from: storedPlaces) { return places } return [Place]() @@ -37,7 +29,7 @@ class Global { let newFavoritesList = allFavorites.filter { favorite != $0 } do { let data = try encoder.encode(newFavoritesList) - sharedUserDefaults?.set(data, forKey: Constants.UserDefaults.favorites) + userDefaults.set(data, forKey: Constants.UserDefaults.favorites) AppShortcuts.shared.updateShortcutItems() } catch let error { print(error) @@ -86,11 +78,7 @@ class Global { do { let data = try encoder.encode(places) - if key == Constants.UserDefaults.favorites { - sharedUserDefaults?.set(data, forKey: key) - } else { - userDefaults.set(data, forKey: key) - } + userDefaults.set(data, forKey: key) AppShortcuts.shared.updateShortcutItems() } catch let error { print(error) diff --git a/TCAT/Utils/Shared.swift b/TCAT/Utils/Shared.swift index d6f24184..8edc08b0 100644 --- a/TCAT/Utils/Shared.swift +++ b/TCAT/Utils/Shared.swift @@ -10,9 +10,6 @@ import Foundation /// This class is for shared enums between TCAT and the Today Extension. -/// This is used for favorites between targets (e.g. TCAT.app, Today Extension) -let sharedUserDefaults = UserDefaults.init(suiteName: Constants.UserDefaults.group) - enum SearchType: String { case arriveBy, leaveAt, leaveNow } diff --git a/TCAT/Utils/StoreReviewHelper.swift b/TCAT/Utils/StoreReviewHelper.swift index 64e3dfe2..4243a908 100644 --- a/TCAT/Utils/StoreReviewHelper.swift +++ b/TCAT/Utils/StoreReviewHelper.swift @@ -57,6 +57,7 @@ class StoreReviewHelper { switch appOpenCount { case firstRequestLaunchCount, secondRequestLaunchCount, thirdRequestLaunchCount: StoreReviewHelper.shared.requestReview() + case _ where appOpenCount % futureRequestInterval == 0: StoreReviewHelper.shared.requestReview() default: diff --git a/TCAT/Utils/Styles.swift b/TCAT/Utils/Styles.swift index 730d872b..028324ee 100644 --- a/TCAT/Utils/Styles.swift +++ b/TCAT/Utils/Styles.swift @@ -66,17 +66,31 @@ extension UIFont { var fontString: String if size >= 14 { switch name { - case .regular: fontString = Fonts.SanFrancisco.ProDisplay.regular - case .medium: fontString = Fonts.SanFrancisco.ProDisplay.medium - case .semibold: fontString = Fonts.SanFrancisco.ProDisplay.semibold - case .bold: fontString = Fonts.SanFrancisco.ProDisplay.bold + case .regular: + fontString = Fonts.SanFrancisco.ProDisplay.regular + + case .medium: + fontString = Fonts.SanFrancisco.ProDisplay.medium + + case .semibold: + fontString = Fonts.SanFrancisco.ProDisplay.semibold + + case .bold: + fontString = Fonts.SanFrancisco.ProDisplay.bold } } else { switch name { - case .regular: fontString = Fonts.SanFrancisco.ProText.regular - case .medium: fontString = Fonts.SanFrancisco.ProText.medium - case .semibold: fontString = Fonts.SanFrancisco.ProText.semibold - case .bold: fontString = Fonts.SanFrancisco.ProText.bold + case .regular: + fontString = Fonts.SanFrancisco.ProText.regular + + case .medium: + fontString = Fonts.SanFrancisco.ProText.medium + + case .semibold: + fontString = Fonts.SanFrancisco.ProText.semibold + + case .bold: + fontString = Fonts.SanFrancisco.ProText.bold } } return UIFont(name: fontString, size: size)! diff --git a/TCAT/Views/BusIcon.swift b/TCAT/Views/BusIcon.swift index 8152c8e3..91710967 100755 --- a/TCAT/Views/BusIcon.swift +++ b/TCAT/Views/BusIcon.swift @@ -16,8 +16,10 @@ enum BusIconType: String { switch self { case .blueBannerSmall, .directionSmall, .redBannerSmall: return 48 + case .directionLarge: return 72 + case .liveTracking: return 72 } @@ -28,8 +30,10 @@ enum BusIconType: String { switch self { case .blueBannerSmall, .directionSmall, .redBannerSmall: return 24 + case .directionLarge: return 36 + case .liveTracking: return 30 } @@ -40,6 +44,7 @@ enum BusIconType: String { switch self { case .directionLarge: return 8 + default: return 4 } @@ -49,6 +54,7 @@ enum BusIconType: String { switch self { case .blueBannerSmall, .redBannerSmall: return Colors.white + case .directionLarge, .directionSmall, .liveTracking: return Colors.tcatBlue } @@ -58,8 +64,10 @@ enum BusIconType: String { switch self { case .blueBannerSmall: return Colors.tcatBlue + case .directionLarge, .directionSmall, .liveTracking: return Colors.white + case .redBannerSmall: return Colors.lateRed } @@ -88,9 +96,14 @@ class BusIcon: UIView { var fontSize: CGFloat switch type { - case .blueBannerSmall, .directionSmall, .redBannerSmall: fontSize = 14 - case .directionLarge: fontSize = 20 - case .liveTracking: fontSize = 16 + case .blueBannerSmall, .directionSmall, .redBannerSmall: + fontSize = 14 + + case .directionLarge: + fontSize = 20 + + case .liveTracking: + fontSize = 16 } backgroundColor = .clear @@ -122,9 +135,14 @@ class BusIcon: UIView { var constant: CGFloat switch type { - case .blueBannerSmall, .directionSmall, .redBannerSmall: constant = 0.75 - case .directionLarge: constant = 1 - case .liveTracking: constant = 0.87 + case .blueBannerSmall, .directionSmall, .redBannerSmall: + constant = 0.75 + + case .directionLarge: + constant = 1 + + case .liveTracking: + constant = 0.87 } let imageSize = CGSize(width: image.frame.width * constant, height: image.frame.height * constant) diff --git a/TCAT/Views/Circle.swift b/TCAT/Views/Circle.swift index bbf00e63..4b98b4d3 100755 --- a/TCAT/Views/Circle.swift +++ b/TCAT/Views/Circle.swift @@ -46,6 +46,7 @@ class Circle: UIView { switch style { case .solid: backgroundColor = color + case .bordered: backgroundColor = Colors.white layer.borderColor = color.cgColor @@ -64,6 +65,7 @@ class Circle: UIView { make.centerX.centerY.equalToSuperview() make.size.equalTo(CGSize(width: solidCircleDiameter, height: solidCircleDiameter)) } + case .outline: backgroundColor = Colors.white layer.borderColor = color.cgColor diff --git a/TCAT/Views/DatePickerView.swift b/TCAT/Views/DatePickerView.swift index 1b0bafe2..7f08faf3 100755 --- a/TCAT/Views/DatePickerView.swift +++ b/TCAT/Views/DatePickerView.swift @@ -82,7 +82,7 @@ class DatePickerView: UIView { private func setupTimeTypeSegmentedControl() { styleSegmentedControl(timeTypeSegmentedControl) - setSegmentedControlOptions(timeTypeSegmentedControl, options: [leaveNowElement.title,leaveAtElement.title, arriveByElement.title]) + setSegmentedControlOptions(timeTypeSegmentedControl, options: [leaveNowElement.title, leaveAtElement.title, arriveByElement.title]) timeTypeSegmentedControl.selectedSegmentIndex = leaveNowElement.index addSubview(timeTypeSegmentedControl) @@ -157,6 +157,7 @@ class DatePickerView: UIView { switch searchTimeType { case .leaveAt, .leaveNow: timeTypeSegmentedControl.selectedSegmentIndex = leaveAtElement.index + case .arriveBy: timeTypeSegmentedControl.selectedSegmentIndex = arriveByElement.index } @@ -170,8 +171,10 @@ class DatePickerView: UIView { switch timeTypeSegmentedControl.selectedSegmentIndex { case arriveByElement.index: searchTimeType = .arriveBy + case leaveAtElement.index: searchTimeType = .leaveAt + default: break } diff --git a/TCAT/Views/HeaderView.swift b/TCAT/Views/HeaderView.swift index 9cae955a..d1a977cd 100644 --- a/TCAT/Views/HeaderView.swift +++ b/TCAT/Views/HeaderView.swift @@ -77,6 +77,7 @@ class HeaderView: UITableViewHeaderFooterView { case .clear: button?.setTitle(Constants.Buttons.clear, for: .normal) button?.addTarget(self, action: #selector(clearRecentSearches), for: .touchUpInside) + default: return } diff --git a/TCAT/Views/NotificationBannerView.swift b/TCAT/Views/NotificationBannerView.swift index 482e0428..86458eb3 100644 --- a/TCAT/Views/NotificationBannerView.swift +++ b/TCAT/Views/NotificationBannerView.swift @@ -16,6 +16,7 @@ enum NotificationType { switch self { case .beforeBoarding: return Constants.Notification.notifyBeforeBoarding + case .delay: return Constants.Notification.notifyDelay } @@ -31,6 +32,7 @@ enum NotificationBannerType { switch self { case .beforeBoardingConfirmation, .busArriving, .delayConfirmation: return Colors.tcatBlue + case .busDelay: return Colors.lateRed } @@ -81,8 +83,10 @@ class NotificationBannerView: UIView { switch type { case .beforeBoardingConfirmation: beginningText = Constants.Notification.beforeBoardingConfirmation + case .delayConfirmation: beginningText = Constants.Notification.delayConfirmation + default: beginningText = "" } From 81d4b91bcf9015ec0281816fc33ee58895f79aad Mon Sep 17 00:00:00 2001 From: Jayson Hahn <46629787+JaysonHahn@users.noreply.github.com> Date: Mon, 4 Nov 2024 00:24:05 -0500 Subject: [PATCH 04/13] Added notifications --- Podfile.lock | 2 +- TCAT.xcodeproj/project.pbxproj | 21 +++- TCAT/Base/AppDelegate.swift | 56 +-------- .../NotificationToggleTableViewCell.swift | 27 +++- .../RouteDetail+DrawerViewController.swift | 11 +- ...etailDrawerViewController+Extensions.swift | 19 ++- .../NotificationSubscriptionManager.swift | 115 ++++++++++++++++++ TCAT/Managers/NotificationTokenHandler.swift | 104 ++++++++++++++++ TCAT/{Models => Managers}/SearchManager.swift | 0 TCAT/Services/Network/ApiEndpoint.swift | 1 - TCAT/Services/Network/NetworkManager.swift | 73 ++++++++++- TCAT/Services/Network/RequestModels.swift | 17 +++ TCAT/Services/Transit/TransitProvider.swift | 37 +++++- TCAT/Services/Transit/TransitService.swift | 74 +++++++++++ TCAT/Supporting/Constants.swift | 4 + TCAT/Supporting/TransitEnvironment.swift | 11 ++ 16 files changed, 499 insertions(+), 73 deletions(-) create mode 100644 TCAT/Managers/NotificationSubscriptionManager.swift create mode 100644 TCAT/Managers/NotificationTokenHandler.swift rename TCAT/{Models => Managers}/SearchManager.swift (100%) diff --git a/Podfile.lock b/Podfile.lock index 3a27799f..59aabfdb 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -237,6 +237,6 @@ SPEC CHECKSUMS: Wormholy: ab1c8c2f02f58587a0941deb0088555ffbf039a1 Zip: 8877eede3dda76bcac281225c20e71c25270774c -PODFILE CHECKSUM: 03571a87e3df2cb79c3c62b5bd19cd6713131c52 +PODFILE CHECKSUM: af336d88f53594af448d02dc18637c2b6ebe685e COCOAPODS: 1.15.0 diff --git a/TCAT.xcodeproj/project.pbxproj b/TCAT.xcodeproj/project.pbxproj index 7bb4dca9..ad311872 100644 --- a/TCAT.xcodeproj/project.pbxproj +++ b/TCAT.xcodeproj/project.pbxproj @@ -124,6 +124,8 @@ BF74AC1A1F945D7D00AFD4E4 /* GoogleMapsBase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF74AC191F945D7D00AFD4E4 /* GoogleMapsBase.framework */; }; BF74AC1D1F945D8E00AFD4E4 /* GoogleMapsCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF74AC1B1F945D8E00AFD4E4 /* GoogleMapsCore.framework */; }; BF74AC1E1F945D8E00AFD4E4 /* GoogleMaps.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF74AC1C1F945D8E00AFD4E4 /* GoogleMaps.framework */; }; + FD44EC532CD86A5F009269A2 /* NotificationSubscriptionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD44EC522CD86A56009269A2 /* NotificationSubscriptionManager.swift */; }; + FD44EC552CD86C55009269A2 /* NotificationTokenHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD44EC542CD86C4A009269A2 /* NotificationTokenHandler.swift */; }; FDA3439F2CB6DF5800608A1A /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA3439E2CB6DF4D00608A1A /* NetworkMonitor.swift */; }; FDE68D1E2C97E24900024A69 /* NetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D1D2C97E24900024A69 /* NetworkManager.swift */; }; FDE68D202C97EBBE00024A69 /* ApiErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D1F2C97EBBE00024A69 /* ApiErrorHandler.swift */; }; @@ -271,11 +273,11 @@ BF74AC191F945D7D00AFD4E4 /* GoogleMapsBase.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GoogleMapsBase.framework; path = Pods/GoogleMaps/Base/Frameworks/GoogleMapsBase.framework; sourceTree = ""; }; BF74AC1B1F945D8E00AFD4E4 /* GoogleMapsCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GoogleMapsCore.framework; path = Pods/GoogleMaps/Maps/Frameworks/GoogleMapsCore.framework; sourceTree = ""; }; BF74AC1C1F945D8E00AFD4E4 /* GoogleMaps.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GoogleMaps.framework; path = Pods/GoogleMaps/Maps/Frameworks/GoogleMaps.framework; sourceTree = ""; }; - D4756EA123986CB500FE7F0D /* ReachabilityManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReachabilityManager.swift; sourceTree = ""; }; - DD3D9C201F94297100B164D4 /* Reachability.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Reachability.swift; sourceTree = ""; }; EEB26AE02C9F998C002E863F /* TCATLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TCATLocal.entitlements; sourceTree = ""; }; EEB26AE12C9F9B9A002E863F /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = System/Library/Frameworks/UserNotifications.framework; sourceTree = SDKROOT; }; EEB26AE32C9FA60E002E863F /* TCATDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TCATDebug.entitlements; sourceTree = ""; }; + FD44EC522CD86A56009269A2 /* NotificationSubscriptionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSubscriptionManager.swift; sourceTree = ""; }; + FD44EC542CD86C4A009269A2 /* NotificationTokenHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTokenHandler.swift; sourceTree = ""; }; FD69AF2A2B89212F00970C7E /* ci_post_clone.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = ci_post_clone.sh; sourceTree = ""; }; FDA3439E2CB6DF4D00608A1A /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; FDE68D1D2C97E24900024A69 /* NetworkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkManager.swift; sourceTree = ""; }; @@ -290,7 +292,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - EEB26AE22C9F9B9A002E863F /* UserNotifications.framework in Frameworks */, BF74AC1D1F945D8E00AFD4E4 /* GoogleMapsCore.framework in Frameworks */, BF74AC1E1F945D8E00AFD4E4 /* GoogleMaps.framework in Frameworks */, BF74AC1A1F945D7D00AFD4E4 /* GoogleMapsBase.framework in Frameworks */, @@ -401,7 +402,6 @@ 2E9416AD2BC61731003DEB44 /* Place.swift */, 2E9416AC2BC61731003DEB44 /* PlaceCoordinates.swift */, 2E9416B32BC61731003DEB44 /* Route.swift */, - 2E9416B62BC61731003DEB44 /* SearchManager.swift */, 2E9416B72BC61731003DEB44 /* Section.swift */, 2E9416AE2BC61731003DEB44 /* ServiceAlert.swift */, 2E9416B42BC61731003DEB44 /* WalkPath.swift */, @@ -597,6 +597,7 @@ 2E94166C2BC61604003DEB44 /* Cells */, 2E9416822BC6168C003DEB44 /* Controllers */, 2E94165E2BC60A3B003DEB44 /* Ecosystem */, + FD44EC562CD8914D009269A2 /* Managers */, 2E9416AB2BC616DE003DEB44 /* Models */, FDE68D292C988CDB00024A69 /* Services */, 2E9416C72BC61763003DEB44 /* Supporting */, @@ -616,6 +617,16 @@ path = Pods; sourceTree = ""; }; + FD44EC562CD8914D009269A2 /* Managers */ = { + isa = PBXGroup; + children = ( + FD44EC542CD86C4A009269A2 /* NotificationTokenHandler.swift */, + FD44EC522CD86A56009269A2 /* NotificationSubscriptionManager.swift */, + 2E9416B62BC61731003DEB44 /* SearchManager.swift */, + ); + path = Managers; + sourceTree = ""; + }; FD69AF292B8920D500970C7E /* ci_scripts */ = { isa = PBXGroup; children = ( @@ -875,6 +886,7 @@ 2E9416C22BC61731003DEB44 /* AppleSearchResponse.swift in Sources */, 2E9416A12BC616B9003DEB44 /* RouteOptionsViewController.swift in Sources */, 2E9416BF2BC61731003DEB44 /* Direction.swift in Sources */, + FD44EC532CD86A5F009269A2 /* NotificationSubscriptionManager.swift in Sources */, 2EC1F5122BC66972001D9F66 /* ApolloClientProtocol.swift in Sources */, 2E9FFA902BC673240051793C /* UpliftAPI.graphql.swift in Sources */, 2E9416BD2BC61731003DEB44 /* LocationObject.swift in Sources */, @@ -904,6 +916,7 @@ 2E9416F12BC61984003DEB44 /* Shared.swift in Sources */, 2E9FFA892BC673240051793C /* Capacity.graphql.swift in Sources */, 2E9416BB2BC61731003DEB44 /* ServiceAlert.swift in Sources */, + FD44EC552CD86C55009269A2 /* NotificationTokenHandler.swift in Sources */, FDE68D222C97EF6200024A69 /* ApiEndpoint.swift in Sources */, 2EC1F5162BC66CBA001D9F66 /* Publishers.swift in Sources */, 2E94169E2BC616B9003DEB44 /* SearchResultsViewController.swift in Sources */, diff --git a/TCAT/Base/AppDelegate.swift b/TCAT/Base/AppDelegate.swift index fe33e1a7..e8f74dfc 100755 --- a/TCAT/Base/AppDelegate.swift +++ b/TCAT/Base/AppDelegate.swift @@ -35,7 +35,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate, UNUser // Set Up Google Services FirebaseApp.configure() - + GMSServices.provideAPIKey(TransitEnvironment.googleMaps) // Update shortcut items @@ -71,40 +71,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate, UNUser self.window = UIWindow(frame: UIScreen.main.bounds) self.window?.rootViewController = navigationController self.window?.makeKeyAndVisible() - - //Set up notifications - UNUserNotificationCenter.current().delegate = self - - let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound] - UNUserNotificationCenter.current().requestAuthorization( - options: authOptions, - completionHandler: { _, _ in } - ) - application.registerForRemoteNotifications() - Messaging.messaging().delegate = self - + + // Initialize and setup notifications + _ = NotificationTokenHandler.shared + return true } func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { handleShortcut(item: shortcutItem) } - - func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { - Messaging.messaging().apnsToken = deviceToken - Messaging.messaging().token { token, error in - if let error = error { - print("Error fetching FCM registration token: \(error)") - } else if let token = token { - print("FCM registration token: \(token)") - - } - } - - } - func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { - print("application didFailToRegisterForRemoteNotificationsWithError") - } // MARK: - Helper Functions @@ -181,25 +157,3 @@ extension UIWindow { } } - -extension AppDelegate { - - func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { - print("Firebase registration token: \(String(describing: fcmToken))") - - let dataDict: [String: String] = ["token": fcmToken ?? ""] - NotificationCenter.default.post( - name: Notification.Name("FCMToken"), - object: nil, - userInfo: dataDict - ) - // TODO: If necessary send token to application server. - // Note: This callback is fired at each app startup and whenever a new token is generated. - } - - //UNUserNotificationCenterDelegate - func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { - print("APNs received with: \(userInfo)") - } - -} diff --git a/TCAT/Cells/NotificationToggleTableViewCell.swift b/TCAT/Cells/NotificationToggleTableViewCell.swift index 78642c88..dbd462bf 100644 --- a/TCAT/Cells/NotificationToggleTableViewCell.swift +++ b/TCAT/Cells/NotificationToggleTableViewCell.swift @@ -23,6 +23,9 @@ class NotificationToggleTableViewCell: UITableViewCell { private let notificationSwitch = UISwitch() private let notificationTitleLabel = UILabel() + private var startTime: String = "" + private var tripId: String = "" + private var stopId: String? private let hairlineHeight = 0.5 override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -77,7 +80,17 @@ class NotificationToggleTableViewCell: UITableViewCell { } } - func configure(for type: NotificationType, isFirst: Bool, delegate: NotificationToggleTableViewDelegate? = nil) { + func configure( + for type: NotificationType, + isFirst: Bool, + delegate: NotificationToggleTableViewDelegate? = nil, + startTime: String, + tripId: String, + stopId: String? + ) { + self.startTime = startTime + self.tripId = tripId + self.stopId = stopId self.delegate = delegate self.type = type notificationTitleLabel.text = type.title @@ -91,9 +104,21 @@ class NotificationToggleTableViewCell: UITableViewCell { switch type { case .beforeBoarding: delegate?.displayNotificationBanner(type: .beforeBoardingConfirmation) + NotificationSubscriptionManager.shared.subscribeToDepartureNotifications(startTime: startTime) case .delay: delegate?.displayNotificationBanner(type: .delayConfirmation) + NotificationSubscriptionManager.shared.subscribeToDelayNotifications(stopID: stopId, tripID: tripId) + + default: break + } + } else { + switch type { + case .beforeBoarding: + NotificationSubscriptionManager.shared.unsubscribeFromDepartureNotifications(startTime: startTime) + + case .delay: + NotificationSubscriptionManager.shared.subscribeToDelayNotifications(stopID: stopId, tripID: tripId) default: break } diff --git a/TCAT/Controllers/RouteDetail+DrawerViewController.swift b/TCAT/Controllers/RouteDetail+DrawerViewController.swift index 12bcddd1..17c9e48d 100644 --- a/TCAT/Controllers/RouteDetail+DrawerViewController.swift +++ b/TCAT/Controllers/RouteDetail+DrawerViewController.swift @@ -59,7 +59,7 @@ class RouteDetailDrawerViewController: UIViewController { /// Number of seconds to wait before auto-refreshing bus delay network call. private var busDelayNetworkRefreshRate: Double = 10 private let chevronFlipDurationTime = 0.25 - private let route: Route + internal let route: Route // MARK: - Initalization init(route: Route) { @@ -151,14 +151,13 @@ class RouteDetailDrawerViewController: UIViewController { RouteDetailItem.notificationType(.beforeBoarding) ] - _ = Section(type: .notification, items: notificationTypes) + let notificationSection = Section(type: .notification, items: notificationTypes) let routeDetailSection = Section(type: .routeDetail, items: directionsAndVisibleStops) sections = [routeDetailSection] - // TODO: Uncomment when notifications are implemented on backend - // if !route.isRawWalkingRoute() { - // sections.append(notificationSection) - // } + if !route.isRawWalkingRoute() { + sections.append(notificationSection) + } } private func setupConstraints() { diff --git a/TCAT/Controllers/RouteDetailDrawerViewController+Extensions.swift b/TCAT/Controllers/RouteDetailDrawerViewController+Extensions.swift index 9747b33e..8b82435b 100644 --- a/TCAT/Controllers/RouteDetailDrawerViewController+Extensions.swift +++ b/TCAT/Controllers/RouteDetailDrawerViewController+Extensions.swift @@ -206,10 +206,27 @@ extension RouteDetailDrawerViewController: UITableViewDataSource { guard let cell = tableView.dequeueReusableCell( withIdentifier: Constants.Cells.notificationToggleCellIdentifier ) as? NotificationToggleTableViewCell else { return UITableViewCell() } + + guard let delayDirection = route.getFirstDepartRawDirection() else { + return UITableViewCell() + } + + // Ensure tripId is non-optional + guard let tripId = delayDirection.tripIdentifiers?.first else { + return UITableViewCell() + } + + // Convert startTime to the desired string format + let startTime = String(Int(delayDirection.startTime.timeIntervalSince1970)) + + let stopId = delayDirection.stops.first?.id cell.configure( for: type, isFirst: indexPath.row == 0, - delegate: self + delegate: self, + startTime: startTime, + tripId: tripId, + stopId: stopId ) return cell } diff --git a/TCAT/Managers/NotificationSubscriptionManager.swift b/TCAT/Managers/NotificationSubscriptionManager.swift new file mode 100644 index 00000000..deef9329 --- /dev/null +++ b/TCAT/Managers/NotificationSubscriptionManager.swift @@ -0,0 +1,115 @@ +// +// NotificationManager.swift +// TCAT +// +// Created by Jayson Hahn on 11/3/24. +// Copyright © 2024 Cornell AppDev. All rights reserved. +// + +import Combine + +// Helper class to manage notifications +class NotificationSubscriptionManager { + + static let shared = NotificationSubscriptionManager() + + private var cancellables = Set() + + func subscribeToDelayNotifications(stopID: String?, tripID: String) { + NotificationTokenHandler.shared.getDeviceToken { [weak self] token in + guard let token = token, + let uid = userDefaults.string(forKey: Constants.UserDefaults.uid) else { return } + + TransitService.shared.subscribeToDelayNotifications( + deviceToken: token, + stopID: stopID, + tripID: tripID, + uid: uid + ) + .sink( + receiveCompletion: { completion in + if case .failure(let error) = completion { + print("Failed to subscribe to departure notification: \(error)") + } + }, + receiveValue: { success in + print("Departure notification subscription success: \(success)") + } + ) + .store(in: &self!.cancellables) + } + } + + func subscribeToDepartureNotifications(startTime: String) { + NotificationTokenHandler.shared.getDeviceToken { [weak self] token in + guard let token = token, + let uid = userDefaults.string(forKey: Constants.UserDefaults.uid) else { return } + + TransitService.shared.subscribeToDepartureNotifications( + deviceToken: token, + startTime: startTime, + uid: uid + ) + .sink( + receiveCompletion: { completion in + if case .failure(let error) = completion { + print("Failed to subscribe to delay notification: \(error)") + } + }, + receiveValue: { success in + print("Delay notification subscription success: \(success)") + } + ) + .store(in: &self!.cancellables) + } + } + + func unsubscribeFromDelayNotifications(stopID: String?, tripID: String) { + NotificationTokenHandler.shared.getDeviceToken { [weak self] token in + guard let token = token, + let uid = userDefaults.string(forKey: Constants.UserDefaults.uid) else { return } + + TransitService.shared.unsubscribeFromDelayNotifications( + deviceToken: token, + stopID: stopID, + tripID: tripID, + uid: uid + ) + .sink( + receiveCompletion: { completion in + if case .failure(let error) = completion { + print("Failed to unsubscribe from Delay notification: \(error)") + } + }, + receiveValue: { success in + print("Delay notification has been unsubscribed: \(success)") + } + ) + .store(in: &self!.cancellables) + } + } + + func unsubscribeFromDepartureNotifications(startTime: String) { + NotificationTokenHandler.shared.getDeviceToken { [weak self] token in + guard let token = token, + let uid = userDefaults.string(forKey: Constants.UserDefaults.uid) else { return } + + TransitService.shared.unsubscribeFromDepartureNotifications( + deviceToken: token, + startTime: startTime, + uid: uid + ) + .sink( + receiveCompletion: { completion in + if case .failure(let error) = completion { + print("Failed to unsubscribe to departure notification: \(error)") + } + }, + receiveValue: { success in + print("Departure notification has been unsubscribed: \(success)") + } + ) + .store(in: &self!.cancellables) + } + } +} diff --git a/TCAT/Managers/NotificationTokenHandler.swift b/TCAT/Managers/NotificationTokenHandler.swift new file mode 100644 index 00000000..fefafc3b --- /dev/null +++ b/TCAT/Managers/NotificationTokenHandler.swift @@ -0,0 +1,104 @@ +// +// NotificationTokenHandler.swift +// TCAT +// +// Created by Jayson Hahn on 11/3/24. +// Copyright © 2024 Cornell AppDev. All rights reserved. +// + +import FirebaseMessaging +import UserNotifications +import UIKit + +class NotificationTokenHandler: NSObject, MessagingDelegate, UNUserNotificationCenterDelegate { + + static let shared = NotificationTokenHandler() + + override init() { + super.init() + setupNotifications() + } + + private func setupNotifications() { + UNUserNotificationCenter.current().delegate = self + Messaging.messaging().delegate = self + + let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound] + UNUserNotificationCenter.current().requestAuthorization( + options: authOptions, + completionHandler: { _, _ in } + ) + + UIApplication.shared.registerForRemoteNotifications() + } + + func getDeviceToken(completion: @escaping (String?) -> Void) { + Messaging.messaging().token { token, error in + if let error = error { + print("Error fetching FCM registration token: \(error)") + completion(nil) + } else if let token = token { + print("FCM registration token: \(token)") + completion(token) + } + } + } + + // MARK: - MessagingDelegate + + func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { + print("Firebase registration token: \(String(describing: fcmToken))") + + let dataDict: [String: String] = ["token": fcmToken ?? ""] + NotificationCenter.default.post( + name: Notification.Name("FCMToken"), + object: nil, + userInfo: dataDict + ) + } + + // MARK: - UNUserNotificationCenterDelegate + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + // Show notification when app is in foreground + completionHandler([[.banner, .sound]]) + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + // Handle notification tap + let userInfo = response.notification.request.content.userInfo + print("Notification tapped with info: \(userInfo)") + completionHandler() + } + + // MARK: - UIApplicationDelegate + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + Messaging.messaging().apnsToken = deviceToken + Messaging.messaging().token { token, error in + if let error = error { + print("Error fetching FCM registration token: \(error)") + } else if let token = token { + print("FCM registration token: \(token)") + } + } + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + print("application didFailToRegisterForRemoteNotificationsWithError: \(error)") + } + + func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + print("APNs received with: \(userInfo)") + completionHandler(.newData) + } + +} diff --git a/TCAT/Models/SearchManager.swift b/TCAT/Managers/SearchManager.swift similarity index 100% rename from TCAT/Models/SearchManager.swift rename to TCAT/Managers/SearchManager.swift diff --git a/TCAT/Services/Network/ApiEndpoint.swift b/TCAT/Services/Network/ApiEndpoint.swift index ea7a0787..8c0dcc0e 100644 --- a/TCAT/Services/Network/ApiEndpoint.swift +++ b/TCAT/Services/Network/ApiEndpoint.swift @@ -73,7 +73,6 @@ extension ApiEndpoint { longPath.append(separatorPath) } - longPath.append("/") longPath.append(path) urlComponents?.path = longPath diff --git a/TCAT/Services/Network/NetworkManager.swift b/TCAT/Services/Network/NetworkManager.swift index ea394821..246e55ca 100644 --- a/TCAT/Services/Network/NetworkManager.swift +++ b/TCAT/Services/Network/NetworkManager.swift @@ -15,8 +15,21 @@ protocol NetworkService { /// - Parameters: /// - request: The `URLRequest` to be sent. /// - decodingType: The type to decode the response into. Must conform to `Decodable`. + /// - responseType: The type of response format expected (.standard or .simple) /// - Returns: A publisher that emits the decoded object of type `T` or an `ApiErrorHandler` on failure. - func request(_ request: URLRequest, decodingType: T.Type) -> AnyPublisher + func request( + _ request: URLRequest, + decodingType: T.Type, + responseType: ResponseFormat + ) -> AnyPublisher< + T, + ApiErrorHandler + > +} + +enum ResponseFormat { + case standard // Format with success and data + case simple // Format with only success } class NetworkManager: NetworkService { @@ -27,14 +40,20 @@ class NetworkManager: NetworkService { self.session = session } - func request(_ request: URLRequest, decodingType: T.Type) -> AnyPublisher { + func request( + _ request: URLRequest, + decodingType: T.Type, + responseType: ResponseFormat = .standard + ) -> AnyPublisher< + T, + ApiErrorHandler + > { return session.dataTaskPublisher(for: request) .tryMap { result in try self.handleResponse(result) } - .decode(type: APIResponse.self, decoder: JSONDecoder()) - .tryMap { response in - try self.validateAPIResponse(response) + .flatMap { data in + self.decodeResponse(data: data, decodingType: decodingType, responseType: responseType) } .mapError { error in self.mapToAPIError(error) @@ -60,7 +79,39 @@ class NetworkManager: NetworkService { } } - // Validate API response and handle future error cases + // Decodes the response based on response format + private func decodeResponse( + data: Data, + decodingType: T.Type, + responseType: ResponseFormat + ) -> AnyPublisher< + T, + Error + > { + let decoder = JSONDecoder() + switch responseType { + case .standard: + return Just(data) + .decode(type: APIResponse.self, decoder: decoder) + .tryMap { response in + try self.validateAPIResponse(response) + } + .eraseToAnyPublisher() + case .simple: + return Just(data) + .decode(type: SimpleAPIResponse.self, decoder: decoder) + .tryMap { response in + let success = try self.validateSimpleResponse(response) + guard let result = success as? T else { + throw ApiErrorHandler.requestFailed + } + return result + } + .eraseToAnyPublisher() + } + } + + // Validate standard API response private func validateAPIResponse(_ response: APIResponse) throws -> T { guard response.success else { // TODO: Update when backend sends more error codes @@ -70,6 +121,16 @@ class NetworkManager: NetworkService { return response.data } + // Validate simple API response + private func validateSimpleResponse(_ response: SimpleAPIResponse) throws -> Bool { + guard response.success else { + // TODO: Update when backend sends more error codes + throw ApiErrorHandler.customApiError(ApiError(code: "500", message: "Internal server error")) + } + + return response.success + } + // Map Combine errors to custom APIErrorHandler types private func mapToAPIError(_ error: Error) -> ApiErrorHandler { if let apiError = error as? ApiErrorHandler { diff --git a/TCAT/Services/Network/RequestModels.swift b/TCAT/Services/Network/RequestModels.swift index 3c536643..bd12b5aa 100644 --- a/TCAT/Services/Network/RequestModels.swift +++ b/TCAT/Services/Network/RequestModels.swift @@ -87,7 +87,24 @@ internal struct Delay: Codable { let delay: Int? } +internal struct DelayNotificationBody: Codable { + let deviceToken: String + let stopID: String? + let tripID: String + let uid: String +} + +internal struct DepartureNotificationBody: Codable { + let deviceToken: String + let startTime: String + let uid: String +} + struct APIResponse: Decodable { var success: Bool var data: T } + +struct SimpleAPIResponse: Decodable { + var success: Bool +} diff --git a/TCAT/Services/Transit/TransitProvider.swift b/TCAT/Services/Transit/TransitProvider.swift index fe923549..bd73e230 100644 --- a/TCAT/Services/Transit/TransitProvider.swift +++ b/TCAT/Services/Transit/TransitProvider.swift @@ -16,7 +16,11 @@ enum TransitProvider { case applePlaces(ApplePlacesBody) case appleSearch(SearchResultsBody) case busLocations(GetBusLocationsBody) + case cancelDelayNotification(DelayNotificationBody) + case cancelDepartureNotification(DepartureNotificationBody) case delay(GetDelayBody) + case delayNotification(DelayNotificationBody) + case departueNotification(DepartureNotificationBody) case routes(GetRoutesBody) } @@ -25,7 +29,15 @@ extension TransitProvider: ApiEndpoint { /// Base URL string for the transit API. var baseURLString: String { - return TransitEnvironment.transitURL +// return TransitEnvironment.transitURL + // TODO: Remove once the Notifications moves to prod + switch self { + case .delayNotification, .departueNotification, .cancelDelayNotification, .cancelDepartureNotification: + return TransitEnvironment.devTransitURL + + default: + return TransitEnvironment.transitURL + } } /// API path for the transit endpoints. @@ -36,6 +48,9 @@ extension TransitProvider: ApiEndpoint { /// API version for the transit endpoints. var apiVersion: String { switch self { + case .delayNotification, .departueNotification, .cancelDelayNotification, .cancelDepartureNotification: + return "v1" + case .routes: return "v2" @@ -48,7 +63,7 @@ extension TransitProvider: ApiEndpoint { var separatorPath: String? { switch self { default: - return "" + return nil } } @@ -73,9 +88,21 @@ extension TransitProvider: ApiEndpoint { case .busLocations: return Constants.Endpoints.busLocations + case .cancelDelayNotification: + return Constants.Endpoints.cancelDelayNotification + + case .cancelDepartureNotification: + return Constants.Endpoints.cancelDepartureNotification + case .delay: return Constants.Endpoints.delay + case .departueNotification: + return Constants.Endpoints.departureNotification + + case .delayNotification: + return Constants.Endpoints.delayNotification + case .routes: return Constants.Endpoints.getRoutes } @@ -137,6 +164,12 @@ extension TransitProvider: ApiEndpoint { case .delay(let getDelayBody): return try? JSONEncoder().encode(getDelayBody) + case .delayNotification(let delayNotificationBody), .cancelDelayNotification(let delayNotificationBody): + return try? JSONEncoder().encode(delayNotificationBody) + + case .departueNotification(let departureNotificationBody), .cancelDepartureNotification(let departureNotificationBody): + return try? JSONEncoder().encode(departureNotificationBody) + case .routes(let getRoutesBody): return try? JSONEncoder().encode(getRoutesBody) diff --git a/TCAT/Services/Transit/TransitService.swift b/TCAT/Services/Transit/TransitService.swift index 17f55ab2..d20a4199 100644 --- a/TCAT/Services/Transit/TransitService.swift +++ b/TCAT/Services/Transit/TransitService.swift @@ -56,6 +56,37 @@ protocol TransitServiceProtocol: AnyObject { /// - Returns: A publisher emitting a `RouteSectionsObject` with route details or an `ApiErrorHandler` on error. func getRoutes(start: Place, end: Place, time: Date, type: SearchType) -> AnyPublisher + /// Subscribes to delay notification for a specific trip's arrival + /// The notification is sent when there is a change in the delay. + /// - Parameters: + /// - deviceToken: The FCM token for the device + /// - stopID: The stop ID to monitor + /// - tripID: The trip ID to monitor + /// - uid: The unique identifier for the user + /// - Returns: A publisher that emits Bool on success, or an ApiErrorHandler on failure + func subscribeToDelayNotifications( + deviceToken: String, + stopID: String?, + tripID: String, + uid: String + ) -> AnyPublisher + + /// Subscribes to departure notifications for a specific trip + /// - Parameters: + /// - deviceToken: The FCM token for the device + /// - startTime: The timestamp to start monitoring from + /// - uid: The unique identifier for the user + /// - Returns: A publisher that emits Bool on success, or an ApiErrorHandler on failure + func subscribeToDepartureNotifications( + deviceToken: String, + startTime: String, + uid: String + ) -> AnyPublisher + + func unsubscribeFromDelayNotifications(deviceToken: String, stopID: String?, tripID: String, uid: String) -> AnyPublisher + + func unsubscribeFromDepartureNotifications(deviceToken: String, startTime: String, uid: String) -> AnyPublisher + /// Updates the local cache of Apple places based on the search text and provided locations. /// - Parameters: /// - searchText: The query text used for retrieving places. @@ -179,6 +210,49 @@ class TransitService: TransitServiceProtocol { return networkManager.request(request, decodingType: RouteSectionsObject.self) } + func subscribeToDelayNotifications( + deviceToken: String, + stopID: String?, + tripID: String, + uid: String + ) -> AnyPublisher { + let body = DelayNotificationBody(deviceToken: deviceToken, stopID: stopID, tripID: tripID, uid: uid) + let request = TransitProvider.delayNotification(body).makeRequest + return networkManager.request(request, decodingType: Bool.self, responseType: .simple) + } + + func subscribeToDepartureNotifications( + deviceToken: String, + startTime: String, + uid: String + ) -> AnyPublisher { + print("startTime: \(startTime)") + let body = DepartureNotificationBody(deviceToken: deviceToken, startTime: startTime, uid: uid) + let request = TransitProvider.departueNotification(body).makeRequest + return networkManager.request(request, decodingType: Bool.self, responseType: .simple) + } + + func unsubscribeFromDelayNotifications( + deviceToken: String, + stopID: String?, + tripID: String, + uid: String + ) -> AnyPublisher { + let body = DelayNotificationBody(deviceToken: deviceToken, stopID: stopID, tripID: tripID, uid: uid) + let request = TransitProvider.delayNotification(body).makeRequest + return networkManager.request(request, decodingType: Bool.self, responseType: .simple) + } + + func unsubscribeFromDepartureNotifications( + deviceToken: String, + startTime: String, + uid: String + ) -> AnyPublisher { + let body = DepartureNotificationBody(deviceToken: deviceToken, startTime: startTime, uid: uid) + let request = TransitProvider.departueNotification(body).makeRequest + return networkManager.request(request, decodingType: Bool.self, responseType: .simple) + } + func updateApplePlacesCache(searchText: String, places: [Place]) -> AnyPublisher { let body = ApplePlacesBody(query: searchText, places: places) let request = TransitProvider.applePlaces(body).makeRequest diff --git a/TCAT/Supporting/Constants.swift b/TCAT/Supporting/Constants.swift index 56033122..75dca990 100644 --- a/TCAT/Supporting/Constants.swift +++ b/TCAT/Supporting/Constants.swift @@ -166,8 +166,12 @@ struct Constants { static let applePlaces = "/applePlaces" static let appleSearch = "/appleSearch" static let busLocations = "/tracking" + static let cancelDelayNotification = "/cancelDelayNotification" + static let cancelDepartureNotification = "/cancelDepartureNotification" static let delay = "/delay" + static let delayNotification = "/delayNotification" static let delays = "/delays" + static let departureNotification = "/departureNotification" static let getRoutes = "/route" } diff --git a/TCAT/Supporting/TransitEnvironment.swift b/TCAT/Supporting/TransitEnvironment.swift index 468a3c19..e7f9edf2 100644 --- a/TCAT/Supporting/TransitEnvironment.swift +++ b/TCAT/Supporting/TransitEnvironment.swift @@ -28,6 +28,9 @@ enum TransitEnvironment { static let announcementsHost = "ANNOUNCEMENTS_HOST" static let announcementsPath = "ANNOUNCEMENTS_PATH" static let announcementsScheme = "ANNOUNCEMENTS_SCHEME" + + // TODO: Remove once the Notifications moves to prod + static let devTransitURL = "TRANSIT_DEV_URL" } /// A dictionary storing key-value pairs from Keys.plist. @@ -56,6 +59,14 @@ enum TransitEnvironment { return baseURLString }() + // TODO: Remove once Notifications moves to prod + static let devTransitURL: String = { + guard let baseURLString = TransitEnvironment.keysDict[Keys.devTransitURL] as? String else { + fatalError("TRANSIT_DEV_URL not found in Keys.plist") + } + return baseURLString + }() + /** The base URL of Uplift's backend server. From 2cd863cd390da7fc2a17eec39355940ad55faf65 Mon Sep 17 00:00:00 2001 From: Jayson Hahn <46629787+JaysonHahn@users.noreply.github.com> Date: Mon, 4 Nov 2024 14:30:19 -0500 Subject: [PATCH 05/13] Prevent departure notif 10 minute before boarding --- .../NotificationToggleTableViewCell.swift | 18 ++++++++++++------ ...DetailDrawerViewController+Extensions.swift | 2 +- TCAT/Services/Transit/TransitProvider.swift | 6 +++++- TCAT/Supporting/Constants.swift | 1 + TCAT/Views/NotificationBannerView.swift | 7 +++++-- 5 files changed, 24 insertions(+), 10 deletions(-) diff --git a/TCAT/Cells/NotificationToggleTableViewCell.swift b/TCAT/Cells/NotificationToggleTableViewCell.swift index dbd462bf..6b90c80b 100644 --- a/TCAT/Cells/NotificationToggleTableViewCell.swift +++ b/TCAT/Cells/NotificationToggleTableViewCell.swift @@ -23,7 +23,7 @@ class NotificationToggleTableViewCell: UITableViewCell { private let notificationSwitch = UISwitch() private let notificationTitleLabel = UILabel() - private var startTime: String = "" + private var startTime: Int = 0 private var tripId: String = "" private var stopId: String? private let hairlineHeight = 0.5 @@ -84,7 +84,7 @@ class NotificationToggleTableViewCell: UITableViewCell { for type: NotificationType, isFirst: Bool, delegate: NotificationToggleTableViewDelegate? = nil, - startTime: String, + startTime: Int, tripId: String, stopId: String? ) { @@ -103,9 +103,15 @@ class NotificationToggleTableViewCell: UITableViewCell { if notificationSwitch.isOn { switch type { case .beforeBoarding: - delegate?.displayNotificationBanner(type: .beforeBoardingConfirmation) - NotificationSubscriptionManager.shared.subscribeToDepartureNotifications(startTime: startTime) - + // TODO debug this logic +// print("diff: \(startTime - Int(Date().timeIntervalSince1970))") + if startTime - Int(Date().timeIntervalSince1970) > 10 { + delegate?.displayNotificationBanner(type: .beforeBoardingConfirmation) + NotificationSubscriptionManager.shared.subscribeToDepartureNotifications(startTime: String(startTime)) + } else { + notificationSwitch.setOn(false, animated: true) + delegate?.displayNotificationBanner(type: .unableToConfirmBeforeBoarding) + } case .delay: delegate?.displayNotificationBanner(type: .delayConfirmation) NotificationSubscriptionManager.shared.subscribeToDelayNotifications(stopID: stopId, tripID: tripId) @@ -115,7 +121,7 @@ class NotificationToggleTableViewCell: UITableViewCell { } else { switch type { case .beforeBoarding: - NotificationSubscriptionManager.shared.unsubscribeFromDepartureNotifications(startTime: startTime) + NotificationSubscriptionManager.shared.unsubscribeFromDepartureNotifications(startTime: String(startTime)) case .delay: NotificationSubscriptionManager.shared.subscribeToDelayNotifications(stopID: stopId, tripID: tripId) diff --git a/TCAT/Controllers/RouteDetailDrawerViewController+Extensions.swift b/TCAT/Controllers/RouteDetailDrawerViewController+Extensions.swift index 8b82435b..3bbfe5e8 100644 --- a/TCAT/Controllers/RouteDetailDrawerViewController+Extensions.swift +++ b/TCAT/Controllers/RouteDetailDrawerViewController+Extensions.swift @@ -217,7 +217,7 @@ extension RouteDetailDrawerViewController: UITableViewDataSource { } // Convert startTime to the desired string format - let startTime = String(Int(delayDirection.startTime.timeIntervalSince1970)) + let startTime = Int(delayDirection.startTime.timeIntervalSince1970) let stopId = delayDirection.stops.first?.id cell.configure( diff --git a/TCAT/Services/Transit/TransitProvider.swift b/TCAT/Services/Transit/TransitProvider.swift index bd73e230..2e9c6001 100644 --- a/TCAT/Services/Transit/TransitProvider.swift +++ b/TCAT/Services/Transit/TransitProvider.swift @@ -167,7 +167,11 @@ extension TransitProvider: ApiEndpoint { case .delayNotification(let delayNotificationBody), .cancelDelayNotification(let delayNotificationBody): return try? JSONEncoder().encode(delayNotificationBody) - case .departueNotification(let departureNotificationBody), .cancelDepartureNotification(let departureNotificationBody): + case .departueNotification( + let departureNotificationBody + ), .cancelDepartureNotification( + let departureNotificationBody + ): return try? JSONEncoder().encode(departureNotificationBody) case .routes(let getRoutesBody): diff --git a/TCAT/Supporting/Constants.swift b/TCAT/Supporting/Constants.swift index 75dca990..3c4bb0ba 100644 --- a/TCAT/Supporting/Constants.swift +++ b/TCAT/Supporting/Constants.swift @@ -247,6 +247,7 @@ struct Constants { static let delayNotification = "has been delayed to" static let notifyBeforeBoarding = "Notify me 10 min before boarding" static let notifyDelay = "Notify me about delays" + static let unableToConfirmBeforeBoarding = "The bus is arriving in less than 10 minutes, so notifications are unavailable." } struct SearchBar { diff --git a/TCAT/Views/NotificationBannerView.swift b/TCAT/Views/NotificationBannerView.swift index 86458eb3..e91aba72 100644 --- a/TCAT/Views/NotificationBannerView.swift +++ b/TCAT/Views/NotificationBannerView.swift @@ -26,14 +26,14 @@ enum NotificationType { enum NotificationBannerType { - case beforeBoardingConfirmation, busArriving, busDelay, delayConfirmation + case beforeBoardingConfirmation, busArriving, busDelay, delayConfirmation, unableToConfirmBeforeBoarding var bannerColor: UIColor { switch self { case .beforeBoardingConfirmation, .busArriving, .delayConfirmation: return Colors.tcatBlue - case .busDelay: + case .busDelay, .unableToConfirmBeforeBoarding: return Colors.lateRed } } @@ -87,6 +87,9 @@ class NotificationBannerView: UIView { case .delayConfirmation: beginningText = Constants.Notification.delayConfirmation + case .unableToConfirmBeforeBoarding: + beginningText = Constants.Notification.unableToConfirmBeforeBoarding + default: beginningText = "" } From 7f596c2017a746a482b69c9ab6dfe2b2d3a3c148 Mon Sep 17 00:00:00 2001 From: Jayson Hahn <46629787+JaysonHahn@users.noreply.github.com> Date: Wed, 20 Nov 2024 17:28:08 -0500 Subject: [PATCH 06/13] temp stash --- .../NotificationToggleTableViewCell.swift | 4 +- TCAT/Managers/NotificationTokenHandler.swift | 73 ++++++++++++++++++- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/TCAT/Cells/NotificationToggleTableViewCell.swift b/TCAT/Cells/NotificationToggleTableViewCell.swift index 6b90c80b..fb390c7c 100644 --- a/TCAT/Cells/NotificationToggleTableViewCell.swift +++ b/TCAT/Cells/NotificationToggleTableViewCell.swift @@ -103,7 +103,7 @@ class NotificationToggleTableViewCell: UITableViewCell { if notificationSwitch.isOn { switch type { case .beforeBoarding: - // TODO debug this logic + // TODO: debug this logic // print("diff: \(startTime - Int(Date().timeIntervalSince1970))") if startTime - Int(Date().timeIntervalSince1970) > 10 { delegate?.displayNotificationBanner(type: .beforeBoardingConfirmation) @@ -124,7 +124,7 @@ class NotificationToggleTableViewCell: UITableViewCell { NotificationSubscriptionManager.shared.unsubscribeFromDepartureNotifications(startTime: String(startTime)) case .delay: - NotificationSubscriptionManager.shared.subscribeToDelayNotifications(stopID: stopId, tripID: tripId) + NotificationSubscriptionManager.shared.unsubscribeFromDelayNotifications(stopID: stopId, tripID: tripId) default: break } diff --git a/TCAT/Managers/NotificationTokenHandler.swift b/TCAT/Managers/NotificationTokenHandler.swift index fefafc3b..1c921e8c 100644 --- a/TCAT/Managers/NotificationTokenHandler.swift +++ b/TCAT/Managers/NotificationTokenHandler.swift @@ -19,19 +19,35 @@ class NotificationTokenHandler: NSObject, MessagingDelegate, UNUserNotificationC setupNotifications() } + /** + Sets up notifications by configuring the necessary delegates and requesting authorization for notifications. + */ private func setupNotifications() { + // Set the current UNUserNotificationCenter delegate to self UNUserNotificationCenter.current().delegate = self + + // Set the Messaging delegate to self Messaging.messaging().delegate = self - + + // Request authorization for notifications with alert, badge, and sound options let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound] UNUserNotificationCenter.current().requestAuthorization( options: authOptions, completionHandler: { _, _ in } ) - + + // Register the application for remote notifications UIApplication.shared.registerForRemoteNotifications() } + /// Retrieves the device's FCM (Firebase Cloud Messaging) registration token. + /// + /// - Parameter completion: A closure that is called with the FCM registration token as a `String?`. + /// If there is an error fetching the token, the closure is called with `nil`. + /// + /// This function uses Firebase Messaging to asynchronously fetch the device's FCM registration token. + /// If the token is successfully retrieved, it is passed to the completion handler. If an error occurs, + /// the error is printed to the console and the completion handler is called with `nil`. func getDeviceToken(completion: @escaping (String?) -> Void) { Messaging.messaging().token { token, error in if let error = error { @@ -46,6 +62,14 @@ class NotificationTokenHandler: NSObject, MessagingDelegate, UNUserNotificationC // MARK: - MessagingDelegate + /// Called when a new Firebase Cloud Messaging (FCM) registration token is received. + /// - Parameters: + /// - messaging: The messaging instance that received the token. + /// - fcmToken: The new FCM registration token, or `nil` if the token could not be retrieved. + /// + /// This method prints the new FCM registration token and posts a notification with the token + /// using `NotificationCenter`. The notification name is "FCMToken" and the token is included + /// in the `userInfo` dictionary with the key "token". func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { print("Firebase registration token: \(String(describing: fcmToken))") @@ -59,6 +83,11 @@ class NotificationTokenHandler: NSObject, MessagingDelegate, UNUserNotificationC // MARK: - UNUserNotificationCenterDelegate + /// Handles the presentation of a notification when the app is in the foreground. + /// - Parameters: + /// - center: The notification center that received the notification. + /// - notification: The notification that is about to be presented. + /// - completionHandler: The block to execute with the presentation options for the notification. func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, @@ -68,6 +97,14 @@ class NotificationTokenHandler: NSObject, MessagingDelegate, UNUserNotificationC completionHandler([[.banner, .sound]]) } + /** + Handles the event when a user taps on a notification. + + - Parameters: + - center: The notification center that received the notification. + - response: The user's response to the notification. + - completionHandler: The block to execute when you have finished processing the user's response. + */ func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, @@ -81,6 +118,16 @@ class NotificationTokenHandler: NSObject, MessagingDelegate, UNUserNotificationC // MARK: - UIApplicationDelegate + /// Handles the registration of the device for remote notifications and retrieves the FCM registration token. + /// + /// - Parameters: + /// - application: The singleton app object. + /// - deviceToken: A token that identifies the device to APNs. + /// + /// This method is called when the app successfully registers with Apple Push Notification service (APNs). + /// It sets the APNs token for Firebase Cloud Messaging (FCM) and attempts to retrieve the FCM registration token. + /// If an error occurs while fetching the FCM registration token, it prints the error. + /// Otherwise, it prints the FCM registration token. func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { Messaging.messaging().apnsToken = deviceToken Messaging.messaging().token { token, error in @@ -92,11 +139,31 @@ class NotificationTokenHandler: NSObject, MessagingDelegate, UNUserNotificationC } } + /// Called when the app fails to register for remote notifications. + /// - Parameters: + /// - application: The singleton app object. + /// - error: An error object that encapsulates information why registration did not succeed. func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { print("application didFailToRegisterForRemoteNotificationsWithError: \(error)") } - func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + /** + Handles the receipt of a remote notification. + + - Parameters: + - application: The singleton app object. + - userInfo: A dictionary that contains information related to the remote notification. + - completionHandler: The block to execute when the download operation is complete. You must call this handler and pass in the appropriate `UIBackgroundFetchResult` value. + + This method is called when a remote notification is received. It logs the notification's userInfo and calls the completion handler with `.newData`. + */ + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping ( + UIBackgroundFetchResult + ) -> Void + ) { print("APNs received with: \(userInfo)") completionHandler(.newData) } From bc4d0ebb133648523144d2230750ac14493e8224 Mon Sep 17 00:00:00 2001 From: Jayson Hahn <46629787+JaysonHahn@users.noreply.github.com> Date: Mon, 17 Feb 2025 11:26:33 -0500 Subject: [PATCH 07/13] Change File structure of networking --- TCAT.xcodeproj/project.pbxproj | 78 ++++--------------- TCAT/Base/AppDelegate.swift | 4 +- .../NotificationToggleTableViewCell.swift | 8 +- .../Network/Base}/ApiEndpoint.swift | 0 .../Network/Base}/ApiErrorHandler.swift | 0 .../Network/Base}/NetworkManager.swift | 0 .../Network/Base}/NetworkMonitor.swift | 0 .../TransitAPI/Models}/RequestModels.swift | 11 --- .../TransitAPI/Models/ResponseModels.swift | 20 +++++ .../Network/TransitAPI}/TransitProvider.swift | 0 .../Network/TransitAPI}/TransitService.swift | 0 ...er.swift => PushNotificationService.swift} | 6 +- ...ft => TransitNotificationSubscriber.swift} | 15 ++-- 13 files changed, 53 insertions(+), 89 deletions(-) rename TCAT/{Services/Network => Core/Network/Base}/ApiEndpoint.swift (100%) rename TCAT/{Services/Network => Core/Network/Base}/ApiErrorHandler.swift (100%) rename TCAT/{Services/Network => Core/Network/Base}/NetworkManager.swift (100%) rename TCAT/{Services/Network => Core/Network/Base}/NetworkMonitor.swift (100%) rename TCAT/{Services/Network => Core/Network/TransitAPI/Models}/RequestModels.swift (90%) create mode 100644 TCAT/Core/Network/TransitAPI/Models/ResponseModels.swift rename TCAT/{Services/Transit => Core/Network/TransitAPI}/TransitProvider.swift (100%) rename TCAT/{Services/Transit => Core/Network/TransitAPI}/TransitService.swift (100%) rename TCAT/Managers/{NotificationTokenHandler.swift => PushNotificationService.swift} (97%) rename TCAT/Managers/{NotificationSubscriptionManager.swift => TransitNotificationSubscriber.swift} (88%) diff --git a/TCAT.xcodeproj/project.pbxproj b/TCAT.xcodeproj/project.pbxproj index ad311872..4811fcca 100644 --- a/TCAT.xcodeproj/project.pbxproj +++ b/TCAT.xcodeproj/project.pbxproj @@ -3,11 +3,10 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ - 22948BFD221B75C5003FC43F /* RequestModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22948BFB221B75C5003FC43F /* RequestModels.swift */; }; 28EA3E17A0C473892F5506EC /* Pods_TCAT.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 542B073726DFD1EE044EA97F /* Pods_TCAT.framework */; }; 2E70434E2BB75E10003AC1D6 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 2E70434D2BB75E10003AC1D6 /* PrivacyInfo.xcprivacy */; }; 2E9416602BC60A59003DEB44 /* UpliftQueries.graphql in Resources */ = {isa = PBXBuildFile; fileRef = 2E94165F2BC60A59003DEB44 /* UpliftQueries.graphql */; }; @@ -124,14 +123,8 @@ BF74AC1A1F945D7D00AFD4E4 /* GoogleMapsBase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF74AC191F945D7D00AFD4E4 /* GoogleMapsBase.framework */; }; BF74AC1D1F945D8E00AFD4E4 /* GoogleMapsCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF74AC1B1F945D8E00AFD4E4 /* GoogleMapsCore.framework */; }; BF74AC1E1F945D8E00AFD4E4 /* GoogleMaps.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF74AC1C1F945D8E00AFD4E4 /* GoogleMaps.framework */; }; - FD44EC532CD86A5F009269A2 /* NotificationSubscriptionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD44EC522CD86A56009269A2 /* NotificationSubscriptionManager.swift */; }; - FD44EC552CD86C55009269A2 /* NotificationTokenHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD44EC542CD86C4A009269A2 /* NotificationTokenHandler.swift */; }; - FDA3439F2CB6DF5800608A1A /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA3439E2CB6DF4D00608A1A /* NetworkMonitor.swift */; }; - FDE68D1E2C97E24900024A69 /* NetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D1D2C97E24900024A69 /* NetworkManager.swift */; }; - FDE68D202C97EBBE00024A69 /* ApiErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D1F2C97EBBE00024A69 /* ApiErrorHandler.swift */; }; - FDE68D222C97EF6200024A69 /* ApiEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D212C97EF6200024A69 /* ApiEndpoint.swift */; }; - FDE68D262C97FC0D00024A69 /* TransitService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D252C97FC0D00024A69 /* TransitService.swift */; }; - FDE68D282C97FC4600024A69 /* TransitProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D272C97FC4600024A69 /* TransitProvider.swift */; }; + FD44EC532CD86A5F009269A2 /* TransitNotificationSubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD44EC522CD86A56009269A2 /* TransitNotificationSubscriber.swift */; }; + FD44EC552CD86C55009269A2 /* PushNotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD44EC542CD86C4A009269A2 /* PushNotificationService.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -148,7 +141,6 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 22948BFB221B75C5003FC43F /* RequestModels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RequestModels.swift; sourceTree = ""; }; 2E70434D2BB75E10003AC1D6 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 2E94165F2BC60A59003DEB44 /* UpliftQueries.graphql */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = UpliftQueries.graphql; sourceTree = ""; }; 2E9416672BC615DF003DEB44 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -276,17 +268,15 @@ EEB26AE02C9F998C002E863F /* TCATLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TCATLocal.entitlements; sourceTree = ""; }; EEB26AE12C9F9B9A002E863F /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = System/Library/Frameworks/UserNotifications.framework; sourceTree = SDKROOT; }; EEB26AE32C9FA60E002E863F /* TCATDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TCATDebug.entitlements; sourceTree = ""; }; - FD44EC522CD86A56009269A2 /* NotificationSubscriptionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSubscriptionManager.swift; sourceTree = ""; }; - FD44EC542CD86C4A009269A2 /* NotificationTokenHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationTokenHandler.swift; sourceTree = ""; }; + FD44EC522CD86A56009269A2 /* TransitNotificationSubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitNotificationSubscriber.swift; sourceTree = ""; }; + FD44EC542CD86C4A009269A2 /* PushNotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationService.swift; sourceTree = ""; }; FD69AF2A2B89212F00970C7E /* ci_post_clone.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = ci_post_clone.sh; sourceTree = ""; }; - FDA3439E2CB6DF4D00608A1A /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; - FDE68D1D2C97E24900024A69 /* NetworkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkManager.swift; sourceTree = ""; }; - FDE68D1F2C97EBBE00024A69 /* ApiErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiErrorHandler.swift; sourceTree = ""; }; - FDE68D212C97EF6200024A69 /* ApiEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiEndpoint.swift; sourceTree = ""; }; - FDE68D252C97FC0D00024A69 /* TransitService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitService.swift; sourceTree = ""; }; - FDE68D272C97FC4600024A69 /* TransitProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitProvider.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + FD2707CE2D63938C00BF2DF2 /* Core */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Core; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 449A7C721D80D0E80019300C /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -316,18 +306,6 @@ name = Frameworks; sourceTree = ""; }; - 2292486621B891790004279C /* Network */ = { - isa = PBXGroup; - children = ( - FDE68D212C97EF6200024A69 /* ApiEndpoint.swift */, - FDE68D1F2C97EBBE00024A69 /* ApiErrorHandler.swift */, - FDE68D1D2C97E24900024A69 /* NetworkManager.swift */, - FDA3439E2CB6DF4D00608A1A /* NetworkMonitor.swift */, - 22948BFB221B75C5003FC43F /* RequestModels.swift */, - ); - path = Network; - sourceTree = ""; - }; 2E94165E2BC60A3B003DEB44 /* Ecosystem */ = { isa = PBXGroup; children = ( @@ -599,7 +577,7 @@ 2E94165E2BC60A3B003DEB44 /* Ecosystem */, FD44EC562CD8914D009269A2 /* Managers */, 2E9416AB2BC616DE003DEB44 /* Models */, - FDE68D292C988CDB00024A69 /* Services */, + FD2707CE2D63938C00BF2DF2 /* Core */, 2E9416C72BC61763003DEB44 /* Supporting */, 2E9416E02BC618E6003DEB44 /* Utils */, 2E9416FD2BC61CAE003DEB44 /* Views */, @@ -620,8 +598,8 @@ FD44EC562CD8914D009269A2 /* Managers */ = { isa = PBXGroup; children = ( - FD44EC542CD86C4A009269A2 /* NotificationTokenHandler.swift */, - FD44EC522CD86A56009269A2 /* NotificationSubscriptionManager.swift */, + FD44EC542CD86C4A009269A2 /* PushNotificationService.swift */, + FD44EC522CD86A56009269A2 /* TransitNotificationSubscriber.swift */, 2E9416B62BC61731003DEB44 /* SearchManager.swift */, ); path = Managers; @@ -635,24 +613,6 @@ path = ci_scripts; sourceTree = ""; }; - FDE68D292C988CDB00024A69 /* Services */ = { - isa = PBXGroup; - children = ( - 2292486621B891790004279C /* Network */, - FDE68D2A2C98933900024A69 /* Transit */, - ); - path = Services; - sourceTree = ""; - }; - FDE68D2A2C98933900024A69 /* Transit */ = { - isa = PBXGroup; - children = ( - FDE68D272C97FC4600024A69 /* TransitProvider.swift */, - FDE68D252C97FC0D00024A69 /* TransitService.swift */, - ); - path = Transit; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -674,6 +634,9 @@ ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + FD2707CE2D63938C00BF2DF2 /* Core */, + ); name = TCAT; productName = TCAT; productReference = 449A7C751D80D0E80019300C /* TCAT.app */; @@ -864,9 +827,7 @@ 2E9416C12BC61731003DEB44 /* WalkPath.swift in Sources */, 2E9416BC2BC61731003DEB44 /* Waypoint.swift in Sources */, 2E9417202BC61CF1003DEB44 /* WalkWithDistanceIcon.swift in Sources */, - FDE68D282C97FC4600024A69 /* TransitProvider.swift in Sources */, 2E94169B2BC616B9003DEB44 /* StopPickerViewController.swift in Sources */, - FDE68D1E2C97E24900024A69 /* NetworkManager.swift in Sources */, 2E9417162BC61CF1003DEB44 /* SearchBarView.swift in Sources */, 2E9FFA882BC673240051793C /* Amenity.graphql.swift in Sources */, 2E9FFA852BC673240051793C /* AmenityType.graphql.swift in Sources */, @@ -886,7 +847,7 @@ 2E9416C22BC61731003DEB44 /* AppleSearchResponse.swift in Sources */, 2E9416A12BC616B9003DEB44 /* RouteOptionsViewController.swift in Sources */, 2E9416BF2BC61731003DEB44 /* Direction.swift in Sources */, - FD44EC532CD86A5F009269A2 /* NotificationSubscriptionManager.swift in Sources */, + FD44EC532CD86A5F009269A2 /* TransitNotificationSubscriber.swift in Sources */, 2EC1F5122BC66972001D9F66 /* ApolloClientProtocol.swift in Sources */, 2E9FFA902BC673240051793C /* UpliftAPI.graphql.swift in Sources */, 2E9416BD2BC61731003DEB44 /* LocationObject.swift in Sources */, @@ -903,21 +864,18 @@ 2E9417212BC61CF1003DEB44 /* NotificationBannerView.swift in Sources */, 2E9FFA832BC673240051793C /* OpenHoursFields.graphql.swift in Sources */, 2E9416972BC616B9003DEB44 /* RouteDetail+ContentViewController.swift in Sources */, - FDE68D262C97FC0D00024A69 /* TransitService.swift in Sources */, 2E9416F62BC61984003DEB44 /* Time.swift in Sources */, 2E9FFA8D2BC673240051793C /* Query.graphql.swift in Sources */, 2E9416792BC61679003DEB44 /* AddFavoritesCollectionViewCell.swift in Sources */, 2E9FFA812BC673240051793C /* FacilityFields.graphql.swift in Sources */, 2E94171F2BC61CF1003DEB44 /* BusIcon.swift in Sources */, - FDA3439F2CB6DF5800608A1A /* NetworkMonitor.swift in Sources */, 2E9417242BC61CF1003DEB44 /* DetailIconView.swift in Sources */, 2E94171A2BC61CF1003DEB44 /* SummaryView.swift in Sources */, 2E9416992BC616B9003DEB44 /* RouteDetailContentViewController+Extensions.swift in Sources */, 2E9416F12BC61984003DEB44 /* Shared.swift in Sources */, 2E9FFA892BC673240051793C /* Capacity.graphql.swift in Sources */, 2E9416BB2BC61731003DEB44 /* ServiceAlert.swift in Sources */, - FD44EC552CD86C55009269A2 /* NotificationTokenHandler.swift in Sources */, - FDE68D222C97EF6200024A69 /* ApiEndpoint.swift in Sources */, + FD44EC552CD86C55009269A2 /* PushNotificationService.swift in Sources */, 2EC1F5162BC66CBA001D9F66 /* Publishers.swift in Sources */, 2E94169E2BC616B9003DEB44 /* SearchResultsViewController.swift in Sources */, 2E9FFA822BC673240051793C /* GymFields.graphql.swift in Sources */, @@ -949,8 +907,6 @@ 2E9FFA8B2BC673240051793C /* Gym.graphql.swift in Sources */, 2E9FFA8E2BC673240051793C /* SchemaConfiguration.swift in Sources */, 2E9416EF2BC61984003DEB44 /* EventPayload.swift in Sources */, - FDE68D202C97EBBE00024A69 /* ApiErrorHandler.swift in Sources */, - 22948BFD221B75C5003FC43F /* RequestModels.swift in Sources */, 2E94167A2BC61679003DEB44 /* GeneralTableViewCell.swift in Sources */, 2E9417222BC61CF1003DEB44 /* BusLocationView.swift in Sources */, 2E9416C42BC61731003DEB44 /* Section.swift in Sources */, diff --git a/TCAT/Base/AppDelegate.swift b/TCAT/Base/AppDelegate.swift index e8f74dfc..2da37c60 100755 --- a/TCAT/Base/AppDelegate.swift +++ b/TCAT/Base/AppDelegate.swift @@ -19,7 +19,7 @@ import FirebaseMessaging let userDefaults = UserDefaults.standard @UIApplicationMain -class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate, UNUserNotificationCenterDelegate { +class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? private let encoder = JSONEncoder() @@ -73,7 +73,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate, UNUser self.window?.makeKeyAndVisible() // Initialize and setup notifications - _ = NotificationTokenHandler.shared + _ = PushNotificationService.shared return true } diff --git a/TCAT/Cells/NotificationToggleTableViewCell.swift b/TCAT/Cells/NotificationToggleTableViewCell.swift index fb390c7c..9399ca1c 100644 --- a/TCAT/Cells/NotificationToggleTableViewCell.swift +++ b/TCAT/Cells/NotificationToggleTableViewCell.swift @@ -107,24 +107,24 @@ class NotificationToggleTableViewCell: UITableViewCell { // print("diff: \(startTime - Int(Date().timeIntervalSince1970))") if startTime - Int(Date().timeIntervalSince1970) > 10 { delegate?.displayNotificationBanner(type: .beforeBoardingConfirmation) - NotificationSubscriptionManager.shared.subscribeToDepartureNotifications(startTime: String(startTime)) + TransitNotificationSubscriber.shared.subscribeToDepartureNotifications(startTime: String(startTime)) } else { notificationSwitch.setOn(false, animated: true) delegate?.displayNotificationBanner(type: .unableToConfirmBeforeBoarding) } case .delay: delegate?.displayNotificationBanner(type: .delayConfirmation) - NotificationSubscriptionManager.shared.subscribeToDelayNotifications(stopID: stopId, tripID: tripId) + TransitNotificationSubscriber.shared.subscribeToDelayNotifications(stopID: stopId, tripID: tripId) default: break } } else { switch type { case .beforeBoarding: - NotificationSubscriptionManager.shared.unsubscribeFromDepartureNotifications(startTime: String(startTime)) + TransitNotificationSubscriber.shared.unsubscribeFromDepartureNotifications(startTime: String(startTime)) case .delay: - NotificationSubscriptionManager.shared.unsubscribeFromDelayNotifications(stopID: stopId, tripID: tripId) + TransitNotificationSubscriber.shared.unsubscribeFromDelayNotifications(stopID: stopId, tripID: tripId) default: break } diff --git a/TCAT/Services/Network/ApiEndpoint.swift b/TCAT/Core/Network/Base/ApiEndpoint.swift similarity index 100% rename from TCAT/Services/Network/ApiEndpoint.swift rename to TCAT/Core/Network/Base/ApiEndpoint.swift diff --git a/TCAT/Services/Network/ApiErrorHandler.swift b/TCAT/Core/Network/Base/ApiErrorHandler.swift similarity index 100% rename from TCAT/Services/Network/ApiErrorHandler.swift rename to TCAT/Core/Network/Base/ApiErrorHandler.swift diff --git a/TCAT/Services/Network/NetworkManager.swift b/TCAT/Core/Network/Base/NetworkManager.swift similarity index 100% rename from TCAT/Services/Network/NetworkManager.swift rename to TCAT/Core/Network/Base/NetworkManager.swift diff --git a/TCAT/Services/Network/NetworkMonitor.swift b/TCAT/Core/Network/Base/NetworkMonitor.swift similarity index 100% rename from TCAT/Services/Network/NetworkMonitor.swift rename to TCAT/Core/Network/Base/NetworkMonitor.swift diff --git a/TCAT/Services/Network/RequestModels.swift b/TCAT/Core/Network/TransitAPI/Models/RequestModels.swift similarity index 90% rename from TCAT/Services/Network/RequestModels.swift rename to TCAT/Core/Network/TransitAPI/Models/RequestModels.swift index bd12b5aa..7b9041c3 100644 --- a/TCAT/Services/Network/RequestModels.swift +++ b/TCAT/Core/Network/TransitAPI/Models/RequestModels.swift @@ -56,12 +56,6 @@ internal struct BusLocationsInfo: Codable { let tripIdentifiers: [String] } -class RouteSectionsObject: Codable { - var fromStop: [Route] - var boardingSoon: [Route] - var walking: [Route] -} - internal struct GetDelayBody: Codable { let stopID: String @@ -82,11 +76,6 @@ internal struct TripBody: Codable { var data: [Trip] } -internal struct Delay: Codable { - let tripID: String - let delay: Int? -} - internal struct DelayNotificationBody: Codable { let deviceToken: String let stopID: String? diff --git a/TCAT/Core/Network/TransitAPI/Models/ResponseModels.swift b/TCAT/Core/Network/TransitAPI/Models/ResponseModels.swift new file mode 100644 index 00000000..8e2f5454 --- /dev/null +++ b/TCAT/Core/Network/TransitAPI/Models/ResponseModels.swift @@ -0,0 +1,20 @@ +// +// ResponseModels.swift +// TCAT +// +// Created by Jayson Hahn on 2/17/25. +// Copyright © 2025 Cornell AppDev. All rights reserved. +// + +import Foundation + +internal struct Delay: Codable { + let tripID: String + let delay: Int? +} + +class RouteSectionsObject: Codable { + var fromStop: [Route] + var boardingSoon: [Route] + var walking: [Route] +} diff --git a/TCAT/Services/Transit/TransitProvider.swift b/TCAT/Core/Network/TransitAPI/TransitProvider.swift similarity index 100% rename from TCAT/Services/Transit/TransitProvider.swift rename to TCAT/Core/Network/TransitAPI/TransitProvider.swift diff --git a/TCAT/Services/Transit/TransitService.swift b/TCAT/Core/Network/TransitAPI/TransitService.swift similarity index 100% rename from TCAT/Services/Transit/TransitService.swift rename to TCAT/Core/Network/TransitAPI/TransitService.swift diff --git a/TCAT/Managers/NotificationTokenHandler.swift b/TCAT/Managers/PushNotificationService.swift similarity index 97% rename from TCAT/Managers/NotificationTokenHandler.swift rename to TCAT/Managers/PushNotificationService.swift index 1c921e8c..44fbe84f 100644 --- a/TCAT/Managers/NotificationTokenHandler.swift +++ b/TCAT/Managers/PushNotificationService.swift @@ -1,5 +1,5 @@ // -// NotificationTokenHandler.swift +// PushNotificationService.swift // TCAT // // Created by Jayson Hahn on 11/3/24. @@ -10,9 +10,9 @@ import FirebaseMessaging import UserNotifications import UIKit -class NotificationTokenHandler: NSObject, MessagingDelegate, UNUserNotificationCenterDelegate { +class PushNotificationService: NSObject, MessagingDelegate, UNUserNotificationCenterDelegate { - static let shared = NotificationTokenHandler() + static let shared = PushNotificationService() override init() { super.init() diff --git a/TCAT/Managers/NotificationSubscriptionManager.swift b/TCAT/Managers/TransitNotificationSubscriber.swift similarity index 88% rename from TCAT/Managers/NotificationSubscriptionManager.swift rename to TCAT/Managers/TransitNotificationSubscriber.swift index deef9329..b84a32f8 100644 --- a/TCAT/Managers/NotificationSubscriptionManager.swift +++ b/TCAT/Managers/TransitNotificationSubscriber.swift @@ -1,5 +1,5 @@ // -// NotificationManager.swift +// TransitNotificationSubscriber.swift // TCAT // // Created by Jayson Hahn on 11/3/24. @@ -8,15 +8,14 @@ import Combine -// Helper class to manage notifications -class NotificationSubscriptionManager { +class TransitNotificationSubscriber { - static let shared = NotificationSubscriptionManager() + static let shared = TransitNotificationSubscriber() private var cancellables = Set() func subscribeToDelayNotifications(stopID: String?, tripID: String) { - NotificationTokenHandler.shared.getDeviceToken { [weak self] token in + PushNotificationService.shared.getDeviceToken { [weak self] token in guard let token = token, let uid = userDefaults.string(forKey: Constants.UserDefaults.uid) else { return } @@ -41,7 +40,7 @@ class NotificationSubscriptionManager { } func subscribeToDepartureNotifications(startTime: String) { - NotificationTokenHandler.shared.getDeviceToken { [weak self] token in + PushNotificationService.shared.getDeviceToken { [weak self] token in guard let token = token, let uid = userDefaults.string(forKey: Constants.UserDefaults.uid) else { return } @@ -65,7 +64,7 @@ class NotificationSubscriptionManager { } func unsubscribeFromDelayNotifications(stopID: String?, tripID: String) { - NotificationTokenHandler.shared.getDeviceToken { [weak self] token in + PushNotificationService.shared.getDeviceToken { [weak self] token in guard let token = token, let uid = userDefaults.string(forKey: Constants.UserDefaults.uid) else { return } @@ -90,7 +89,7 @@ class NotificationSubscriptionManager { } func unsubscribeFromDepartureNotifications(startTime: String) { - NotificationTokenHandler.shared.getDeviceToken { [weak self] token in + PushNotificationService.shared.getDeviceToken { [weak self] token in guard let token = token, let uid = userDefaults.string(forKey: Constants.UserDefaults.uid) else { return } From 1388be75370c8e8a1255625c3c2351c16a55ca05 Mon Sep 17 00:00:00 2001 From: Jayson Hahn <46629787+JaysonHahn@users.noreply.github.com> Date: Mon, 17 Feb 2025 11:40:58 -0500 Subject: [PATCH 08/13] Change version to 2.1.0 for new notif feature --- TCAT.xcodeproj/project.pbxproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/TCAT.xcodeproj/project.pbxproj b/TCAT.xcodeproj/project.pbxproj index 4811fcca..42e8e679 100644 --- a/TCAT.xcodeproj/project.pbxproj +++ b/TCAT.xcodeproj/project.pbxproj @@ -1007,7 +1007,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.2; + MARKETING_VERSION = 2.1.0; PRODUCT_BUNDLE_IDENTIFIER = com.cornellappdev.tcat; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1104,7 +1104,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.2; + MARKETING_VERSION = 2.1.0; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" -DDEBUG"; "OTHER_SWIFT_FLAGS[arch=*]" = "$(inherited) \"-D\" \"COCOAPODS\""; PRODUCT_BUNDLE_IDENTIFIER = com.cornellappdev.tcat.debug; @@ -1203,7 +1203,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.2; + MARKETING_VERSION = 2.1.0; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" -DLOCAL"; "OTHER_SWIFT_FLAGS[arch=*]" = "$(inherited) \"-D\" \"COCOAPODS\""; PRODUCT_BUNDLE_IDENTIFIER = com.cornellappdev.tcat.debug; From 801cb8f6b6e053ac680b63a8b6b19451633c9b1f Mon Sep 17 00:00:00 2001 From: Jayson Hahn <46629787+JaysonHahn@users.noreply.github.com> Date: Mon, 17 Feb 2025 13:00:37 -0500 Subject: [PATCH 09/13] Fix 10 min calcualtion for notification --- TCAT/Cells/NotificationToggleTableViewCell.swift | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/TCAT/Cells/NotificationToggleTableViewCell.swift b/TCAT/Cells/NotificationToggleTableViewCell.swift index 9399ca1c..4e1eb3a0 100644 --- a/TCAT/Cells/NotificationToggleTableViewCell.swift +++ b/TCAT/Cells/NotificationToggleTableViewCell.swift @@ -103,9 +103,8 @@ class NotificationToggleTableViewCell: UITableViewCell { if notificationSwitch.isOn { switch type { case .beforeBoarding: - // TODO: debug this logic -// print("diff: \(startTime - Int(Date().timeIntervalSince1970))") - if startTime - Int(Date().timeIntervalSince1970) > 10 { + let now = Int(Date().timeIntervalSince1970) + if startTime - now > 600 { delegate?.displayNotificationBanner(type: .beforeBoardingConfirmation) TransitNotificationSubscriber.shared.subscribeToDepartureNotifications(startTime: String(startTime)) } else { From c1ec163e20693daa054e0a379174c7e5e8c20cfc Mon Sep 17 00:00:00 2001 From: Peter Date: Fri, 29 Aug 2025 15:54:32 -0400 Subject: [PATCH 10/13] update objectversion for cocaopod issue --- TCAT.xcodeproj/project.pbxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TCAT.xcodeproj/project.pbxproj b/TCAT.xcodeproj/project.pbxproj index 42e8e679..7526dacd 100644 --- a/TCAT.xcodeproj/project.pbxproj +++ b/TCAT.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 70; + objectVersion = 56; objects = { /* Begin PBXBuildFile section */ From 194a4570c385dbe80c8f3224cb3a52400a8a3ba6 Mon Sep 17 00:00:00 2001 From: cindy-x-liang <67083541+cindy-x-liang@users.noreply.github.com> Date: Thu, 9 Oct 2025 17:22:09 -0700 Subject: [PATCH 11/13] fixed notifs --- Podfile | 2 +- Podfile.lock | 10 ++--- TCAT.xcodeproj/project.pbxproj | 12 +++++- .../NotificationToggleTableViewCell.swift | 37 ++++++++++++++++++- .../RouteDetail+DrawerViewController.swift | 21 +++++++++++ ...etailDrawerViewController+Extensions.swift | 12 +++++- TCAT/Core/Network/Base/ApiEndpoint.swift | 1 - TCAT/Core/Network/Base/NetworkManager.swift | 1 + .../Network/TransitAPI/TransitProvider.swift | 5 +-- .../Network/TransitAPI/TransitService.swift | 4 +- TCAT/Managers/PushNotificationService.swift | 2 + .../TransitNotificationSubscriber.swift | 2 +- TCAT/Models/Direction.swift | 4 +- TCAT/Models/LocationObject.swift | 2 +- TCAT/Models/Route.swift | 5 +-- 15 files changed, 95 insertions(+), 25 deletions(-) diff --git a/Podfile b/Podfile index f0f947c1..ce3bd01d 100644 --- a/Podfile +++ b/Podfile @@ -23,7 +23,7 @@ target 'TCAT' do pod 'Firebase/Messaging' # File Management - pod 'Zip', '~> 1.1' + pod 'Zip', '~> 2.1.2' # UI Frameworks pod 'DZNEmptyDataSet', :git=> 'https://github.com/cuappdev/DZNEmptyDataSet.git' diff --git a/Podfile.lock b/Podfile.lock index 59aabfdb..3bef0ecf 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -146,7 +146,7 @@ PODS: - SwiftLint (0.54.0) - SwiftyJSON (5.0.1) - Wormholy (1.7.0) - - Zip (1.1.0) + - Zip (2.1.2) DEPENDENCIES: - Apollo (~> 1.9.3) @@ -162,7 +162,7 @@ DEPENDENCIES: - SwiftLint - SwiftyJSON (~> 5.0) - Wormholy - - Zip (~> 1.1) + - Zip (~> 2.1.2) SPEC REPOS: trunk: @@ -235,8 +235,8 @@ SPEC CHECKSUMS: SwiftLint: c1de071d9d08c8aba837545f6254315bc900e211 SwiftyJSON: 2f33a42c6fbc52764d96f13368585094bfd8aa5e Wormholy: ab1c8c2f02f58587a0941deb0088555ffbf039a1 - Zip: 8877eede3dda76bcac281225c20e71c25270774c + Zip: b3fef584b147b6e582b2256a9815c897d60ddc67 -PODFILE CHECKSUM: af336d88f53594af448d02dc18637c2b6ebe685e +PODFILE CHECKSUM: eb65fdf9dc23ed218f08a576b33aef5562c9b667 -COCOAPODS: 1.15.0 +COCOAPODS: 1.16.2 diff --git a/TCAT.xcodeproj/project.pbxproj b/TCAT.xcodeproj/project.pbxproj index 7526dacd..a8e918ec 100644 --- a/TCAT.xcodeproj/project.pbxproj +++ b/TCAT.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 70; objects = { /* Begin PBXBuildFile section */ @@ -723,10 +723,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-resources-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-resources-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-resources.sh\"\n"; @@ -758,10 +762,14 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); + inputPaths = ( + ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); + outputPaths = ( + ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-frameworks.sh\"\n"; @@ -1107,7 +1115,7 @@ MARKETING_VERSION = 2.1.0; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" -DDEBUG"; "OTHER_SWIFT_FLAGS[arch=*]" = "$(inherited) \"-D\" \"COCOAPODS\""; - PRODUCT_BUNDLE_IDENTIFIER = com.cornellappdev.tcat.debug; + PRODUCT_BUNDLE_IDENTIFIER = com.cornellappdev.tcat; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; diff --git a/TCAT/Cells/NotificationToggleTableViewCell.swift b/TCAT/Cells/NotificationToggleTableViewCell.swift index 4e1eb3a0..9e4d8dd2 100644 --- a/TCAT/Cells/NotificationToggleTableViewCell.swift +++ b/TCAT/Cells/NotificationToggleTableViewCell.swift @@ -94,13 +94,47 @@ class NotificationToggleTableViewCell: UITableViewCell { self.delegate = delegate self.type = type notificationTitleLabel.text = type.title + notificationSwitch.setOn(isToggleOn(for: type, tripId: tripId), animated: false) if isFirst { setupFirstHairline() } } + + func setSwitchOn(_ isOn: Bool) { + notificationSwitch.setOn(isOn, animated: false) + } + + // Build a stable key for persistence + private func key(for type: NotificationType, tripId: String) -> String { + let typeKey: String + switch type { + case .delay: typeKey = "delay" + case .beforeBoarding: typeKey = "beforeBoarding" + // add any other cases here + } + return "toggle-\(typeKey)-\(tripId)" + } + + func isToggleOn(for type: NotificationType, tripId: String) -> Bool { + let k = key(for: type, tripId: tripId) + return UserDefaults.standard.bool(forKey: k) + } + + func setToggle(_ on: Bool, for type: NotificationType, tripId: String) { + let k = key(for: type, tripId: tripId) + UserDefaults.standard.set(on, forKey: k) + } + @objc func switchValueChanged() { - if notificationSwitch.isOn { + + let isOn = notificationSwitch.isOn + + setToggle(isOn, for: type, tripId: tripId) + + + + if isOn { switch type { case .beforeBoarding: let now = Int(Date().timeIntervalSince1970) @@ -109,6 +143,7 @@ class NotificationToggleTableViewCell: UITableViewCell { TransitNotificationSubscriber.shared.subscribeToDepartureNotifications(startTime: String(startTime)) } else { notificationSwitch.setOn(false, animated: true) + setToggle(false, for: type, tripId: tripId) delegate?.displayNotificationBanner(type: .unableToConfirmBeforeBoarding) } case .delay: diff --git a/TCAT/Controllers/RouteDetail+DrawerViewController.swift b/TCAT/Controllers/RouteDetail+DrawerViewController.swift index 17c9e48d..9b33caad 100644 --- a/TCAT/Controllers/RouteDetail+DrawerViewController.swift +++ b/TCAT/Controllers/RouteDetail+DrawerViewController.swift @@ -159,6 +159,26 @@ class RouteDetailDrawerViewController: UIViewController { sections.append(notificationSection) } } + + private func key(for type: NotificationType, tripId: String) -> String { + let typeKey: String + switch type { + case .delay: typeKey = "delay" + case .beforeBoarding: typeKey = "beforeBoarding" + } + return "toggle-\(typeKey)-\(tripId)" + } + + // Or persist with UserDefaults: + func isToggleOn(for type: NotificationType, tripId: String) -> Bool { + let k = key(for: type, tripId: tripId) + return UserDefaults.standard.bool(forKey: k) + } + + func setToggle(_ on: Bool, for type: NotificationType, tripId: String) { + let k = key(for: type, tripId: tripId) + UserDefaults.standard.set(on, forKey: k) + } private func setupConstraints() { summaryView.snp.makeConstraints { make in @@ -264,3 +284,4 @@ class RouteDetailDrawerViewController: UIViewController { } } + diff --git a/TCAT/Controllers/RouteDetailDrawerViewController+Extensions.swift b/TCAT/Controllers/RouteDetailDrawerViewController+Extensions.swift index 3bbfe5e8..8853e2e7 100644 --- a/TCAT/Controllers/RouteDetailDrawerViewController+Extensions.swift +++ b/TCAT/Controllers/RouteDetailDrawerViewController+Extensions.swift @@ -217,17 +217,25 @@ extension RouteDetailDrawerViewController: UITableViewDataSource { } // Convert startTime to the desired string format - let startTime = Int(delayDirection.startTime.timeIntervalSince1970) + let startTime = Int(route.departureTime.timeIntervalSince1970) let stopId = delayDirection.stops.first?.id + + let isOn = isToggleOn(for: type, tripId: tripId) + cell.configure( for: type, - isFirst: indexPath.row == 0, + isFirst: false, delegate: self, startTime: startTime, tripId: tripId, stopId: stopId ) + + // Make sure the visual switch matches your persisted state + // If you haven’t added an `isOn` parameter to configure, set it directly: + // (Alternatively, add an `isOn` parameter to `configure` and set inside that method.) + cell.setSwitchOn(isOn) return cell } } diff --git a/TCAT/Core/Network/Base/ApiEndpoint.swift b/TCAT/Core/Network/Base/ApiEndpoint.swift index 8c0dcc0e..19323ea7 100644 --- a/TCAT/Core/Network/Base/ApiEndpoint.swift +++ b/TCAT/Core/Network/Base/ApiEndpoint.swift @@ -102,7 +102,6 @@ extension ApiEndpoint { if let customDataBody = customDataBody { request.httpBody = customDataBody } - return request } } diff --git a/TCAT/Core/Network/Base/NetworkManager.swift b/TCAT/Core/Network/Base/NetworkManager.swift index 246e55ca..01af7232 100644 --- a/TCAT/Core/Network/Base/NetworkManager.swift +++ b/TCAT/Core/Network/Base/NetworkManager.swift @@ -48,6 +48,7 @@ class NetworkManager: NetworkService { T, ApiErrorHandler > { + print(request.url?.absoluteString ?? "No URL") return session.dataTaskPublisher(for: request) .tryMap { result in try self.handleResponse(result) diff --git a/TCAT/Core/Network/TransitAPI/TransitProvider.swift b/TCAT/Core/Network/TransitAPI/TransitProvider.swift index 2e9c6001..ae77bb18 100644 --- a/TCAT/Core/Network/TransitAPI/TransitProvider.swift +++ b/TCAT/Core/Network/TransitAPI/TransitProvider.swift @@ -48,12 +48,9 @@ extension TransitProvider: ApiEndpoint { /// API version for the transit endpoints. var apiVersion: String { switch self { - case .delayNotification, .departueNotification, .cancelDelayNotification, .cancelDepartureNotification: + case .delayNotification, .departueNotification, .cancelDelayNotification, .cancelDepartureNotification, .allStops: return "v1" - case .routes: - return "v2" - default: return "v3" } diff --git a/TCAT/Core/Network/TransitAPI/TransitService.swift b/TCAT/Core/Network/TransitAPI/TransitService.swift index d20a4199..96845609 100644 --- a/TCAT/Core/Network/TransitAPI/TransitService.swift +++ b/TCAT/Core/Network/TransitAPI/TransitService.swift @@ -239,7 +239,7 @@ class TransitService: TransitServiceProtocol { uid: String ) -> AnyPublisher { let body = DelayNotificationBody(deviceToken: deviceToken, stopID: stopID, tripID: tripID, uid: uid) - let request = TransitProvider.delayNotification(body).makeRequest + let request = TransitProvider.cancelDelayNotification(body).makeRequest return networkManager.request(request, decodingType: Bool.self, responseType: .simple) } @@ -249,7 +249,7 @@ class TransitService: TransitServiceProtocol { uid: String ) -> AnyPublisher { let body = DepartureNotificationBody(deviceToken: deviceToken, startTime: startTime, uid: uid) - let request = TransitProvider.departueNotification(body).makeRequest + let request = TransitProvider.cancelDepartureNotification(body).makeRequest return networkManager.request(request, decodingType: Bool.self, responseType: .simple) } diff --git a/TCAT/Managers/PushNotificationService.swift b/TCAT/Managers/PushNotificationService.swift index 44fbe84f..d7556784 100644 --- a/TCAT/Managers/PushNotificationService.swift +++ b/TCAT/Managers/PushNotificationService.swift @@ -94,6 +94,7 @@ class PushNotificationService: NSObject, MessagingDelegate, UNUserNotificationCe withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void ) { // Show notification when app is in foreground + print("Foreground notification received: \(notification.request.content.userInfo)") completionHandler([[.banner, .sound]]) } @@ -157,6 +158,7 @@ class PushNotificationService: NSObject, MessagingDelegate, UNUserNotificationCe This method is called when a remote notification is received. It logs the notification's userInfo and calls the completion handler with `.newData`. */ + func application( _ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], diff --git a/TCAT/Managers/TransitNotificationSubscriber.swift b/TCAT/Managers/TransitNotificationSubscriber.swift index b84a32f8..4be82092 100644 --- a/TCAT/Managers/TransitNotificationSubscriber.swift +++ b/TCAT/Managers/TransitNotificationSubscriber.swift @@ -18,7 +18,7 @@ class TransitNotificationSubscriber { PushNotificationService.shared.getDeviceToken { [weak self] token in guard let token = token, let uid = userDefaults.string(forKey: Constants.UserDefaults.uid) else { return } - + print("device token \(token)") TransitService.shared.subscribeToDelayNotifications( deviceToken: token, stopID: stopID, diff --git a/TCAT/Models/Direction.swift b/TCAT/Models/Direction.swift index bcbf4c9f..f7ee506d 100755 --- a/TCAT/Models/Direction.swift +++ b/TCAT/Models/Direction.swift @@ -80,13 +80,13 @@ class Direction: NSObject, NSCopying, Codable { case endTime case name case path - case routeNumber + case routeNumber = "routeId" case startLocation case startTime case stayOnBusForTransfer case stops case travelDistance = "distance" - case tripIdentifiers + case tripIdentifiers = "tripIds" case type } diff --git a/TCAT/Models/LocationObject.swift b/TCAT/Models/LocationObject.swift index 453d3363..3e1d7e22 100644 --- a/TCAT/Models/LocationObject.swift +++ b/TCAT/Models/LocationObject.swift @@ -36,7 +36,7 @@ class LocationObject: NSObject, Codable { case latitude = "lat" case longitude = "long" case name - case id = "stopID" + case id = "stopId" } /// Blank init to store name diff --git a/TCAT/Models/Route.swift b/TCAT/Models/Route.swift index 39338c31..03a532ac 100755 --- a/TCAT/Models/Route.swift +++ b/TCAT/Models/Route.swift @@ -46,7 +46,6 @@ class Route: NSObject, Codable { /// A unique identifier for the route var routeId: String - /// The distance between the start and finish location, in miles var travelDistance: Double = 0.0 @@ -75,15 +74,15 @@ class Route: NSObject, Codable { case arrivalTime case departureTime case directions - case routeId } required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) departureTime = Date.parseDate(try container.decode(String.self, forKey: .departureTime)) arrivalTime = Date.parseDate(try container.decode(String.self, forKey: .arrivalTime)) - routeId = try container.decode(String.self, forKey: .routeId) + directions = try container.decode([Direction].self, forKey: .directions) + routeId = (directions.first?.routeNumber).map { String($0) } ?? "0" rawDirections = try container.decode([Direction].self, forKey: .directions) startName = Constants.General.currentLocation endName = Constants.General.destination From da396d98efbb10ad9d9d62d79b2a85f4591af90a Mon Sep 17 00:00:00 2001 From: cindy-x-liang <67083541+cindy-x-liang@users.noreply.github.com> Date: Thu, 9 Oct 2025 17:32:46 -0700 Subject: [PATCH 12/13] merge conflicts --- Podfile.lock | 5 +---- TCAT/Cells/NotificationToggleTableViewCell.swift | 7 ------- .../RouteDetail+ContentViewController.swift | 14 +++----------- .../RouteDetail+DrawerViewController.swift | 4 ---- .../Network/TransitAPI/Models/RequestModels.swift | 5 ++--- TCAT/Services/Network/RequestModels.swift | 3 --- TCAT/Views/NotificationBannerView.swift | 7 ------- 7 files changed, 6 insertions(+), 39 deletions(-) diff --git a/Podfile.lock b/Podfile.lock index a32175e4..6d3e4773 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -237,10 +237,7 @@ SPEC CHECKSUMS: Wormholy: ab1c8c2f02f58587a0941deb0088555ffbf039a1 Zip: b3fef584b147b6e582b2256a9815c897d60ddc67 -<<<<<<< HEAD PODFILE CHECKSUM: eb65fdf9dc23ed218f08a576b33aef5562c9b667 -======= -PODFILE CHECKSUM: af336d88f53594af448d02dc18637c2b6ebe685e ->>>>>>> master + COCOAPODS: 1.16.2 diff --git a/TCAT/Cells/NotificationToggleTableViewCell.swift b/TCAT/Cells/NotificationToggleTableViewCell.swift index 3878df86..9e4d8dd2 100644 --- a/TCAT/Cells/NotificationToggleTableViewCell.swift +++ b/TCAT/Cells/NotificationToggleTableViewCell.swift @@ -137,7 +137,6 @@ class NotificationToggleTableViewCell: UITableViewCell { if isOn { switch type { case .beforeBoarding: -<<<<<<< HEAD let now = Int(Date().timeIntervalSince1970) if startTime - now > 600 { delegate?.displayNotificationBanner(type: .beforeBoardingConfirmation) @@ -160,12 +159,6 @@ class NotificationToggleTableViewCell: UITableViewCell { case .delay: TransitNotificationSubscriber.shared.unsubscribeFromDelayNotifications(stopID: stopId, tripID: tripId) -======= - delegate?.displayNotificationBanner(type: .beforeBoardingConfirmation) - - case .delay: - delegate?.displayNotificationBanner(type: .delayConfirmation) ->>>>>>> master default: break } diff --git a/TCAT/Controllers/RouteDetail+ContentViewController.swift b/TCAT/Controllers/RouteDetail+ContentViewController.swift index a2e693c0..ec6450e3 100755 --- a/TCAT/Controllers/RouteDetail+ContentViewController.swift +++ b/TCAT/Controllers/RouteDetail+ContentViewController.swift @@ -30,19 +30,13 @@ class RouteDetailContentViewController: UIViewController { var currentLocation: CLLocationCoordinate2D? var directions: [Direction] = [] var endDestination: Place -<<<<<<< HEAD - /// Number of seconds to wait before auto-refreshing live tracking network call call, timed with live indicator -======= ->>>>>>> master var liveTrackingNetworkRefreshRate: Double = LiveIndicator.interval * 1.0 var liveTrackingNetworkTimer: Timer? private var locationManager = CLLocationManager() var mapView: GMSMapView! -<<<<<<< HEAD -======= + private let mapPadding: CGFloat = 80 private let markerRadius: CGFloat = 8 ->>>>>>> master private var paths: [Path] = [] private var route: Route! private var routeOptionsCell: RouteTableViewCell? @@ -212,11 +206,9 @@ class RouteDetailContentViewController: UIViewController { guard let self = self else { return } if case .failure(let error) = completion { -<<<<<<< HEAD - self.printClass(context: "\(#function) error", message: error.localizedDescription) -======= + self.printClass(context: "\(#function) error", message: error.errorDescription) ->>>>>>> master + if let banner = self.banner, !banner.isDisplaying { self.showBanner(Constants.Banner.cannotConnectLive, status: .danger) } diff --git a/TCAT/Controllers/RouteDetail+DrawerViewController.swift b/TCAT/Controllers/RouteDetail+DrawerViewController.swift index 66bfc372..2072ed56 100644 --- a/TCAT/Controllers/RouteDetail+DrawerViewController.swift +++ b/TCAT/Controllers/RouteDetail+DrawerViewController.swift @@ -59,11 +59,7 @@ class RouteDetailDrawerViewController: UIViewController { /// Number of seconds to wait before auto-refreshing bus delay network call. private var busDelayNetworkRefreshRate: Double = 10 private let chevronFlipDurationTime = 0.25 -<<<<<<< HEAD - internal let route: Route -======= private let route: Route ->>>>>>> master // MARK: - Initalization init(route: Route) { diff --git a/TCAT/Core/Network/TransitAPI/Models/RequestModels.swift b/TCAT/Core/Network/TransitAPI/Models/RequestModels.swift index 61ea54cf..cc2a5f95 100644 --- a/TCAT/Core/Network/TransitAPI/Models/RequestModels.swift +++ b/TCAT/Core/Network/TransitAPI/Models/RequestModels.swift @@ -83,15 +83,14 @@ internal struct DelayNotificationBody: Codable { let uid: String } -<<<<<<<< HEAD:TCAT/Core/Network/TransitAPI/Models/RequestModels.swift + internal struct DepartureNotificationBody: Codable { let deviceToken: String let startTime: String let uid: String } -======== ->>>>>>>> master:TCAT/Services/Network/RequestModels.swift + struct APIResponse: Decodable { var success: Bool var data: T diff --git a/TCAT/Services/Network/RequestModels.swift b/TCAT/Services/Network/RequestModels.swift index 61ea54cf..7b9041c3 100644 --- a/TCAT/Services/Network/RequestModels.swift +++ b/TCAT/Services/Network/RequestModels.swift @@ -83,15 +83,12 @@ internal struct DelayNotificationBody: Codable { let uid: String } -<<<<<<<< HEAD:TCAT/Core/Network/TransitAPI/Models/RequestModels.swift internal struct DepartureNotificationBody: Codable { let deviceToken: String let startTime: String let uid: String } -======== ->>>>>>>> master:TCAT/Services/Network/RequestModels.swift struct APIResponse: Decodable { var success: Bool var data: T diff --git a/TCAT/Views/NotificationBannerView.swift b/TCAT/Views/NotificationBannerView.swift index ab9b3db0..e91aba72 100644 --- a/TCAT/Views/NotificationBannerView.swift +++ b/TCAT/Views/NotificationBannerView.swift @@ -33,11 +33,7 @@ enum NotificationBannerType { case .beforeBoardingConfirmation, .busArriving, .delayConfirmation: return Colors.tcatBlue -<<<<<<< HEAD case .busDelay, .unableToConfirmBeforeBoarding: -======= - case .busDelay: ->>>>>>> master return Colors.lateRed } } @@ -91,12 +87,9 @@ class NotificationBannerView: UIView { case .delayConfirmation: beginningText = Constants.Notification.delayConfirmation -<<<<<<< HEAD case .unableToConfirmBeforeBoarding: beginningText = Constants.Notification.unableToConfirmBeforeBoarding -======= ->>>>>>> master default: beginningText = "" } From d9d45180a5e87ba12087e444d20a1320760c541c Mon Sep 17 00:00:00 2001 From: cindy-x-liang <67083541+cindy-x-liang@users.noreply.github.com> Date: Thu, 9 Oct 2025 19:08:18 -0700 Subject: [PATCH 13/13] fix errors --- Podfile | 2 +- Podfile.lock | 257 +++++++++--------- TCAT.xcodeproj/project.pbxproj | 73 ++--- .../RouteDetail+DrawerViewController.swift | 2 +- TCAT/Services/Network/ApiEndpoint.swift | 2 - TCAT/Services/Network/NetworkManager.swift | 74 ++++- TCAT/Services/Network/RequestModels.swift | 11 + TCAT/Services/Transit/TransitProvider.swift | 43 ++- TCAT/Services/Transit/TransitService.swift | 74 +++++ 9 files changed, 352 insertions(+), 186 deletions(-) diff --git a/Podfile b/Podfile index ce3bd01d..6cf6b1e4 100644 --- a/Podfile +++ b/Podfile @@ -10,7 +10,7 @@ inhibit_all_warnings! target 'TCAT' do # Location - pod 'GoogleMaps' + pod 'GoogleMaps', '~> 8.4.0' # Networking + Data pod 'Apollo', '~> 1.9.3' diff --git a/Podfile.lock b/Podfile.lock index 6d3e4773..1341ff2d 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -3,137 +3,143 @@ PODS: - Apollo/Core (= 1.9.3) - Apollo/Core (1.9.3) - DZNEmptyDataSet (1.8.1) - - Firebase (10.24.0): - - Firebase/Core (= 10.24.0) - - Firebase/Core (10.24.0): + - Firebase (12.4.0): + - Firebase/Core (= 12.4.0) + - Firebase/Core (12.4.0): - Firebase/CoreOnly - - FirebaseAnalytics (~> 10.24.0) - - Firebase/CoreOnly (10.24.0): - - FirebaseCore (= 10.24.0) - - Firebase/Messaging (10.24.0): + - FirebaseAnalytics (~> 12.4.0) + - Firebase/CoreOnly (12.4.0): + - FirebaseCore (~> 12.4.0) + - Firebase/Messaging (12.4.0): - Firebase/CoreOnly - - FirebaseMessaging (~> 10.24.0) - - FirebaseAnalytics (10.24.0): - - FirebaseAnalytics/AdIdSupport (= 10.24.0) - - FirebaseCore (~> 10.0) - - FirebaseInstallations (~> 10.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.11) - - GoogleUtilities/MethodSwizzler (~> 7.11) - - GoogleUtilities/Network (~> 7.11) - - "GoogleUtilities/NSData+zlib (~> 7.11)" - - nanopb (< 2.30911.0, >= 2.30908.0) - - FirebaseAnalytics/AdIdSupport (10.24.0): - - FirebaseCore (~> 10.0) - - FirebaseInstallations (~> 10.0) - - GoogleAppMeasurement (= 10.24.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.11) - - GoogleUtilities/MethodSwizzler (~> 7.11) - - GoogleUtilities/Network (~> 7.11) - - "GoogleUtilities/NSData+zlib (~> 7.11)" - - nanopb (< 2.30911.0, >= 2.30908.0) - - FirebaseCore (10.24.0): - - FirebaseCoreInternal (~> 10.0) - - GoogleUtilities/Environment (~> 7.12) - - GoogleUtilities/Logger (~> 7.12) - - FirebaseCoreExtension (10.24.0): - - FirebaseCore (~> 10.0) - - FirebaseCoreInternal (10.24.0): - - "GoogleUtilities/NSData+zlib (~> 7.8)" - - FirebaseCrashlytics (10.24.0): - - FirebaseCore (~> 10.5) - - FirebaseInstallations (~> 10.0) - - FirebaseRemoteConfigInterop (~> 10.23) - - FirebaseSessions (~> 10.5) - - GoogleDataTransport (~> 9.2) - - GoogleUtilities/Environment (~> 7.8) - - nanopb (< 2.30911.0, >= 2.30908.0) - - PromisesObjC (~> 2.1) - - FirebaseInstallations (10.24.0): - - FirebaseCore (~> 10.0) - - GoogleUtilities/Environment (~> 7.8) - - GoogleUtilities/UserDefaults (~> 7.8) - - PromisesObjC (~> 2.1) - - FirebaseMessaging (10.24.0): - - FirebaseCore (~> 10.0) - - FirebaseInstallations (~> 10.0) - - GoogleDataTransport (~> 9.3) - - GoogleUtilities/AppDelegateSwizzler (~> 7.8) - - GoogleUtilities/Environment (~> 7.8) - - GoogleUtilities/Reachability (~> 7.8) - - GoogleUtilities/UserDefaults (~> 7.8) - - nanopb (< 2.30911.0, >= 2.30908.0) - - FirebaseRemoteConfigInterop (10.24.0) - - FirebaseSessions (10.24.0): - - FirebaseCore (~> 10.5) - - FirebaseCoreExtension (~> 10.0) - - FirebaseInstallations (~> 10.0) - - GoogleDataTransport (~> 9.2) - - GoogleUtilities/Environment (~> 7.10) - - nanopb (< 2.30911.0, >= 2.30908.0) + - FirebaseMessaging (~> 12.4.0) + - FirebaseAnalytics (12.4.0): + - FirebaseAnalytics/Default (= 12.4.0) + - FirebaseCore (~> 12.4.0) + - FirebaseInstallations (~> 12.4.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - FirebaseAnalytics/Default (12.4.0): + - FirebaseCore (~> 12.4.0) + - FirebaseInstallations (~> 12.4.0) + - GoogleAppMeasurement/Default (= 12.4.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - FirebaseCore (12.4.0): + - FirebaseCoreInternal (~> 12.4.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreExtension (12.4.0): + - FirebaseCore (~> 12.4.0) + - FirebaseCoreInternal (12.4.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseCrashlytics (12.4.0): + - FirebaseCore (~> 12.4.0) + - FirebaseInstallations (~> 12.4.0) + - FirebaseRemoteConfigInterop (~> 12.4.0) + - FirebaseSessions (~> 12.4.0) + - GoogleDataTransport (~> 10.1) + - GoogleUtilities/Environment (~> 8.1) + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - FirebaseInstallations (12.4.0): + - FirebaseCore (~> 12.4.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - PromisesObjC (~> 2.4) + - FirebaseMessaging (12.4.0): + - FirebaseCore (~> 12.4.0) + - FirebaseInstallations (~> 12.4.0) + - GoogleDataTransport (~> 10.1) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Reachability (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - nanopb (~> 3.30910.0) + - FirebaseRemoteConfigInterop (12.4.0) + - FirebaseSessions (12.4.0): + - FirebaseCore (~> 12.4.0) + - FirebaseCoreExtension (~> 12.4.0) + - FirebaseInstallations (~> 12.4.0) + - GoogleDataTransport (~> 10.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - nanopb (~> 3.30910.0) - PromisesSwift (~> 2.1) - - GoogleAppMeasurement (10.24.0): - - GoogleAppMeasurement/AdIdSupport (= 10.24.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.11) - - GoogleUtilities/MethodSwizzler (~> 7.11) - - GoogleUtilities/Network (~> 7.11) - - "GoogleUtilities/NSData+zlib (~> 7.11)" - - nanopb (< 2.30911.0, >= 2.30908.0) - - GoogleAppMeasurement/AdIdSupport (10.24.0): - - GoogleAppMeasurement/WithoutAdIdSupport (= 10.24.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.11) - - GoogleUtilities/MethodSwizzler (~> 7.11) - - GoogleUtilities/Network (~> 7.11) - - "GoogleUtilities/NSData+zlib (~> 7.11)" - - nanopb (< 2.30911.0, >= 2.30908.0) - - GoogleAppMeasurement/WithoutAdIdSupport (10.24.0): - - GoogleUtilities/AppDelegateSwizzler (~> 7.11) - - GoogleUtilities/MethodSwizzler (~> 7.11) - - GoogleUtilities/Network (~> 7.11) - - "GoogleUtilities/NSData+zlib (~> 7.11)" - - nanopb (< 2.30911.0, >= 2.30908.0) - - GoogleDataTransport (9.4.1): - - GoogleUtilities/Environment (~> 7.7) - - nanopb (< 2.30911.0, >= 2.30908.0) - - PromisesObjC (< 3.0, >= 1.2) + - GoogleAdsOnDeviceConversion (3.1.0): + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - nanopb (~> 3.30910.0) + - GoogleAppMeasurement/Core (12.4.0): + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - GoogleAppMeasurement/Default (12.4.0): + - GoogleAdsOnDeviceConversion (~> 3.1.0) + - GoogleAppMeasurement/Core (= 12.4.0) + - GoogleAppMeasurement/IdentitySupport (= 12.4.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - GoogleAppMeasurement/IdentitySupport (12.4.0): + - GoogleAppMeasurement/Core (= 12.4.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - GoogleDataTransport (10.1.0): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) - GoogleMaps (8.4.0): - GoogleMaps/Maps (= 8.4.0) - GoogleMaps/Base (8.4.0) - GoogleMaps/Maps (8.4.0): - GoogleMaps/Base - - GoogleUtilities/AppDelegateSwizzler (7.13.0): + - GoogleUtilities/AppDelegateSwizzler (8.1.0): - GoogleUtilities/Environment - GoogleUtilities/Logger - GoogleUtilities/Network - GoogleUtilities/Privacy - - GoogleUtilities/Environment (7.13.0): + - GoogleUtilities/Environment (8.1.0): - GoogleUtilities/Privacy - - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/Logger (7.13.0): + - GoogleUtilities/Logger (8.1.0): - GoogleUtilities/Environment - GoogleUtilities/Privacy - - GoogleUtilities/MethodSwizzler (7.13.0): + - GoogleUtilities/MethodSwizzler (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - GoogleUtilities/Network (7.13.0): + - GoogleUtilities/Network (8.1.0): - GoogleUtilities/Logger - "GoogleUtilities/NSData+zlib" - GoogleUtilities/Privacy - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (7.13.0)": + - "GoogleUtilities/NSData+zlib (8.1.0)": - GoogleUtilities/Privacy - - GoogleUtilities/Privacy (7.13.0) - - GoogleUtilities/Reachability (7.13.0): + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/Reachability (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - GoogleUtilities/UserDefaults (7.13.0): + - GoogleUtilities/UserDefaults (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - MarqueeLabel (4.0.5) - - nanopb (2.30910.0): - - nanopb/decode (= 2.30910.0) - - nanopb/encode (= 2.30910.0) - - nanopb/decode (2.30910.0) - - nanopb/encode (2.30910.0) + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) - NotificationBannerSwift (3.0.6): - MarqueeLabel (~> 4.0.5) - SnapKit (~> 5.0.1) @@ -143,8 +149,8 @@ PODS: - PromisesObjC (= 2.4.0) - Pulley (2.9.1) - SnapKit (5.0.1) - - SwiftLint (0.54.0) - - SwiftyJSON (5.0.1) + - SwiftLint (0.61.0) + - SwiftyJSON (5.0.2) - Wormholy (1.7.0) - Zip (2.1.2) @@ -154,7 +160,7 @@ DEPENDENCIES: - Firebase - Firebase/Messaging - FirebaseCrashlytics - - GoogleMaps + - GoogleMaps (~> 8.4.0) - NotificationBannerSwift (~> 3.0.0) - Presentation (from `https://github.com/cuappdev/Presentation.git`) - Pulley (~> 2.7) @@ -177,6 +183,7 @@ SPEC REPOS: - FirebaseMessaging - FirebaseRemoteConfigInterop - FirebaseSessions + - GoogleAdsOnDeviceConversion - GoogleAppMeasurement - GoogleDataTransport - GoogleMaps @@ -210,34 +217,34 @@ CHECKOUT OPTIONS: SPEC CHECKSUMS: Apollo: b339a44b439f6b64208eb8761a0336813287a903 DZNEmptyDataSet: b94434220f87d9dda46660eb4f07a424778e93b4 - Firebase: 91fefd38712feb9186ea8996af6cbdef41473442 - FirebaseAnalytics: b5efc493eb0f40ec560b04a472e3e1a15d39ca13 - FirebaseCore: 11dc8a16dfb7c5e3c3f45ba0e191a33ac4f50894 - FirebaseCoreExtension: af5fd85e817ea9d19f9a2659a376cf9cf99f03c0 - FirebaseCoreInternal: bcb5acffd4ea05e12a783ecf835f2210ce3dc6af - FirebaseCrashlytics: af38ea4adfa606f6e63fcc22091b61e7938fcf66 - FirebaseInstallations: 8f581fca6478a50705d2bd2abd66d306e0f5736e - FirebaseMessaging: 4d52717dd820707cc4eadec5eb981b4832ec8d5d - FirebaseRemoteConfigInterop: 6c349a466490aeace3ce9c091c86be1730711634 - FirebaseSessions: 2651b464e241c93fd44112f995d5ab663c970487 - GoogleAppMeasurement: f3abf08495ef2cba7829f15318c373b8d9226491 - GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a + Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e + FirebaseAnalytics: 0fc2b20091f0ddd21bf73397cf8f0eb5346dc24f + FirebaseCore: bb595f3114953664e3c1dc032f008a244147cfd3 + FirebaseCoreExtension: 7e1f7118ee970e001a8013719fb90950ee5e0018 + FirebaseCoreInternal: d7f5a043c2cd01a08103ab586587c1468047bca6 + FirebaseCrashlytics: a6ece278a837c7e88de2d9b5da0a3542f2342395 + FirebaseInstallations: ae9f4902cb5bf1d0c5eaa31ec1f4e5495a0714e2 + FirebaseMessaging: d33971b7bb252745ea6cd31ab190d1a1df4b8ed5 + FirebaseRemoteConfigInterop: 1e31ec72b89c9924367c59bfb5ec9ab60d1d6766 + FirebaseSessions: ba7c7a7ca8696a8d540eb3fe3800fbe98c79786d + GoogleAdsOnDeviceConversion: e03a386840803ea7eef3fd22a061930142c039c1 + GoogleAppMeasurement: 1e718274b7e015cefd846ac1fcf7820c70dc017d + GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleMaps: 8939898920281c649150e0af74aa291c60f2e77d - GoogleUtilities: d053d902a8edaa9904e1bd00c37535385b8ed152 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 MarqueeLabel: 00cc0bcd087111dca575878b3531af980559707d - nanopb: 438bc412db1928dac798aa6fd75726007be04262 + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 NotificationBannerSwift: 7021be2338f8f29cf424b0aca43da462bf9e2a1a Presentation: c66e877bb3e8a6437ca9c19ab018cfa4b04a98ee PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 Pulley: a4c28c930958f42978d69631000bc1abb82cb232 SnapKit: 97b92857e3df3a0c71833cce143274bf6ef8e5eb - SwiftLint: c1de071d9d08c8aba837545f6254315bc900e211 - SwiftyJSON: 2f33a42c6fbc52764d96f13368585094bfd8aa5e + SwiftLint: bf6da11a31c6644a0bbb27f4fa15fd9636db00b3 + SwiftyJSON: f5b1bf1cd8dd53cd25887ac0eabcfd92301c6a5a Wormholy: ab1c8c2f02f58587a0941deb0088555ffbf039a1 Zip: b3fef584b147b6e582b2256a9815c897d60ddc67 -PODFILE CHECKSUM: eb65fdf9dc23ed218f08a576b33aef5562c9b667 - +PODFILE CHECKSUM: fe3e20ea2d105a197821fb521e7cab43423411dd COCOAPODS: 1.16.2 diff --git a/TCAT.xcodeproj/project.pbxproj b/TCAT.xcodeproj/project.pbxproj index 26435dc7..dbd46b03 100644 --- a/TCAT.xcodeproj/project.pbxproj +++ b/TCAT.xcodeproj/project.pbxproj @@ -3,12 +3,12 @@ archiveVersion = 1; classes = { }; - objectVersion = 70; + objectVersion = 56; objects = { /* Begin PBXBuildFile section */ + 11DED336304A84735BDCFEC3 /* Pods_TCAT.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4C43A009E3DAAEEFC9A0BD5 /* Pods_TCAT.framework */; }; 22948BFD221B75C5003FC43F /* RequestModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22948BFB221B75C5003FC43F /* RequestModels.swift */; }; - 28EA3E17A0C473892F5506EC /* Pods_TCAT.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 542B073726DFD1EE044EA97F /* Pods_TCAT.framework */; }; 2E70434E2BB75E10003AC1D6 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 2E70434D2BB75E10003AC1D6 /* PrivacyInfo.xcprivacy */; }; 2E9416602BC60A59003DEB44 /* UpliftQueries.graphql in Resources */ = {isa = PBXBuildFile; fileRef = 2E94165F2BC60A59003DEB44 /* UpliftQueries.graphql */; }; 2E9416692BC615DF003DEB44 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E9416672BC615DF003DEB44 /* AppDelegate.swift */; }; @@ -264,18 +264,18 @@ 2EC1F5152BC66CBA001D9F66 /* Publishers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Publishers.swift; sourceTree = ""; }; 449A7C751D80D0E80019300C /* TCAT.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TCAT.app; sourceTree = BUILT_PRODUCTS_DIR; }; 449A7C7F1D80D0E80019300C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 542B073726DFD1EE044EA97F /* Pods_TCAT.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_TCAT.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 78CE6235AE56D8B3776331B2 /* Pods-TCAT.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TCAT.release.xcconfig"; path = "Target Support Files/Pods-TCAT/Pods-TCAT.release.xcconfig"; sourceTree = ""; }; - 7A621E1F21DF0FC2CACD61FE /* Pods-TCAT.local.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TCAT.local.xcconfig"; path = "Target Support Files/Pods-TCAT/Pods-TCAT.local.xcconfig"; sourceTree = ""; }; - 7C562FAA4261465E07ACE741 /* Pods-TCAT.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TCAT.debug.xcconfig"; path = "Target Support Files/Pods-TCAT/Pods-TCAT.debug.xcconfig"; sourceTree = ""; }; 7E14AEC02177E846006A344D /* IntentsUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IntentsUI.framework; path = System/Library/Frameworks/IntentsUI.framework; sourceTree = SDKROOT; }; 7EEF189C21B39C6200343FFD /* NotificationCenter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NotificationCenter.framework; path = System/Library/Frameworks/NotificationCenter.framework; sourceTree = SDKROOT; }; + 7F774800664BACA8CD504187 /* Pods-TCAT.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TCAT.debug.xcconfig"; path = "Target Support Files/Pods-TCAT/Pods-TCAT.debug.xcconfig"; sourceTree = ""; }; + AB65818DE0F8825F8E2AFDA3 /* Pods-TCAT.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TCAT.release.xcconfig"; path = "Target Support Files/Pods-TCAT/Pods-TCAT.release.xcconfig"; sourceTree = ""; }; BF74AC191F945D7D00AFD4E4 /* GoogleMapsBase.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GoogleMapsBase.framework; path = Pods/GoogleMaps/Base/Frameworks/GoogleMapsBase.framework; sourceTree = ""; }; BF74AC1B1F945D8E00AFD4E4 /* GoogleMapsCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GoogleMapsCore.framework; path = Pods/GoogleMaps/Maps/Frameworks/GoogleMapsCore.framework; sourceTree = ""; }; BF74AC1C1F945D8E00AFD4E4 /* GoogleMaps.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GoogleMaps.framework; path = Pods/GoogleMaps/Maps/Frameworks/GoogleMaps.framework; sourceTree = ""; }; + DEED3C993CDEA110A0400D70 /* Pods-TCAT.local.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TCAT.local.xcconfig"; path = "Target Support Files/Pods-TCAT/Pods-TCAT.local.xcconfig"; sourceTree = ""; }; EEB26AE02C9F998C002E863F /* TCATLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TCATLocal.entitlements; sourceTree = ""; }; EEB26AE12C9F9B9A002E863F /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = System/Library/Frameworks/UserNotifications.framework; sourceTree = SDKROOT; }; EEB26AE32C9FA60E002E863F /* TCATDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TCATDebug.entitlements; sourceTree = ""; }; + F4C43A009E3DAAEEFC9A0BD5 /* Pods_TCAT.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_TCAT.framework; sourceTree = BUILT_PRODUCTS_DIR; }; FD44EC522CD86A56009269A2 /* TransitNotificationSubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitNotificationSubscriber.swift; sourceTree = ""; }; FD44EC542CD86C4A009269A2 /* PushNotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationService.swift; sourceTree = ""; }; FD69AF2A2B89212F00970C7E /* ci_post_clone.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = ci_post_clone.sh; sourceTree = ""; }; @@ -287,10 +287,6 @@ FDE68D272C97FC4600024A69 /* TransitProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitProvider.swift; sourceTree = ""; }; /* End PBXFileReference section */ -/* Begin PBXFileSystemSynchronizedRootGroup section */ - FD2707CE2D63938C00BF2DF2 /* Core */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Core; sourceTree = ""; }; -/* End PBXFileSystemSynchronizedRootGroup section */ - /* Begin PBXFrameworksBuildPhase section */ 449A7C721D80D0E80019300C /* Frameworks */ = { isa = PBXFrameworksBuildPhase; @@ -299,7 +295,7 @@ BF74AC1D1F945D8E00AFD4E4 /* GoogleMapsCore.framework in Frameworks */, BF74AC1E1F945D8E00AFD4E4 /* GoogleMaps.framework in Frameworks */, BF74AC1A1F945D7D00AFD4E4 /* GoogleMapsBase.framework in Frameworks */, - 28EA3E17A0C473892F5506EC /* Pods_TCAT.framework in Frameworks */, + 11DED336304A84735BDCFEC3 /* Pods_TCAT.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -315,12 +311,11 @@ BF74AC191F945D7D00AFD4E4 /* GoogleMapsBase.framework */, 7E14AEC02177E846006A344D /* IntentsUI.framework */, 7EEF189C21B39C6200343FFD /* NotificationCenter.framework */, - 542B073726DFD1EE044EA97F /* Pods_TCAT.framework */, + F4C43A009E3DAAEEFC9A0BD5 /* Pods_TCAT.framework */, ); name = Frameworks; sourceTree = ""; }; - 2292486621B891790004279C /* Network */ = { isa = PBXGroup; children = ( @@ -604,7 +599,6 @@ 2E94165E2BC60A3B003DEB44 /* Ecosystem */, FD44EC562CD8914D009269A2 /* Managers */, 2E9416AB2BC616DE003DEB44 /* Models */, - FD2707CE2D63938C00BF2DF2 /* Core */, FDE68D292C988CDB00024A69 /* Services */, 2E9416C72BC61763003DEB44 /* Supporting */, 2E9416E02BC618E6003DEB44 /* Utils */, @@ -616,9 +610,9 @@ 44BE841D0263A527944A6E0F /* Pods */ = { isa = PBXGroup; children = ( - 7C562FAA4261465E07ACE741 /* Pods-TCAT.debug.xcconfig */, - 7A621E1F21DF0FC2CACD61FE /* Pods-TCAT.local.xcconfig */, - 78CE6235AE56D8B3776331B2 /* Pods-TCAT.release.xcconfig */, + 7F774800664BACA8CD504187 /* Pods-TCAT.debug.xcconfig */, + DEED3C993CDEA110A0400D70 /* Pods-TCAT.local.xcconfig */, + AB65818DE0F8825F8E2AFDA3 /* Pods-TCAT.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -666,23 +660,20 @@ isa = PBXNativeTarget; buildConfigurationList = 449A7C9D1D80D0E80019300C /* Build configuration list for PBXNativeTarget "TCAT" */; buildPhases = ( - E23EEB875E8C363D642AB893 /* [CP] Check Pods Manifest.lock */, + F6E30D44AE7060FBA9CA34DF /* [CP] Check Pods Manifest.lock */, 449A7C711D80D0E80019300C /* Sources */, 449A7C721D80D0E80019300C /* Frameworks */, 449A7C731D80D0E80019300C /* Resources */, 2292F9DB215722ED00C8C931 /* SwiftLint */, 7E14AED52177E846006A344D /* Embed Foundation Extensions */, CE26CBF62B879837005D099A /* Crashlytics */, - 882B9E91268F347446806E32 /* [CP] Embed Pods Frameworks */, - 0B4CA64206AF6DA1763F9ACB /* [CP] Copy Pods Resources */, + 10126D331FC535DE1C43147A /* [CP] Embed Pods Frameworks */, + AF0480DFC65AF5CB62CA0646 /* [CP] Copy Pods Resources */, ); buildRules = ( ); dependencies = ( ); - fileSystemSynchronizedGroups = ( - FD2707CE2D63938C00BF2DF2 /* Core */, - ); name = TCAT; productName = TCAT; productReference = 449A7C751D80D0E80019300C /* TCAT.app */; @@ -761,25 +752,21 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 0B4CA64206AF6DA1763F9ACB /* [CP] Copy Pods Resources */ = { + 10126D331FC535DE1C43147A /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Copy Pods Resources"; + name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - outputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-resources.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; 2292F9DB215722ED00C8C931 /* SwiftLint */ = { @@ -800,25 +787,21 @@ shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/SwiftLint/swiftlint\"\n\nif which swiftlint >/dev/null; then\n swiftlint --fix && swiftlint\nelse\n echo \"WARNING: SwiftLint not installed\"\nfi\n"; }; - 882B9E91268F347446806E32 /* [CP] Embed Pods Frameworks */ = { + AF0480DFC65AF5CB62CA0646 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-resources-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Embed Pods Frameworks"; + name = "[CP] Copy Pods Resources"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - outputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-resources-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-frameworks.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-resources.sh\"\n"; showEnvVarsInLog = 0; }; CE26CBF62B879837005D099A /* Crashlytics */ = { @@ -844,7 +827,7 @@ shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/FirebaseCrashlytics/run\"\n"; }; - E23EEB875E8C363D642AB893 /* [CP] Check Pods Manifest.lock */ = { + F6E30D44AE7060FBA9CA34DF /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -1043,7 +1026,7 @@ }; 449A7C9F1D80D0E80019300C /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 78CE6235AE56D8B3776331B2 /* Pods-TCAT.release.xcconfig */; + baseConfigurationReference = AB65818DE0F8825F8E2AFDA3 /* Pods-TCAT.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; BUNDLE_APP_DISPLAY_NAME = Navi; @@ -1140,7 +1123,7 @@ }; BFF7E5EF223BFDF0001C6032 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7C562FAA4261465E07ACE741 /* Pods-TCAT.debug.xcconfig */; + baseConfigurationReference = 7F774800664BACA8CD504187 /* Pods-TCAT.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; BUNDLE_APP_DISPLAY_NAME = "Navi Beta"; @@ -1239,7 +1222,7 @@ }; C27549D5233491FA00D5A754 /* Local */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7A621E1F21DF0FC2CACD61FE /* Pods-TCAT.local.xcconfig */; + baseConfigurationReference = DEED3C993CDEA110A0400D70 /* Pods-TCAT.local.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; BUNDLE_APP_DISPLAY_NAME = "Navi Local"; diff --git a/TCAT/Controllers/RouteDetail+DrawerViewController.swift b/TCAT/Controllers/RouteDetail+DrawerViewController.swift index 2072ed56..9b33caad 100644 --- a/TCAT/Controllers/RouteDetail+DrawerViewController.swift +++ b/TCAT/Controllers/RouteDetail+DrawerViewController.swift @@ -59,7 +59,7 @@ class RouteDetailDrawerViewController: UIViewController { /// Number of seconds to wait before auto-refreshing bus delay network call. private var busDelayNetworkRefreshRate: Double = 10 private let chevronFlipDurationTime = 0.25 - private let route: Route + internal let route: Route // MARK: - Initalization init(route: Route) { diff --git a/TCAT/Services/Network/ApiEndpoint.swift b/TCAT/Services/Network/ApiEndpoint.swift index ea7a0787..19323ea7 100644 --- a/TCAT/Services/Network/ApiEndpoint.swift +++ b/TCAT/Services/Network/ApiEndpoint.swift @@ -73,7 +73,6 @@ extension ApiEndpoint { longPath.append(separatorPath) } - longPath.append("/") longPath.append(path) urlComponents?.path = longPath @@ -103,7 +102,6 @@ extension ApiEndpoint { if let customDataBody = customDataBody { request.httpBody = customDataBody } - return request } } diff --git a/TCAT/Services/Network/NetworkManager.swift b/TCAT/Services/Network/NetworkManager.swift index ea394821..01af7232 100644 --- a/TCAT/Services/Network/NetworkManager.swift +++ b/TCAT/Services/Network/NetworkManager.swift @@ -15,8 +15,21 @@ protocol NetworkService { /// - Parameters: /// - request: The `URLRequest` to be sent. /// - decodingType: The type to decode the response into. Must conform to `Decodable`. + /// - responseType: The type of response format expected (.standard or .simple) /// - Returns: A publisher that emits the decoded object of type `T` or an `ApiErrorHandler` on failure. - func request(_ request: URLRequest, decodingType: T.Type) -> AnyPublisher + func request( + _ request: URLRequest, + decodingType: T.Type, + responseType: ResponseFormat + ) -> AnyPublisher< + T, + ApiErrorHandler + > +} + +enum ResponseFormat { + case standard // Format with success and data + case simple // Format with only success } class NetworkManager: NetworkService { @@ -27,14 +40,21 @@ class NetworkManager: NetworkService { self.session = session } - func request(_ request: URLRequest, decodingType: T.Type) -> AnyPublisher { + func request( + _ request: URLRequest, + decodingType: T.Type, + responseType: ResponseFormat = .standard + ) -> AnyPublisher< + T, + ApiErrorHandler + > { + print(request.url?.absoluteString ?? "No URL") return session.dataTaskPublisher(for: request) .tryMap { result in try self.handleResponse(result) } - .decode(type: APIResponse.self, decoder: JSONDecoder()) - .tryMap { response in - try self.validateAPIResponse(response) + .flatMap { data in + self.decodeResponse(data: data, decodingType: decodingType, responseType: responseType) } .mapError { error in self.mapToAPIError(error) @@ -60,7 +80,39 @@ class NetworkManager: NetworkService { } } - // Validate API response and handle future error cases + // Decodes the response based on response format + private func decodeResponse( + data: Data, + decodingType: T.Type, + responseType: ResponseFormat + ) -> AnyPublisher< + T, + Error + > { + let decoder = JSONDecoder() + switch responseType { + case .standard: + return Just(data) + .decode(type: APIResponse.self, decoder: decoder) + .tryMap { response in + try self.validateAPIResponse(response) + } + .eraseToAnyPublisher() + case .simple: + return Just(data) + .decode(type: SimpleAPIResponse.self, decoder: decoder) + .tryMap { response in + let success = try self.validateSimpleResponse(response) + guard let result = success as? T else { + throw ApiErrorHandler.requestFailed + } + return result + } + .eraseToAnyPublisher() + } + } + + // Validate standard API response private func validateAPIResponse(_ response: APIResponse) throws -> T { guard response.success else { // TODO: Update when backend sends more error codes @@ -70,6 +122,16 @@ class NetworkManager: NetworkService { return response.data } + // Validate simple API response + private func validateSimpleResponse(_ response: SimpleAPIResponse) throws -> Bool { + guard response.success else { + // TODO: Update when backend sends more error codes + throw ApiErrorHandler.customApiError(ApiError(code: "500", message: "Internal server error")) + } + + return response.success + } + // Map Combine errors to custom APIErrorHandler types private func mapToAPIError(_ error: Error) -> ApiErrorHandler { if let apiError = error as? ApiErrorHandler { diff --git a/TCAT/Services/Network/RequestModels.swift b/TCAT/Services/Network/RequestModels.swift index 7b9041c3..a8f8e50b 100644 --- a/TCAT/Services/Network/RequestModels.swift +++ b/TCAT/Services/Network/RequestModels.swift @@ -89,6 +89,17 @@ internal struct DepartureNotificationBody: Codable { let uid: String } +internal struct Delay: Codable { + let tripID: String + let delay: Int? +} + +class RouteSectionsObject: Codable { + var fromStop: [Route] + var boardingSoon: [Route] + var walking: [Route] +} + struct APIResponse: Decodable { var success: Bool var data: T diff --git a/TCAT/Services/Transit/TransitProvider.swift b/TCAT/Services/Transit/TransitProvider.swift index 9032de29..ae77bb18 100644 --- a/TCAT/Services/Transit/TransitProvider.swift +++ b/TCAT/Services/Transit/TransitProvider.swift @@ -16,7 +16,11 @@ enum TransitProvider { case applePlaces(ApplePlacesBody) case appleSearch(SearchResultsBody) case busLocations(GetBusLocationsBody) + case cancelDelayNotification(DelayNotificationBody) + case cancelDepartureNotification(DepartureNotificationBody) case delay(GetDelayBody) + case delayNotification(DelayNotificationBody) + case departueNotification(DepartureNotificationBody) case routes(GetRoutesBody) } @@ -25,7 +29,15 @@ extension TransitProvider: ApiEndpoint { /// Base URL string for the transit API. var baseURLString: String { - return TransitEnvironment.transitURL +// return TransitEnvironment.transitURL + // TODO: Remove once the Notifications moves to prod + switch self { + case .delayNotification, .departueNotification, .cancelDelayNotification, .cancelDepartureNotification: + return TransitEnvironment.devTransitURL + + default: + return TransitEnvironment.transitURL + } } /// API path for the transit endpoints. @@ -36,12 +48,9 @@ extension TransitProvider: ApiEndpoint { /// API version for the transit endpoints. var apiVersion: String { switch self { - case .alerts, .allStops: + case .delayNotification, .departueNotification, .cancelDelayNotification, .cancelDepartureNotification, .allStops: return "v1" - case .appleSearch, .routes: - return "v2" - default: return "v3" } @@ -51,7 +60,7 @@ extension TransitProvider: ApiEndpoint { var separatorPath: String? { switch self { default: - return "" + return nil } } @@ -76,9 +85,21 @@ extension TransitProvider: ApiEndpoint { case .busLocations: return Constants.Endpoints.busLocations + case .cancelDelayNotification: + return Constants.Endpoints.cancelDelayNotification + + case .cancelDepartureNotification: + return Constants.Endpoints.cancelDepartureNotification + case .delay: return Constants.Endpoints.delay + case .departueNotification: + return Constants.Endpoints.departureNotification + + case .delayNotification: + return Constants.Endpoints.delayNotification + case .routes: return Constants.Endpoints.getRoutes } @@ -140,6 +161,16 @@ extension TransitProvider: ApiEndpoint { case .delay(let getDelayBody): return try? JSONEncoder().encode(getDelayBody) + case .delayNotification(let delayNotificationBody), .cancelDelayNotification(let delayNotificationBody): + return try? JSONEncoder().encode(delayNotificationBody) + + case .departueNotification( + let departureNotificationBody + ), .cancelDepartureNotification( + let departureNotificationBody + ): + return try? JSONEncoder().encode(departureNotificationBody) + case .routes(let getRoutesBody): return try? JSONEncoder().encode(getRoutesBody) diff --git a/TCAT/Services/Transit/TransitService.swift b/TCAT/Services/Transit/TransitService.swift index 17f55ab2..96845609 100644 --- a/TCAT/Services/Transit/TransitService.swift +++ b/TCAT/Services/Transit/TransitService.swift @@ -56,6 +56,37 @@ protocol TransitServiceProtocol: AnyObject { /// - Returns: A publisher emitting a `RouteSectionsObject` with route details or an `ApiErrorHandler` on error. func getRoutes(start: Place, end: Place, time: Date, type: SearchType) -> AnyPublisher + /// Subscribes to delay notification for a specific trip's arrival + /// The notification is sent when there is a change in the delay. + /// - Parameters: + /// - deviceToken: The FCM token for the device + /// - stopID: The stop ID to monitor + /// - tripID: The trip ID to monitor + /// - uid: The unique identifier for the user + /// - Returns: A publisher that emits Bool on success, or an ApiErrorHandler on failure + func subscribeToDelayNotifications( + deviceToken: String, + stopID: String?, + tripID: String, + uid: String + ) -> AnyPublisher + + /// Subscribes to departure notifications for a specific trip + /// - Parameters: + /// - deviceToken: The FCM token for the device + /// - startTime: The timestamp to start monitoring from + /// - uid: The unique identifier for the user + /// - Returns: A publisher that emits Bool on success, or an ApiErrorHandler on failure + func subscribeToDepartureNotifications( + deviceToken: String, + startTime: String, + uid: String + ) -> AnyPublisher + + func unsubscribeFromDelayNotifications(deviceToken: String, stopID: String?, tripID: String, uid: String) -> AnyPublisher + + func unsubscribeFromDepartureNotifications(deviceToken: String, startTime: String, uid: String) -> AnyPublisher + /// Updates the local cache of Apple places based on the search text and provided locations. /// - Parameters: /// - searchText: The query text used for retrieving places. @@ -179,6 +210,49 @@ class TransitService: TransitServiceProtocol { return networkManager.request(request, decodingType: RouteSectionsObject.self) } + func subscribeToDelayNotifications( + deviceToken: String, + stopID: String?, + tripID: String, + uid: String + ) -> AnyPublisher { + let body = DelayNotificationBody(deviceToken: deviceToken, stopID: stopID, tripID: tripID, uid: uid) + let request = TransitProvider.delayNotification(body).makeRequest + return networkManager.request(request, decodingType: Bool.self, responseType: .simple) + } + + func subscribeToDepartureNotifications( + deviceToken: String, + startTime: String, + uid: String + ) -> AnyPublisher { + print("startTime: \(startTime)") + let body = DepartureNotificationBody(deviceToken: deviceToken, startTime: startTime, uid: uid) + let request = TransitProvider.departueNotification(body).makeRequest + return networkManager.request(request, decodingType: Bool.self, responseType: .simple) + } + + func unsubscribeFromDelayNotifications( + deviceToken: String, + stopID: String?, + tripID: String, + uid: String + ) -> AnyPublisher { + let body = DelayNotificationBody(deviceToken: deviceToken, stopID: stopID, tripID: tripID, uid: uid) + let request = TransitProvider.cancelDelayNotification(body).makeRequest + return networkManager.request(request, decodingType: Bool.self, responseType: .simple) + } + + func unsubscribeFromDepartureNotifications( + deviceToken: String, + startTime: String, + uid: String + ) -> AnyPublisher { + let body = DepartureNotificationBody(deviceToken: deviceToken, startTime: startTime, uid: uid) + let request = TransitProvider.cancelDepartureNotification(body).makeRequest + return networkManager.request(request, decodingType: Bool.self, responseType: .simple) + } + func updateApplePlacesCache(searchText: String, places: [Place]) -> AnyPublisher { let body = ApplePlacesBody(query: searchText, places: places) let request = TransitProvider.applePlaces(body).makeRequest