From 48511035006eab910b5d4dc076285fc66ef2079e Mon Sep 17 00:00:00 2001 From: Jayson Hahn <46629787+JaysonHahn@users.noreply.github.com> Date: Wed, 20 Nov 2024 17:17:40 -0600 Subject: [PATCH] Modernized networking architecture with Swift's Combine framework. (#394) * Initial Networking * Finish network refactor * Fix code styling * Merge branch 'master' into Jayson/Networking * update version --- Podfile | 2 - Podfile.lock | 16 +- TCAT.xcodeproj/project.pbxproj | 72 ++-- TCAT/Base/AppDelegate.swift | 121 ++----- TCAT/Cells/GeneralTableViewCell.swift | 2 + .../NotificationToggleTableViewCell.swift | 2 + TCAT/Cells/RouteTableViewCell.swift | 2 - .../CustomNavigationController.swift | 35 +- .../FavoritesTableViewController.swift | 49 ++- TCAT/Controllers/HomeMapViewController.swift | 1 - ...OptionsCardViewController+Extensions.swift | 39 +- .../HomeOptionsCardViewController.swift | 58 ++- .../ParentHomeViewController.swift | 12 - .../RouteDetail+ContentViewController.swift | 139 ++++---- .../RouteDetail+DrawerViewController.swift | 105 ++---- ...etailDrawerViewController+Extensions.swift | 7 + ...outeOptionsViewController+Extensions.swift | 20 +- .../RouteOptionsViewController.swift | 199 +++++------ .../SearchResultsViewController.swift | 55 +-- .../ServiceAlertsViewController.swift | 41 ++- .../StopPickerViewController.swift | 75 ++-- TCAT/Models/Direction.swift | 3 + TCAT/Models/SearchManager.swift | 214 +++++------ TCAT/Models/Section.swift | 36 +- TCAT/Models/Waypoint.swift | 6 + TCAT/Network/Endpoints.swift | 119 ------- TCAT/Network/Reachability.swift | 334 ------------------ TCAT/Network/ReachabilityManager.swift | 57 --- TCAT/Services/Network/ApiEndpoint.swift | 109 ++++++ TCAT/Services/Network/ApiErrorHandler.swift | 67 ++++ TCAT/Services/Network/NetworkManager.swift | 81 +++++ TCAT/Services/Network/NetworkMonitor.swift | 75 ++++ .../Network/RequestModels.swift} | 3 +- TCAT/Services/Transit/TransitProvider.swift | 148 ++++++++ TCAT/Services/Transit/TransitService.swift | 187 ++++++++++ TCAT/Supporting/Constants.swift | 3 - TCAT/Utils/Extensions+App.swift | 33 +- 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 + 48 files changed, 1400 insertions(+), 1251 deletions(-) delete mode 100755 TCAT/Network/Endpoints.swift delete mode 100755 TCAT/Network/Reachability.swift delete mode 100644 TCAT/Network/ReachabilityManager.swift create mode 100644 TCAT/Services/Network/ApiEndpoint.swift create mode 100644 TCAT/Services/Network/ApiErrorHandler.swift create mode 100644 TCAT/Services/Network/NetworkManager.swift create mode 100644 TCAT/Services/Network/NetworkMonitor.swift rename TCAT/{Network/Models.swift => Services/Network/RequestModels.swift} (97%) create mode 100644 TCAT/Services/Transit/TransitProvider.swift create mode 100644 TCAT/Services/Transit/TransitService.swift diff --git a/Podfile b/Podfile index 482be536..f0f947c1 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 @@ -32,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 f3cd992e..59aabfdb 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -71,7 +71,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) @@ -146,7 +145,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) @@ -156,7 +154,6 @@ DEPENDENCIES: - Firebase - Firebase/Messaging - FirebaseCrashlytics - - FutureNova (from `https://github.com/cuappdev/ios-networking.git`) - GoogleMaps - NotificationBannerSwift (~> 3.0.0) - Presentation (from `https://github.com/cuappdev/Presentation.git`) @@ -164,7 +161,6 @@ DEPENDENCIES: - SnapKit (~> 5.0) - SwiftLint - SwiftyJSON (~> 5.0) - - WhatsNewKit (~> 1.1) - Wormholy - Zip (~> 1.1) @@ -194,15 +190,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 @@ -210,9 +203,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 @@ -230,7 +220,6 @@ SPEC CHECKSUMS: FirebaseMessaging: 4d52717dd820707cc4eadec5eb981b4832ec8d5d FirebaseRemoteConfigInterop: 6c349a466490aeace3ce9c091c86be1730711634 FirebaseSessions: 2651b464e241c93fd44112f995d5ab663c970487 - FutureNova: 95f9aa352b2c250253b96fdf380754afcc87c7f3 GoogleAppMeasurement: f3abf08495ef2cba7829f15318c373b8d9226491 GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a GoogleMaps: 8939898920281c649150e0af74aa291c60f2e77d @@ -245,10 +234,9 @@ SPEC CHECKSUMS: SnapKit: 97b92857e3df3a0c71833cce143274bf6ef8e5eb SwiftLint: c1de071d9d08c8aba837545f6254315bc900e211 SwiftyJSON: 2f33a42c6fbc52764d96f13368585094bfd8aa5e - WhatsNewKit: c87028c4059dccd113495422801914cc53f6aab0 Wormholy: ab1c8c2f02f58587a0941deb0088555ffbf039a1 Zip: 8877eede3dda76bcac281225c20e71c25270774c -PODFILE CHECKSUM: 03571a87e3df2cb79c3c62b5bd19cd6713131c52 +PODFILE CHECKSUM: af336d88f53594af448d02dc18637c2b6ebe685e -COCOAPODS: 1.15.2 +COCOAPODS: 1.15.0 diff --git a/TCAT.xcodeproj/project.pbxproj b/TCAT.xcodeproj/project.pbxproj index ed0b1369..93ae1a61 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,13 +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 */; }; - EEB26AE22C9F9B9A002E863F /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EEB26AE12C9F9B9A002E863F /* UserNotifications.framework */; }; + 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 */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -144,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 = ""; }; @@ -266,16 +268,19 @@ 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 = ""; }; 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 = ""; }; 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 PBXFrameworksBuildPhase section */ @@ -283,7 +288,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 */, @@ -311,10 +315,11 @@ 2292486621B891790004279C /* Network */ = { isa = PBXGroup; children = ( - BF250D7E222FB12300E7F271 /* Endpoints.swift */, - 22948BFB221B75C5003FC43F /* Models.swift */, - D4756EA123986CB500FE7F0D /* ReachabilityManager.swift */, - DD3D9C201F94297100B164D4 /* Reachability.swift */, + FDE68D212C97EF6200024A69 /* ApiEndpoint.swift */, + FDE68D1F2C97EBBE00024A69 /* ApiErrorHandler.swift */, + FDE68D1D2C97E24900024A69 /* NetworkManager.swift */, + FDA3439E2CB6DF4D00608A1A /* NetworkMonitor.swift */, + 22948BFB221B75C5003FC43F /* RequestModels.swift */, ); path = Network; sourceTree = ""; @@ -375,8 +380,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; @@ -590,7 +595,7 @@ 2E9416822BC6168C003DEB44 /* Controllers */, 2E94165E2BC60A3B003DEB44 /* Ecosystem */, 2E9416AB2BC616DE003DEB44 /* Models */, - 2292486621B891790004279C /* Network */, + FDE68D292C988CDB00024A69 /* Services */, 2E9416C72BC61763003DEB44 /* Supporting */, 2E9416E02BC618E6003DEB44 /* Utils */, 2E9416FD2BC61CAE003DEB44 /* Views */, @@ -616,6 +621,24 @@ 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 */ @@ -827,7 +850,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 */, @@ -853,8 +878,6 @@ 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 */, @@ -863,20 +886,22 @@ 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 */, 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 */, + FDE68D222C97EF6200024A69 /* ApiEndpoint.swift in Sources */, 2EC1F5162BC66CBA001D9F66 /* Publishers.swift in Sources */, 2E94169E2BC616B9003DEB44 /* SearchResultsViewController.swift in Sources */, 2E9FFA822BC673240051793C /* GymFields.graphql.swift in Sources */, @@ -908,7 +933,8 @@ 2E9FFA8B2BC673240051793C /* Gym.graphql.swift in Sources */, 2E9FFA8E2BC673240051793C /* SchemaConfiguration.swift in Sources */, 2E9416EF2BC61984003DEB44 /* EventPayload.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 */, @@ -1009,7 +1035,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.3; + MARKETING_VERSION = 2.0.4; PRODUCT_BUNDLE_IDENTIFIER = com.cornellappdev.tcat; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1106,7 +1132,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.3; + MARKETING_VERSION = 2.0.4; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" -DDEBUG"; "OTHER_SWIFT_FLAGS[arch=*]" = "$(inherited) \"-D\" \"COCOAPODS\""; PRODUCT_BUNDLE_IDENTIFIER = com.cornellappdev.tcat.debug; @@ -1205,7 +1231,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.3; + MARKETING_VERSION = 2.0.4; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" -DLOCAL"; "OTHER_SWIFT_FLAGS[arch=*]" = "$(inherited) \"-D\" \"COCOAPODS\""; PRODUCT_BUNDLE_IDENTIFIER = com.cornellappdev.tcat.debug; diff --git a/TCAT/Base/AppDelegate.swift b/TCAT/Base/AppDelegate.swift index 351a8286..fe33e1a7 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 @@ -23,18 +23,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate, UNUser 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() @@ -46,19 +44,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate, UNUser // Log basic information let payload = AppLaunchedPayload() 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 uid in UserDefaults values if needed + userDefaults.setupUniqueIdentifier() + + // Initialize UserDefaults values if needed + userDefaults.initialize(with: userDataInits) // Track number of app opens for Store Review prompt StoreReviewHelper.incrementAppOpenedCount() @@ -66,9 +57,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate, UNUser // 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(), @@ -76,16 +65,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate, UNUser ) let rootVC = showOnboarding ? OnboardingViewController(initialViewing: true) : parentHomeViewController 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 -// ) + CustomNavigationController(rootViewController: rootVC) // Initalize window without storyboard self.window = UIWindow(frame: UIScreen.main.bounds) @@ -128,20 +108,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate, UNUser // MARK: - Helper Functions - /// 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) { - 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 { - print("[AppDelegate] Unable to access shortcutData['place']") - return + let destination = try? JSONDecoder().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 { @@ -152,44 +124,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate, UNUser } } - 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 + // 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() let navigationController = CustomNavigationController(rootViewController: rootVC) @@ -198,23 +138,30 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate, UNUser 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 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, + let destType = items?.filter({ $0.name == "destinationType" }).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.split(separator: "%").joined(separator: " ") + if destType == "applePlace" { + placeType = .applePlace + } + } - if let latitude = latitude, let longitude = longitude, let stopName = stopName { - let place = Place(name: stopName, type: .busStop, latitude: latitude, longitude: longitude) + 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) return true @@ -228,7 +175,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate, UNUser 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/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/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..da000260 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,15 @@ 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 +139,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..0ca88a05 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 { [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..37ad26fb 100644 --- a/TCAT/Controllers/HomeOptionsCardViewController+Extensions.swift +++ b/TCAT/Controllers/HomeOptionsCardViewController+Extensions.swift @@ -60,14 +60,13 @@ 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) { @@ -122,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 } } @@ -139,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 @@ -176,8 +183,10 @@ extension HomeOptionsCardViewController: UITableViewDelegate { switch sections[section] { case .recentSearches: return headerHeight + case .seeAllStops: return HeaderView.separatorViewHeight + default: return 0 } @@ -192,10 +201,13 @@ extension HomeOptionsCardViewController: UITableViewDelegate { separatorVisible: true, delegate: self ) + case .seeAllStops: return HeaderView(separatorVisible: true) + case .searchResults: return nil + default: return nil } @@ -209,6 +221,7 @@ extension HomeOptionsCardViewController: UITableViewDelegate { switch section { case .recentSearches: return .delete + default: return .none } @@ -225,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 } } @@ -239,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 0ebba13a..292b112c 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,11 @@ 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 isNetworkDown = false + private var currentSearchCancellable: AnyCancellable? private let infoButtonAnimationDuration = 0.1 private var keyboardHeight: CGFloat = 0 private let maxFavoritesCount = 2 @@ -118,7 +117,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 +127,16 @@ 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.updateSections() + } else { + self.sections = [] } + + self.isNetworkDown = !NetworkMonitor.shared.isReachable + self.searchBar.isUserInteractionEnabled = NetworkMonitor.shared.isReachable + self.setNeedsStatusBarAppearanceUpdate() } private func setupTableView() { @@ -245,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 } @@ -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 { [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/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 dabf3967..bcc43893 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 @@ -16,47 +16,62 @@ import SwiftyJSON import UIKit class RouteDetailContentViewController: UIViewController { - - private var banner: StatusBarNotificationBanner? { - didSet { - setNeedsStatusBarAppearanceUpdate() - } - } + + var drawerDisplayController: RouteDetailDrawerViewController? + + /// Keep track of statuses of bus routes throughout view life cycle + var noDataRouteList: [Int] = [] + + /// General Variables var bounds = GMSCoordinateBounds() var busIndicators = [GMSMarker]() var buses = [GMSMarker]() + private var cancellables = Set() var currentLocation: CLLocationCoordinate2D? var directions: [Direction] = [] - var drawerDisplayController: RouteDetailDrawerViewController? - private var finalDestinationCircles: [GMSCircle] = [] - private var finalDestinationMarkers: [GMSMarker] = [] - private var finalRouteSegment: [GMSCircle] = [] - private let finalWalkSegment = GMSMutablePath() - private var firstRouteSegment: [GMSCircle] = [] - private let firstWalkSegment = GMSMutablePath() - /// Number of seconds to wait before auto-refreshing live tracking network call call, timed with live indicator + var endDestination: Place var liveTrackingNetworkRefreshRate: Double = LiveIndicator.interval * 1.0 var liveTrackingNetworkTimer: Timer? private var locationManager = CLLocationManager() var mapView: GMSMapView! private let mapPadding: CGFloat = 80 private let markerRadius: CGFloat = 8 - /// Keep track of statuses of bus routes throughout view life cycle - var noDataRouteList: [Int] = [] - private let networking: Networking = URLSession.shared.request private var paths: [Path] = [] private var route: Route! private var routeOptionsCell: RouteTableViewCell? + /// Banner and Notifications + private var banner: StatusBarNotificationBanner? { + didSet { + setNeedsStatusBarAppearanceUpdate() + } + } + + /// Final Destination Variables + private var finalDestinationCircles: [GMSCircle] = [] + private var finalDestinationMarkers: [GMSMarker] = [] + private var finalRouteSegment: [GMSCircle] = [] + private let finalWalkSegment = GMSMutablePath() + + /// First Route Segment Variables + private var firstRouteSegment: [GMSCircle] = [] + private let firstWalkSegment = GMSMutablePath() + + /// 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?) { - super.init(nibName: nil, bundle: nil) + init(route: Route, endDestination: Place, currentLocation: CLLocationCoordinate2D?, routeOptionsCell: RouteTableViewCell?) { 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() @@ -164,21 +179,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. @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( @@ -190,17 +198,13 @@ 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 { [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) @@ -212,9 +216,18 @@ class RouteDetailContentViewController: UIViewController { ) TransitAnalytics.shared.log(payload) } + } 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) } - } - // Bounce any visible indicators + .store(in: &cancellables) + bounceIndicators() } @@ -225,6 +238,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) @@ -322,7 +336,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? { @@ -443,14 +457,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 } }() @@ -592,11 +620,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, currentLocation: nil, routeOptionsCell: nil) - } - } diff --git a/TCAT/Controllers/RouteDetail+DrawerViewController.swift b/TCAT/Controllers/RouteDetail+DrawerViewController.swift index 0c192f42..12bcddd1 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,7 +178,7 @@ 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 { @@ -202,51 +186,20 @@ class RouteDetailDrawerViewController: UIViewController { } 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 { [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,15 +208,31 @@ class RouteDetailDrawerViewController: UIViewController { ) TransitAnalytics.shared.log(payload) } + } receiveValue: { [weak self] delay in + guard let self = self else { return } + + delayDirection.delay = delay + firstDepartDirection.delay = delay + + 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) } } - private func getDelay(tripId: String, stopId: String) -> Future> { - return networking(Endpoint.getDelay(tripID: tripId, stopID: stopId)).decode() - } - func getFirstDirection() -> Direction? { return route.directions.first(where: { $0.type == .depart }) } 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 8acd57b7..21a277a5 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()) } } } @@ -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() @@ -283,7 +289,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) } } @@ -298,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 2ca2dfa9..5b017036 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, @@ -306,6 +296,7 @@ class RouteOptionsViewController: UIViewController { searchBarText = startingDestinationName } placeholder = Constants.General.fromSearchBarPlaceholder + case .to: let endingDestinationName = searchTo.name if endingDestinationName != Constants.General.currentLocation { @@ -361,51 +352,47 @@ 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 - } - 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) - } else { - delayState = DelayState.onTime(date: departTime) - } - self.delayDictionary[routeId] = delayState - route.getFirstDepartRawDirection()?.delay = delay - } - case .error(let error): - self.printClass(context: "\(#function) error", message: error.localizedDescription) + private func updateAllRoutesLiveTracking() { + TransitService.shared.getAllDelays(trips: trips, refreshInterval: busDelaysNetworkRefreshRate) + .receive(on: DispatchQueue.main) + .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", 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)) + + let delayState: DelayState + delayState = delayedDepartTime > departTime ? .late( + date: delayedDepartTime + ) : .late( + date: departTime + ) + + self.delayDictionary[routeId] = delayState + route.getFirstDepartRawDirection()?.delay = delay + } } } - }) + .store(in: &cancellables) } @objc private func refreshRoutesAndTime() { @@ -442,6 +429,7 @@ class RouteOptionsViewController: UIViewController { switch searchType { case .from: routeSelection.updateSearchBarTitles(from: searchFrom.name) + case .to: routeSelection.updateSearchBarTitles(to: searchTo.name) } @@ -494,39 +482,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 - } - } - - 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. @@ -546,36 +501,40 @@ 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 { [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 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) + + // 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) { @@ -627,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 @@ -637,6 +597,7 @@ class RouteOptionsViewController: UIViewController { ) TransitAnalytics.shared.log(payload) + case .hideBanner: banner?.dismiss() banner = nil @@ -650,7 +611,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 { @@ -691,6 +653,7 @@ class RouteOptionsViewController: UIViewController { let contentViewController = RouteDetailContentViewController( route: route, + endDestination: searchTo, currentLocation: routeDetailCurrentLocation, routeOptionsCell: routeOptionsCell ) diff --git a/TCAT/Controllers/SearchResultsViewController.swift b/TCAT/Controllers/SearchResultsViewController.swift index 73633253..98927ef7 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 { [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() - } } } @@ -184,6 +183,7 @@ extension SearchResultsViewController: UITableViewDataSource { switch sections[section] { case .recentSearches: return recentLocations.count + default: return sections[section].getItems().count } @@ -197,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 @@ -217,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 } @@ -232,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 } } @@ -311,14 +317,13 @@ 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..b20d08b4 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,17 @@ 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 +113,16 @@ 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]] { @@ -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 dc1618c3..5e18c40d 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,45 @@ 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 { [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: + handleGetAllStopsError() - DispatchQueue.main.async { - self.loadingIndicator?.removeFromSuperview() - self.loadingIndicator = nil - self.tableView.reloadData() + case .finished: + break } + } 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) + } + + // 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 +175,7 @@ extension StopPickerViewController: DZNEmptyDataSetDelegate { func emptyDataSet(_ scrollView: UIScrollView, didTap didTapButton: UIButton) { setUpLoadingIndicator() - refreshStops() + getAllStops() } } 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 71e98fbf..f65ffa66 100644 --- a/TCAT/Models/SearchManager.swift +++ b/TCAT/Models/SearchManager.swift @@ -6,153 +6,121 @@ // 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 { 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 getAppleSearchResults(searchText: String) -> Future> { - return networking(Endpoint.getAppleSearchResults(searchText: searchText)).decode() + 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)) + } + } } -} - -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() - }) + private func performLocalSearch(with query: String) { + guard !query.isEmpty else { + self.searchPublisher.send(.failure(.noSearchResultsFound)) + return } - 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 - } + + let searchRequest = MKLocalSearch.Request() + searchRequest.naturalLanguageQuery = query + let localSearch = MKLocalSearch(request: searchRequest) + + 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/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/Network/Endpoints.swift b/TCAT/Network/Endpoints.swift deleted file mode 100755 index 4ad5a1a8..00000000 --- a/TCAT/Network/Endpoints.swift +++ /dev/null @@ -1,119 +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 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) - } - - 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 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 } - - 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) - } - - 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/Network/Reachability.swift b/TCAT/Network/Reachability.swift deleted file mode 100755 index 05b8a2ea..00000000 --- a/TCAT/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/Network/ReachabilityManager.swift b/TCAT/Network/ReachabilityManager.swift deleted file mode 100644 index 853a788a..00000000 --- a/TCAT/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/ApiEndpoint.swift b/TCAT/Services/Network/ApiEndpoint.swift new file mode 100644 index 00000000..ea7a0787 --- /dev/null +++ b/TCAT/Services/Network/ApiEndpoint.swift @@ -0,0 +1,109 @@ +// +// ApiEndpoint.swift +// TCAT +// +// Created by Jayson Hahn on 9/16/24. +// Copyright © 2024 Cornell AppDev. All rights reserved. +// + +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 + case PUT + case DELETE + 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 } + 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 } +} + +/** + 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) + 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 + } + + return request + } +} diff --git a/TCAT/Services/Network/ApiErrorHandler.swift b/TCAT/Services/Network/ApiErrorHandler.swift new file mode 100644 index 00000000..12edbc1b --- /dev/null +++ b/TCAT/Services/Network/ApiErrorHandler.swift @@ -0,0 +1,67 @@ +// +// ApiErrorHandler.swift +// TCAT +// +// Created by Jayson Hahn on 9/16/24. +// Copyright © 2024 Cornell AppDev. All rights reserved. +// + +import Foundation + +/// Represents an API error with optional code and message. +struct ApiError: Codable { + let code: String? + let message: String? +} + +/// 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): + 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 new file mode 100644 index 00000000..ea394821 --- /dev/null +++ b/TCAT/Services/Network/NetworkManager.swift @@ -0,0 +1,81 @@ +// +// NetworkManager.swift +// TCAT +// +// Created by Jayson Hahn on 9/15/24. +// Copyright © 2024 Cornell AppDev. All rights reserved. +// + +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 + 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 { + // 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 new file mode 100644 index 00000000..0309d0fc --- /dev/null +++ b/TCAT/Services/Network/NetworkMonitor.swift @@ -0,0 +1,75 @@ +// +// NetworkMonitor.swift +// TCAT +// +// Created by Jayson Hahn on 10/9/24. +// Copyright © 2024 Cornell AppDev. All rights reserved. +// + +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 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() {} + + 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) + } + + /// 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/Network/Models.swift b/TCAT/Services/Network/RequestModels.swift similarity index 97% rename from TCAT/Network/Models.swift rename to TCAT/Services/Network/RequestModels.swift index c701dc6a..3c536643 100644 --- a/TCAT/Network/Models.swift +++ b/TCAT/Services/Network/RequestModels.swift @@ -87,8 +87,7 @@ internal struct Delay: Codable { let delay: Int? } -// Response -struct Response: Codable { +struct APIResponse: Decodable { var success: Bool var data: T } diff --git a/TCAT/Services/Transit/TransitProvider.swift b/TCAT/Services/Transit/TransitProvider.swift new file mode 100644 index 00000000..fe923549 --- /dev/null +++ b/TCAT/Services/Transit/TransitProvider.swift @@ -0,0 +1,148 @@ +// +// Providers.swift +// TCAT +// +// Created by Jayson Hahn on 9/16/24. +// Copyright © 2024 Cornell AppDev. All rights reserved. +// + +import Foundation + +/// Enum representing various transit providers and their associated API endpoints. +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 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: + return "" + } + } + + /// 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: + return ["Content-Type": "application/json"] + } + } + + /// 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: + return nil + } + } + + /// 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 new file mode 100644 index 00000000..17f55ab2 --- /dev/null +++ b/TCAT/Services/Transit/TransitService.swift @@ -0,0 +1,187 @@ +// +// Services.swift +// TCAT +// +// Created by Jayson Hahn on 9/16/24. +// Copyright © 2024 Cornell AppDev. All rights reserved. +// + +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 + + /// 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 + + /// 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 +} + +/// Service implementing `TransitServiceProtocol` to fetch and manage transit-related data. +class TransitService: TransitServiceProtocol { + + // Singleton instance + static var shared = TransitService(networkManager: NetworkManager()) + + /// Manages network requests for transit services. + private let networkManager: NetworkManager + + // Initializer + init(networkManager: NetworkManager) { + 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 + + 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 = userDefaults.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/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..d28e86c9 100755 --- a/TCAT/Utils/Extensions+App.swift +++ b/TCAT/Utils/Extensions+App.swift @@ -230,17 +230,25 @@ 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 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] - 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] @@ -268,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 = "" }