diff --git a/Podfile b/Podfile index f0f947c1..6cf6b1e4 100644 --- a/Podfile +++ b/Podfile @@ -10,7 +10,7 @@ inhibit_all_warnings! target 'TCAT' do # Location - pod 'GoogleMaps' + pod 'GoogleMaps', '~> 8.4.0' # Networking + Data pod 'Apollo', '~> 1.9.3' @@ -23,7 +23,7 @@ target 'TCAT' do pod 'Firebase/Messaging' # File Management - pod 'Zip', '~> 1.1' + pod 'Zip', '~> 2.1.2' # UI Frameworks pod 'DZNEmptyDataSet', :git=> 'https://github.com/cuappdev/DZNEmptyDataSet.git' diff --git a/Podfile.lock b/Podfile.lock index 18b8d8a8..1341ff2d 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -3,137 +3,143 @@ PODS: - Apollo/Core (= 1.9.3) - Apollo/Core (1.9.3) - DZNEmptyDataSet (1.8.1) - - Firebase (10.24.0): - - Firebase/Core (= 10.24.0) - - Firebase/Core (10.24.0): + - Firebase (12.4.0): + - Firebase/Core (= 12.4.0) + - Firebase/Core (12.4.0): - Firebase/CoreOnly - - FirebaseAnalytics (~> 10.24.0) - - Firebase/CoreOnly (10.24.0): - - FirebaseCore (= 10.24.0) - - Firebase/Messaging (10.24.0): + - FirebaseAnalytics (~> 12.4.0) + - Firebase/CoreOnly (12.4.0): + - FirebaseCore (~> 12.4.0) + - Firebase/Messaging (12.4.0): - Firebase/CoreOnly - - FirebaseMessaging (~> 10.24.0) - - FirebaseAnalytics (10.24.0): - - FirebaseAnalytics/AdIdSupport (= 10.24.0) - - FirebaseCore (~> 10.0) - - FirebaseInstallations (~> 10.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.11) - - GoogleUtilities/MethodSwizzler (~> 7.11) - - GoogleUtilities/Network (~> 7.11) - - "GoogleUtilities/NSData+zlib (~> 7.11)" - - nanopb (< 2.30911.0, >= 2.30908.0) - - FirebaseAnalytics/AdIdSupport (10.24.0): - - FirebaseCore (~> 10.0) - - FirebaseInstallations (~> 10.0) - - GoogleAppMeasurement (= 10.24.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.11) - - GoogleUtilities/MethodSwizzler (~> 7.11) - - GoogleUtilities/Network (~> 7.11) - - "GoogleUtilities/NSData+zlib (~> 7.11)" - - nanopb (< 2.30911.0, >= 2.30908.0) - - FirebaseCore (10.24.0): - - FirebaseCoreInternal (~> 10.0) - - GoogleUtilities/Environment (~> 7.12) - - GoogleUtilities/Logger (~> 7.12) - - FirebaseCoreExtension (10.24.0): - - FirebaseCore (~> 10.0) - - FirebaseCoreInternal (10.24.0): - - "GoogleUtilities/NSData+zlib (~> 7.8)" - - FirebaseCrashlytics (10.24.0): - - FirebaseCore (~> 10.5) - - FirebaseInstallations (~> 10.0) - - FirebaseRemoteConfigInterop (~> 10.23) - - FirebaseSessions (~> 10.5) - - GoogleDataTransport (~> 9.2) - - GoogleUtilities/Environment (~> 7.8) - - nanopb (< 2.30911.0, >= 2.30908.0) - - PromisesObjC (~> 2.1) - - FirebaseInstallations (10.24.0): - - FirebaseCore (~> 10.0) - - GoogleUtilities/Environment (~> 7.8) - - GoogleUtilities/UserDefaults (~> 7.8) - - PromisesObjC (~> 2.1) - - FirebaseMessaging (10.24.0): - - FirebaseCore (~> 10.0) - - FirebaseInstallations (~> 10.0) - - GoogleDataTransport (~> 9.3) - - GoogleUtilities/AppDelegateSwizzler (~> 7.8) - - GoogleUtilities/Environment (~> 7.8) - - GoogleUtilities/Reachability (~> 7.8) - - GoogleUtilities/UserDefaults (~> 7.8) - - nanopb (< 2.30911.0, >= 2.30908.0) - - FirebaseRemoteConfigInterop (10.24.0) - - FirebaseSessions (10.24.0): - - FirebaseCore (~> 10.5) - - FirebaseCoreExtension (~> 10.0) - - FirebaseInstallations (~> 10.0) - - GoogleDataTransport (~> 9.2) - - GoogleUtilities/Environment (~> 7.10) - - nanopb (< 2.30911.0, >= 2.30908.0) + - FirebaseMessaging (~> 12.4.0) + - FirebaseAnalytics (12.4.0): + - FirebaseAnalytics/Default (= 12.4.0) + - FirebaseCore (~> 12.4.0) + - FirebaseInstallations (~> 12.4.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - FirebaseAnalytics/Default (12.4.0): + - FirebaseCore (~> 12.4.0) + - FirebaseInstallations (~> 12.4.0) + - GoogleAppMeasurement/Default (= 12.4.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - FirebaseCore (12.4.0): + - FirebaseCoreInternal (~> 12.4.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - FirebaseCoreExtension (12.4.0): + - FirebaseCore (~> 12.4.0) + - FirebaseCoreInternal (12.4.0): + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - FirebaseCrashlytics (12.4.0): + - FirebaseCore (~> 12.4.0) + - FirebaseInstallations (~> 12.4.0) + - FirebaseRemoteConfigInterop (~> 12.4.0) + - FirebaseSessions (~> 12.4.0) + - GoogleDataTransport (~> 10.1) + - GoogleUtilities/Environment (~> 8.1) + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) + - FirebaseInstallations (12.4.0): + - FirebaseCore (~> 12.4.0) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - PromisesObjC (~> 2.4) + - FirebaseMessaging (12.4.0): + - FirebaseCore (~> 12.4.0) + - FirebaseInstallations (~> 12.4.0) + - GoogleDataTransport (~> 10.1) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Reachability (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - nanopb (~> 3.30910.0) + - FirebaseRemoteConfigInterop (12.4.0) + - FirebaseSessions (12.4.0): + - FirebaseCore (~> 12.4.0) + - FirebaseCoreExtension (~> 12.4.0) + - FirebaseInstallations (~> 12.4.0) + - GoogleDataTransport (~> 10.1) + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/UserDefaults (~> 8.1) + - nanopb (~> 3.30910.0) - PromisesSwift (~> 2.1) - - GoogleAppMeasurement (10.24.0): - - GoogleAppMeasurement/AdIdSupport (= 10.24.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.11) - - GoogleUtilities/MethodSwizzler (~> 7.11) - - GoogleUtilities/Network (~> 7.11) - - "GoogleUtilities/NSData+zlib (~> 7.11)" - - nanopb (< 2.30911.0, >= 2.30908.0) - - GoogleAppMeasurement/AdIdSupport (10.24.0): - - GoogleAppMeasurement/WithoutAdIdSupport (= 10.24.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.11) - - GoogleUtilities/MethodSwizzler (~> 7.11) - - GoogleUtilities/Network (~> 7.11) - - "GoogleUtilities/NSData+zlib (~> 7.11)" - - nanopb (< 2.30911.0, >= 2.30908.0) - - GoogleAppMeasurement/WithoutAdIdSupport (10.24.0): - - GoogleUtilities/AppDelegateSwizzler (~> 7.11) - - GoogleUtilities/MethodSwizzler (~> 7.11) - - GoogleUtilities/Network (~> 7.11) - - "GoogleUtilities/NSData+zlib (~> 7.11)" - - nanopb (< 2.30911.0, >= 2.30908.0) - - GoogleDataTransport (9.4.1): - - GoogleUtilities/Environment (~> 7.7) - - nanopb (< 2.30911.0, >= 2.30908.0) - - PromisesObjC (< 3.0, >= 1.2) + - GoogleAdsOnDeviceConversion (3.1.0): + - GoogleUtilities/Environment (~> 8.1) + - GoogleUtilities/Logger (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - nanopb (~> 3.30910.0) + - GoogleAppMeasurement/Core (12.4.0): + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - GoogleAppMeasurement/Default (12.4.0): + - GoogleAdsOnDeviceConversion (~> 3.1.0) + - GoogleAppMeasurement/Core (= 12.4.0) + - GoogleAppMeasurement/IdentitySupport (= 12.4.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - GoogleAppMeasurement/IdentitySupport (12.4.0): + - GoogleAppMeasurement/Core (= 12.4.0) + - GoogleUtilities/AppDelegateSwizzler (~> 8.1) + - GoogleUtilities/MethodSwizzler (~> 8.1) + - GoogleUtilities/Network (~> 8.1) + - "GoogleUtilities/NSData+zlib (~> 8.1)" + - nanopb (~> 3.30910.0) + - GoogleDataTransport (10.1.0): + - nanopb (~> 3.30910.0) + - PromisesObjC (~> 2.4) - GoogleMaps (8.4.0): - GoogleMaps/Maps (= 8.4.0) - GoogleMaps/Base (8.4.0) - GoogleMaps/Maps (8.4.0): - GoogleMaps/Base - - GoogleUtilities/AppDelegateSwizzler (7.13.0): + - GoogleUtilities/AppDelegateSwizzler (8.1.0): - GoogleUtilities/Environment - GoogleUtilities/Logger - GoogleUtilities/Network - GoogleUtilities/Privacy - - GoogleUtilities/Environment (7.13.0): + - GoogleUtilities/Environment (8.1.0): - GoogleUtilities/Privacy - - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/Logger (7.13.0): + - GoogleUtilities/Logger (8.1.0): - GoogleUtilities/Environment - GoogleUtilities/Privacy - - GoogleUtilities/MethodSwizzler (7.13.0): + - GoogleUtilities/MethodSwizzler (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - GoogleUtilities/Network (7.13.0): + - GoogleUtilities/Network (8.1.0): - GoogleUtilities/Logger - "GoogleUtilities/NSData+zlib" - GoogleUtilities/Privacy - GoogleUtilities/Reachability - - "GoogleUtilities/NSData+zlib (7.13.0)": + - "GoogleUtilities/NSData+zlib (8.1.0)": - GoogleUtilities/Privacy - - GoogleUtilities/Privacy (7.13.0) - - GoogleUtilities/Reachability (7.13.0): + - GoogleUtilities/Privacy (8.1.0) + - GoogleUtilities/Reachability (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - - GoogleUtilities/UserDefaults (7.13.0): + - GoogleUtilities/UserDefaults (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy - MarqueeLabel (4.0.5) - - nanopb (2.30910.0): - - nanopb/decode (= 2.30910.0) - - nanopb/encode (= 2.30910.0) - - nanopb/decode (2.30910.0) - - nanopb/encode (2.30910.0) + - nanopb (3.30910.0): + - nanopb/decode (= 3.30910.0) + - nanopb/encode (= 3.30910.0) + - nanopb/decode (3.30910.0) + - nanopb/encode (3.30910.0) - NotificationBannerSwift (3.0.6): - MarqueeLabel (~> 4.0.5) - SnapKit (~> 5.0.1) @@ -143,10 +149,10 @@ PODS: - PromisesObjC (= 2.4.0) - Pulley (2.9.1) - SnapKit (5.0.1) - - SwiftLint (0.54.0) - - SwiftyJSON (5.0.1) + - SwiftLint (0.61.0) + - SwiftyJSON (5.0.2) - Wormholy (1.7.0) - - Zip (1.1.0) + - Zip (2.1.2) DEPENDENCIES: - Apollo (~> 1.9.3) @@ -154,7 +160,7 @@ DEPENDENCIES: - Firebase - Firebase/Messaging - FirebaseCrashlytics - - GoogleMaps + - GoogleMaps (~> 8.4.0) - NotificationBannerSwift (~> 3.0.0) - Presentation (from `https://github.com/cuappdev/Presentation.git`) - Pulley (~> 2.7) @@ -162,7 +168,7 @@ DEPENDENCIES: - SwiftLint - SwiftyJSON (~> 5.0) - Wormholy - - Zip (~> 1.1) + - Zip (~> 2.1.2) SPEC REPOS: trunk: @@ -177,6 +183,7 @@ SPEC REPOS: - FirebaseMessaging - FirebaseRemoteConfigInterop - FirebaseSessions + - GoogleAdsOnDeviceConversion - GoogleAppMeasurement - GoogleDataTransport - GoogleMaps @@ -210,33 +217,34 @@ CHECKOUT OPTIONS: SPEC CHECKSUMS: Apollo: b339a44b439f6b64208eb8761a0336813287a903 DZNEmptyDataSet: b94434220f87d9dda46660eb4f07a424778e93b4 - Firebase: 91fefd38712feb9186ea8996af6cbdef41473442 - FirebaseAnalytics: b5efc493eb0f40ec560b04a472e3e1a15d39ca13 - FirebaseCore: 11dc8a16dfb7c5e3c3f45ba0e191a33ac4f50894 - FirebaseCoreExtension: af5fd85e817ea9d19f9a2659a376cf9cf99f03c0 - FirebaseCoreInternal: bcb5acffd4ea05e12a783ecf835f2210ce3dc6af - FirebaseCrashlytics: af38ea4adfa606f6e63fcc22091b61e7938fcf66 - FirebaseInstallations: 8f581fca6478a50705d2bd2abd66d306e0f5736e - FirebaseMessaging: 4d52717dd820707cc4eadec5eb981b4832ec8d5d - FirebaseRemoteConfigInterop: 6c349a466490aeace3ce9c091c86be1730711634 - FirebaseSessions: 2651b464e241c93fd44112f995d5ab663c970487 - GoogleAppMeasurement: f3abf08495ef2cba7829f15318c373b8d9226491 - GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a + Firebase: f07b15ae5a6ec0f93713e30b923d9970d144af3e + FirebaseAnalytics: 0fc2b20091f0ddd21bf73397cf8f0eb5346dc24f + FirebaseCore: bb595f3114953664e3c1dc032f008a244147cfd3 + FirebaseCoreExtension: 7e1f7118ee970e001a8013719fb90950ee5e0018 + FirebaseCoreInternal: d7f5a043c2cd01a08103ab586587c1468047bca6 + FirebaseCrashlytics: a6ece278a837c7e88de2d9b5da0a3542f2342395 + FirebaseInstallations: ae9f4902cb5bf1d0c5eaa31ec1f4e5495a0714e2 + FirebaseMessaging: d33971b7bb252745ea6cd31ab190d1a1df4b8ed5 + FirebaseRemoteConfigInterop: 1e31ec72b89c9924367c59bfb5ec9ab60d1d6766 + FirebaseSessions: ba7c7a7ca8696a8d540eb3fe3800fbe98c79786d + GoogleAdsOnDeviceConversion: e03a386840803ea7eef3fd22a061930142c039c1 + GoogleAppMeasurement: 1e718274b7e015cefd846ac1fcf7820c70dc017d + GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleMaps: 8939898920281c649150e0af74aa291c60f2e77d - GoogleUtilities: d053d902a8edaa9904e1bd00c37535385b8ed152 + GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 MarqueeLabel: 00cc0bcd087111dca575878b3531af980559707d - nanopb: 438bc412db1928dac798aa6fd75726007be04262 + nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 NotificationBannerSwift: 7021be2338f8f29cf424b0aca43da462bf9e2a1a Presentation: c66e877bb3e8a6437ca9c19ab018cfa4b04a98ee PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851 Pulley: a4c28c930958f42978d69631000bc1abb82cb232 SnapKit: 97b92857e3df3a0c71833cce143274bf6ef8e5eb - SwiftLint: c1de071d9d08c8aba837545f6254315bc900e211 - SwiftyJSON: 2f33a42c6fbc52764d96f13368585094bfd8aa5e + SwiftLint: bf6da11a31c6644a0bbb27f4fa15fd9636db00b3 + SwiftyJSON: f5b1bf1cd8dd53cd25887ac0eabcfd92301c6a5a Wormholy: ab1c8c2f02f58587a0941deb0088555ffbf039a1 - Zip: 8877eede3dda76bcac281225c20e71c25270774c + Zip: b3fef584b147b6e582b2256a9815c897d60ddc67 -PODFILE CHECKSUM: af336d88f53594af448d02dc18637c2b6ebe685e +PODFILE CHECKSUM: fe3e20ea2d105a197821fb521e7cab43423411dd COCOAPODS: 1.16.2 diff --git a/TCAT.xcodeproj/project.pbxproj b/TCAT.xcodeproj/project.pbxproj index 76f9a74c..dbd46b03 100644 --- a/TCAT.xcodeproj/project.pbxproj +++ b/TCAT.xcodeproj/project.pbxproj @@ -7,8 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 11DED336304A84735BDCFEC3 /* Pods_TCAT.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4C43A009E3DAAEEFC9A0BD5 /* Pods_TCAT.framework */; }; 22948BFD221B75C5003FC43F /* RequestModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22948BFB221B75C5003FC43F /* RequestModels.swift */; }; - 28EA3E17A0C473892F5506EC /* Pods_TCAT.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 542B073726DFD1EE044EA97F /* Pods_TCAT.framework */; }; 2E70434E2BB75E10003AC1D6 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 2E70434D2BB75E10003AC1D6 /* PrivacyInfo.xcprivacy */; }; 2E9416602BC60A59003DEB44 /* UpliftQueries.graphql in Resources */ = {isa = PBXBuildFile; fileRef = 2E94165F2BC60A59003DEB44 /* UpliftQueries.graphql */; }; 2E9416692BC615DF003DEB44 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E9416672BC615DF003DEB44 /* AppDelegate.swift */; }; @@ -124,6 +124,8 @@ BF74AC1A1F945D7D00AFD4E4 /* GoogleMapsBase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF74AC191F945D7D00AFD4E4 /* GoogleMapsBase.framework */; }; BF74AC1D1F945D8E00AFD4E4 /* GoogleMapsCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF74AC1B1F945D8E00AFD4E4 /* GoogleMapsCore.framework */; }; BF74AC1E1F945D8E00AFD4E4 /* GoogleMaps.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF74AC1C1F945D8E00AFD4E4 /* GoogleMaps.framework */; }; + FD44EC532CD86A5F009269A2 /* TransitNotificationSubscriber.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD44EC522CD86A56009269A2 /* TransitNotificationSubscriber.swift */; }; + FD44EC552CD86C55009269A2 /* PushNotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD44EC542CD86C4A009269A2 /* PushNotificationService.swift */; }; FDA3439F2CB6DF5800608A1A /* NetworkMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDA3439E2CB6DF4D00608A1A /* NetworkMonitor.swift */; }; FDE68D1E2C97E24900024A69 /* NetworkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D1D2C97E24900024A69 /* NetworkManager.swift */; }; FDE68D202C97EBBE00024A69 /* ApiErrorHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D1F2C97EBBE00024A69 /* ApiErrorHandler.swift */; }; @@ -262,18 +264,20 @@ 2EC1F5152BC66CBA001D9F66 /* Publishers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Publishers.swift; sourceTree = ""; }; 449A7C751D80D0E80019300C /* TCAT.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TCAT.app; sourceTree = BUILT_PRODUCTS_DIR; }; 449A7C7F1D80D0E80019300C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 542B073726DFD1EE044EA97F /* Pods_TCAT.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_TCAT.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 78CE6235AE56D8B3776331B2 /* Pods-TCAT.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TCAT.release.xcconfig"; path = "Target Support Files/Pods-TCAT/Pods-TCAT.release.xcconfig"; sourceTree = ""; }; - 7A621E1F21DF0FC2CACD61FE /* Pods-TCAT.local.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TCAT.local.xcconfig"; path = "Target Support Files/Pods-TCAT/Pods-TCAT.local.xcconfig"; sourceTree = ""; }; - 7C562FAA4261465E07ACE741 /* Pods-TCAT.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TCAT.debug.xcconfig"; path = "Target Support Files/Pods-TCAT/Pods-TCAT.debug.xcconfig"; sourceTree = ""; }; 7E14AEC02177E846006A344D /* IntentsUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IntentsUI.framework; path = System/Library/Frameworks/IntentsUI.framework; sourceTree = SDKROOT; }; 7EEF189C21B39C6200343FFD /* NotificationCenter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NotificationCenter.framework; path = System/Library/Frameworks/NotificationCenter.framework; sourceTree = SDKROOT; }; + 7F774800664BACA8CD504187 /* Pods-TCAT.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TCAT.debug.xcconfig"; path = "Target Support Files/Pods-TCAT/Pods-TCAT.debug.xcconfig"; sourceTree = ""; }; + AB65818DE0F8825F8E2AFDA3 /* Pods-TCAT.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TCAT.release.xcconfig"; path = "Target Support Files/Pods-TCAT/Pods-TCAT.release.xcconfig"; sourceTree = ""; }; BF74AC191F945D7D00AFD4E4 /* GoogleMapsBase.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GoogleMapsBase.framework; path = Pods/GoogleMaps/Base/Frameworks/GoogleMapsBase.framework; sourceTree = ""; }; BF74AC1B1F945D8E00AFD4E4 /* GoogleMapsCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GoogleMapsCore.framework; path = Pods/GoogleMaps/Maps/Frameworks/GoogleMapsCore.framework; sourceTree = ""; }; BF74AC1C1F945D8E00AFD4E4 /* GoogleMaps.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = GoogleMaps.framework; path = Pods/GoogleMaps/Maps/Frameworks/GoogleMaps.framework; sourceTree = ""; }; + DEED3C993CDEA110A0400D70 /* Pods-TCAT.local.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-TCAT.local.xcconfig"; path = "Target Support Files/Pods-TCAT/Pods-TCAT.local.xcconfig"; sourceTree = ""; }; EEB26AE02C9F998C002E863F /* TCATLocal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TCATLocal.entitlements; sourceTree = ""; }; EEB26AE12C9F9B9A002E863F /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = System/Library/Frameworks/UserNotifications.framework; sourceTree = SDKROOT; }; EEB26AE32C9FA60E002E863F /* TCATDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TCATDebug.entitlements; sourceTree = ""; }; + F4C43A009E3DAAEEFC9A0BD5 /* Pods_TCAT.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_TCAT.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + FD44EC522CD86A56009269A2 /* TransitNotificationSubscriber.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitNotificationSubscriber.swift; sourceTree = ""; }; + FD44EC542CD86C4A009269A2 /* PushNotificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationService.swift; sourceTree = ""; }; FD69AF2A2B89212F00970C7E /* ci_post_clone.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = ci_post_clone.sh; sourceTree = ""; }; FDA3439E2CB6DF4D00608A1A /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; FDE68D1D2C97E24900024A69 /* NetworkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkManager.swift; sourceTree = ""; }; @@ -291,7 +295,7 @@ BF74AC1D1F945D8E00AFD4E4 /* GoogleMapsCore.framework in Frameworks */, BF74AC1E1F945D8E00AFD4E4 /* GoogleMaps.framework in Frameworks */, BF74AC1A1F945D7D00AFD4E4 /* GoogleMapsBase.framework in Frameworks */, - 28EA3E17A0C473892F5506EC /* Pods_TCAT.framework in Frameworks */, + 11DED336304A84735BDCFEC3 /* Pods_TCAT.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -307,7 +311,7 @@ BF74AC191F945D7D00AFD4E4 /* GoogleMapsBase.framework */, 7E14AEC02177E846006A344D /* IntentsUI.framework */, 7EEF189C21B39C6200343FFD /* NotificationCenter.framework */, - 542B073726DFD1EE044EA97F /* Pods_TCAT.framework */, + F4C43A009E3DAAEEFC9A0BD5 /* Pods_TCAT.framework */, ); name = Frameworks; sourceTree = ""; @@ -398,7 +402,6 @@ 2E9416AD2BC61731003DEB44 /* Place.swift */, 2E9416AC2BC61731003DEB44 /* PlaceCoordinates.swift */, 2E9416B32BC61731003DEB44 /* Route.swift */, - 2E9416B62BC61731003DEB44 /* SearchManager.swift */, 2E9416B72BC61731003DEB44 /* Section.swift */, 2E9416AE2BC61731003DEB44 /* ServiceAlert.swift */, 2E9416B42BC61731003DEB44 /* WalkPath.swift */, @@ -594,6 +597,7 @@ 2E94166C2BC61604003DEB44 /* Cells */, 2E9416822BC6168C003DEB44 /* Controllers */, 2E94165E2BC60A3B003DEB44 /* Ecosystem */, + FD44EC562CD8914D009269A2 /* Managers */, 2E9416AB2BC616DE003DEB44 /* Models */, FDE68D292C988CDB00024A69 /* Services */, 2E9416C72BC61763003DEB44 /* Supporting */, @@ -606,13 +610,23 @@ 44BE841D0263A527944A6E0F /* Pods */ = { isa = PBXGroup; children = ( - 7C562FAA4261465E07ACE741 /* Pods-TCAT.debug.xcconfig */, - 7A621E1F21DF0FC2CACD61FE /* Pods-TCAT.local.xcconfig */, - 78CE6235AE56D8B3776331B2 /* Pods-TCAT.release.xcconfig */, + 7F774800664BACA8CD504187 /* Pods-TCAT.debug.xcconfig */, + DEED3C993CDEA110A0400D70 /* Pods-TCAT.local.xcconfig */, + AB65818DE0F8825F8E2AFDA3 /* Pods-TCAT.release.xcconfig */, ); path = Pods; sourceTree = ""; }; + FD44EC562CD8914D009269A2 /* Managers */ = { + isa = PBXGroup; + children = ( + FD44EC542CD86C4A009269A2 /* PushNotificationService.swift */, + FD44EC522CD86A56009269A2 /* TransitNotificationSubscriber.swift */, + 2E9416B62BC61731003DEB44 /* SearchManager.swift */, + ); + path = Managers; + sourceTree = ""; + }; FD69AF292B8920D500970C7E /* ci_scripts */ = { isa = PBXGroup; children = ( @@ -646,15 +660,15 @@ isa = PBXNativeTarget; buildConfigurationList = 449A7C9D1D80D0E80019300C /* Build configuration list for PBXNativeTarget "TCAT" */; buildPhases = ( - E23EEB875E8C363D642AB893 /* [CP] Check Pods Manifest.lock */, + F6E30D44AE7060FBA9CA34DF /* [CP] Check Pods Manifest.lock */, 449A7C711D80D0E80019300C /* Sources */, 449A7C721D80D0E80019300C /* Frameworks */, 449A7C731D80D0E80019300C /* Resources */, 2292F9DB215722ED00C8C931 /* SwiftLint */, 7E14AED52177E846006A344D /* Embed Foundation Extensions */, CE26CBF62B879837005D099A /* Crashlytics */, - 882B9E91268F347446806E32 /* [CP] Embed Pods Frameworks */, - 0B4CA64206AF6DA1763F9ACB /* [CP] Copy Pods Resources */, + 10126D331FC535DE1C43147A /* [CP] Embed Pods Frameworks */, + AF0480DFC65AF5CB62CA0646 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -738,25 +752,21 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 0B4CA64206AF6DA1763F9ACB /* [CP] Copy Pods Resources */ = { + 10126D331FC535DE1C43147A /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Copy Pods Resources"; + name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - outputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-resources.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; 2292F9DB215722ED00C8C931 /* SwiftLint */ = { @@ -777,25 +787,21 @@ shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/SwiftLint/swiftlint\"\n\nif which swiftlint >/dev/null; then\n swiftlint --fix && swiftlint\nelse\n echo \"WARNING: SwiftLint not installed\"\nfi\n"; }; - 882B9E91268F347446806E32 /* [CP] Embed Pods Frameworks */ = { + AF0480DFC65AF5CB62CA0646 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-resources-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Embed Pods Frameworks"; + name = "[CP] Copy Pods Resources"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - outputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-resources-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-frameworks.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-TCAT/Pods-TCAT-resources.sh\"\n"; showEnvVarsInLog = 0; }; CE26CBF62B879837005D099A /* Crashlytics */ = { @@ -821,7 +827,7 @@ shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/FirebaseCrashlytics/run\"\n"; }; - E23EEB875E8C363D642AB893 /* [CP] Check Pods Manifest.lock */ = { + F6E30D44AE7060FBA9CA34DF /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -880,6 +886,7 @@ 2E9416C22BC61731003DEB44 /* AppleSearchResponse.swift in Sources */, 2E9416A12BC616B9003DEB44 /* RouteOptionsViewController.swift in Sources */, 2E9416BF2BC61731003DEB44 /* Direction.swift in Sources */, + FD44EC532CD86A5F009269A2 /* TransitNotificationSubscriber.swift in Sources */, 2EC1F5122BC66972001D9F66 /* ApolloClientProtocol.swift in Sources */, 2E9FFA902BC673240051793C /* UpliftAPI.graphql.swift in Sources */, 2E9416BD2BC61731003DEB44 /* LocationObject.swift in Sources */, @@ -909,6 +916,7 @@ 2E9416F12BC61984003DEB44 /* Shared.swift in Sources */, 2E9FFA892BC673240051793C /* Capacity.graphql.swift in Sources */, 2E9416BB2BC61731003DEB44 /* ServiceAlert.swift in Sources */, + FD44EC552CD86C55009269A2 /* PushNotificationService.swift in Sources */, FDE68D222C97EF6200024A69 /* ApiEndpoint.swift in Sources */, 2EC1F5162BC66CBA001D9F66 /* Publishers.swift in Sources */, 2E94169E2BC616B9003DEB44 /* SearchResultsViewController.swift in Sources */, @@ -1018,7 +1026,7 @@ }; 449A7C9F1D80D0E80019300C /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 78CE6235AE56D8B3776331B2 /* Pods-TCAT.release.xcconfig */; + baseConfigurationReference = AB65818DE0F8825F8E2AFDA3 /* Pods-TCAT.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; BUNDLE_APP_DISPLAY_NAME = Navi; @@ -1115,7 +1123,7 @@ }; BFF7E5EF223BFDF0001C6032 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7C562FAA4261465E07ACE741 /* Pods-TCAT.debug.xcconfig */; + baseConfigurationReference = 7F774800664BACA8CD504187 /* Pods-TCAT.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; BUNDLE_APP_DISPLAY_NAME = "Navi Beta"; @@ -1143,7 +1151,7 @@ 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; + PRODUCT_BUNDLE_IDENTIFIER = com.cornellappdev.tcat; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; @@ -1214,7 +1222,7 @@ }; C27549D5233491FA00D5A754 /* Local */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7A621E1F21DF0FC2CACD61FE /* Pods-TCAT.local.xcconfig */; + baseConfigurationReference = DEED3C993CDEA110A0400D70 /* Pods-TCAT.local.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; BUNDLE_APP_DISPLAY_NAME = "Navi Local"; diff --git a/TCAT/Base/AppDelegate.swift b/TCAT/Base/AppDelegate.swift index fe33e1a7..2da37c60 100755 --- a/TCAT/Base/AppDelegate.swift +++ b/TCAT/Base/AppDelegate.swift @@ -19,7 +19,7 @@ import FirebaseMessaging let userDefaults = UserDefaults.standard @UIApplicationMain -class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate, UNUserNotificationCenterDelegate { +class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? private let encoder = JSONEncoder() @@ -35,7 +35,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate, UNUser // Set Up Google Services FirebaseApp.configure() - + GMSServices.provideAPIKey(TransitEnvironment.googleMaps) // Update shortcut items @@ -71,40 +71,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate, UNUser self.window = UIWindow(frame: UIScreen.main.bounds) self.window?.rootViewController = navigationController self.window?.makeKeyAndVisible() - - //Set up notifications - UNUserNotificationCenter.current().delegate = self - - let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound] - UNUserNotificationCenter.current().requestAuthorization( - options: authOptions, - completionHandler: { _, _ in } - ) - application.registerForRemoteNotifications() - Messaging.messaging().delegate = self - + + // Initialize and setup notifications + _ = PushNotificationService.shared + return true } func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { handleShortcut(item: shortcutItem) } - - func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { - Messaging.messaging().apnsToken = deviceToken - Messaging.messaging().token { token, error in - if let error = error { - print("Error fetching FCM registration token: \(error)") - } else if let token = token { - print("FCM registration token: \(token)") - - } - } - - } - func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { - print("application didFailToRegisterForRemoteNotificationsWithError") - } // MARK: - Helper Functions @@ -181,25 +157,3 @@ extension UIWindow { } } - -extension AppDelegate { - - func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { - print("Firebase registration token: \(String(describing: fcmToken))") - - let dataDict: [String: String] = ["token": fcmToken ?? ""] - NotificationCenter.default.post( - name: Notification.Name("FCMToken"), - object: nil, - userInfo: dataDict - ) - // TODO: If necessary send token to application server. - // Note: This callback is fired at each app startup and whenever a new token is generated. - } - - //UNUserNotificationCenterDelegate - func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { - print("APNs received with: \(userInfo)") - } - -} diff --git a/TCAT/Cells/NotificationToggleTableViewCell.swift b/TCAT/Cells/NotificationToggleTableViewCell.swift index 78642c88..9e4d8dd2 100644 --- a/TCAT/Cells/NotificationToggleTableViewCell.swift +++ b/TCAT/Cells/NotificationToggleTableViewCell.swift @@ -23,6 +23,9 @@ class NotificationToggleTableViewCell: UITableViewCell { private let notificationSwitch = UISwitch() private let notificationTitleLabel = UILabel() + private var startTime: Int = 0 + private var tripId: String = "" + private var stopId: String? private let hairlineHeight = 0.5 override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) { @@ -77,23 +80,85 @@ class NotificationToggleTableViewCell: UITableViewCell { } } - func configure(for type: NotificationType, isFirst: Bool, delegate: NotificationToggleTableViewDelegate? = nil) { + func configure( + for type: NotificationType, + isFirst: Bool, + delegate: NotificationToggleTableViewDelegate? = nil, + startTime: Int, + tripId: String, + stopId: String? + ) { + self.startTime = startTime + self.tripId = tripId + self.stopId = stopId self.delegate = delegate self.type = type notificationTitleLabel.text = type.title + notificationSwitch.setOn(isToggleOn(for: type, tripId: tripId), animated: false) if isFirst { setupFirstHairline() } } + + func setSwitchOn(_ isOn: Bool) { + notificationSwitch.setOn(isOn, animated: false) + } + + // Build a stable key for persistence + private func key(for type: NotificationType, tripId: String) -> String { + let typeKey: String + switch type { + case .delay: typeKey = "delay" + case .beforeBoarding: typeKey = "beforeBoarding" + // add any other cases here + } + return "toggle-\(typeKey)-\(tripId)" + } + + func isToggleOn(for type: NotificationType, tripId: String) -> Bool { + let k = key(for: type, tripId: tripId) + return UserDefaults.standard.bool(forKey: k) + } + + func setToggle(_ on: Bool, for type: NotificationType, tripId: String) { + let k = key(for: type, tripId: tripId) + UserDefaults.standard.set(on, forKey: k) + } + @objc func switchValueChanged() { - if notificationSwitch.isOn { + + let isOn = notificationSwitch.isOn + + setToggle(isOn, for: type, tripId: tripId) + + + + if isOn { switch type { case .beforeBoarding: - delegate?.displayNotificationBanner(type: .beforeBoardingConfirmation) - + let now = Int(Date().timeIntervalSince1970) + if startTime - now > 600 { + delegate?.displayNotificationBanner(type: .beforeBoardingConfirmation) + TransitNotificationSubscriber.shared.subscribeToDepartureNotifications(startTime: String(startTime)) + } else { + notificationSwitch.setOn(false, animated: true) + setToggle(false, for: type, tripId: tripId) + delegate?.displayNotificationBanner(type: .unableToConfirmBeforeBoarding) + } case .delay: delegate?.displayNotificationBanner(type: .delayConfirmation) + TransitNotificationSubscriber.shared.subscribeToDelayNotifications(stopID: stopId, tripID: tripId) + + default: break + } + } else { + switch type { + case .beforeBoarding: + TransitNotificationSubscriber.shared.unsubscribeFromDepartureNotifications(startTime: String(startTime)) + + case .delay: + TransitNotificationSubscriber.shared.unsubscribeFromDelayNotifications(stopID: stopId, tripID: tripId) default: break } diff --git a/TCAT/Controllers/RouteDetail+ContentViewController.swift b/TCAT/Controllers/RouteDetail+ContentViewController.swift index 5a4064f7..ec6450e3 100755 --- a/TCAT/Controllers/RouteDetail+ContentViewController.swift +++ b/TCAT/Controllers/RouteDetail+ContentViewController.swift @@ -34,6 +34,7 @@ class RouteDetailContentViewController: UIViewController { var liveTrackingNetworkTimer: Timer? private var locationManager = CLLocationManager() var mapView: GMSMapView! + private let mapPadding: CGFloat = 80 private let markerRadius: CGFloat = 8 private var paths: [Path] = [] @@ -205,7 +206,9 @@ class RouteDetailContentViewController: UIViewController { guard let self = self else { return } if case .failure(let error) = completion { + self.printClass(context: "\(#function) error", message: error.errorDescription) + if let banner = self.banner, !banner.isDisplaying { self.showBanner(Constants.Banner.cannotConnectLive, status: .danger) } diff --git a/TCAT/Controllers/RouteDetail+DrawerViewController.swift b/TCAT/Controllers/RouteDetail+DrawerViewController.swift index 12bcddd1..9b33caad 100644 --- a/TCAT/Controllers/RouteDetail+DrawerViewController.swift +++ b/TCAT/Controllers/RouteDetail+DrawerViewController.swift @@ -59,7 +59,7 @@ class RouteDetailDrawerViewController: UIViewController { /// Number of seconds to wait before auto-refreshing bus delay network call. private var busDelayNetworkRefreshRate: Double = 10 private let chevronFlipDurationTime = 0.25 - private let route: Route + internal let route: Route // MARK: - Initalization init(route: Route) { @@ -151,14 +151,33 @@ class RouteDetailDrawerViewController: UIViewController { RouteDetailItem.notificationType(.beforeBoarding) ] - _ = Section(type: .notification, items: notificationTypes) + let notificationSection = Section(type: .notification, items: notificationTypes) let routeDetailSection = Section(type: .routeDetail, items: directionsAndVisibleStops) sections = [routeDetailSection] - // TODO: Uncomment when notifications are implemented on backend - // if !route.isRawWalkingRoute() { - // sections.append(notificationSection) - // } + if !route.isRawWalkingRoute() { + sections.append(notificationSection) + } + } + + private func key(for type: NotificationType, tripId: String) -> String { + let typeKey: String + switch type { + case .delay: typeKey = "delay" + case .beforeBoarding: typeKey = "beforeBoarding" + } + return "toggle-\(typeKey)-\(tripId)" + } + + // Or persist with UserDefaults: + func isToggleOn(for type: NotificationType, tripId: String) -> Bool { + let k = key(for: type, tripId: tripId) + return UserDefaults.standard.bool(forKey: k) + } + + func setToggle(_ on: Bool, for type: NotificationType, tripId: String) { + let k = key(for: type, tripId: tripId) + UserDefaults.standard.set(on, forKey: k) } private func setupConstraints() { @@ -265,3 +284,4 @@ class RouteDetailDrawerViewController: UIViewController { } } + diff --git a/TCAT/Controllers/RouteDetailDrawerViewController+Extensions.swift b/TCAT/Controllers/RouteDetailDrawerViewController+Extensions.swift index 9747b33e..8853e2e7 100644 --- a/TCAT/Controllers/RouteDetailDrawerViewController+Extensions.swift +++ b/TCAT/Controllers/RouteDetailDrawerViewController+Extensions.swift @@ -206,11 +206,36 @@ extension RouteDetailDrawerViewController: UITableViewDataSource { guard let cell = tableView.dequeueReusableCell( withIdentifier: Constants.Cells.notificationToggleCellIdentifier ) as? NotificationToggleTableViewCell else { return UITableViewCell() } + + guard let delayDirection = route.getFirstDepartRawDirection() else { + return UITableViewCell() + } + + // Ensure tripId is non-optional + guard let tripId = delayDirection.tripIdentifiers?.first else { + return UITableViewCell() + } + + // Convert startTime to the desired string format + let startTime = Int(route.departureTime.timeIntervalSince1970) + + let stopId = delayDirection.stops.first?.id + + let isOn = isToggleOn(for: type, tripId: tripId) + cell.configure( for: type, - isFirst: indexPath.row == 0, - delegate: self + isFirst: false, + delegate: self, + startTime: startTime, + tripId: tripId, + stopId: stopId ) + + // Make sure the visual switch matches your persisted state + // If you haven’t added an `isOn` parameter to configure, set it directly: + // (Alternatively, add an `isOn` parameter to `configure` and set inside that method.) + cell.setSwitchOn(isOn) return cell } } diff --git a/TCAT/Core/Network/Base/ApiEndpoint.swift b/TCAT/Core/Network/Base/ApiEndpoint.swift new file mode 100644 index 00000000..19323ea7 --- /dev/null +++ b/TCAT/Core/Network/Base/ApiEndpoint.swift @@ -0,0 +1,107 @@ +// +// 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(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/Core/Network/Base/ApiErrorHandler.swift b/TCAT/Core/Network/Base/ApiErrorHandler.swift new file mode 100644 index 00000000..12edbc1b --- /dev/null +++ b/TCAT/Core/Network/Base/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/Core/Network/Base/NetworkManager.swift b/TCAT/Core/Network/Base/NetworkManager.swift new file mode 100644 index 00000000..01af7232 --- /dev/null +++ b/TCAT/Core/Network/Base/NetworkManager.swift @@ -0,0 +1,143 @@ +// +// 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`. + /// - responseType: The type of response format expected (.standard or .simple) + /// - Returns: A publisher that emits the decoded object of type `T` or an `ApiErrorHandler` on failure. + func request( + _ request: URLRequest, + decodingType: T.Type, + responseType: ResponseFormat + ) -> AnyPublisher< + T, + ApiErrorHandler + > +} + +enum ResponseFormat { + case standard // Format with success and data + case simple // Format with only success +} + +class NetworkManager: NetworkService { + + private let session: URLSession + + init(session: URLSession = .shared) { + self.session = session + } + + func request( + _ request: URLRequest, + decodingType: T.Type, + responseType: ResponseFormat = .standard + ) -> AnyPublisher< + T, + ApiErrorHandler + > { + print(request.url?.absoluteString ?? "No URL") + return session.dataTaskPublisher(for: request) + .tryMap { result in + try self.handleResponse(result) + } + .flatMap { data in + self.decodeResponse(data: data, decodingType: decodingType, responseType: responseType) + } + .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) + } + } + } + + // Decodes the response based on response format + private func decodeResponse( + data: Data, + decodingType: T.Type, + responseType: ResponseFormat + ) -> AnyPublisher< + T, + Error + > { + let decoder = JSONDecoder() + switch responseType { + case .standard: + return Just(data) + .decode(type: APIResponse.self, decoder: decoder) + .tryMap { response in + try self.validateAPIResponse(response) + } + .eraseToAnyPublisher() + case .simple: + return Just(data) + .decode(type: SimpleAPIResponse.self, decoder: decoder) + .tryMap { response in + let success = try self.validateSimpleResponse(response) + guard let result = success as? T else { + throw ApiErrorHandler.requestFailed + } + return result + } + .eraseToAnyPublisher() + } + } + + // Validate standard API response + private func validateAPIResponse(_ response: APIResponse) throws -> T { + guard response.success else { + // TODO: Update when backend sends more error codes + throw ApiErrorHandler.customApiError(ApiError(code: "500", message: "Internal server error")) + } + + return response.data + } + + // Validate simple API response + private func validateSimpleResponse(_ response: SimpleAPIResponse) throws -> Bool { + guard response.success else { + // TODO: Update when backend sends more error codes + throw ApiErrorHandler.customApiError(ApiError(code: "500", message: "Internal server error")) + } + + return response.success + } + + // Map Combine errors to custom APIErrorHandler types + private func mapToAPIError(_ error: Error) -> ApiErrorHandler { + if let apiError = error as? ApiErrorHandler { + return apiError + } + + return ApiErrorHandler.normalError(error) + } +} diff --git a/TCAT/Core/Network/Base/NetworkMonitor.swift b/TCAT/Core/Network/Base/NetworkMonitor.swift new file mode 100644 index 00000000..0309d0fc --- /dev/null +++ b/TCAT/Core/Network/Base/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/Core/Network/TransitAPI/Models/RequestModels.swift b/TCAT/Core/Network/TransitAPI/Models/RequestModels.swift new file mode 100644 index 00000000..cc2a5f95 --- /dev/null +++ b/TCAT/Core/Network/TransitAPI/Models/RequestModels.swift @@ -0,0 +1,101 @@ +// +// Network+Models.swift +// TCAT +// +// Created by Austin Astorga on 4/6/17. +// Copyright © 2017 cuappdev. All rights reserved. +// + +import CoreLocation +import Foundation +import SwiftyJSON + +// MARK: - Request Bodies +internal struct ApplePlacesBody: Codable { + let query: String + let places: [Place] +} + +internal struct GetRoutesBody: Codable { + let arriveBy: Bool + let end: String + let start: String + let time: Double + let destinationName: String + let originName: String + let uid: String? +} + +internal struct MultiRoutesBody: Codable { + let start: String + let time: Double + let end: [String] + let destinationNames: [String] +} + +internal struct PlaceIDCoordinatesBody: Codable { + let placeID: String +} + +internal struct SearchResultsBody: Codable { + let query: String +} + +internal struct RouteSelectedBody: Codable { + let routeId: String + let uid: String? +} + +internal struct GetBusLocationsBody: Codable { + var data: [BusLocationsInfo] +} + +internal struct BusLocationsInfo: Codable { + let stopID: String + let routeID: String + let tripIdentifiers: [String] +} + +internal struct GetDelayBody: Codable { + + let stopID: String + let tripID: String + + func toQueryItems() -> [URLQueryItem] { + return [URLQueryItem(name: "stopID", value: stopID), URLQueryItem(name: "tripID", value: tripID)] + } + +} + +internal struct Trip: Codable { + let stopID: String + let tripID: String +} + +internal struct TripBody: Codable { + var data: [Trip] +} + +internal struct DelayNotificationBody: Codable { + let deviceToken: String + let stopID: String? + let tripID: String + let uid: String +} + + +internal struct DepartureNotificationBody: Codable { + let deviceToken: String + let startTime: String + let uid: String +} + + +struct APIResponse: Decodable { + var success: Bool + var data: T +} + +struct SimpleAPIResponse: Decodable { + var success: Bool +} diff --git a/TCAT/Core/Network/TransitAPI/Models/ResponseModels.swift b/TCAT/Core/Network/TransitAPI/Models/ResponseModels.swift new file mode 100644 index 00000000..8e2f5454 --- /dev/null +++ b/TCAT/Core/Network/TransitAPI/Models/ResponseModels.swift @@ -0,0 +1,20 @@ +// +// ResponseModels.swift +// TCAT +// +// Created by Jayson Hahn on 2/17/25. +// Copyright © 2025 Cornell AppDev. All rights reserved. +// + +import Foundation + +internal struct Delay: Codable { + let tripID: String + let delay: Int? +} + +class RouteSectionsObject: Codable { + var fromStop: [Route] + var boardingSoon: [Route] + var walking: [Route] +} diff --git a/TCAT/Core/Network/TransitAPI/TransitProvider.swift b/TCAT/Core/Network/TransitAPI/TransitProvider.swift new file mode 100644 index 00000000..ae77bb18 --- /dev/null +++ b/TCAT/Core/Network/TransitAPI/TransitProvider.swift @@ -0,0 +1,182 @@ +// +// 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 cancelDelayNotification(DelayNotificationBody) + case cancelDepartureNotification(DepartureNotificationBody) + case delay(GetDelayBody) + case delayNotification(DelayNotificationBody) + case departueNotification(DepartureNotificationBody) + 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 + // TODO: Remove once the Notifications moves to prod + switch self { + case .delayNotification, .departueNotification, .cancelDelayNotification, .cancelDepartureNotification: + return TransitEnvironment.devTransitURL + + default: + return TransitEnvironment.transitURL + } + } + + /// API path for the transit endpoints. + var apiPath: String { + return "api" + } + + /// API version for the transit endpoints. + var apiVersion: String { + switch self { + case .delayNotification, .departueNotification, .cancelDelayNotification, .cancelDepartureNotification, .allStops: + return "v1" + + default: + return "v3" + } + } + + /// Separator path for the transit endpoints. + var separatorPath: String? { + switch self { + default: + return nil + } + } + + /// 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 .cancelDelayNotification: + return Constants.Endpoints.cancelDelayNotification + + case .cancelDepartureNotification: + return Constants.Endpoints.cancelDepartureNotification + + case .delay: + return Constants.Endpoints.delay + + case .departueNotification: + return Constants.Endpoints.departureNotification + + case .delayNotification: + return Constants.Endpoints.delayNotification + + case .routes: + return Constants.Endpoints.getRoutes + } + } + + /// 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 .delayNotification(let delayNotificationBody), .cancelDelayNotification(let delayNotificationBody): + return try? JSONEncoder().encode(delayNotificationBody) + + case .departueNotification( + let departureNotificationBody + ), .cancelDepartureNotification( + let departureNotificationBody + ): + return try? JSONEncoder().encode(departureNotificationBody) + + case .routes(let getRoutesBody): + return try? JSONEncoder().encode(getRoutesBody) + + default: + return nil + } + } + +} diff --git a/TCAT/Core/Network/TransitAPI/TransitService.swift b/TCAT/Core/Network/TransitAPI/TransitService.swift new file mode 100644 index 00000000..96845609 --- /dev/null +++ b/TCAT/Core/Network/TransitAPI/TransitService.swift @@ -0,0 +1,261 @@ +// +// 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 + + /// Subscribes to delay notification for a specific trip's arrival + /// The notification is sent when there is a change in the delay. + /// - Parameters: + /// - deviceToken: The FCM token for the device + /// - stopID: The stop ID to monitor + /// - tripID: The trip ID to monitor + /// - uid: The unique identifier for the user + /// - Returns: A publisher that emits Bool on success, or an ApiErrorHandler on failure + func subscribeToDelayNotifications( + deviceToken: String, + stopID: String?, + tripID: String, + uid: String + ) -> AnyPublisher + + /// Subscribes to departure notifications for a specific trip + /// - Parameters: + /// - deviceToken: The FCM token for the device + /// - startTime: The timestamp to start monitoring from + /// - uid: The unique identifier for the user + /// - Returns: A publisher that emits Bool on success, or an ApiErrorHandler on failure + func subscribeToDepartureNotifications( + deviceToken: String, + startTime: String, + uid: String + ) -> AnyPublisher + + func unsubscribeFromDelayNotifications(deviceToken: String, stopID: String?, tripID: String, uid: String) -> AnyPublisher + + func unsubscribeFromDepartureNotifications(deviceToken: String, startTime: String, uid: String) -> AnyPublisher + + /// Updates the local cache of Apple places based on the search text and provided locations. + /// - Parameters: + /// - searchText: The query text used for retrieving places. + /// - 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 subscribeToDelayNotifications( + deviceToken: String, + stopID: String?, + tripID: String, + uid: String + ) -> AnyPublisher { + let body = DelayNotificationBody(deviceToken: deviceToken, stopID: stopID, tripID: tripID, uid: uid) + let request = TransitProvider.delayNotification(body).makeRequest + return networkManager.request(request, decodingType: Bool.self, responseType: .simple) + } + + func subscribeToDepartureNotifications( + deviceToken: String, + startTime: String, + uid: String + ) -> AnyPublisher { + print("startTime: \(startTime)") + let body = DepartureNotificationBody(deviceToken: deviceToken, startTime: startTime, uid: uid) + let request = TransitProvider.departueNotification(body).makeRequest + return networkManager.request(request, decodingType: Bool.self, responseType: .simple) + } + + func unsubscribeFromDelayNotifications( + deviceToken: String, + stopID: String?, + tripID: String, + uid: String + ) -> AnyPublisher { + let body = DelayNotificationBody(deviceToken: deviceToken, stopID: stopID, tripID: tripID, uid: uid) + let request = TransitProvider.cancelDelayNotification(body).makeRequest + return networkManager.request(request, decodingType: Bool.self, responseType: .simple) + } + + func unsubscribeFromDepartureNotifications( + deviceToken: String, + startTime: String, + uid: String + ) -> AnyPublisher { + let body = DepartureNotificationBody(deviceToken: deviceToken, startTime: startTime, uid: uid) + let request = TransitProvider.cancelDepartureNotification(body).makeRequest + return networkManager.request(request, decodingType: Bool.self, responseType: .simple) + } + + func updateApplePlacesCache(searchText: String, places: [Place]) -> AnyPublisher { + let body = ApplePlacesBody(query: searchText, places: places) + let request = TransitProvider.applePlaces(body).makeRequest + return networkManager.request(request, decodingType: Bool.self) + } +} diff --git a/TCAT/Managers/PushNotificationService.swift b/TCAT/Managers/PushNotificationService.swift new file mode 100644 index 00000000..d7556784 --- /dev/null +++ b/TCAT/Managers/PushNotificationService.swift @@ -0,0 +1,173 @@ +// +// PushNotificationService.swift +// TCAT +// +// Created by Jayson Hahn on 11/3/24. +// Copyright © 2024 Cornell AppDev. All rights reserved. +// + +import FirebaseMessaging +import UserNotifications +import UIKit + +class PushNotificationService: NSObject, MessagingDelegate, UNUserNotificationCenterDelegate { + + static let shared = PushNotificationService() + + override init() { + super.init() + setupNotifications() + } + + /** + Sets up notifications by configuring the necessary delegates and requesting authorization for notifications. + */ + private func setupNotifications() { + // Set the current UNUserNotificationCenter delegate to self + UNUserNotificationCenter.current().delegate = self + + // Set the Messaging delegate to self + Messaging.messaging().delegate = self + + // Request authorization for notifications with alert, badge, and sound options + let authOptions: UNAuthorizationOptions = [.alert, .badge, .sound] + UNUserNotificationCenter.current().requestAuthorization( + options: authOptions, + completionHandler: { _, _ in } + ) + + // Register the application for remote notifications + UIApplication.shared.registerForRemoteNotifications() + } + + /// Retrieves the device's FCM (Firebase Cloud Messaging) registration token. + /// + /// - Parameter completion: A closure that is called with the FCM registration token as a `String?`. + /// If there is an error fetching the token, the closure is called with `nil`. + /// + /// This function uses Firebase Messaging to asynchronously fetch the device's FCM registration token. + /// If the token is successfully retrieved, it is passed to the completion handler. If an error occurs, + /// the error is printed to the console and the completion handler is called with `nil`. + func getDeviceToken(completion: @escaping (String?) -> Void) { + Messaging.messaging().token { token, error in + if let error = error { + print("Error fetching FCM registration token: \(error)") + completion(nil) + } else if let token = token { + print("FCM registration token: \(token)") + completion(token) + } + } + } + + // MARK: - MessagingDelegate + + /// Called when a new Firebase Cloud Messaging (FCM) registration token is received. + /// - Parameters: + /// - messaging: The messaging instance that received the token. + /// - fcmToken: The new FCM registration token, or `nil` if the token could not be retrieved. + /// + /// This method prints the new FCM registration token and posts a notification with the token + /// using `NotificationCenter`. The notification name is "FCMToken" and the token is included + /// in the `userInfo` dictionary with the key "token". + func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { + print("Firebase registration token: \(String(describing: fcmToken))") + + let dataDict: [String: String] = ["token": fcmToken ?? ""] + NotificationCenter.default.post( + name: Notification.Name("FCMToken"), + object: nil, + userInfo: dataDict + ) + } + + // MARK: - UNUserNotificationCenterDelegate + + /// Handles the presentation of a notification when the app is in the foreground. + /// - Parameters: + /// - center: The notification center that received the notification. + /// - notification: The notification that is about to be presented. + /// - completionHandler: The block to execute with the presentation options for the notification. + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void + ) { + // Show notification when app is in foreground + print("Foreground notification received: \(notification.request.content.userInfo)") + completionHandler([[.banner, .sound]]) + } + + /** + Handles the event when a user taps on a notification. + + - Parameters: + - center: The notification center that received the notification. + - response: The user's response to the notification. + - completionHandler: The block to execute when you have finished processing the user's response. + */ + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + // Handle notification tap + let userInfo = response.notification.request.content.userInfo + print("Notification tapped with info: \(userInfo)") + completionHandler() + } + + // MARK: - UIApplicationDelegate + + /// Handles the registration of the device for remote notifications and retrieves the FCM registration token. + /// + /// - Parameters: + /// - application: The singleton app object. + /// - deviceToken: A token that identifies the device to APNs. + /// + /// This method is called when the app successfully registers with Apple Push Notification service (APNs). + /// It sets the APNs token for Firebase Cloud Messaging (FCM) and attempts to retrieve the FCM registration token. + /// If an error occurs while fetching the FCM registration token, it prints the error. + /// Otherwise, it prints the FCM registration token. + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + Messaging.messaging().apnsToken = deviceToken + Messaging.messaging().token { token, error in + if let error = error { + print("Error fetching FCM registration token: \(error)") + } else if let token = token { + print("FCM registration token: \(token)") + } + } + } + + /// Called when the app fails to register for remote notifications. + /// - Parameters: + /// - application: The singleton app object. + /// - error: An error object that encapsulates information why registration did not succeed. + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + print("application didFailToRegisterForRemoteNotificationsWithError: \(error)") + } + + /** + Handles the receipt of a remote notification. + + - Parameters: + - application: The singleton app object. + - userInfo: A dictionary that contains information related to the remote notification. + - completionHandler: The block to execute when the download operation is complete. You must call this handler and pass in the appropriate `UIBackgroundFetchResult` value. + + This method is called when a remote notification is received. It logs the notification's userInfo and calls the completion handler with `.newData`. + */ + + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping ( + UIBackgroundFetchResult + ) -> Void + ) { + print("APNs received with: \(userInfo)") + completionHandler(.newData) + } + +} diff --git a/TCAT/Models/SearchManager.swift b/TCAT/Managers/SearchManager.swift similarity index 100% rename from TCAT/Models/SearchManager.swift rename to TCAT/Managers/SearchManager.swift diff --git a/TCAT/Managers/TransitNotificationSubscriber.swift b/TCAT/Managers/TransitNotificationSubscriber.swift new file mode 100644 index 00000000..4be82092 --- /dev/null +++ b/TCAT/Managers/TransitNotificationSubscriber.swift @@ -0,0 +1,114 @@ +// +// TransitNotificationSubscriber.swift +// TCAT +// +// Created by Jayson Hahn on 11/3/24. +// Copyright © 2024 Cornell AppDev. All rights reserved. +// + +import Combine + +class TransitNotificationSubscriber { + + static let shared = TransitNotificationSubscriber() + + private var cancellables = Set() + + func subscribeToDelayNotifications(stopID: String?, tripID: String) { + PushNotificationService.shared.getDeviceToken { [weak self] token in + guard let token = token, + let uid = userDefaults.string(forKey: Constants.UserDefaults.uid) else { return } + print("device token \(token)") + TransitService.shared.subscribeToDelayNotifications( + deviceToken: token, + stopID: stopID, + tripID: tripID, + uid: uid + ) + .sink( + receiveCompletion: { completion in + if case .failure(let error) = completion { + print("Failed to subscribe to departure notification: \(error)") + } + }, + receiveValue: { success in + print("Departure notification subscription success: \(success)") + } + ) + .store(in: &self!.cancellables) + } + } + + func subscribeToDepartureNotifications(startTime: String) { + PushNotificationService.shared.getDeviceToken { [weak self] token in + guard let token = token, + let uid = userDefaults.string(forKey: Constants.UserDefaults.uid) else { return } + + TransitService.shared.subscribeToDepartureNotifications( + deviceToken: token, + startTime: startTime, + uid: uid + ) + .sink( + receiveCompletion: { completion in + if case .failure(let error) = completion { + print("Failed to subscribe to delay notification: \(error)") + } + }, + receiveValue: { success in + print("Delay notification subscription success: \(success)") + } + ) + .store(in: &self!.cancellables) + } + } + + func unsubscribeFromDelayNotifications(stopID: String?, tripID: String) { + PushNotificationService.shared.getDeviceToken { [weak self] token in + guard let token = token, + let uid = userDefaults.string(forKey: Constants.UserDefaults.uid) else { return } + + TransitService.shared.unsubscribeFromDelayNotifications( + deviceToken: token, + stopID: stopID, + tripID: tripID, + uid: uid + ) + .sink( + receiveCompletion: { completion in + if case .failure(let error) = completion { + print("Failed to unsubscribe from Delay notification: \(error)") + } + }, + receiveValue: { success in + print("Delay notification has been unsubscribed: \(success)") + } + ) + .store(in: &self!.cancellables) + } + } + + func unsubscribeFromDepartureNotifications(startTime: String) { + PushNotificationService.shared.getDeviceToken { [weak self] token in + guard let token = token, + let uid = userDefaults.string(forKey: Constants.UserDefaults.uid) else { return } + + TransitService.shared.unsubscribeFromDepartureNotifications( + deviceToken: token, + startTime: startTime, + uid: uid + ) + .sink( + receiveCompletion: { completion in + if case .failure(let error) = completion { + print("Failed to unsubscribe to departure notification: \(error)") + } + }, + receiveValue: { success in + print("Departure notification has been unsubscribed: \(success)") + } + ) + .store(in: &self!.cancellables) + } + } +} diff --git a/TCAT/Models/Direction.swift b/TCAT/Models/Direction.swift index bcbf4c9f..f7ee506d 100755 --- a/TCAT/Models/Direction.swift +++ b/TCAT/Models/Direction.swift @@ -80,13 +80,13 @@ class Direction: NSObject, NSCopying, Codable { case endTime case name case path - case routeNumber + case routeNumber = "routeId" case startLocation case startTime case stayOnBusForTransfer case stops case travelDistance = "distance" - case tripIdentifiers + case tripIdentifiers = "tripIds" case type } diff --git a/TCAT/Models/LocationObject.swift b/TCAT/Models/LocationObject.swift index 453d3363..3e1d7e22 100644 --- a/TCAT/Models/LocationObject.swift +++ b/TCAT/Models/LocationObject.swift @@ -36,7 +36,7 @@ class LocationObject: NSObject, Codable { case latitude = "lat" case longitude = "long" case name - case id = "stopID" + case id = "stopId" } /// Blank init to store name diff --git a/TCAT/Models/Route.swift b/TCAT/Models/Route.swift index 39338c31..03a532ac 100755 --- a/TCAT/Models/Route.swift +++ b/TCAT/Models/Route.swift @@ -46,7 +46,6 @@ class Route: NSObject, Codable { /// A unique identifier for the route var routeId: String - /// The distance between the start and finish location, in miles var travelDistance: Double = 0.0 @@ -75,15 +74,15 @@ class Route: NSObject, Codable { case arrivalTime case departureTime case directions - case routeId } required init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) departureTime = Date.parseDate(try container.decode(String.self, forKey: .departureTime)) arrivalTime = Date.parseDate(try container.decode(String.self, forKey: .arrivalTime)) - routeId = try container.decode(String.self, forKey: .routeId) + directions = try container.decode([Direction].self, forKey: .directions) + routeId = (directions.first?.routeNumber).map { String($0) } ?? "0" rawDirections = try container.decode([Direction].self, forKey: .directions) startName = Constants.General.currentLocation endName = Constants.General.destination diff --git a/TCAT/Services/Network/ApiEndpoint.swift b/TCAT/Services/Network/ApiEndpoint.swift index ea7a0787..19323ea7 100644 --- a/TCAT/Services/Network/ApiEndpoint.swift +++ b/TCAT/Services/Network/ApiEndpoint.swift @@ -73,7 +73,6 @@ extension ApiEndpoint { longPath.append(separatorPath) } - longPath.append("/") longPath.append(path) urlComponents?.path = longPath @@ -103,7 +102,6 @@ extension ApiEndpoint { if let customDataBody = customDataBody { request.httpBody = customDataBody } - return request } } diff --git a/TCAT/Services/Network/NetworkManager.swift b/TCAT/Services/Network/NetworkManager.swift index ea394821..01af7232 100644 --- a/TCAT/Services/Network/NetworkManager.swift +++ b/TCAT/Services/Network/NetworkManager.swift @@ -15,8 +15,21 @@ protocol NetworkService { /// - Parameters: /// - request: The `URLRequest` to be sent. /// - decodingType: The type to decode the response into. Must conform to `Decodable`. + /// - responseType: The type of response format expected (.standard or .simple) /// - Returns: A publisher that emits the decoded object of type `T` or an `ApiErrorHandler` on failure. - func request(_ request: URLRequest, decodingType: T.Type) -> AnyPublisher + func request( + _ request: URLRequest, + decodingType: T.Type, + responseType: ResponseFormat + ) -> AnyPublisher< + T, + ApiErrorHandler + > +} + +enum ResponseFormat { + case standard // Format with success and data + case simple // Format with only success } class NetworkManager: NetworkService { @@ -27,14 +40,21 @@ class NetworkManager: NetworkService { self.session = session } - func request(_ request: URLRequest, decodingType: T.Type) -> AnyPublisher { + func request( + _ request: URLRequest, + decodingType: T.Type, + responseType: ResponseFormat = .standard + ) -> AnyPublisher< + T, + ApiErrorHandler + > { + print(request.url?.absoluteString ?? "No URL") return session.dataTaskPublisher(for: request) .tryMap { result in try self.handleResponse(result) } - .decode(type: APIResponse.self, decoder: JSONDecoder()) - .tryMap { response in - try self.validateAPIResponse(response) + .flatMap { data in + self.decodeResponse(data: data, decodingType: decodingType, responseType: responseType) } .mapError { error in self.mapToAPIError(error) @@ -60,7 +80,39 @@ class NetworkManager: NetworkService { } } - // Validate API response and handle future error cases + // Decodes the response based on response format + private func decodeResponse( + data: Data, + decodingType: T.Type, + responseType: ResponseFormat + ) -> AnyPublisher< + T, + Error + > { + let decoder = JSONDecoder() + switch responseType { + case .standard: + return Just(data) + .decode(type: APIResponse.self, decoder: decoder) + .tryMap { response in + try self.validateAPIResponse(response) + } + .eraseToAnyPublisher() + case .simple: + return Just(data) + .decode(type: SimpleAPIResponse.self, decoder: decoder) + .tryMap { response in + let success = try self.validateSimpleResponse(response) + guard let result = success as? T else { + throw ApiErrorHandler.requestFailed + } + return result + } + .eraseToAnyPublisher() + } + } + + // Validate standard API response private func validateAPIResponse(_ response: APIResponse) throws -> T { guard response.success else { // TODO: Update when backend sends more error codes @@ -70,6 +122,16 @@ class NetworkManager: NetworkService { return response.data } + // Validate simple API response + private func validateSimpleResponse(_ response: SimpleAPIResponse) throws -> Bool { + guard response.success else { + // TODO: Update when backend sends more error codes + throw ApiErrorHandler.customApiError(ApiError(code: "500", message: "Internal server error")) + } + + return response.success + } + // Map Combine errors to custom APIErrorHandler types private func mapToAPIError(_ error: Error) -> ApiErrorHandler { if let apiError = error as? ApiErrorHandler { diff --git a/TCAT/Services/Network/RequestModels.swift b/TCAT/Services/Network/RequestModels.swift index 3c536643..a8f8e50b 100644 --- a/TCAT/Services/Network/RequestModels.swift +++ b/TCAT/Services/Network/RequestModels.swift @@ -56,12 +56,6 @@ internal struct BusLocationsInfo: Codable { let tripIdentifiers: [String] } -class RouteSectionsObject: Codable { - var fromStop: [Route] - var boardingSoon: [Route] - var walking: [Route] -} - internal struct GetDelayBody: Codable { let stopID: String @@ -82,12 +76,35 @@ internal struct TripBody: Codable { var data: [Trip] } +internal struct DelayNotificationBody: Codable { + let deviceToken: String + let stopID: String? + let tripID: String + let uid: String +} + +internal struct DepartureNotificationBody: Codable { + let deviceToken: String + let startTime: String + let uid: String +} + internal struct Delay: Codable { let tripID: String let delay: Int? } +class RouteSectionsObject: Codable { + var fromStop: [Route] + var boardingSoon: [Route] + var walking: [Route] +} + struct APIResponse: Decodable { var success: Bool var data: T } + +struct SimpleAPIResponse: Decodable { + var success: Bool +} diff --git a/TCAT/Services/Transit/TransitProvider.swift b/TCAT/Services/Transit/TransitProvider.swift index 9032de29..ae77bb18 100644 --- a/TCAT/Services/Transit/TransitProvider.swift +++ b/TCAT/Services/Transit/TransitProvider.swift @@ -16,7 +16,11 @@ enum TransitProvider { case applePlaces(ApplePlacesBody) case appleSearch(SearchResultsBody) case busLocations(GetBusLocationsBody) + case cancelDelayNotification(DelayNotificationBody) + case cancelDepartureNotification(DepartureNotificationBody) case delay(GetDelayBody) + case delayNotification(DelayNotificationBody) + case departueNotification(DepartureNotificationBody) case routes(GetRoutesBody) } @@ -25,7 +29,15 @@ extension TransitProvider: ApiEndpoint { /// Base URL string for the transit API. var baseURLString: String { - return TransitEnvironment.transitURL +// return TransitEnvironment.transitURL + // TODO: Remove once the Notifications moves to prod + switch self { + case .delayNotification, .departueNotification, .cancelDelayNotification, .cancelDepartureNotification: + return TransitEnvironment.devTransitURL + + default: + return TransitEnvironment.transitURL + } } /// API path for the transit endpoints. @@ -36,12 +48,9 @@ extension TransitProvider: ApiEndpoint { /// API version for the transit endpoints. var apiVersion: String { switch self { - case .alerts, .allStops: + case .delayNotification, .departueNotification, .cancelDelayNotification, .cancelDepartureNotification, .allStops: return "v1" - case .appleSearch, .routes: - return "v2" - default: return "v3" } @@ -51,7 +60,7 @@ extension TransitProvider: ApiEndpoint { var separatorPath: String? { switch self { default: - return "" + return nil } } @@ -76,9 +85,21 @@ extension TransitProvider: ApiEndpoint { case .busLocations: return Constants.Endpoints.busLocations + case .cancelDelayNotification: + return Constants.Endpoints.cancelDelayNotification + + case .cancelDepartureNotification: + return Constants.Endpoints.cancelDepartureNotification + case .delay: return Constants.Endpoints.delay + case .departueNotification: + return Constants.Endpoints.departureNotification + + case .delayNotification: + return Constants.Endpoints.delayNotification + case .routes: return Constants.Endpoints.getRoutes } @@ -140,6 +161,16 @@ extension TransitProvider: ApiEndpoint { case .delay(let getDelayBody): return try? JSONEncoder().encode(getDelayBody) + case .delayNotification(let delayNotificationBody), .cancelDelayNotification(let delayNotificationBody): + return try? JSONEncoder().encode(delayNotificationBody) + + case .departueNotification( + let departureNotificationBody + ), .cancelDepartureNotification( + let departureNotificationBody + ): + return try? JSONEncoder().encode(departureNotificationBody) + case .routes(let getRoutesBody): return try? JSONEncoder().encode(getRoutesBody) diff --git a/TCAT/Services/Transit/TransitService.swift b/TCAT/Services/Transit/TransitService.swift index 17f55ab2..96845609 100644 --- a/TCAT/Services/Transit/TransitService.swift +++ b/TCAT/Services/Transit/TransitService.swift @@ -56,6 +56,37 @@ protocol TransitServiceProtocol: AnyObject { /// - Returns: A publisher emitting a `RouteSectionsObject` with route details or an `ApiErrorHandler` on error. func getRoutes(start: Place, end: Place, time: Date, type: SearchType) -> AnyPublisher + /// Subscribes to delay notification for a specific trip's arrival + /// The notification is sent when there is a change in the delay. + /// - Parameters: + /// - deviceToken: The FCM token for the device + /// - stopID: The stop ID to monitor + /// - tripID: The trip ID to monitor + /// - uid: The unique identifier for the user + /// - Returns: A publisher that emits Bool on success, or an ApiErrorHandler on failure + func subscribeToDelayNotifications( + deviceToken: String, + stopID: String?, + tripID: String, + uid: String + ) -> AnyPublisher + + /// Subscribes to departure notifications for a specific trip + /// - Parameters: + /// - deviceToken: The FCM token for the device + /// - startTime: The timestamp to start monitoring from + /// - uid: The unique identifier for the user + /// - Returns: A publisher that emits Bool on success, or an ApiErrorHandler on failure + func subscribeToDepartureNotifications( + deviceToken: String, + startTime: String, + uid: String + ) -> AnyPublisher + + func unsubscribeFromDelayNotifications(deviceToken: String, stopID: String?, tripID: String, uid: String) -> AnyPublisher + + func unsubscribeFromDepartureNotifications(deviceToken: String, startTime: String, uid: String) -> AnyPublisher + /// Updates the local cache of Apple places based on the search text and provided locations. /// - Parameters: /// - searchText: The query text used for retrieving places. @@ -179,6 +210,49 @@ class TransitService: TransitServiceProtocol { return networkManager.request(request, decodingType: RouteSectionsObject.self) } + func subscribeToDelayNotifications( + deviceToken: String, + stopID: String?, + tripID: String, + uid: String + ) -> AnyPublisher { + let body = DelayNotificationBody(deviceToken: deviceToken, stopID: stopID, tripID: tripID, uid: uid) + let request = TransitProvider.delayNotification(body).makeRequest + return networkManager.request(request, decodingType: Bool.self, responseType: .simple) + } + + func subscribeToDepartureNotifications( + deviceToken: String, + startTime: String, + uid: String + ) -> AnyPublisher { + print("startTime: \(startTime)") + let body = DepartureNotificationBody(deviceToken: deviceToken, startTime: startTime, uid: uid) + let request = TransitProvider.departueNotification(body).makeRequest + return networkManager.request(request, decodingType: Bool.self, responseType: .simple) + } + + func unsubscribeFromDelayNotifications( + deviceToken: String, + stopID: String?, + tripID: String, + uid: String + ) -> AnyPublisher { + let body = DelayNotificationBody(deviceToken: deviceToken, stopID: stopID, tripID: tripID, uid: uid) + let request = TransitProvider.cancelDelayNotification(body).makeRequest + return networkManager.request(request, decodingType: Bool.self, responseType: .simple) + } + + func unsubscribeFromDepartureNotifications( + deviceToken: String, + startTime: String, + uid: String + ) -> AnyPublisher { + let body = DepartureNotificationBody(deviceToken: deviceToken, startTime: startTime, uid: uid) + let request = TransitProvider.cancelDepartureNotification(body).makeRequest + return networkManager.request(request, decodingType: Bool.self, responseType: .simple) + } + func updateApplePlacesCache(searchText: String, places: [Place]) -> AnyPublisher { let body = ApplePlacesBody(query: searchText, places: places) let request = TransitProvider.applePlaces(body).makeRequest diff --git a/TCAT/Supporting/Constants.swift b/TCAT/Supporting/Constants.swift index ef2784d4..cfe3b0bf 100644 --- a/TCAT/Supporting/Constants.swift +++ b/TCAT/Supporting/Constants.swift @@ -166,8 +166,12 @@ struct Constants { static let applePlaces = "/applePlaces" static let appleSearch = "/appleSearch" static let busLocations = "/tracking" + static let cancelDelayNotification = "/cancelDelayNotification" + static let cancelDepartureNotification = "/cancelDepartureNotification" static let delay = "/delay" + static let delayNotification = "/delayNotification" static let delays = "/delays" + static let departureNotification = "/departureNotification" static let getRoutes = "/route" } @@ -243,6 +247,7 @@ struct Constants { static let delayNotification = "has been delayed to" static let notifyBeforeBoarding = "Notify me 10 min before boarding" static let notifyDelay = "Notify me about delays" + static let unableToConfirmBeforeBoarding = "The bus is arriving in less than 10 minutes, so notifications are unavailable." } struct SearchBar { diff --git a/TCAT/Supporting/TransitEnvironment.swift b/TCAT/Supporting/TransitEnvironment.swift index 468a3c19..e7f9edf2 100644 --- a/TCAT/Supporting/TransitEnvironment.swift +++ b/TCAT/Supporting/TransitEnvironment.swift @@ -28,6 +28,9 @@ enum TransitEnvironment { static let announcementsHost = "ANNOUNCEMENTS_HOST" static let announcementsPath = "ANNOUNCEMENTS_PATH" static let announcementsScheme = "ANNOUNCEMENTS_SCHEME" + + // TODO: Remove once the Notifications moves to prod + static let devTransitURL = "TRANSIT_DEV_URL" } /// A dictionary storing key-value pairs from Keys.plist. @@ -56,6 +59,14 @@ enum TransitEnvironment { return baseURLString }() + // TODO: Remove once Notifications moves to prod + static let devTransitURL: String = { + guard let baseURLString = TransitEnvironment.keysDict[Keys.devTransitURL] as? String else { + fatalError("TRANSIT_DEV_URL not found in Keys.plist") + } + return baseURLString + }() + /** The base URL of Uplift's backend server. diff --git a/TCAT/Views/NotificationBannerView.swift b/TCAT/Views/NotificationBannerView.swift index 86458eb3..e91aba72 100644 --- a/TCAT/Views/NotificationBannerView.swift +++ b/TCAT/Views/NotificationBannerView.swift @@ -26,14 +26,14 @@ enum NotificationType { enum NotificationBannerType { - case beforeBoardingConfirmation, busArriving, busDelay, delayConfirmation + case beforeBoardingConfirmation, busArriving, busDelay, delayConfirmation, unableToConfirmBeforeBoarding var bannerColor: UIColor { switch self { case .beforeBoardingConfirmation, .busArriving, .delayConfirmation: return Colors.tcatBlue - case .busDelay: + case .busDelay, .unableToConfirmBeforeBoarding: return Colors.lateRed } } @@ -87,6 +87,9 @@ class NotificationBannerView: UIView { case .delayConfirmation: beginningText = Constants.Notification.delayConfirmation + case .unableToConfirmBeforeBoarding: + beginningText = Constants.Notification.unableToConfirmBeforeBoarding + default: beginningText = "" }