diff --git a/Podfile b/Podfile index 7c2b6f10..6cf6b1e4 100644 --- a/Podfile +++ b/Podfile @@ -10,20 +10,20 @@ inhibit_all_warnings! target 'TCAT' do # Location - pod 'GoogleMaps' + pod 'GoogleMaps', '~> 8.4.0' # Networking + Data pod 'Apollo', '~> 1.9.3' pod 'SwiftyJSON', '~> 5.0' - pod 'FutureNova', :git => 'https://github.com/cuappdev/ios-networking.git' pod 'Wormholy', :configurations => ['Debug'] # Analytics pod 'Firebase' pod 'FirebaseCrashlytics' + 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' @@ -31,7 +31,6 @@ target 'TCAT' do pod 'Pulley', '~> 2.7' pod 'Presentation', :git=> 'https://github.com/cuappdev/Presentation.git' pod 'SnapKit', '~> 5.0' - pod 'WhatsNewKit', '~> 1.1' # Other pod 'SwiftLint' diff --git a/Podfile.lock b/Podfile.lock index f5a62843..1341ff2d 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -3,126 +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) - - 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) - - 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) + - FirebaseAnalytics (~> 12.4.0) + - Firebase/CoreOnly (12.4.0): + - FirebaseCore (~> 12.4.0) + - Firebase/Messaging (12.4.0): + - Firebase/CoreOnly + - 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) - - FutureNova (0.1.6) - - 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) @@ -132,28 +149,26 @@ PODS: - PromisesObjC (= 2.4.0) - Pulley (2.9.1) - SnapKit (5.0.1) - - SwiftLint (0.54.0) - - SwiftyJSON (5.0.1) - - WhatsNewKit (1.3.7) + - 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) - DZNEmptyDataSet (from `https://github.com/cuappdev/DZNEmptyDataSet.git`) - Firebase + - Firebase/Messaging - FirebaseCrashlytics - - FutureNova (from `https://github.com/cuappdev/ios-networking.git`) - - GoogleMaps + - GoogleMaps (~> 8.4.0) - NotificationBannerSwift (~> 3.0.0) - Presentation (from `https://github.com/cuappdev/Presentation.git`) - Pulley (~> 2.7) - SnapKit (~> 5.0) - SwiftLint - SwiftyJSON (~> 5.0) - - WhatsNewKit (~> 1.1) - Wormholy - - Zip (~> 1.1) + - Zip (~> 2.1.2) SPEC REPOS: trunk: @@ -165,8 +180,10 @@ SPEC REPOS: - FirebaseCoreInternal - FirebaseCrashlytics - FirebaseInstallations + - FirebaseMessaging - FirebaseRemoteConfigInterop - FirebaseSessions + - GoogleAdsOnDeviceConversion - GoogleAppMeasurement - GoogleDataTransport - GoogleMaps @@ -180,15 +197,12 @@ SPEC REPOS: - SnapKit - SwiftLint - SwiftyJSON - - WhatsNewKit - Wormholy - Zip EXTERNAL SOURCES: DZNEmptyDataSet: :git: https://github.com/cuappdev/DZNEmptyDataSet.git - FutureNova: - :git: https://github.com/cuappdev/ios-networking.git Presentation: :git: https://github.com/cuappdev/Presentation.git @@ -196,9 +210,6 @@ CHECKOUT OPTIONS: DZNEmptyDataSet: :commit: a4a007e7ade7d9711f067f4d6510085fa1d92629 :git: https://github.com/cuappdev/DZNEmptyDataSet.git - FutureNova: - :commit: db0540d78bd5bfb67f39945bbaf0fd3f2fbf56b5 - :git: https://github.com/cuappdev/ios-networking.git Presentation: :commit: b53eb453d2e1520e724cfac5e3e444e730ffe985 :git: https://github.com/cuappdev/Presentation.git @@ -206,34 +217,34 @@ CHECKOUT OPTIONS: SPEC CHECKSUMS: Apollo: b339a44b439f6b64208eb8761a0336813287a903 DZNEmptyDataSet: b94434220f87d9dda46660eb4f07a424778e93b4 - Firebase: 91fefd38712feb9186ea8996af6cbdef41473442 - FirebaseAnalytics: b5efc493eb0f40ec560b04a472e3e1a15d39ca13 - FirebaseCore: 11dc8a16dfb7c5e3c3f45ba0e191a33ac4f50894 - FirebaseCoreExtension: af5fd85e817ea9d19f9a2659a376cf9cf99f03c0 - FirebaseCoreInternal: bcb5acffd4ea05e12a783ecf835f2210ce3dc6af - FirebaseCrashlytics: af38ea4adfa606f6e63fcc22091b61e7938fcf66 - FirebaseInstallations: 8f581fca6478a50705d2bd2abd66d306e0f5736e - FirebaseRemoteConfigInterop: 6c349a466490aeace3ce9c091c86be1730711634 - FirebaseSessions: 2651b464e241c93fd44112f995d5ab663c970487 - FutureNova: 95f9aa352b2c250253b96fdf380754afcc87c7f3 - 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 - WhatsNewKit: c87028c4059dccd113495422801914cc53f6aab0 + SwiftLint: bf6da11a31c6644a0bbb27f4fa15fd9636db00b3 + SwiftyJSON: f5b1bf1cd8dd53cd25887ac0eabcfd92301c6a5a Wormholy: ab1c8c2f02f58587a0941deb0088555ffbf039a1 - Zip: 8877eede3dda76bcac281225c20e71c25270774c + Zip: b3fef584b147b6e582b2256a9815c897d60ddc67 -PODFILE CHECKSUM: a3b80dd04ea30998a17c032f2730e21ee8517238 +PODFILE CHECKSUM: fe3e20ea2d105a197821fb521e7cab43423411dd -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/README.md b/README.md index 2fb87ed7..ea0ac48b 100644 --- a/README.md +++ b/README.md @@ -36,31 +36,17 @@ fi ``` -- There should also be another run script labeled **UpliftAPI** If not, create a **New Run Script Phase** with the following script: - -```bash -CLI_PATH="./Pods/Apollo/apollo-ios-cli" -SECRETS_PATH="${SRCROOT}/TransitSecrets" - -if [ "${CONFIGURATION}" != "Release" ]; then - CONFIG_PATH="${SECRETS_PATH}/uplift-codegen-config-dev.json" -fi - -if [ "${CONFIGURATION}" = "Release" ]; then - CONFIG_PATH="${SECRETS_PATH}/uplift-codegen-config-prod.json" -fi +5. Select the `TCAT Debug` schema to use our development server and `TCAT Release` to use our production server. +6. Generate the Uplift API: -"${CLI_PATH}" generate -p "${CONFIG_PATH}" -f +- Dev: `./Pods/Apollo/apollo-ios-cli generate -p "TransitSecrets/uplift-codegen-config-dev.json" -f` +- Prod: `./Pods/Apollo/apollo-ios-cli generate -p "TransitSecrets/uplift-codegen-config-prod.json" -f` -``` - -5. Select the `TCAT Debug` schema to use our development server and `TCAT Release` to use our production server. -6. Generate the Uplift API with the following command: `./Pods/Apollo/apollo-ios-cli generate -p "TransitSecrets/uplift-codegen-config-dev.json" -f` 7. Build the project and you should be good to go. ## Common Issues -- If the build script for generating the API folder doesn't work, you can manually generate the API via `./Pods/Apollo/apollo-ios-cli generate -p "TransitSecrets/uplift-codegen-config-dev.json" -f` +- If the API is not working properly, try manually generating the API with the CLI. - If UpliftAPI is not detected or if your new written queries/mutations are not generated by Apollo, make sure that the generated UpliftAPI folder is linked to the TCAT target. You can do this by simply deleting the UpliftAPI group via the project navigator on Xcode and dragging the generated UpliftAPI folder from Finder to Xcode. diff --git a/TCAT.xcodeproj/project.pbxproj b/TCAT.xcodeproj/project.pbxproj index 3150cc59..2e920b4f 100644 --- a/TCAT.xcodeproj/project.pbxproj +++ b/TCAT.xcodeproj/project.pbxproj @@ -7,8 +7,8 @@ objects = { /* Begin PBXBuildFile section */ - 22948BFD221B75C5003FC43F /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22948BFB221B75C5003FC43F /* Models.swift */; }; - 28EA3E17A0C473892F5506EC /* Pods_TCAT.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 542B073726DFD1EE044EA97F /* Pods_TCAT.framework */; }; + 11DED336304A84735BDCFEC3 /* Pods_TCAT.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4C43A009E3DAAEEFC9A0BD5 /* Pods_TCAT.framework */; }; + 22948BFD221B75C5003FC43F /* RequestModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22948BFB221B75C5003FC43F /* RequestModels.swift */; }; 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 */; }; @@ -121,12 +121,17 @@ 2EC1F5142BC66A19001D9F66 /* ApolloNetwork.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC1F5132BC66A19001D9F66 /* ApolloNetwork.swift */; }; 2EC1F5162BC66CBA001D9F66 /* Publishers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2EC1F5152BC66CBA001D9F66 /* Publishers.swift */; }; 449A7C801D80D0E80019300C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 449A7C7F1D80D0E80019300C /* Assets.xcassets */; }; - BF250D7F222FB12400E7F271 /* Endpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF250D7E222FB12300E7F271 /* Endpoints.swift */; }; BF74AC1A1F945D7D00AFD4E4 /* GoogleMapsBase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF74AC191F945D7D00AFD4E4 /* GoogleMapsBase.framework */; }; BF74AC1D1F945D8E00AFD4E4 /* GoogleMapsCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF74AC1B1F945D8E00AFD4E4 /* GoogleMapsCore.framework */; }; BF74AC1E1F945D8E00AFD4E4 /* GoogleMaps.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BF74AC1C1F945D8E00AFD4E4 /* GoogleMaps.framework */; }; - D4756EA223986CB500FE7F0D /* ReachabilityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4756EA123986CB500FE7F0D /* ReachabilityManager.swift */; }; - DD3D9C211F94297100B164D4 /* Reachability.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3D9C201F94297100B164D4 /* Reachability.swift */; }; + 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 */; }; + FDE68D222C97EF6200024A69 /* ApiEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D212C97EF6200024A69 /* ApiEndpoint.swift */; }; + FDE68D262C97FC0D00024A69 /* TransitService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D252C97FC0D00024A69 /* TransitService.swift */; }; + FDE68D282C97FC4600024A69 /* TransitProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE68D272C97FC4600024A69 /* TransitProvider.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -143,7 +148,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 22948BFB221B75C5003FC43F /* Models.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + 22948BFB221B75C5003FC43F /* RequestModels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RequestModels.swift; sourceTree = ""; }; + 22948BFB221B75C5003FC43F /* RequestModels.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RequestModels.swift; sourceTree = ""; }; 2E70434D2BB75E10003AC1D6 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 2E94165F2BC60A59003DEB44 /* UpliftQueries.graphql */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = UpliftQueries.graphql; sourceTree = ""; }; 2E9416672BC615DF003DEB44 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -259,19 +265,33 @@ 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; }; - BF250D7E222FB12300E7F271 /* Endpoints.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Endpoints.swift; sourceTree = ""; }; + 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 = ""; }; - D4756EA123986CB500FE7F0D /* ReachabilityManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReachabilityManager.swift; sourceTree = ""; }; - DD3D9C201F94297100B164D4 /* Reachability.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Reachability.swift; sourceTree = ""; }; + 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 = ""; }; + FDE68D1F2C97EBBE00024A69 /* ApiErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiErrorHandler.swift; sourceTree = ""; }; + FDE68D212C97EF6200024A69 /* ApiEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiEndpoint.swift; sourceTree = ""; }; + FDE68D252C97FC0D00024A69 /* TransitService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitService.swift; sourceTree = ""; }; + FDE68D272C97FC4600024A69 /* TransitProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitProvider.swift; sourceTree = ""; }; + FDA3439E2CB6DF4D00608A1A /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; + FDE68D1D2C97E24900024A69 /* NetworkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkManager.swift; sourceTree = ""; }; + FDE68D1F2C97EBBE00024A69 /* ApiErrorHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiErrorHandler.swift; sourceTree = ""; }; + FDE68D212C97EF6200024A69 /* ApiEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApiEndpoint.swift; sourceTree = ""; }; + FDE68D252C97FC0D00024A69 /* TransitService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitService.swift; sourceTree = ""; }; + FDE68D272C97FC4600024A69 /* TransitProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransitProvider.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -279,10 +299,11 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + EEB26AE22C9F9B9A002E863F /* UserNotifications.framework in Frameworks */, BF74AC1D1F945D8E00AFD4E4 /* GoogleMapsCore.framework in Frameworks */, BF74AC1E1F945D8E00AFD4E4 /* GoogleMaps.framework in Frameworks */, BF74AC1A1F945D7D00AFD4E4 /* GoogleMapsBase.framework in Frameworks */, - 28EA3E17A0C473892F5506EC /* Pods_TCAT.framework in Frameworks */, + 11DED336304A84735BDCFEC3 /* Pods_TCAT.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -292,12 +313,14 @@ 12F774CEB5023E6938BDCF3A /* Frameworks */ = { isa = PBXGroup; children = ( + EEB26AE12C9F9B9A002E863F /* UserNotifications.framework */, + EEB26AE12C9F9B9A002E863F /* UserNotifications.framework */, BF74AC1C1F945D8E00AFD4E4 /* GoogleMaps.framework */, BF74AC1B1F945D8E00AFD4E4 /* GoogleMapsCore.framework */, BF74AC191F945D7D00AFD4E4 /* GoogleMapsBase.framework */, 7E14AEC02177E846006A344D /* IntentsUI.framework */, 7EEF189C21B39C6200343FFD /* NotificationCenter.framework */, - 542B073726DFD1EE044EA97F /* Pods_TCAT.framework */, + F4C43A009E3DAAEEFC9A0BD5 /* Pods_TCAT.framework */, ); name = Frameworks; sourceTree = ""; @@ -305,10 +328,16 @@ 2292486621B891790004279C /* Network */ = { isa = PBXGroup; children = ( - BF250D7E222FB12300E7F271 /* Endpoints.swift */, - 22948BFB221B75C5003FC43F /* Models.swift */, - D4756EA123986CB500FE7F0D /* ReachabilityManager.swift */, - DD3D9C201F94297100B164D4 /* Reachability.swift */, + FDE68D212C97EF6200024A69 /* ApiEndpoint.swift */, + FDE68D1F2C97EBBE00024A69 /* ApiErrorHandler.swift */, + FDE68D1D2C97E24900024A69 /* NetworkManager.swift */, + FDA3439E2CB6DF4D00608A1A /* NetworkMonitor.swift */, + 22948BFB221B75C5003FC43F /* RequestModels.swift */, + FDE68D212C97EF6200024A69 /* ApiEndpoint.swift */, + FDE68D1F2C97EBBE00024A69 /* ApiErrorHandler.swift */, + FDE68D1D2C97E24900024A69 /* NetworkManager.swift */, + FDA3439E2CB6DF4D00608A1A /* NetworkMonitor.swift */, + 22948BFB221B75C5003FC43F /* RequestModels.swift */, ); path = Network; sourceTree = ""; @@ -369,8 +398,9 @@ 2E9416832BC616B9003DEB44 /* RouteDetailViewController.swift */, 2E94168E2BC616B9003DEB44 /* RouteOptionsViewController.swift */, 2E9416892BC616B9003DEB44 /* RouteOptionsViewController+Extensions.swift */, - 2E94168B2BC616B9003DEB44 /* SearchResultsViewController.swift */, 2E94168F2BC616B9003DEB44 /* ServiceAlertsViewController.swift */, + 2E94168B2BC616B9003DEB44 /* SearchResultsViewController.swift */, + 2E94168B2BC616B9003DEB44 /* SearchResultsViewController.swift */, 2E9416882BC616B9003DEB44 /* StopPickerViewController.swift */, ); path = Controllers; @@ -387,7 +417,6 @@ 2E9416AD2BC61731003DEB44 /* Place.swift */, 2E9416AC2BC61731003DEB44 /* PlaceCoordinates.swift */, 2E9416B32BC61731003DEB44 /* Route.swift */, - 2E9416B62BC61731003DEB44 /* SearchManager.swift */, 2E9416B72BC61731003DEB44 /* Section.swift */, 2E9416AE2BC61731003DEB44 /* ServiceAlert.swift */, 2E9416B42BC61731003DEB44 /* WalkPath.swift */, @@ -575,14 +604,20 @@ 449A7C771D80D0E80019300C /* TCAT */ = { isa = PBXGroup; children = ( + EEB26AE32C9FA60E002E863F /* TCATDebug.entitlements */, + EEB26AE02C9F998C002E863F /* TCATLocal.entitlements */, + EEB26AE32C9FA60E002E863F /* TCATDebug.entitlements */, + EEB26AE02C9F998C002E863F /* TCATLocal.entitlements */, 449A7C7F1D80D0E80019300C /* Assets.xcassets */, 2E70434D2BB75E10003AC1D6 /* PrivacyInfo.xcprivacy */, 2E9416662BC615B0003DEB44 /* Base */, 2E94166C2BC61604003DEB44 /* Cells */, 2E9416822BC6168C003DEB44 /* Controllers */, 2E94165E2BC60A3B003DEB44 /* Ecosystem */, + FD44EC562CD8914D009269A2 /* Managers */, 2E9416AB2BC616DE003DEB44 /* Models */, - 2292486621B891790004279C /* Network */, + FDE68D292C988CDB00024A69 /* Services */, + FDE68D292C988CDB00024A69 /* Services */, 2E9416C72BC61763003DEB44 /* Supporting */, 2E9416E02BC618E6003DEB44 /* Utils */, 2E9416FD2BC61CAE003DEB44 /* Views */, @@ -593,13 +628,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 = ( @@ -608,6 +653,42 @@ path = ci_scripts; sourceTree = ""; }; + FDE68D292C988CDB00024A69 /* Services */ = { + isa = PBXGroup; + children = ( + 2292486621B891790004279C /* Network */, + FDE68D2A2C98933900024A69 /* Transit */, + ); + path = Services; + sourceTree = ""; + }; + FDE68D2A2C98933900024A69 /* Transit */ = { + isa = PBXGroup; + children = ( + FDE68D272C97FC4600024A69 /* TransitProvider.swift */, + FDE68D252C97FC0D00024A69 /* TransitService.swift */, + ); + path = Transit; + sourceTree = ""; + }; + FDE68D292C988CDB00024A69 /* Services */ = { + isa = PBXGroup; + children = ( + 2292486621B891790004279C /* Network */, + FDE68D2A2C98933900024A69 /* Transit */, + ); + path = Services; + sourceTree = ""; + }; + FDE68D2A2C98933900024A69 /* Transit */ = { + isa = PBXGroup; + children = ( + FDE68D272C97FC4600024A69 /* TransitProvider.swift */, + FDE68D252C97FC0D00024A69 /* TransitService.swift */, + ); + path = Transit; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -615,16 +696,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 */, - 2E9416612BC60AE7003DEB44 /* UpliftAPI */, - 882B9E91268F347446806E32 /* [CP] Embed Pods Frameworks */, - 0B4CA64206AF6DA1763F9ACB /* [CP] Copy Pods Resources */, + 10126D331FC535DE1C43147A /* [CP] Embed Pods Frameworks */, + AF0480DFC65AF5CB62CA0646 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -708,21 +788,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", + "${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", + "${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 */ = { @@ -743,40 +823,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"; }; - 2E9416612BC60AE7003DEB44 /* UpliftAPI */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = UpliftAPI; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "CLI_PATH=\"./Pods/Apollo/apollo-ios-cli\"\nSECRETS_PATH=\"${SRCROOT}/TransitSecrets\"\n\nif [ \"${CONFIGURATION}\" != \"Release\" ]; then\n CONFIG_PATH=\"${SECRETS_PATH}/uplift-codegen-config-dev.json\"\nfi\n\nif [ \"${CONFIGURATION}\" = \"Release\" ]; then\n CONFIG_PATH=\"${SECRETS_PATH}/uplift-codegen-config-prod.json\"\nfi\n\n\"${CLI_PATH}\" generate -p \"${CONFIG_PATH}\" -f\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", + "${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", + "${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 */ = { @@ -802,7 +863,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 = ( @@ -839,7 +900,11 @@ 2E9416C12BC61731003DEB44 /* WalkPath.swift in Sources */, 2E9416BC2BC61731003DEB44 /* Waypoint.swift in Sources */, 2E9417202BC61CF1003DEB44 /* WalkWithDistanceIcon.swift in Sources */, + FDE68D282C97FC4600024A69 /* TransitProvider.swift in Sources */, + FDE68D282C97FC4600024A69 /* TransitProvider.swift in Sources */, 2E94169B2BC616B9003DEB44 /* StopPickerViewController.swift in Sources */, + FDE68D1E2C97E24900024A69 /* NetworkManager.swift in Sources */, + FDE68D1E2C97E24900024A69 /* NetworkManager.swift in Sources */, 2E9417162BC61CF1003DEB44 /* SearchBarView.swift in Sources */, 2E9FFA882BC673240051793C /* Amenity.graphql.swift in Sources */, 2E9FFA852BC673240051793C /* AmenityType.graphql.swift in Sources */, @@ -859,14 +924,13 @@ 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 */, 2E9416802BC61679003DEB44 /* RouteTableViewCell.swift in Sources */, 2E94171B2BC61CF1003DEB44 /* LiveIndicator.swift in Sources */, 2E94167C2BC61679003DEB44 /* SmallDetailTableViewCell.swift in Sources */, - DD3D9C211F94297100B164D4 /* Reachability.swift in Sources */, - BF250D7F222FB12400E7F271 /* Endpoints.swift in Sources */, 2E9416A32BC616B9003DEB44 /* HomeMapViewController.swift in Sources */, 2E9FFA8A2BC673240051793C /* Facility.graphql.swift in Sources */, 2E9416B92BC61731003DEB44 /* PlaceCoordinates.swift in Sources */, @@ -875,20 +939,25 @@ 2E9417182BC61CF1003DEB44 /* RouteDiagramSegment.swift in Sources */, 2E9416C32BC61731003DEB44 /* SearchManager.swift in Sources */, 2E9417212BC61CF1003DEB44 /* NotificationBannerView.swift in Sources */, - D4756EA223986CB500FE7F0D /* ReachabilityManager.swift in Sources */, 2E9FFA832BC673240051793C /* OpenHoursFields.graphql.swift in Sources */, 2E9416972BC616B9003DEB44 /* RouteDetail+ContentViewController.swift in Sources */, + FDE68D262C97FC0D00024A69 /* TransitService.swift in Sources */, + FDE68D262C97FC0D00024A69 /* TransitService.swift in Sources */, 2E9416F62BC61984003DEB44 /* Time.swift in Sources */, 2E9FFA8D2BC673240051793C /* Query.graphql.swift in Sources */, 2E9416792BC61679003DEB44 /* AddFavoritesCollectionViewCell.swift in Sources */, 2E9FFA812BC673240051793C /* FacilityFields.graphql.swift in Sources */, 2E94171F2BC61CF1003DEB44 /* BusIcon.swift in Sources */, + FDA3439F2CB6DF5800608A1A /* NetworkMonitor.swift in Sources */, + FDA3439F2CB6DF5800608A1A /* NetworkMonitor.swift in Sources */, 2E9417242BC61CF1003DEB44 /* DetailIconView.swift in Sources */, 2E94171A2BC61CF1003DEB44 /* SummaryView.swift in Sources */, 2E9416992BC616B9003DEB44 /* RouteDetailContentViewController+Extensions.swift in Sources */, 2E9416F12BC61984003DEB44 /* Shared.swift in Sources */, 2E9FFA892BC673240051793C /* Capacity.graphql.swift in Sources */, 2E9416BB2BC61731003DEB44 /* ServiceAlert.swift in Sources */, + FD44EC552CD86C55009269A2 /* PushNotificationService.swift in Sources */, + FDE68D222C97EF6200024A69 /* ApiEndpoint.swift in Sources */, 2EC1F5162BC66CBA001D9F66 /* Publishers.swift in Sources */, 2E94169E2BC616B9003DEB44 /* SearchResultsViewController.swift in Sources */, 2E9FFA822BC673240051793C /* GymFields.graphql.swift in Sources */, @@ -920,7 +989,10 @@ 2E9FFA8B2BC673240051793C /* Gym.graphql.swift in Sources */, 2E9FFA8E2BC673240051793C /* SchemaConfiguration.swift in Sources */, 2E9416EF2BC61984003DEB44 /* EventPayload.swift in Sources */, - 22948BFD221B75C5003FC43F /* Models.swift in Sources */, + FDE68D202C97EBBE00024A69 /* ApiErrorHandler.swift in Sources */, + 22948BFD221B75C5003FC43F /* RequestModels.swift in Sources */, + FDE68D202C97EBBE00024A69 /* ApiErrorHandler.swift in Sources */, + 22948BFD221B75C5003FC43F /* RequestModels.swift in Sources */, 2E94167A2BC61679003DEB44 /* GeneralTableViewCell.swift in Sources */, 2E9417222BC61CF1003DEB44 /* BusLocationView.swift in Sources */, 2E9416C42BC61731003DEB44 /* Section.swift in Sources */, @@ -996,10 +1068,11 @@ }; 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 = "Ithaca Transit"; + BUNDLE_APP_DISPLAY_NAME = Navi; + BUNDLE_APP_DISPLAY_NAME = Navi; CODE_SIGN_ENTITLEMENTS = TCAT/Supporting/TCAT.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; @@ -1014,14 +1087,16 @@ "$(PROJECT_DIR)/Pods/GoogleMaps/Maps/Frameworks", ); INFOPLIST_FILE = TCAT/Supporting/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "Ithaca Transit"; + INFOPLIST_KEY_CFBundleDisplayName = Navi; + INFOPLIST_KEY_CFBundleDisplayName = Navi; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.travel"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.2; + MARKETING_VERSION = 2.0.4; + MARKETING_VERSION = 2.0.4; PRODUCT_BUNDLE_IDENTIFIER = com.cornellappdev.tcat; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -1093,11 +1168,13 @@ }; 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 = "Ithaca Transit Beta"; - CODE_SIGN_ENTITLEMENTS = TCAT/Supporting/TCAT.entitlements; + BUNDLE_APP_DISPLAY_NAME = "Navi Beta"; + CODE_SIGN_ENTITLEMENTS = TCAT/TCATDebug.entitlements; + BUNDLE_APP_DISPLAY_NAME = "Navi Beta"; + CODE_SIGN_ENTITLEMENTS = TCAT/TCATDebug.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; @@ -1111,17 +1188,19 @@ "$(PROJECT_DIR)/Pods/GoogleMaps/Maps/Frameworks", ); INFOPLIST_FILE = TCAT/Supporting/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "Ithaca Transit"; + INFOPLIST_KEY_CFBundleDisplayName = Navi; + INFOPLIST_KEY_CFBundleDisplayName = Navi; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.travel"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.2; + MARKETING_VERSION = 2.0.4; + 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; @@ -1192,11 +1271,13 @@ }; 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 = "Ithaca Transit Local"; - CODE_SIGN_ENTITLEMENTS = TCAT/Supporting/TCAT.entitlements; + BUNDLE_APP_DISPLAY_NAME = "Navi Local"; + CODE_SIGN_ENTITLEMENTS = TCAT/TCATLocal.entitlements; + BUNDLE_APP_DISPLAY_NAME = "Navi Local"; + CODE_SIGN_ENTITLEMENTS = TCAT/TCATLocal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; @@ -1210,14 +1291,16 @@ "$(PROJECT_DIR)/Pods/GoogleMaps/Maps/Frameworks", ); INFOPLIST_FILE = TCAT/Supporting/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "Ithaca Transit"; + INFOPLIST_KEY_CFBundleDisplayName = Navi; + INFOPLIST_KEY_CFBundleDisplayName = Navi; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.travel"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 2.0.2; + MARKETING_VERSION = 2.0.4; + MARKETING_VERSION = 2.0.4; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" -DLOCAL"; "OTHER_SWIFT_FLAGS[arch=*]" = "$(inherited) \"-D\" \"COCOAPODS\""; PRODUCT_BUNDLE_IDENTIFIER = com.cornellappdev.tcat.debug; diff --git a/TCAT/Assets.xcassets/AppIcon.appiconset/Contents.json b/TCAT/Assets.xcassets/AppIcon.appiconset/Contents.json index 40623651..ac068e14 100644 --- a/TCAT/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/TCAT/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,158 +1,14 @@ { "images" : [ { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "57x57", - "idiom" : "iphone", - "filename" : "Icon-App-57x57@1x.png", - "scale" : "1x" - }, - { - "size" : "57x57", - "idiom" : "iphone", - "filename" : "Icon-App-57x57@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "50x50", - "idiom" : "ipad", - "filename" : "Icon-Small-50x50@1x.png", - "scale" : "1x" - }, - { - "size" : "50x50", - "idiom" : "ipad", - "filename" : "Icon-Small-50x50@2x.png", - "scale" : "2x" - }, - { - "size" : "72x72", - "idiom" : "ipad", - "filename" : "Icon-App-72x72@1x.png", - "scale" : "1x" - }, - { - "size" : "72x72", - "idiom" : "ipad", - "filename" : "Icon-App-72x72@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "ItunesArtwork@2x.png", - "scale" : "1x" + "filename" : "Navi.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index 107e6c03..00000000 Binary files a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null differ diff --git a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index ceae8417..00000000 Binary files a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null differ diff --git a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index 96a9a044..00000000 Binary files a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index 2019bb25..00000000 Binary files a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null differ diff --git a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index f9144688..00000000 Binary files a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null differ diff --git a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index 6348bd63..00000000 Binary files a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index ceae8417..00000000 Binary files a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null differ diff --git a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index c50f49df..00000000 Binary files a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null differ diff --git a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index 56162768..00000000 Binary files a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png deleted file mode 100644 index 9a29ac8f..00000000 Binary files a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and /dev/null differ diff --git a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png deleted file mode 100644 index 4f901e4a..00000000 Binary files a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and /dev/null differ diff --git a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index 56162768..00000000 Binary files a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index a0382bd6..00000000 Binary files a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png deleted file mode 100644 index ce290a06..00000000 Binary files a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and /dev/null differ diff --git a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png deleted file mode 100644 index 28144b5b..00000000 Binary files a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and /dev/null differ diff --git a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index 5ec4843e..00000000 Binary files a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null differ diff --git a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png deleted file mode 100644 index ecaaec5e..00000000 Binary files a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and /dev/null differ diff --git a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index 46de484a..00000000 Binary files a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-Small-50x50@1x.png b/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-Small-50x50@1x.png deleted file mode 100644 index e4f9e183..00000000 Binary files a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-Small-50x50@1x.png and /dev/null differ diff --git a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-Small-50x50@2x.png b/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-Small-50x50@2x.png deleted file mode 100644 index 4158cc95..00000000 Binary files a/TCAT/Assets.xcassets/AppIcon.appiconset/Icon-Small-50x50@2x.png and /dev/null differ diff --git a/TCAT/Assets.xcassets/AppIcon.appiconset/ItunesArtwork@2x.png b/TCAT/Assets.xcassets/AppIcon.appiconset/ItunesArtwork@2x.png deleted file mode 100644 index 2cca64bf..00000000 Binary files a/TCAT/Assets.xcassets/AppIcon.appiconset/ItunesArtwork@2x.png and /dev/null differ diff --git a/TCAT/Assets.xcassets/AppIcon.appiconset/Navi.png b/TCAT/Assets.xcassets/AppIcon.appiconset/Navi.png new file mode 100644 index 00000000..149bb5ee Binary files /dev/null and b/TCAT/Assets.xcassets/AppIcon.appiconset/Navi.png differ diff --git a/TCAT/Assets.xcassets/Contents.json b/TCAT/Assets.xcassets/Contents.json index da4a164c..73c00596 100755 --- a/TCAT/Assets.xcassets/Contents.json +++ b/TCAT/Assets.xcassets/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/TCAT/Assets.xcassets/locationMarker.imageset/Contents.json b/TCAT/Assets.xcassets/locationMarker.imageset/Contents.json new file mode 100644 index 00000000..39ebecab --- /dev/null +++ b/TCAT/Assets.xcassets/locationMarker.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "locationMarker.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TCAT/Assets.xcassets/locationMarker.imageset/locationMarker.png b/TCAT/Assets.xcassets/locationMarker.imageset/locationMarker.png new file mode 100644 index 00000000..6f562c36 Binary files /dev/null and b/TCAT/Assets.xcassets/locationMarker.imageset/locationMarker.png differ diff --git a/TCAT/Base/AppDelegate.swift b/TCAT/Base/AppDelegate.swift index fc137710..a4a559b5 100755 --- a/TCAT/Base/AppDelegate.swift +++ b/TCAT/Base/AppDelegate.swift @@ -6,37 +6,36 @@ // Copyright © 2016 cuappdev. All rights reserved. // +import Combine import Firebase -import FutureNova import GoogleMaps import Intents import SafariServices import SwiftyJSON import UIKit +import FirebaseMessaging /// This is used for app-specific preferences let userDefaults = UserDefaults.standard @UIApplicationMain -class AppDelegate: UIResponder, UIApplicationDelegate { +class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate, UNUserNotificationCenterDelegate { var window: UIWindow? private let encoder = JSONEncoder() + private let transitService: TransitServiceProtocol = TransitService.shared + private let userDataInits: [(key: String, defaultValue: Any)] = [ (key: Constants.UserDefaults.onboardingShown, defaultValue: false), (key: Constants.UserDefaults.recentSearch, defaultValue: [Any]()), (key: Constants.UserDefaults.favorites, defaultValue: [Any]()) ] - private let networking: Networking = URLSession.shared.request func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - // Set up networking - Endpoint.setupEndpointConfig() - // Set Up Google Services FirebaseApp.configure() - + GMSServices.provideAPIKey(TransitEnvironment.googleMaps) // Update shortcut items @@ -45,19 +44,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Log basic information let payload = AppLaunchedPayload() TransitAnalytics.shared.log(payload) - setupUniqueIdentifier() - - for (key, defaultValue) in userDataInits { - if userDefaults.value(forKey: key) == nil { - if key == Constants.UserDefaults.favorites && sharedUserDefaults?.value(forKey: key) == nil { - sharedUserDefaults?.set(defaultValue, forKey: key) - } else { - userDefaults.set(defaultValue, forKey: key) - } - } else if key == Constants.UserDefaults.favorites && sharedUserDefaults?.value(forKey: key) == nil { - sharedUserDefaults?.set(userDefaults.value(forKey: key), forKey: key) - } - } + + // Initialize uid in UserDefaults values if needed + userDefaults.setupUniqueIdentifier() + + // Initialize UserDefaults values if needed + userDefaults.initialize(with: userDataInits) // Track number of app opens for Store Review prompt StoreReviewHelper.incrementAppOpenedCount() @@ -65,9 +57,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Debug - Always Show Onboarding // userDefaults.set(false, forKey: Constants.UserDefaults.onboardingShown) - getBusStops() - - // Initalize first view based on context + // Initialize first view based on context + // Initialize first view based on context let showOnboarding = !userDefaults.bool(forKey: Constants.UserDefaults.onboardingShown) let parentHomeViewController = ParentHomeMapViewController( contentViewController: HomeMapViewController(), @@ -75,22 +66,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ) let rootVC = showOnboarding ? OnboardingViewController(initialViewing: true) : parentHomeViewController let navigationController = showOnboarding ? OnboardingNavigationController(rootViewController: rootVC) : - CustomNavigationController(rootViewController: rootVC) - - // Setup networking for AppDevAnnouncements - // TODO: Set up announcements once it's done -// AnnouncementNetworking.setupConfig( -// scheme: TransitEnvironment.announcementsScheme, -// host: TransitEnvironment.announcementsHost, -// commonPath: TransitEnvironment.announcementsCommonPath, -// announcementPath: TransitEnvironment.announcementsPath -// ) + CustomNavigationController(rootViewController: rootVC) // Initalize window without storyboard self.window = UIWindow(frame: UIScreen.main.bounds) self.window?.rootViewController = navigationController self.window?.makeKeyAndVisible() + // Initialize and setup notifications + _ = PushNotificationService.shared + return true } @@ -100,20 +85,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // MARK: - Helper Functions - /// Creates and sets a unique identifier. If the device identifier changes, updates it. - func setupUniqueIdentifier() { - if let uid = UIDevice.current.identifierForVendor?.uuidString, - uid != sharedUserDefaults?.string(forKey: Constants.UserDefaults.uid) { - sharedUserDefaults?.set(uid, forKey: Constants.UserDefaults.uid) - } - } - - func handleShortcut(item: UIApplicationShortcutItem) { + private func handleShortcut(item: UIApplicationShortcutItem) { if let shortcutData = item.userInfo as? [String: Data] { guard let place = shortcutData["place"], - let destination = try? decoder.decode(Place.self, from: place) else { - print("[AppDelegate] Unable to access shortcutData['place']") - return + let destination = try? JSONDecoder().decode(Place.self, from: place) else { + print("[AppDelegate] Unable to access shortcutData['place']") + return } let optionsVC = RouteOptionsViewController(searchTo: destination) if let navController = window?.rootViewController as? CustomNavigationController { @@ -124,44 +101,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } } - private func getAllStops() -> Future> { - return networking(Endpoint.getAllStops()).decode() - } - - /// Get all bus stops and store in userDefaults - func getBusStops() { - getAllStops().observe { [weak self] result in - guard let self = self else { return } - DispatchQueue.main.async { - switch result { - case .value(let response): - if response.data.isEmpty { self.handleGetAllStopsError() } else { - let encodedObject = try? JSONEncoder().encode(response.data) - userDefaults.set(encodedObject, forKey: Constants.UserDefaults.allBusStops) - } - case .error(let error): - print("getBusStops error:", error.localizedDescription) - self.handleGetAllStopsError() - } - } - } - } - - /// Present an alert indicating bus stops weren't fetched. - func handleGetAllStopsError() { - let title = "Couldn't Fetch Bus Stops" - let message = "The app will continue trying on launch. You can continue to use the app as normal." - let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) - alertController.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil)) - UIApplication.shared.keyWindow?.presentInApp(alertController) - } - /// Open the app when opened via URL scheme func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { // URLs for testing - // BusStop: ithaca-transit://getRoutes?lat=42.442558&long=-76.485336&stopName=Collegetown - // PlaceResult: ithaca-transit://getRoutes?lat=42.44707979999999&long=-76.4885196&destinationName=Hans%20Bethe%20House + // BusStop: ithaca-transit://getRoutes?lat=42.442558&long=-76.485336&stopName=Collegetown&destinationType=busStop + // PlaceResult: ithaca-transit://getRoutes?lat=42.4440892&long=-76.4847823&destinationName=Hollister%Hall&destinationType=applePlace let rootVC = HomeMapViewController() let navigationController = CustomNavigationController(rootViewController: rootVC) @@ -170,23 +115,30 @@ class AppDelegate: UIResponder, UIApplicationDelegate { self.window?.makeKeyAndVisible() let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems + var placeType: PlaceType = .busStop - if url.absoluteString.contains("getRoutes") { // siri URL scheme + if url.absoluteString.contains("getRoutes") { var latitude: CLLocationDegrees? var longitude: CLLocationDegrees? - var stopName: String? + var destination: String? + + if let lat = items?.filter({ $0.name == "lat" }).first?.value, + let long = items?.filter({ $0.name == "long" }).first?.value, + let dest = items?.filter({ $0.name == "stopName" }).first?.value ?? + items?.filter({ $0.name == "destinationName" }).first?.value, + let destType = items?.filter({ $0.name == "destinationType" }).first?.value { - if - let lat = items?.filter({ $0.name == "lat" }).first?.value, - let long = items?.filter({ $0.name == "long" }).first?.value, - let stop = items?.filter({ $0.name == "stopName" }).first?.value { latitude = Double(lat) longitude = Double(long) - stopName = stop + destination = dest.split(separator: "%").joined(separator: " ") + if destType == "applePlace" { + placeType = .applePlace + } + } - if let latitude = latitude, let longitude = longitude, let stopName = stopName { - let place = Place(name: stopName, type: .busStop, latitude: latitude, longitude: longitude) + if let latitude, let longitude, let destination { + let place = Place(name: destination, type: placeType, latitude: latitude, longitude: longitude) let optionsVC = RouteOptionsViewController(searchTo: place) navigationController.pushViewController(optionsVC, animated: false) return true @@ -200,9 +152,31 @@ class AppDelegate: UIResponder, UIApplicationDelegate { extension UIWindow { - /// Find the visible view controller in the root navigation controller and present passed in view controlelr. + /// Find the visible view controller in the root navigation controller and present passed in view controller. func presentInApp(_ viewController: UIViewController) { (rootViewController as? UINavigationController)?.visibleViewController?.present(viewController, animated: true) } } + +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/GeneralTableViewCell.swift b/TCAT/Cells/GeneralTableViewCell.swift index 3d446c18..f714012e 100644 --- a/TCAT/Cells/GeneralTableViewCell.swift +++ b/TCAT/Cells/GeneralTableViewCell.swift @@ -49,9 +49,11 @@ class GeneralTableViewCell: UITableViewCell { case .seeAllStops: titleLabel.text = Constants.General.seeAllStops iconView.image = #imageLiteral(resourceName: "list") + case .currentLocation: titleLabel.text = Constants.General.currentLocation iconView.image = #imageLiteral(resourceName: "location") + default: break } } diff --git a/TCAT/Cells/NotificationToggleTableViewCell.swift b/TCAT/Cells/NotificationToggleTableViewCell.swift index 2f095972..31668180 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,22 +80,84 @@ 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/Cells/RouteTableViewCell.swift b/TCAT/Cells/RouteTableViewCell.swift index 78ed935d..23ac4a62 100755 --- a/TCAT/Cells/RouteTableViewCell.swift +++ b/TCAT/Cells/RouteTableViewCell.swift @@ -6,7 +6,6 @@ // Copyright © 2017 cuappdev. All rights reserved. // -import FutureNova import SwiftyJSON import UIKit @@ -25,7 +24,6 @@ class RouteTableViewCell: UITableViewCell { // MARK: - Data vars private let containerViewLayoutInsets = UIEdgeInsets(top: 16, left: 16, bottom: 16, right: 12) - private let networking: Networking = URLSession.shared.request // MARK: - Init diff --git a/TCAT/Controllers/CustomNavigationController.swift b/TCAT/Controllers/CustomNavigationController.swift index 0bbe3155..da000260 100644 --- a/TCAT/Controllers/CustomNavigationController.swift +++ b/TCAT/Controllers/CustomNavigationController.swift @@ -31,19 +31,10 @@ class CustomNavigationController: UINavigationController, UINavigationController super.init(rootViewController: rootViewController) view.backgroundColor = Colors.white customizeAppearance() + } - ReachabilityManager.shared.addListener(self) { [weak self] connection in - guard let self = self else { return } - switch connection { - case .wifi, .cellular: - self.banner.dismiss() - case .none: - self.banner.show(queuePosition: .front, on: self) - self.banner.autoDismiss = false - self.banner.isUserInteractionEnabled = false - } - self.setNeedsStatusBarAppearanceUpdate() - } + deinit { + NotificationCenter.default.removeObserver(self, name: .reachabilityChanged, object: nil) } override open var childForStatusBarStyle: UIViewController? { @@ -73,6 +64,15 @@ class CustomNavigationController: UINavigationController, UINavigationController let payload = ScreenshotTakenPayload(location: "\(type(of: currentViewController))") TransitAnalytics.shared.log(payload) } + + NotificationCenter.default.addObserver( + self, + selector: #selector( + handleReachabilityChange + ), + name: .reachabilityChanged, + object: nil + ) } override func viewWillDisappear(_ animated: Bool) { @@ -139,6 +139,17 @@ class CustomNavigationController: UINavigationController, UINavigationController _ = popViewController(animated: true) } + @objc func handleReachabilityChange() { + if NetworkMonitor.shared.isReachable { + self.banner.dismiss() + } else { + self.banner.show(queuePosition: .front, on: self) + self.banner.autoDismiss = false + self.banner.isUserInteractionEnabled = false + } + self.setNeedsStatusBarAppearanceUpdate() + } + // MARK: - UINavigationController Functions override func pushViewController(_ viewController: UIViewController, animated: Bool) { diff --git a/TCAT/Controllers/FavoritesTableViewController.swift b/TCAT/Controllers/FavoritesTableViewController.swift index 0ade6198..0ca88a05 100644 --- a/TCAT/Controllers/FavoritesTableViewController.swift +++ b/TCAT/Controllers/FavoritesTableViewController.swift @@ -8,15 +8,14 @@ import UIKit import DZNEmptyDataSet -import FutureNova +import Combine class FavoritesTableViewController: UIViewController { private var searchBar = UISearchBar() private var tableView: UITableView! - private var timer: Timer? - private let networking: Networking = URLSession.shared.request + private var currentSearchCancellable: AnyCancellable? private var resultsSection = Section.searchResults(items: []) { didSet { tableView.reloadData() @@ -158,35 +157,31 @@ extension FavoritesTableViewController: DZNEmptyDataSetSource { // MARK: - Search extension FavoritesTableViewController: UISearchBarDelegate { func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { - timer?.invalidate() - timer = Timer.scheduledTimer( - timeInterval: 0.2, - target: self, - selector: #selector(getPlaces), - userInfo: ["searchText": searchText], - repeats: false - ) + startSearch(for: searchText) } - /// Get Search Results - @objc func getPlaces(timer: Timer) { - if let userInfo = timer.userInfo as? [String: String], - let searchText = userInfo["searchText"], - !searchText.isEmpty { - SearchManager.shared.performLookup(for: searchText) { [weak self] (searchResults, error) in + private func startSearch(for searchText: String) { + currentSearchCancellable?.cancel() + + currentSearchCancellable = SearchManager.shared.search(for: searchText) + .receive(on: DispatchQueue.main) + .sink { [weak self] result in guard let self = self else { return } - DispatchQueue.main.async { - if let error = error { - self.printClass(context: "SearchManager lookup error", message: error.localizedDescription) - self.resultsSection = Section.recentSearches(items: []) - return - } - self.resultsSection = Section.searchResults(items: searchResults) + + switch result { + case .success(let searchResults): + self.updateSearchResults(with: searchResults) + + case .failure(let error): + print("[FavoritesTableViewController] Search failed: \(error.errorDescription)") } } - } else { - resultsSection = Section.searchResults(items: []) - } + } + + // Update UI with the new search results + private func updateSearchResults(with searchResults: [Place]) { + self.resultsSection = Section.searchResults(items: searchResults) + self.tableView.reloadData() } } diff --git a/TCAT/Controllers/HomeMapViewController.swift b/TCAT/Controllers/HomeMapViewController.swift index 23e24a17..946a0576 100644 --- a/TCAT/Controllers/HomeMapViewController.swift +++ b/TCAT/Controllers/HomeMapViewController.swift @@ -7,7 +7,6 @@ // import CoreLocation -import FutureNova import GoogleMaps import SnapKit import UIKit @@ -78,8 +77,10 @@ class HomeMapViewController: UIViewController { mapView.settings.indoorPicker = false mapView.isBuildingsEnabled = false mapView.isIndoorEnabled = false - mapView.padding = .init(top: 0, left: 0, bottom: 10, right: 10) - + let bottomPaddingPercentage: CGFloat = 0.10 + let bottomPadding = UIScreen.main.bounds.height * bottomPaddingPercentage + mapView.padding = UIEdgeInsets(top: 0, left: 0, bottom: bottomPadding, right: 0) + let northEast = CLLocationCoordinate2D( latitude: Constants.Values.RouteMaxima.north, longitude: Constants.Values.RouteMaxima.east diff --git a/TCAT/Controllers/HomeOptionsCardViewController+Extensions.swift b/TCAT/Controllers/HomeOptionsCardViewController+Extensions.swift index 8b2248d0..37ad26fb 100644 --- a/TCAT/Controllers/HomeOptionsCardViewController+Extensions.swift +++ b/TCAT/Controllers/HomeOptionsCardViewController+Extensions.swift @@ -60,14 +60,13 @@ extension HomeOptionsCardViewController: UISearchBarDelegate { func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { searchBar.returnKeyType = searchText.isEmpty ? .default : .search searchBar.setShowsCancelButton(true, animated: true) - timer?.invalidate() - timer = Timer.scheduledTimer( - timeInterval: 0.2, - target: self, - selector: #selector(getPlaces), - userInfo: ["searchText": searchText], - repeats: false - ) + + guard !searchText.isEmpty else { + updateSections() + return + } + + startSearch(for: searchText) } func searchBarSearchButtonClicked(_ searchBar: UISearchBar) { @@ -122,10 +121,17 @@ extension HomeOptionsCardViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { switch sections[section] { - case .seeAllStops: return 1 - case .recentSearches: return recentLocations.count - case .searchResults: return sections[section].getItems().count - default: return 0 + case .seeAllStops: + return 1 + + case .recentSearches: + return recentLocations.count + + case .searchResults: + return sections[section].getItems().count + + default: + return 0 } } @@ -139,6 +145,7 @@ extension HomeOptionsCardViewController: UITableViewDataSource { ) as? GeneralTableViewCell else { return UITableViewCell() } cell.configure(for: .seeAllStops) return cell + default: // Recent searches, etc. guard let cell = tableView.dequeueReusableCell( withIdentifier: Constants.Cells.placeIdentifier @@ -176,8 +183,10 @@ extension HomeOptionsCardViewController: UITableViewDelegate { switch sections[section] { case .recentSearches: return headerHeight + case .seeAllStops: return HeaderView.separatorViewHeight + default: return 0 } @@ -192,10 +201,13 @@ extension HomeOptionsCardViewController: UITableViewDelegate { separatorVisible: true, delegate: self ) + case .seeAllStops: return HeaderView(separatorVisible: true) + case .searchResults: return nil + default: return nil } @@ -209,6 +221,7 @@ extension HomeOptionsCardViewController: UITableViewDelegate { switch section { case .recentSearches: return .delete + default: return .none } @@ -225,6 +238,7 @@ extension HomeOptionsCardViewController: UITableViewDelegate { let place = sections[indexPath.section].getItems()[indexPath.row] recentLocations = Global.shared.deleteRecent(recent: place, allRecents: recentLocations) updateSections() + default: break } } @@ -239,6 +253,7 @@ extension HomeOptionsCardViewController: UITableViewDelegate { self.navigationController?.pushViewController(optionsVC, animated: true) } navigationController?.pushViewController(stopPickerVC, animated: true) + default: if let searchText = searchBar.text { let payload = SearchResultSelectedPayload( diff --git a/TCAT/Controllers/HomeOptionsCardViewController.swift b/TCAT/Controllers/HomeOptionsCardViewController.swift index 0823cd3e..292b112c 100644 --- a/TCAT/Controllers/HomeOptionsCardViewController.swift +++ b/TCAT/Controllers/HomeOptionsCardViewController.swift @@ -6,8 +6,8 @@ // Copyright © 2019 cuappdev. All rights reserved. // +import Combine import CoreLocation -import FutureNova import GoogleMaps import SnapKit import UIKit @@ -28,12 +28,11 @@ class HomeOptionsCardViewController: UIViewController { var searchBar: UISearchBar! var tableView: UITableView! - private let networking: Networking = URLSession.shared.request private var searchResultsSection: Section! var currentLocation: CLLocation? { return delegate?.getCurrentLocation() } - var timer: Timer? var isNetworkDown = false + private var currentSearchCancellable: AnyCancellable? private let infoButtonAnimationDuration = 0.1 private var keyboardHeight: CGFloat = 0 private let maxFavoritesCount = 2 @@ -118,7 +117,7 @@ class HomeOptionsCardViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - addReachabilityListener() + NotificationCenter.default.addObserver(self, selector: #selector(handleReachabilityChange), name: .reachabilityChanged, object: nil) setupTableView() setupInfoButton() @@ -128,21 +127,16 @@ class HomeOptionsCardViewController: UIViewController { updatePlaces() } - private func addReachabilityListener() { - ReachabilityManager.shared.addListener(self) { [weak self] connection in - guard let self = self else { return } - - switch connection { - case .none: - self.isNetworkDown = true - self.searchBar.isUserInteractionEnabled = false - self.sections = [] - case .cellular, .wifi: - self.isNetworkDown = false - self.updateSections() - self.searchBar.isUserInteractionEnabled = true - } + @objc func handleReachabilityChange() { + if NetworkMonitor.shared.isReachable { + self.updateSections() + } else { + self.sections = [] } + + self.isNetworkDown = !NetworkMonitor.shared.isReachable + self.searchBar.isUserInteractionEnabled = NetworkMonitor.shared.isReachable + self.setNeedsStatusBarAppearanceUpdate() } private func setupTableView() { @@ -245,8 +239,10 @@ class HomeOptionsCardViewController: UIViewController { switch section { case .recentSearches: return headerHeight + tableViewRowHeight * CGFloat(section.getItems().count) + result + case .seeAllStops: return HeaderView.separatorViewHeight + tableViewRowHeight + result + default: return tableViewRowHeight * CGFloat(section.getItems().count) + result } @@ -300,27 +296,25 @@ class HomeOptionsCardViewController: UIViewController { } // MARK: - Get Search Results - /// Get Search Results - @objc func getPlaces(timer: Timer) { - if let userInfo = timer.userInfo as? [String: String], - let searchText = userInfo["searchText"], - !searchText.isEmpty { - SearchManager.shared.performLookup(for: searchText) { [weak self] (searchResults, error) in + internal func startSearch(for searchText: String) { + currentSearchCancellable?.cancel() + + currentSearchCancellable = SearchManager.shared.search(for: searchText) + .receive(on: DispatchQueue.main) + .sink { [weak self] result in guard let self = self else { return } - if let error = error { - self.printClass(context: "SearchManager lookup error", message: error.localizedDescription) - return - } - DispatchQueue.main.async { + + switch result { + case .success(let searchResults): self.searchResultsSection = Section.searchResults(items: searchResults) self.tableView.contentOffset = .zero self.sections = [self.searchResultsSection] + + case .failure(let error): + print("Search error: \(error.errorDescription)") } } - } else { - updateSections() - } } // MARK: - Keyboard diff --git a/TCAT/Controllers/InformationViewController.swift b/TCAT/Controllers/InformationViewController.swift index 47d0701a..f47a4e3d 100644 --- a/TCAT/Controllers/InformationViewController.swift +++ b/TCAT/Controllers/InformationViewController.swift @@ -112,7 +112,7 @@ class InformationViewController: UIViewController { } @objc private func showMoreApps() { - let appStorePage = "https://itunes.apple.com/us/developer/walker-white/id1089672961" + let appStorePage = "https://apps.apple.com/us/developer/walker-white/id1089672961" open(appStorePage, inApp: false) } diff --git a/TCAT/Controllers/ParentHomeViewController.swift b/TCAT/Controllers/ParentHomeViewController.swift index ce1f486c..83f02fed 100644 --- a/TCAT/Controllers/ParentHomeViewController.swift +++ b/TCAT/Controllers/ParentHomeViewController.swift @@ -11,18 +11,6 @@ import UIKit class ParentHomeMapViewController: PulleyViewController { - override func viewDidLoad() { - super.viewDidLoad() - - // Present announcement if there are any new ones to present - // TODO: Set up announcements once it's done -// presentAnnouncement { presented in -// if presented { -// TransitAnalytics.shared.log(AnnouncementPresentedPayload()) -// } -// } - } - required init(contentViewController: UIViewController, drawerViewController: UIViewController) { super.init(contentViewController: contentViewController, drawerViewController: drawerViewController) } diff --git a/TCAT/Controllers/RouteDetail+ContentViewController.swift b/TCAT/Controllers/RouteDetail+ContentViewController.swift index 4bf4f602..3c66524f 100755 --- a/TCAT/Controllers/RouteDetail+ContentViewController.swift +++ b/TCAT/Controllers/RouteDetail+ContentViewController.swift @@ -6,8 +6,8 @@ // Copyright © 2017 cuappdev. All rights reserved. // +import Combine import CoreLocation -import FutureNova import GoogleMaps import MapKit import NotificationBannerSwift @@ -22,39 +22,57 @@ class RouteDetailContentViewController: UIViewController { /// Keep track of statuses of bus routes throughout view life cycle var noDataRouteList: [Int] = [] + /// General Variables var bounds = GMSCoordinateBounds() var busIndicators = [GMSMarker]() var buses = [GMSMarker]() + private var cancellables = Set() var currentLocation: CLLocationCoordinate2D? var directions: [Direction] = [] - /// Number of seconds to wait before auto-refreshing live tracking network call call, timed with live indicator + var endDestination: Place var liveTrackingNetworkRefreshRate: Double = LiveIndicator.interval * 1.0 var liveTrackingNetworkTimer: Timer? private var locationManager = CLLocationManager() var mapView: GMSMapView! - private let networking: Networking = URLSession.shared.request + + private let mapPadding: CGFloat = 80 + private let markerRadius: CGFloat = 8 private var paths: [Path] = [] private var route: Route! private var routeOptionsCell: RouteTableViewCell? + /// Banner and Notifications private var banner: StatusBarNotificationBanner? { didSet { setNeedsStatusBarAppearanceUpdate() } } - private let mapPadding: CGFloat = 80 - private let markerRadius: CGFloat = 8 + /// Final Destination Variables + private var finalDestinationCircles: [GMSCircle] = [] + private var finalDestinationMarkers: [GMSMarker] = [] + private var finalRouteSegment: [GMSCircle] = [] + private let finalWalkSegment = GMSMutablePath() + + /// First Route Segment Variables + private var firstRouteSegment: [GMSCircle] = [] + private let firstWalkSegment = GMSMutablePath() + /// Initalize RouteDetailViewController. Be sure to send a valid route, otherwise /// dummy data will be used. The directions parameter have logical assumptions, /// such as ArriveDirection always comes after DepartDirection. - init(route: Route, currentLocation: CLLocationCoordinate2D?, routeOptionsCell: RouteTableViewCell?) { - super.init(nibName: nil, bundle: nil) + init(route: Route, endDestination: Place, currentLocation: CLLocationCoordinate2D?, routeOptionsCell: RouteTableViewCell?) { self.routeOptionsCell = routeOptionsCell + self.endDestination = endDestination + super.init(nibName: nil, bundle: nil) initializeRoute(route, currentLocation) } + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + override func viewDidLoad() { super.viewDidLoad() @@ -162,21 +180,14 @@ class RouteDetailContentViewController: UIViewController { // MARK: - Network Calls - private func busLocations(_ directions: [Direction]) -> Future> { - return networking(Endpoint.getBusLocations(directions)).decode() - } - /// Fetch live-tracking information for the first direction's bus route. - /// Handles connection issues with banners. Animated indicators. + /// Handles connection issues with banners. Animated indicators. @objc func getBusLocations() { - // swiftlint:disable:next reduce_boolean - let directionsAreValid = route.directions.reduce(true) { result, direction in - if direction.type == .depart { - return result && direction.routeNumber > 0 && direction.tripIdentifiers != nil - } else { - return true - } + // Check if directions are valid for live tracking + let directionsAreValid = route.directions.allSatisfy { direction in + direction.type != .depart || (direction.routeNumber > 0 && direction.tripIdentifiers != nil) } + if !directionsAreValid { printClass(context: "\(#function)", message: "Directions are not valid") let payload = NetworkErrorPayload( @@ -188,18 +199,14 @@ class RouteDetailContentViewController: UIViewController { return } - busLocations(route.directions).observe { [weak self] result in - guard let self = self else { return } - DispatchQueue.main.async { - switch result { - case .value(let response): - if response.data.isEmpty { - // Reset banner in case transitioned from Error to Online - No Bus Locations - self.hideBanner() - } - self.parseBusLocationsData(data: response.data) - case .error(let error): - self.printClass(context: "\(#function) error", message: error.localizedDescription) + // Fetch bus locations using the TransitService + TransitService.shared.getBusLocations(route.directions) + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + guard let self = self else { return } + + if case .failure(let error) = completion { + self.printClass(context: "\(#function) error", message: error.errorDescription) if let banner = self.banner, !banner.isDisplaying { self.showBanner(Constants.Banner.cannotConnectLive, status: .danger) } @@ -210,9 +217,18 @@ class RouteDetailContentViewController: UIViewController { ) TransitAnalytics.shared.log(payload) } + } receiveValue: { [weak self] busLocations in + guard let self = self else { return } + + if busLocations.isEmpty { + // Reset banner in case of transition from Error to Online - No Bus Locations + self.hideBanner() + } + + self.parseBusLocationsData(data: busLocations) } - } - // Bounce any visible indicators + .store(in: &cancellables) + bounceIndicators() } @@ -223,6 +239,7 @@ class RouteDetailContentViewController: UIViewController { if !self.noDataRouteList.contains(busLocation.routeNumber) { self.noDataRouteList.append(busLocation.routeNumber) } + case .invalidData: if let previouslyUnavailableRoute = self.noDataRouteList.firstIndex(of: busLocation.routeNumber) { self.noDataRouteList.remove(at: previouslyUnavailableRoute) @@ -320,7 +337,7 @@ class RouteDetailContentViewController: UIViewController { // MARK: - Share Function @objc func shareRoute() { - presentShareSheet(from: view, for: route, with: routeOptionsCell?.getImage()) + presentShareSheet(from: view, for: endDestination, with: routeOptionsCell?.getImage()) } func calculatePlacement(position: CLLocationCoordinate2D, view: UIView) -> CLLocationCoordinate2D? { @@ -441,21 +458,90 @@ class RouteDetailContentViewController: UIViewController { func setIndex(of marker: GMSMarker, with waypointType: WaypointType) { marker.zIndex = { switch waypointType { - case .bus: return 1 - case .walk: return 1 - case .origin: return 3 - case .destination: return 3 - case .stop: return 1 - case .walking: return 0 + case .bus: + return 1 + + case .walk: + return 1 + + case .origin: + return 3 + + case .destination: + return 3 + + case .stop: + return 1 + + case .walking: + return 0 + // For live bus icon / indicators - case .bussing: return 999 // large constant to place above other elements + case .bussing: + return 999 // large constant to place above other elements + default: return 0 } }() } - + + /// Helper function to create individual walking circles + func createWalkPathCircle() -> UIImage { + let fillColor = UIColor(white: 0.82, alpha: 1.0) + let borderColor = UIColor(white: 0.57, alpha: 1.0) + let diameter: CGFloat = 70.0 + let borderWidth: CGFloat = 13.0 + + let renderer = UIGraphicsImageRenderer(size: CGSize(width: diameter, height: diameter)) + return renderer.image { context in + context.cgContext.setFillColor(borderColor.cgColor) + context.cgContext.setStrokeColor(borderColor.cgColor) + context.cgContext.setLineWidth(borderWidth) + context.cgContext.addEllipse(in: CGRect(x: borderWidth / 2, y: borderWidth / 2, width: diameter - borderWidth, height: diameter - borderWidth)) + context.cgContext.drawPath(using: .fillStroke) + + context.cgContext.setFillColor(fillColor.cgColor) + context.cgContext.addEllipse(in: CGRect(x: borderWidth, y: borderWidth, width: diameter - 2 * borderWidth, height: diameter - 2 * borderWidth)) + context.cgContext.fillPath() + } + } + + /// Configure polylines for each walking segment + func configurePolyline(for path: GMSPath) { + let walkPathCircle = createWalkPathCircle() + let polyline = GMSPolyline(path: path) + let stampStyle = GMSSpriteStyle(image: walkPathCircle) + polyline.strokeWidth = 7 + polyline.spans = [GMSStyleSpan(style: GMSStrokeStyle.transparentStroke(withStamp: stampStyle))] + polyline.map = mapView + } + /// Draw all waypoints initially for all paths in [Path] or [[CLLocationCoordinate2D]], plus fill bounds private func drawMapRoute() { + var pathCount = 0 + // Helper function to create bus stop circles + func busStopCircles(at coordinate: CLLocationCoordinate2D, on mapView: GMSMapView) -> GMSCircle { + let circle = GMSCircle(position: coordinate, radius: 50) + circle.fillColor = UIColor.white.withAlphaComponent(1.0) + circle.strokeColor = UIColor.black + circle.strokeWidth = 2.0 + circle.map = mapView + circle.zIndex = 2 + return circle + } + + // Helper function to map final location marker + func mapLocationMarker() -> UIImage? { + let targetSize = CGSize(width: 18, height: 30) + guard let originalImage = UIImage(named: "locationMarker") else { return nil } + + UIGraphicsBeginImageContextWithOptions(targetSize, false, 0.0) + originalImage.draw(in: CGRect(origin: .zero, size: targetSize)) + let resizedImage = UIGraphicsGetImageFromCurrentImageContext() + UIGraphicsEndImageContext() + return resizedImage + } + for path in paths { path.traveledPolyline.map = mapView path.map = mapView @@ -466,6 +552,68 @@ class RouteDetailContentViewController: UIViewController { setIndex(of: marker, with: waypoint.wpType) bounds = bounds.includingCoordinate(waypoint.coordinate) } + + if let busPath = path as? BusPath { + // Create circles at the first and last coordinate points / stops for bus route(s) + if let startBusStopCoordinate = busPath.waypoints.first { + let startCircle = busStopCircles(at: startBusStopCoordinate.coordinate, on: mapView) + finalDestinationCircles.append(startCircle) + } + + if let finalBusStopCoordinate = busPath.waypoints.last { + let endCircle = busStopCircles(at: finalBusStopCoordinate.coordinate, on: mapView) + finalDestinationCircles.append(endCircle) + } + } + + // Extract and append all coordinates of waypoints + if let walkPath = path as? WalkPath { + for circleInfo in walkPath.circles { + let circle = GMSCircle(position: circleInfo.coordinate, radius: circleInfo.radius) + if pathCount == 0 { + firstRouteSegment.append(circle) + } else { + finalRouteSegment.append(circle) + } + } + } + pathCount += 1 + } + + func mapRouteSegment(_ segment: [GMSCircle], to path: GMSMutablePath, addMarker: Bool = false) { + segment.enumerated().forEach { index, waypoint in + let coordinates = CLLocation(latitude: waypoint.position.latitude, longitude: waypoint.position.longitude) + path.addLatitude(coordinates.coordinate.latitude, longitude: coordinates.coordinate.longitude) + if addMarker && index == segment.count - 1 { + let finalDestinationMarker = GMSMarker(position: coordinates.coordinate) + + if let locationMarker = mapLocationMarker() { + finalDestinationMarker.icon = locationMarker + } + finalDestinationMarkers.append(finalDestinationMarker) + finalDestinationMarker.map = mapView + } + } + } + + // Map each route segment and draw final location marker for the last segment + mapRouteSegment(firstRouteSegment, to: firstWalkSegment, addMarker: finalRouteSegment.isEmpty) + if !finalRouteSegment.isEmpty { + mapRouteSegment(finalRouteSegment, to: finalWalkSegment, addMarker: true) + } + + configurePolyline(for: firstWalkSegment) + configurePolyline(for: finalWalkSegment) + + } + + /// Adjusts the size of endpoint bus stop circles based on zoom level + func updateBusStopCircleSize() { + let circleRadiusScale = 1 / mapView.projection.points(forMeters: 1, at: mapView.camera.target) + let circleRadius = 4.5 * CLLocationDistance(circleRadiusScale) + + for circle in finalDestinationCircles { + circle.radius = circleRadius } } @@ -473,11 +621,4 @@ class RouteDetailContentViewController: UIViewController { return drawerDisplayController } - required convenience init(coder aDecoder: NSCoder) { - guard let route = aDecoder.decodeObject(forKey: "route") as? Route - else { fatalError("init(coder:) has not been implemented") } - - self.init(route: route, currentLocation: nil, routeOptionsCell: nil) - } - } diff --git a/TCAT/Controllers/RouteDetail+DrawerViewController.swift b/TCAT/Controllers/RouteDetail+DrawerViewController.swift index 0c192f42..9b33caad 100644 --- a/TCAT/Controllers/RouteDetail+DrawerViewController.swift +++ b/TCAT/Controllers/RouteDetail+DrawerViewController.swift @@ -6,7 +6,7 @@ // Copyright © 2017 cuappdev. All rights reserved. // -import FutureNova +import Combine import Pulley import SwiftyJSON import UIKit @@ -49,6 +49,7 @@ class RouteDetailDrawerViewController: UIViewController { var summaryView: SummaryView! let tableView = UITableView(frame: .zero, style: .grouped) + private var cancellables = Set() var currentPulleyPosition: PulleyPosition? var directionsAndVisibleStops: [RouteDetailItem] = [] var expandedDirections: Set = [] @@ -57,10 +58,8 @@ class RouteDetailDrawerViewController: UIViewController { /// Number of seconds to wait before auto-refreshing bus delay network call. private var busDelayNetworkRefreshRate: Double = 10 - private var busDelayNetworkTimer: Timer? private let chevronFlipDurationTime = 0.25 - private let networking: Networking = URLSession.shared.request - private let route: Route + internal let route: Route // MARK: - Initalization init(route: Route) { @@ -89,29 +88,14 @@ class RouteDetailDrawerViewController: UIViewController { if let drawer = self.parent as? RouteDetailViewController { drawer.initialDrawerPosition = .partiallyRevealed } - + getDelays() setupConstraints() } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - // Bus Delay Network Timer - busDelayNetworkTimer?.invalidate() - busDelayNetworkTimer = Timer.scheduledTimer( - timeInterval: busDelayNetworkRefreshRate, - target: self, - selector: #selector(getDelays), - userInfo: nil, - repeats: true - ) - busDelayNetworkTimer?.fire() - - } - override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - busDelayNetworkTimer?.invalidate() + cancellables.forEach { $0.cancel() } + cancellables.removeAll() } private func setupSummaryView() { @@ -167,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() { @@ -194,7 +197,7 @@ class RouteDetailDrawerViewController: UIViewController { } /// Fetch delay information and update table view cells. - @objc private func getDelays() { + private func getDelays() { // First depart direction(s) guard let delayDirection = route.getFirstDepartRawDirection() else { @@ -202,51 +205,20 @@ class RouteDetailDrawerViewController: UIViewController { } let directions = directionsAndVisibleStops.compactMap { $0.getDirection() } + guard let firstDepartDirection = directions.first(where: { $0.type == .depart }) else { return } - let firstDepartDirection = directions.first(where: { $0.type == .depart })! - + // Reset delays for directions directions.forEach { $0.delay = nil } + // Check if tripId and stopId are available if let tripId = delayDirection.tripIdentifiers?.first, - let stopId = delayDirection.stops.first?.id { - - getDelay(tripId: tripId, stopId: stopId).observe(with: { [weak self] result in - guard let self = self else { return } - DispatchQueue.main.async { - switch result { - case .value(let response): - if response.success { - - delayDirection.delay = response.data - firstDepartDirection.delay = response.data - - // Update delay variable of other ensuing directions - directions.filter { - let isAfter = directions.firstIndex( - of: firstDepartDirection - )! < directions.firstIndex(of: $0)! - return isAfter && $0.type != .depart - } - .forEach { direction in - if direction.delay != nil { - direction.delay! += delayDirection.delay ?? 0 - } else { - direction.delay = delayDirection.delay - } - } - - self.tableView.reloadData() - self.summaryView.updateTimes(for: self.route) - } else { - self.printClass(context: "\(#function) success", message: "false") - let payload = NetworkErrorPayload( - location: "\(self) Get Delay", - type: "Response Failure", - description: "Response Failure" - ) - TransitAnalytics.shared.log(payload) - } - case .error(let error): + let stopId = delayDirection.stops.first?.id { + TransitService.shared.getDelay(tripID: tripId, stopID: stopId, refreshInterval: busDelayNetworkRefreshRate) + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + guard let self = self else { return } + + if case .failure(let error) = completion { self.printClass(context: "\(#function) error", message: error.localizedDescription) let payload = NetworkErrorPayload( location: "\(self) Get Delay", @@ -255,15 +227,31 @@ class RouteDetailDrawerViewController: UIViewController { ) TransitAnalytics.shared.log(payload) } + } receiveValue: { [weak self] delay in + guard let self = self else { return } + + delayDirection.delay = delay + firstDepartDirection.delay = delay + + directions.filter { + let isAfter = directions.firstIndex(of: firstDepartDirection)! < directions.firstIndex(of: $0)! + return isAfter && $0.type != .depart + } + .forEach { direction in + if let currentDelay = direction.delay { + direction.delay = currentDelay + (delay ?? 0) + } else { + direction.delay = delay + } + } + + self.tableView.reloadData() + self.summaryView.updateTimes(for: self.route) } - }) + .store(in: &cancellables) } } - private func getDelay(tripId: String, stopId: String) -> Future> { - return networking(Endpoint.getDelay(tripID: tripId, stopID: stopId)).decode() - } - func getFirstDirection() -> Direction? { return route.directions.first(where: { $0.type == .depart }) } @@ -296,3 +284,4 @@ class RouteDetailDrawerViewController: UIViewController { } } + diff --git a/TCAT/Controllers/RouteDetailContentViewController+Extensions.swift b/TCAT/Controllers/RouteDetailContentViewController+Extensions.swift index 3233fc31..00656d54 100644 --- a/TCAT/Controllers/RouteDetailContentViewController+Extensions.swift +++ b/TCAT/Controllers/RouteDetailContentViewController+Extensions.swift @@ -187,6 +187,8 @@ extension RouteDetailContentViewController: GMSMapViewDelegate { busIndicators.append(indicator) } } + // Updates bus stop circle sizes + updateBusStopCircleSize() } } diff --git a/TCAT/Controllers/RouteDetailDrawerViewController+Extensions.swift b/TCAT/Controllers/RouteDetailDrawerViewController+Extensions.swift index c160ac94..91205192 100644 --- a/TCAT/Controllers/RouteDetailDrawerViewController+Extensions.swift +++ b/TCAT/Controllers/RouteDetailDrawerViewController+Extensions.swift @@ -24,8 +24,10 @@ extension RouteDetailDrawerViewController: UIGestureRecognizerDelegate { } else { drawer.setDrawerPosition(position: .open, animated: true) } + case .open: drawer.setDrawerPosition(position: .collapsed, animated: true) + default: break } } @@ -127,6 +129,7 @@ extension RouteDetailDrawerViewController: PulleyDrawerViewControllerDelegate { } else { contentViewController.centerMapOnOverview(drawerPreviewing: drawerPosition == .partiallyRevealed) } + default: break } } @@ -171,6 +174,7 @@ extension RouteDetailDrawerViewController: UITableViewDataSource { else { return UITableViewCell() } cell.configure(for: busStop.name) return cell + case .direction(let direction): switch direction.type { case .walk, .arrive: @@ -184,6 +188,7 @@ extension RouteDetailDrawerViewController: UITableViewDataSource { isLastStep: indexPath.row == section.items.count - 1 ) return cell + default: guard let cell = tableView.dequeueReusableCell( withIdentifier: Constants.Cells.largeDetailCellIdentifier @@ -196,15 +201,38 @@ extension RouteDetailDrawerViewController: UITableViewDataSource { ) return cell } + case .notificationType(let type): 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 ) + + cell.setSwitchOn(isOn) return cell } } @@ -224,6 +252,7 @@ extension RouteDetailDrawerViewController: UITableViewDelegate { } else { return RouteDetailCellSize.smallHeight } + case .notification: return notificationCellHeight } } diff --git a/TCAT/Controllers/RouteOptionsViewController+Extensions.swift b/TCAT/Controllers/RouteOptionsViewController+Extensions.swift index 8acd57b7..21a277a5 100644 --- a/TCAT/Controllers/RouteOptionsViewController+Extensions.swift +++ b/TCAT/Controllers/RouteOptionsViewController+Extensions.swift @@ -19,7 +19,7 @@ extension RouteOptionsViewController: UIViewControllerPreviewingDelegate { if let indexPath = routeResults.indexPathForRow(at: point), let cell = routeResults.cellForRow(at: indexPath) { let route = routes[indexPath.section][indexPath.row] - presentShareSheet(from: view, for: route, with: cell.getImage()) + presentShareSheet(from: view, for: searchTo, with: cell.getImage()) } } } @@ -72,6 +72,7 @@ extension RouteOptionsViewController: DestinationDelegate { switch searchType { case .from: searchFrom = place + case .to: searchTo = place } @@ -114,9 +115,14 @@ extension RouteOptionsViewController: DatePickerViewDelegate { routeSelection.setDatepickerTitle(withDate: date, withSearchTimeType: searchTimeType) var buttonTapped = "" switch searchType { - case .arriveBy: buttonTapped = "Arrive By Tapped" - case .leaveAt: buttonTapped = "Leave At Tapped" - case .leaveNow: buttonTapped = "Leave Now Tapped" + case .arriveBy: + buttonTapped = "Arrive By Tapped" + + case .leaveAt: + buttonTapped = "Leave At Tapped" + + case .leaveNow: + buttonTapped = "Leave Now Tapped" } dismissDatePicker() @@ -283,7 +289,6 @@ extension RouteOptionsViewController: UITableViewDelegate { let payload = RouteResultsCellTappedEventPayload() TransitAnalytics.shared.log(payload) let routeId = routes[indexPath.section][indexPath.row].routeId - routeSelected(routeId: routeId) navigationController?.pushViewController(routeDetailViewController, animated: true) } } @@ -298,7 +303,10 @@ extension RouteOptionsViewController: UITableViewDelegate { } else { return Constants.TableHeaders.boardingSoonFromNearby } - case 2: return Constants.TableHeaders.walking + + case 2: + return Constants.TableHeaders.walking + default: return nil } } diff --git a/TCAT/Controllers/RouteOptionsViewController.swift b/TCAT/Controllers/RouteOptionsViewController.swift index 2ca2dfa9..5b017036 100755 --- a/TCAT/Controllers/RouteOptionsViewController.swift +++ b/TCAT/Controllers/RouteOptionsViewController.swift @@ -6,9 +6,9 @@ // Copyright © 2017 cuappdev. All rights reserved. // +import Combine import CoreLocation import DZNEmptyDataSet -import FutureNova import Intents import NotificationBannerSwift import Pulley @@ -38,6 +38,8 @@ class RouteOptionsViewController: UIViewController { let routeSelection = RouteSelectionView() var searchBarView = SearchBarView() + private var busDelaysNetworkRefreshRate: Double = 5.0 + private var cancellables = Set() var cellUserInteraction = true var currentLocation: CLLocationCoordinate2D? var lastRouteRefreshDate = Date() @@ -57,11 +59,9 @@ class RouteOptionsViewController: UIViewController { private let estimatedRowHeight: CGFloat = 115 private let mediumTapticGenerator = UIImpactFeedbackGenerator(style: .medium) - private let networking: Networking = URLSession.shared.request private let routeResultsTitle: String = Constants.Titles.routeResults - /// Timer to retrieve route delays and update route cells - private var routeTimer: Timer? + /// Timer to retrieve route update route cells private var updateTimer: Timer? /// Dictionary to map route id to delay @@ -100,7 +100,7 @@ class RouteOptionsViewController: UIViewController { title = Constants.Titles.routeOptions - addReachabilityListener() + NotificationCenter.default.addObserver(self, selector: #selector(setUserInteraction), name: .reachabilityChanged, object: nil) setupRouteSelection(destination: searchTo) setupSearchBar() @@ -117,14 +117,7 @@ class RouteOptionsViewController: UIViewController { } searchForRoutes() - - routeTimer = Timer.scheduledTimer( - timeInterval: 5.0, - target: self, - selector: #selector(updateAllRoutesLiveTracking(sender:)), - userInfo: nil, - repeats: true - ) + updateAllRoutesLiveTracking() updateTimer = Timer.scheduledTimer( timeInterval: 20.0, target: self, @@ -151,7 +144,10 @@ class RouteOptionsViewController: UIViewController { // Remove banner banner?.dismiss() banner = nil - routeTimer?.invalidate() + + cancellables.forEach { $0.cancel() } + cancellables.removeAll() + updateTimer?.invalidate() // Remove notification observer // swiftlint:disable:next notification_center_detachment @@ -162,12 +158,6 @@ class RouteOptionsViewController: UIViewController { return banner != nil ? .lightContent : .default } - private func addReachabilityListener() { - ReachabilityManager.shared.addListener(self) { [weak self] connection in - self?.setUserInteraction(to: connection != .none) - } - } - private func setupRouteSelection(destination: Place?) { routeSelection.configure( delegate: self, @@ -306,6 +296,7 @@ class RouteOptionsViewController: UIViewController { searchBarText = startingDestinationName } placeholder = Constants.General.fromSearchBarPlaceholder + case .to: let endingDestinationName = searchTo.name if endingDestinationName != Constants.General.currentLocation { @@ -361,51 +352,47 @@ class RouteOptionsViewController: UIViewController { routeResults.reloadData() } - private func getAllDelays(trips: [Trip]) -> Future> { - return networking(Endpoint.getAllDelays(trips: trips)).decode() - } - - @objc func updateAllRoutesLiveTracking(sender: Timer) { - getAllDelays(trips: trips).observe(with: { result in - DispatchQueue.main.async { - switch result { - case .value(let delaysResponse): - if !delaysResponse.success { return } - let allDelays = delaysResponse.data - for delayResponse in allDelays { - let tripRoute = self.tripDictionary[delayResponse.tripID] - guard let route = tripRoute, - let routeId = tripRoute?.routeId, - let direction = route.getFirstDepartRawDirection(), - let delay = delayResponse.delay else { - continue - } - let departTime = direction.startTime - let delayedDepartTime = departTime.addingTimeInterval(TimeInterval(delay)) - var delayState: DelayState! - let isLateDelay = Time.compare( - date1: delayedDepartTime, - date2: departTime - ) == .orderedDescending - if isLateDelay { - delayState = DelayState.late(date: delayedDepartTime) - } else { - delayState = DelayState.onTime(date: departTime) - } - self.delayDictionary[routeId] = delayState - route.getFirstDepartRawDirection()?.delay = delay - } - case .error(let error): - self.printClass(context: "\(#function) error", message: error.localizedDescription) + private func updateAllRoutesLiveTracking() { + TransitService.shared.getAllDelays(trips: trips, refreshInterval: busDelaysNetworkRefreshRate) + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + guard let self = self else { return } + + if case .failure(let error) = completion { let payload = NetworkErrorPayload( location: "\(self) Get All Delays", type: "\((error as NSError).domain)", description: error.localizedDescription ) TransitAnalytics.shared.log(payload) + self.printClass(context: "\(#function) error", message: error.localizedDescription) + } + } receiveValue: { [weak self] delays in + guard let self = self else { return } + + for delayResponse in delays { + if let route = self.tripDictionary[delayResponse.tripID], + let direction = route.getFirstDepartRawDirection(), + let delay = delayResponse.delay { + + let routeId = route.routeId + + let departTime = direction.startTime + let delayedDepartTime = departTime.addingTimeInterval(TimeInterval(delay)) + + let delayState: DelayState + delayState = delayedDepartTime > departTime ? .late( + date: delayedDepartTime + ) : .late( + date: departTime + ) + + self.delayDictionary[routeId] = delayState + route.getFirstDepartRawDirection()?.delay = delay + } } } - }) + .store(in: &cancellables) } @objc private func refreshRoutesAndTime() { @@ -442,6 +429,7 @@ class RouteOptionsViewController: UIViewController { switch searchType { case .from: routeSelection.updateSearchBarTitles(from: searchFrom.name) + case .to: routeSelection.updateSearchBarTitles(to: searchTo.name) } @@ -494,39 +482,6 @@ class RouteOptionsViewController: UIViewController { } } - private func getRoutes( - start: Place, - end: Place, - time: Date, - type: SearchType - ) -> Future>? { - if let endpoint = Endpoint.getRoutes(start: start, end: end, time: time, type: type) { - return networking(endpoint).decode() - } else { - return nil - } - } - - func routeSelected(routeId: String) { - networking(Endpoint.routeSelected(routeId: routeId)).observe { [weak self] result in - guard let self = self else { return } - DispatchQueue.main.async { - switch result { - case .value: - self.printClass(context: "\(#function)", message: "success") - case .error(let error): - self.printClass(context: "\(#function) error", message: error.localizedDescription) - let payload = NetworkErrorPayload( - location: "\(self) Get Route Selected", - type: "\((error as NSError).domain)", - description: error.localizedDescription - ) - TransitAnalytics.shared.log(payload) - } - } - } - } - private func getRoutesTrips() { // For each route in each route array inside of the 'routes' array, get its // tripId and stopId to create trip array for request to get all delays. @@ -546,36 +501,40 @@ class RouteOptionsViewController: UIViewController { } private func processRequest(start: Place, end: Place, time: Date, type: SearchType) { - if let result = getRoutes(start: start, end: end, time: time, type: type) { - result.observe(with: { [weak self] result in + TransitService.shared.getRoutes(start: start, end: end, time: time, type: type) + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + guard let self = self else { return } + + switch completion { + case .failure(let error): + self.processRequestError(error: error) + + case .finished: + break + } + } receiveValue: { [weak self] response in guard let self = self else { return } - DispatchQueue.main.async { - switch result { - case .value(let response): - - // Parse sections of routes - [response.data.fromStop, response.data.boardingSoon, response.data.walking] - .forEach { routeSection in - routeSection.forEach { (route) in - route.formatDirections(start: self.searchFrom?.name, end: self.searchTo.name) - } - // Allow for custom display in search results for fromStop. - // We want to display a [] if a bus stop is the origin and doesn't exist - if !routeSection.isEmpty || self.searchFrom?.type == .busStop { - self.routes.append(routeSection) - } - - } - self.getRoutesTrips() - self.requestDidFinish(perform: [.hideBanner]) - case .error(let error): - self.processRequestError(error: error) + + // Parse sections of routes + [response.fromStop, response.boardingSoon, response.walking].forEach { routeSection in + routeSection.forEach { route in + route.formatDirections(start: self.searchFrom?.name, end: self.searchTo.name) + } + // Add routes to results + if !routeSection.isEmpty || self.searchFrom?.type == .busStop { + self.routes.append(routeSection) } - let payload = DestinationSearchedEventPayload(destination: end.name) - TransitAnalytics.shared.log(payload) } - }) - } + + self.getRoutesTrips() + self.requestDidFinish(perform: [.hideBanner]) + + // Log analytics + let payload = DestinationSearchedEventPayload(destination: end.name) + TransitAnalytics.shared.log(payload) + } + .store(in: &cancellables) } private func processRequestError(error: Error) { @@ -627,6 +586,7 @@ class RouteOptionsViewController: UIViewController { let action = UIAlertAction(title: actionTitle, style: .cancel, handler: nil) alertController.addAction(action) present(alertController, animated: true, completion: nil) + case .showError(bannerInfo: let bannerInfo, payload: let payload): banner = StatusBarNotificationBanner(title: bannerInfo.title, style: bannerInfo.style) banner?.autoDismiss = false @@ -637,6 +597,7 @@ class RouteOptionsViewController: UIViewController { ) TransitAnalytics.shared.log(payload) + case .hideBanner: banner?.dismiss() banner = nil @@ -650,7 +611,8 @@ class RouteOptionsViewController: UIViewController { routeResults.reloadData() } - func setUserInteraction(to userInteraction: Bool) { + @objc func setUserInteraction() { + var userInteraction = NetworkMonitor.shared.isReachable cellUserInteraction = userInteraction for cell in routeResults.visibleCells { @@ -691,6 +653,7 @@ class RouteOptionsViewController: UIViewController { let contentViewController = RouteDetailContentViewController( route: route, + endDestination: searchTo, currentLocation: routeDetailCurrentLocation, routeOptionsCell: routeOptionsCell ) diff --git a/TCAT/Controllers/SearchResultsViewController.swift b/TCAT/Controllers/SearchResultsViewController.swift index 73633253..98927ef7 100755 --- a/TCAT/Controllers/SearchResultsViewController.swift +++ b/TCAT/Controllers/SearchResultsViewController.swift @@ -6,9 +6,9 @@ // Copyright © 2017 cuappdev. All rights reserved. // +import Combine import CoreLocation import DZNEmptyDataSet -import FutureNova import MapKit import SwiftyJSON import UIKit @@ -30,11 +30,11 @@ class SearchResultsViewController: UIViewController { private weak var destinationDelegate: DestinationDelegate? private weak var searchBarCancelDelegate: SearchBarCancelDelegate? + private var currentSearchCancellable: AnyCancellable? private var favorites: [Place] = [] private var favoritesSection: Section! private var initialTableViewIndexMinY: CGFloat! private let locationManager = CLLocationManager() - private let networking: Networking = URLSession.shared.request private var recentLocations: [Place] = [] private var recentSearchesSection: Section! private var returningFromAllStopsBusStop: Place? @@ -152,23 +152,22 @@ class SearchResultsViewController: UIViewController { }) } - @objc private func getPlaces(timer: Timer) { - if let userInfo = timer.userInfo as? [String: String], - let searchText = userInfo["searchText"], - !searchText.isEmpty { - SearchManager.shared.performLookup(for: searchText) { [weak self] (searchResults, error) in + private func startSearch(for searchText: String) { + currentSearchCancellable?.cancel() + + currentSearchCancellable = SearchManager.shared.search(for: searchText) + .receive(on: DispatchQueue.main) + .sink { [weak self] result in guard let self = self else { return } - if let error = error { - self.printClass(context: "SearchManager lookup error", message: error.localizedDescription) - return - } - DispatchQueue.main.async { + + switch result { + case .success(let searchResults): self.updateSearchResultsSection(with: searchResults) + + case .failure(let error): + self.printClass(context: "SearchManager lookup error", message: error.localizedDescription) } } - } else { - createDefaultSections() - } } } @@ -184,6 +183,7 @@ extension SearchResultsViewController: UITableViewDataSource { switch sections[section] { case .recentSearches: return recentLocations.count + default: return sections[section].getItems().count } @@ -197,6 +197,7 @@ extension SearchResultsViewController: UITableViewDataSource { ) as? GeneralTableViewCell else { return UITableViewCell() } cell.configure(for: sections[indexPath.section]) return cell + default: guard let cell = tableView.dequeueReusableCell( withIdentifier: Constants.Cells.placeIdentifier @@ -217,8 +218,10 @@ extension SearchResultsViewController: UITableViewDelegate { switch sections[section] { case .recentSearches: header = HeaderView(labelText: Constants.TableHeaders.recentSearches, buttonType: .clear) + case .seeAllStops, .searchResults: return nil + default: break } @@ -232,8 +235,11 @@ extension SearchResultsViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { switch sections[section] { - case .recentSearches: return 50 - default: return 24 + case .recentSearches: + return 50 + + default: + return 24 } } @@ -311,14 +317,13 @@ extension SearchResultsViewController: UISearchBarDelegate, UISearchResultsUpdat } func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { - timer?.invalidate() - timer = Timer.scheduledTimer( - timeInterval: 0.75, - target: self, - selector: #selector(getPlaces), - userInfo: ["searchText": searchText], - repeats: false - ) + // Start the search as the text changes + guard !searchText.isEmpty else { + createDefaultSections() + return + } + + startSearch(for: searchText) } } diff --git a/TCAT/Controllers/ServiceAlertsViewController.swift b/TCAT/Controllers/ServiceAlertsViewController.swift index b2e5877b..b20d08b4 100644 --- a/TCAT/Controllers/ServiceAlertsViewController.swift +++ b/TCAT/Controllers/ServiceAlertsViewController.swift @@ -6,8 +6,8 @@ // Copyright © 2018 cuappdev. All rights reserved. // +import Combine import DZNEmptyDataSet -import FutureNova import SnapKit import UIKit @@ -15,10 +15,10 @@ class ServiceAlertsViewController: UIViewController { private let tableView = UITableView(frame: .zero, style: .grouped) + private var cancellables = Set() private var isLoading: Bool { return loadingIndicator != nil } private var loadingIndicator: LoadingIndicator? private var networkError: Bool = false - private let networking: Networking = URLSession.shared.request private var priorities = [Int]() private var alerts = [Int: [ServiceAlert]]() { @@ -92,22 +92,17 @@ class ServiceAlertsViewController: UIViewController { } } - private func getAlerts() -> Future> { - return networking(Endpoint.getAlerts()).decode() - } - + /// Fetches service alerts using TransitService and updates the table view. private func getServiceAlerts() { - getAlerts().observe(with: { [weak self] result in - guard let self = self else { return } - DispatchQueue.main.async { - switch result { - case .value(let response): - if response.success { - self.removeLoadingIndicator() - self.networkError = false - self.alerts = self.sortedAlerts(alertsList: response.data) - } - case .error(let error): + setUpLoadingIndicator() + + TransitService.shared.getAlerts() + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in + guard let self = self else { return } + + switch completion { + case .failure(let error): self.removeLoadingIndicator() self.networkError = true self.alerts = [:] @@ -118,9 +113,16 @@ class ServiceAlertsViewController: UIViewController { description: error.localizedDescription ) TransitAnalytics.shared.log(payload) + + case .finished: + break } + } receiveValue: { [weak self] alerts in + self?.removeLoadingIndicator() + self?.networkError = false + self?.alerts = self?.sortedAlerts(alertsList: alerts) ?? [:] } - }) + .store(in: &cancellables) } private func sortedAlerts(alertsList: [ServiceAlert]) -> [Int: [ServiceAlert]] { @@ -195,10 +197,13 @@ extension ServiceAlertsViewController: UITableViewDelegate { switch priorities[section] { case 0: return HeaderView(labelText: Constants.TableHeaders.highPriority) + case 1: return HeaderView(labelText: Constants.TableHeaders.mediumPriority) + case 2: return HeaderView(labelText: Constants.TableHeaders.lowPriority) + default: return HeaderView(labelText: Constants.TableHeaders.noPriority) } diff --git a/TCAT/Controllers/StopPickerViewController.swift b/TCAT/Controllers/StopPickerViewController.swift index dc1618c3..5e18c40d 100644 --- a/TCAT/Controllers/StopPickerViewController.swift +++ b/TCAT/Controllers/StopPickerViewController.swift @@ -6,12 +6,13 @@ // Copyright © 2017 cuappdev. All rights reserved. // +import Combine import DZNEmptyDataSet -import FutureNova import UIKit class StopPickerViewController: UIViewController { + private var cancellables = Set() private let tableView = UITableView() private typealias Section = (title: String, places: [Place]) private var sections: [Section] = [] @@ -29,7 +30,7 @@ class StopPickerViewController: UIViewController { title = Constants.Titles.allStops setupTableView() - refreshStops() + getAllStops() } private func setupTableView() { @@ -59,49 +60,45 @@ class StopPickerViewController: UIViewController { } } - // MARK: - Refresh stops - - private func getStopsFromServer() -> Future> { - return URLSession.shared.request(endpoint: Endpoint.getAllStops()).decode() - } - - /// Get all bus stops from the server, update UserDefaults, and refresh the table - private func refreshStops() { + // MARK: - Get all stops + /// Get all bus stops from the server + func getAllStops() { setUpLoadingIndicator() - if let busStopsData = userDefaults.data(forKey: Constants.UserDefaults.allBusStops), - let busStops = try? decoder.decode([Place].self, from: busStopsData) { - loadingIndicator?.removeFromSuperview() - loadingIndicator = nil - sections = tableSections(for: busStops) - tableView.reloadData() - } else { - getStopsFromServer().observe { [weak self] result in + TransitService.shared.getAllStops() + .receive(on: DispatchQueue.main) + .sink { [weak self] completion in guard let self = self else { return } - switch result { - case .value(let response): - guard !response.data.isEmpty else { return } // ensure the response has stops - - do { - // note: response.data is [Place], not Data - let stopsData = try JSONEncoder().encode(response.data) - userDefaults.set(stopsData, forKey: Constants.UserDefaults.allBusStops) - self.sections = self.tableSections(for: response.data) - } catch { - self.logRefreshError(error) - } - case .error(let error): - self.logRefreshError(error) - } + self.loadingIndicator?.removeFromSuperview() + self.loadingIndicator = nil + + switch completion { + case .failure: + handleGetAllStopsError() - DispatchQueue.main.async { - self.loadingIndicator?.removeFromSuperview() - self.loadingIndicator = nil - self.tableView.reloadData() + case .finished: + break } + } receiveValue: { [weak self] response in + guard let self = self else { return } + + guard !response.isEmpty else { return } + + self.sections = self.tableSections(for: response) + self.tableView.reloadData() } - } + .store(in: &cancellables) + } + + // ToDo: Ask whats better when unable to get stop + /// Handle error when bus stops aren't fetched successfully + private func handleGetAllStopsError() { + let title = "Couldn't Fetch Bus Stops" + let message = "The app will continue trying on launch. You can continue to use the app as normal." + let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) + alertController.addAction(UIAlertAction(title: "OK", style: .cancel, handler: nil)) + UIApplication.shared.keyWindow?.presentInApp(alertController) } /// Sorts `busStops` into table `Section`s in alphabetical order. @@ -178,7 +175,7 @@ extension StopPickerViewController: DZNEmptyDataSetDelegate { func emptyDataSet(_ scrollView: UIScrollView, didTap didTapButton: UIButton) { setUpLoadingIndicator() - refreshStops() + getAllStops() } } diff --git a/TCAT/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/Managers/SearchManager.swift b/TCAT/Managers/SearchManager.swift new file mode 100644 index 00000000..f65ffa66 --- /dev/null +++ b/TCAT/Managers/SearchManager.swift @@ -0,0 +1,126 @@ +// +// SearchManager.swift +// TCAT +// +// Created by Kevin Chan on 9/23/19. +// Copyright © 2019 cuappdev. All rights reserved. +// + +import Combine +import Foundation +import MapKit + +class SearchManager: NSObject { + + // MARK: - Public Properties + static let shared = SearchManager() + + // MARK: - Private Properties + private var busStops = [Place]() + private var cancellables = Set() + private var searchQuerySubject = PassthroughSubject() + private var lastSearchQuery: String? + private var searchPublisher = PassthroughSubject, Never>() + + // MARK: - Initializer + override private init() { + super.init() + setUpSearchSubscription() + } + + // MARK: - Public Search Method + func search(for query: String) -> AnyPublisher, Never> { + searchQuerySubject.send(query) + return searchPublisher.eraseToAnyPublisher() + } + + // MARK: - Private Methods + private func setUpSearchSubscription() { + searchQuerySubject + .removeDuplicates() + .debounce(for: .milliseconds(750), scheduler: DispatchQueue.main) + .flatMap { [weak self] searchText -> AnyPublisher in + guard let self = self, !searchText.isEmpty else { + return Fail(error: ApiErrorHandler.noSearchResultsFound).eraseToAnyPublisher() + } + + self.lastSearchQuery = searchText + return TransitService.shared.getAppleSearchResults(searchText: searchText) + } + .sink { completion in + switch completion { + case .failure(let error): + self.searchPublisher.send(.failure(error)) + + case .finished: + break + } + } receiveValue: { [weak self] response in + self?.processSearchResults(response: response) + } + .store(in: &cancellables) + } + + private func processSearchResults(response: AppleSearchResponse) { + busStops = response.busStops + + if let applePlaces = response.applePlaces, !applePlaces.isEmpty { + let combinedResults = applePlaces + busStops + self.searchPublisher.send(.success(combinedResults)) + } else { + if let lastQuery = lastSearchQuery { + performLocalSearch(with: lastQuery) + } else { + self.searchPublisher.send(.failure(.noSearchResultsFound)) + } + } + } + + private func performLocalSearch(with query: String) { + guard !query.isEmpty else { + self.searchPublisher.send(.failure(.noSearchResultsFound)) + return + } + + let searchRequest = MKLocalSearch.Request() + searchRequest.naturalLanguageQuery = query + let localSearch = MKLocalSearch(request: searchRequest) + + localSearch.start { [weak self] response, error in + guard let self = self else { return } + + if let error = error { + self.searchPublisher.send(.failure(.normalError(error))) + return + } + + let places = self.extractPlaces(from: response) + + if places.isEmpty { + self.searchPublisher.send(.failure(.noSearchResultsFound)) + } else { + let combinedResults = places + self.busStops + self.searchPublisher.send(.success(combinedResults)) + } + } + } + + private func extractPlaces(from response: MKLocalSearch.Response?) -> [Place] { + return response?.mapItems.compactMap { mapItem -> Place? in + guard let name = mapItem.name, + let address = mapItem.placemark.thoroughfare, + let city = mapItem.placemark.locality, + let state = mapItem.placemark.administrativeArea, + let country = mapItem.placemark.country else { return nil } + + let description = [address, city, state, country].joined(separator: ", ") + return Place( + name: name, + type: .applePlace, + latitude: mapItem.placemark.coordinate.latitude, + longitude: mapItem.placemark.coordinate.longitude, + placeDescription: description + ) + } ?? [] + } +} diff --git a/TCAT/Managers/TransitNotificationSubscriber.swift b/TCAT/Managers/TransitNotificationSubscriber.swift new file mode 100644 index 00000000..83fd34ac --- /dev/null +++ b/TCAT/Managers/TransitNotificationSubscriber.swift @@ -0,0 +1,118 @@ +// +// 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 self, + 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 self, + 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 self, + 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 self, + 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/BusPath.swift b/TCAT/Models/BusPath.swift index 8c1aa649..524242f9 100755 --- a/TCAT/Models/BusPath.swift +++ b/TCAT/Models/BusPath.swift @@ -45,7 +45,7 @@ class BusPath: Path { dashColors = [color, .clear] - self.polylineWidth = 8 + self.polylineWidth = 5 self.untraveledPath = createPathFromWaypoints(waypoints: waypoints) self.traveledPath = untraveledPath diff --git a/TCAT/Models/Direction.swift b/TCAT/Models/Direction.swift index 2d07cf62..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 } @@ -184,10 +184,13 @@ class Direction: NSObject, NSCopying, Codable { switch type { case .depart: return "at \(name)" + case .arrive: return "Get off at \(name)" + case .walk: return "Walk to \(name)" + case .transfer: return "at \(name). Stay on bus." } diff --git a/TCAT/Models/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/Models/SearchManager.swift b/TCAT/Models/SearchManager.swift deleted file mode 100644 index 2d46a866..00000000 --- a/TCAT/Models/SearchManager.swift +++ /dev/null @@ -1,146 +0,0 @@ -// -// SearchManager.swift -// TCAT -// -// Created by Kevin Chan on 9/23/19. -// Copyright © 2019 cuappdev. All rights reserved. -// - -import FutureNova -import MapKit - -struct SearchManagerError: Swift.Error { - let description: String -} - -class SearchManager: NSObject { - - typealias SearchManagerCallback = (_ searchResults: [Place], _ error: Error?) -> Void - - static let shared = SearchManager() - - // MARK: - Private vars - private var callback: SearchManagerCallback? - private var busStops = [Place]() - private let networking: Networking = URLSession.shared.request - private let searchCompleter = MKLocalSearchCompleter() - private var searchResults = [MKLocalSearchCompletion]() - - override private init() { - super.init() - searchCompleter.delegate = self - if let searchRadius = CLLocationDistance(exactly: Constants.Map.searchRadius) { - let center = CLLocationCoordinate2D( - latitude: Constants.Map.startingLat, - longitude: Constants.Map.startingLong - ) - searchCompleter.region = MKCoordinateRegion( - center: center, - latitudinalMeters: searchRadius, - longitudinalMeters: searchRadius - ) - } - } - - func performLookup(for query: String, completionHandler: @escaping SearchManagerCallback) { - getAppleSearchResults(searchText: query).observe { [weak self] result in - guard let self = self else { - completionHandler([], SearchManagerError(description: "[SearchManager] self is nil")) - return - } - DispatchQueue.main.async { - switch result { - case .value(let response): - let busStops = response.data.busStops - // If the list of Apple Places for this query already exists in - // server cache, no further work is needed - if let applePlaces = response.data.applePlaces { - let searchResults = applePlaces + busStops - completionHandler(searchResults, nil) - } else { - // Otherwise, we need to perform the Apple Places lookup locally - // and only display results after this lookup is done - self.busStops = busStops - self.callback = completionHandler - self.searchCompleter.queryFragment = query - } - case .error(let error): - completionHandler([], error) - } - } - } - } - - private func getAppleSearchResults(searchText: String) -> Future> { - return networking(Endpoint.getAppleSearchResults(searchText: searchText)).decode() - } - -} - -extension SearchManager: MKLocalSearchCompleterDelegate { - - func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) { - // Get list of ApplePlaces for this search query, i.e. completer.queryFragment - let query = completer.queryFragment - var places = [Place]() - let dispatchGroup = DispatchGroup() - searchResults = completer.results - searchResults.forEach { completion in - let searchRequest = MKLocalSearch.Request(completion: completion) - let search = MKLocalSearch(request: searchRequest) - dispatchGroup.enter() - search.start(completionHandler: { (response, error) in - if let error = error { - print("[SearchManager] Apple Places search result error: \(error)") - dispatchGroup.leave() - return - } - if let mapItem = response?.mapItems.first, - let name = mapItem.name, - let address = mapItem.placemark.thoroughfare, - let city = mapItem.placemark.locality, - let state = mapItem.placemark.administrativeArea, - let country = mapItem.placemark.country { - let lat = mapItem.placemark.coordinate.latitude - let long = mapItem.placemark.coordinate.longitude - let description = [address, city, state, country].joined(separator: ", ") - let place = Place( - name: name, - type: .applePlace, - latitude: lat, - longitude: long, - placeDescription: description - ) - places.append(place) - } - dispatchGroup.leave() - }) - } - dispatchGroup.notify(queue: .main) { - let searchResults = places + self.busStops - self.callback?(searchResults, nil) - - self.busStops = [] - self.callback = nil - - // Update server cache of Apple Places for this search query - self.updateApplePlacesCache(searchText: query, places: places).observe { [weak self] result in - guard self != nil else { return } - switch result { - case .value(let response): - print("[SearchManager] Succeeded in updating apple places cache: \(response.data)") - default: break - } - } - } - } - - func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) { - print("[SearchManager] MKLocalSearch failed for error: \(error)") - } - - private func updateApplePlacesCache(searchText: String, places: [Place]) -> Future> { - return networking(Endpoint.updateApplePlacesCache(searchText: searchText, places: places)).decode() - } - -} diff --git a/TCAT/Models/Section.swift b/TCAT/Models/Section.swift index bd73d1cb..22cd21f0 100644 --- a/TCAT/Models/Section.swift +++ b/TCAT/Models/Section.swift @@ -17,9 +17,12 @@ enum Section { private func getVal() -> Any? { switch self { - case .seeAllStops: return nil + case .seeAllStops: + return nil + case .currentLocation(let location): return location + case .recentSearches(let items), .searchResults(let items): return items @@ -28,26 +31,34 @@ enum Section { var isEmpty: Bool { switch self { - case .currentLocation, .seeAllStops: return false - case .recentSearches(let items), - .searchResults(let items): return items.isEmpty + case .currentLocation, .seeAllStops: + return false + + case .recentSearches(let items), .searchResults(let items): + return items.isEmpty } } func getItems() -> [Place] { switch self { - case .seeAllStops: return [] - case .currentLocation(let currLocation): return [currLocation] - case .recentSearches(let items), - .searchResults(let items): return items + case .seeAllStops: + return [] + + case .currentLocation(let currLocation): + return [currLocation] + + case .recentSearches(let items), .searchResults(let items): + return items } } func getItem(at index: Int) -> Place? { switch self { - case .currentLocation, .seeAllStops: return nil - case .recentSearches(let items), - .searchResults(let items): return items[optional: index] + case .currentLocation, .seeAllStops: + return nil + + case .recentSearches(let items), .searchResults(let items): + return items[optional: index] } } @@ -65,11 +76,14 @@ extension Section: Equatable { switch (lhs, rhs) { case (.seeAllStops, .seeAllStops): return true + case (.currentLocation(let locA), .currentLocation(let locB)): return locA == locB + case (.searchResults(let itemsA), .searchResults(let itemsB)), (.recentSearches(let itemsA), .recentSearches(let itemsB)): return itemsA == itemsB + default: return false } } diff --git a/TCAT/Models/WalkPath.swift b/TCAT/Models/WalkPath.swift index 9525f34a..0c851eb5 100644 --- a/TCAT/Models/WalkPath.swift +++ b/TCAT/Models/WalkPath.swift @@ -11,14 +11,14 @@ import GoogleMaps import SwiftyJSON class WalkPath: Path { - - var dashLengths: [NSNumber] = [6, 4] - var polylineWidth: CGFloat = 8 + + var circles: [(coordinate: CLLocationCoordinate2D, radius: Double)] = [] + var dashLengths: [NSNumber] = [30, 40] + var polylineWidth: CGFloat = 0 var traveledPath: GMSMutablePath? var untraveledPath: GMSMutablePath? init(_ waypoints: [Waypoint]) { - super.init(waypoints: waypoints) self.color = Colors.metadataIcon @@ -28,10 +28,36 @@ class WalkPath: Path { self.path = untraveledPath self.strokeColor = color self.strokeWidth = polylineWidth + + guard let path = self.path else { return } + let intervalDistanceIncrement: CGFloat = 20 + var previousCircle: (coordinate: CLLocationCoordinate2D, radius: Double)? + // Maps circle coordinates in incremental distance + for coordinateIndex in 0 ..< path.count() - 1 { + let startCoordinate = path.coordinate(at: coordinateIndex) + let endCoordinate = path.coordinate(at: coordinateIndex + 1) + let startLocation = CLLocation(latitude: startCoordinate.latitude, longitude: startCoordinate.longitude) + let endLocation = CLLocation(latitude: endCoordinate.latitude, longitude: endCoordinate.longitude) + let pathDistance = endLocation.distance(from: startLocation) + let intervalLatIncrement = (endLocation.coordinate.latitude - startLocation.coordinate.latitude) / pathDistance + let intervalLngIncrement = (endLocation.coordinate.longitude - startLocation.coordinate.longitude) / pathDistance + + for intervalDistance in 0 ..< Int(pathDistance) { + let intervalLat = startLocation.coordinate.latitude + (intervalLatIncrement * Double(intervalDistance)) + let intervalLng = startLocation.coordinate.longitude + (intervalLngIncrement * Double(intervalDistance)) + let circleCoordinate = CLLocationCoordinate2D(latitude: intervalLat, longitude: intervalLng) - self.spans = GMSStyleSpans(untraveledPath!, [.solidColor(self.color)], dashLengths, .projected) - self.geodesic = false + if let previousCircle = previousCircle { + let circleLocation = CLLocation(latitude: circleCoordinate.latitude, longitude: circleCoordinate.longitude) + let previousCircleLocation = CLLocation(latitude: previousCircle.coordinate.latitude, longitude: previousCircle.coordinate.longitude) + if circleLocation.distance(from: previousCircleLocation) < intervalDistanceIncrement { continue } + } + + circles.append((coordinate: circleCoordinate, radius: 5.0)) + previousCircle = (coordinate: circleCoordinate, radius: 5.0) + } + } } func createPathFromWaypoints(waypoints: [Waypoint]) -> GMSMutablePath { @@ -41,5 +67,4 @@ class WalkPath: Path { } return path } - } diff --git a/TCAT/Models/Waypoint.swift b/TCAT/Models/Waypoint.swift index a7424995..2a57d394 100755 --- a/TCAT/Models/Waypoint.swift +++ b/TCAT/Models/Waypoint.swift @@ -55,16 +55,20 @@ class Waypoint: NSObject { switch wpType { case .origin: self.iconView = Circle(size: .large, style: .solid, color: isStop ? Colors.tcatBlue : Colors.metadataIcon) + case .destination: self.iconView = Circle( size: .large, style: .bordered, color: isStop ? Colors.tcatBlue : Colors.metadataIcon ) + case .bus: self.iconView = Circle(size: .small, style: .solid, color: Colors.tcatBlue) + case .walk: self.iconView = Circle(size: .small, style: .solid, color: Colors.metadataIcon) + case .none, .stop, .walking, .bussing: self.iconView = UIView() } @@ -126,8 +130,10 @@ class Waypoint: NSObject { switch wpType { case .destination: iconView.layer.borderColor = color.cgColor + case .origin, .stop, .bus, .walk, .bussing, .walking: iconView.backgroundColor = color + case .none: break } diff --git a/TCAT/Network/Endpoints.swift b/TCAT/Network/Endpoints.swift deleted file mode 100755 index 4ad5a1a8..00000000 --- a/TCAT/Network/Endpoints.swift +++ /dev/null @@ -1,119 +0,0 @@ -// -// Network+Endpoints.swift -// TCAT -// -// Created by Austin Astorga on 4/6/17. -// Copyright © 2017 cuappdev. All rights reserved. -// - -import CoreLocation -import Foundation -import FutureNova - -extension Endpoint { - - static func setupEndpointConfig() { - Endpoint.config.scheme = "https" - Endpoint.config.host = TransitEnvironment.transitURL.replacingOccurrences(of: "https://", with: "") - Endpoint.config.commonPath = "/api/v3" - } - - static func getAllStops() -> Endpoint { - return Endpoint(path: Constants.Endpoints.allStops) - } - - static func getAlerts() -> Endpoint { - return Endpoint(path: Constants.Endpoints.alerts) - } - - static func getRoutes( - start: Place, - end: Place, - time: Date, - type: SearchType - ) -> Endpoint? { - let uid = sharedUserDefaults?.string(forKey: Constants.UserDefaults.uid) - let body = GetRoutesBody( - arriveBy: type == .arriveBy, - end: "\(end.latitude),\(end.longitude)", - start: "\(start.latitude),\(start.longitude)", - time: time.timeIntervalSince1970, - destinationName: end.name, - originName: start.name, - uid: uid - ) - // MARK: - Temporary fix for Boom - return Endpoint(path: "/api/v2"+Constants.Endpoints.getRoutes, body: body, useCommonPath: false) - } - - static func getMultiRoutes( - startCoord: CLLocationCoordinate2D, - time: Date, - endCoords: [String], - endPlaceNames: [String] - ) -> Endpoint { - let body = MultiRoutesBody( - start: "\(startCoord.latitude),\(startCoord.longitude)", - time: time.timeIntervalSince1970, - end: endCoords, - destinationNames: endPlaceNames - ) - return Endpoint(path: Constants.Endpoints.multiRoute, body: body) - } - - static func getPlaceIDCoordinates(placeID: String) -> Endpoint { - let body = PlaceIDCoordinatesBody(placeID: placeID) - return Endpoint(path: Constants.Endpoints.placeIDCoordinates, body: body) - } - - static func getAppleSearchResults(searchText: String) -> Endpoint { - let body = SearchResultsBody(query: searchText) - return Endpoint(path: Constants.Endpoints.appleSearch, body: body) - } - - static func updateApplePlacesCache(searchText: String, places: [Place]) -> Endpoint { - let body = ApplePlacesBody(query: searchText, places: places) - return Endpoint(path: Constants.Endpoints.applePlaces, body: body) - } - - static func routeSelected(routeId: String) -> Endpoint { - // Add unique identifier to request - let uid = sharedUserDefaults?.string(forKey: Constants.UserDefaults.uid) - - let body = RouteSelectedBody(routeId: routeId, uid: uid) - return Endpoint(path: Constants.Endpoints.routeSelected, body: body) - } - - static func getBusLocations(_ directions: [Direction]) -> Endpoint { - let departDirections = directions.filter { $0.type == .depart && $0.tripIdentifiers != nil } - - let locationsInfo = departDirections.map { direction -> BusLocationsInfo in - // The id of the location, or bus stop, the bus needs to get to - let stopID = direction.stops.first?.id ?? "-1" - return BusLocationsInfo( - stopID: stopID, - routeID: String(direction.routeNumber), - tripIdentifiers: direction.tripIdentifiers! - ) - } - - let body = GetBusLocationsBody(data: locationsInfo) - return Endpoint(path: Constants.Endpoints.busLocations, body: body) - } - - static func getDelay(tripID: String, stopID: String) -> Endpoint { - let queryItems = GetDelayBody(stopID: stopID, tripID: tripID).toQueryItems() - return Endpoint(path: Constants.Endpoints.delay, queryItems: queryItems) - } - - static func getAllDelays(trips: [Trip]) -> Endpoint { - let body = TripBody(data: trips) - return Endpoint(path: Constants.Endpoints.delays, body: body) - } - - static func getDelayUrl(tripId: String, stopId: String) -> String { - let path = "delay" - return "\(String(describing: Endpoint.config.host))\(path)?stopID=\(stopId)&tripID=\(tripId)" - } - -} diff --git a/TCAT/Network/Reachability.swift b/TCAT/Network/Reachability.swift deleted file mode 100755 index 05b8a2ea..00000000 --- a/TCAT/Network/Reachability.swift +++ /dev/null @@ -1,334 +0,0 @@ -/* -Copyright (c) 2014, Ashley Mills -All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this -list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, -this list of conditions and the following disclaimer in the documentation -and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE -ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE -LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR -CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF -SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS -INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN -CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) -ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE -POSSIBILITY OF SUCH DAMAGE. -*/ - -import SystemConfiguration -import Foundation - -enum ReachabilityError: Swift.Error { - case FailedToCreateWithAddress(sockaddr_in) - case FailedToCreateWithHostname(String) - case UnableToSetCallback - case UnableToSetDispatchQueue -} - -@available(*, unavailable, renamed: "Notification.Name.reachabilityChanged") -public let ReachabilityChangedNotification = NSNotification.Name("ReachabilityChangedNotification") - -extension Notification.Name { - public static let reachabilityChanged = Notification.Name("reachabilityChanged") -} - -func callback(reachability: SCNetworkReachability, flags: SCNetworkReachabilityFlags, info: UnsafeMutableRawPointer?) { - - guard let info = info else { return } - - let reachability = Unmanaged.fromOpaque(info).takeUnretainedValue() - reachability.reachabilityChanged() -} - -public class Reachability { - - public typealias NetworkReachable = (Reachability) -> Void - public typealias NetworkUnreachable = (Reachability) -> Void - - @available(*, unavailable, renamed: "Conection") - public enum NetworkStatus: CustomStringConvertible { - case notReachable, reachableViaWiFi, reachableViaWWAN - public var description: String { - switch self { - case .reachableViaWWAN: return "Cellular" - case .reachableViaWiFi: return "WiFi" - case .notReachable: return "No Connection" - } - } - } - - public enum Connection: CustomStringConvertible { - case none, wifi, cellular - public var description: String { - switch self { - case .cellular: return "Cellular" - case .wifi: return "WiFi" - case .none: return "No Connection" - } - } - } - - public var whenReachable: NetworkReachable? - public var whenUnreachable: NetworkUnreachable? - - @available(*, deprecated, renamed: "allowsCellularConnection") - public let reachableOnWWAN: Bool = true - - /// Set to `false` to force Reachability.connection to .none when on cellular connection (default value `true`) - public var allowsCellularConnection: Bool - - // The notification center on which "reachability changed" events are being posted - public var notificationCenter: NotificationCenter = NotificationCenter.default - - @available(*, deprecated, renamed: "connection.description") - public var currentReachabilityString: String { - return "\(connection)" - } - - @available(*, unavailable, renamed: "connection") - public var currentReachabilityStatus: Connection { - return connection - } - - public var connection: Connection { - - guard isReachableFlagSet else { return .none } - - // If we're reachable, but not on an iOS device (i.e. simulator), we must be on WiFi - guard isRunningOnDevice else { return .wifi } - - var connection = Connection.none - - if !isConnectionRequiredFlagSet { - connection = .wifi - } - - if isConnectionOnTrafficOrDemandFlagSet { - if !isInterventionRequiredFlagSet { - connection = .wifi - } - } - - if isOnWWANFlagSet { - if !allowsCellularConnection { - connection = .none - } else { - connection = .cellular - } - } - - return connection - } - - fileprivate var previousFlags: SCNetworkReachabilityFlags? - - fileprivate var isRunningOnDevice: Bool = { - #if targetEnvironment(simulator) - return false - #else - return true - #endif - }() - - fileprivate var notifierRunning = false - fileprivate let reachabilityRef: SCNetworkReachability - - fileprivate let reachabilitySerialQueue = DispatchQueue(label: "uk.co.ashleymills.reachability") - - public required init(reachabilityRef: SCNetworkReachability) { - allowsCellularConnection = true - self.reachabilityRef = reachabilityRef - } - - public convenience init?(hostname: String) { - - guard let ref = SCNetworkReachabilityCreateWithName(nil, hostname) else { return nil } - - self.init(reachabilityRef: ref) - } - - public convenience init?() { - - var zeroAddress = sockaddr() - zeroAddress.sa_len = UInt8(MemoryLayout.size) - zeroAddress.sa_family = sa_family_t(AF_INET) - - guard let ref = SCNetworkReachabilityCreateWithAddress(nil, &zeroAddress) else { return nil } - - self.init(reachabilityRef: ref) - } - - deinit { - stopNotifier() - } -} - -public extension Reachability { - - // MARK: - *** Notifier methods *** - func startNotifier() throws { - - guard !notifierRunning else { return } - - var context = SCNetworkReachabilityContext( - version: 0, - info: nil, - retain: nil, - release: nil, - copyDescription: nil - ) - context.info = UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) - if !SCNetworkReachabilitySetCallback(reachabilityRef, callback, &context) { - stopNotifier() - throw ReachabilityError.UnableToSetCallback - } - - if !SCNetworkReachabilitySetDispatchQueue(reachabilityRef, reachabilitySerialQueue) { - stopNotifier() - throw ReachabilityError.UnableToSetDispatchQueue - } - - // Perform an initial check - reachabilitySerialQueue.async { - self.reachabilityChanged() - } - - notifierRunning = true - } - - func stopNotifier() { - defer { notifierRunning = false } - - SCNetworkReachabilitySetCallback(reachabilityRef, nil, nil) - SCNetworkReachabilitySetDispatchQueue(reachabilityRef, nil) - } - - // MARK: - *** Connection test methods *** - @available(*, deprecated, message: "Please use `connection != .none`") - var isReachable: Bool { - - guard isReachableFlagSet else { return false } - - if isConnectionRequiredAndTransientFlagSet { - return false - } - - if isRunningOnDevice { - if isOnWWANFlagSet && !reachableOnWWAN { - // We don't want to connect when on cellular connection - return false - } - } - - return true - } - - @available(*, deprecated, message: "Please use `connection == .cellular`") - var isReachableViaWWAN: Bool { - // Check we're not on the simulator, we're REACHABLE and check we're on WWAN - return isRunningOnDevice && isReachableFlagSet && isOnWWANFlagSet - } - - @available(*, deprecated, message: "Please use `connection == .wifi`") - var isReachableViaWiFi: Bool { - - // Check we're reachable - guard isReachableFlagSet else { return false } - - // If reachable we're reachable, but not on an iOS device (i.e. simulator), we must be on WiFi - guard isRunningOnDevice else { return true } - - // Check we're NOT on WWAN - return !isOnWWANFlagSet - } - - var description: String { - - let W = isRunningOnDevice ? (isOnWWANFlagSet ? "W" : "-") : "X" - let R = isReachableFlagSet ? "R" : "-" - let c = isConnectionRequiredFlagSet ? "c" : "-" - let t = isTransientConnectionFlagSet ? "t" : "-" - let i = isInterventionRequiredFlagSet ? "i" : "-" - let C = isConnectionOnTrafficFlagSet ? "C" : "-" - let D = isConnectionOnDemandFlagSet ? "D" : "-" - let l = isLocalAddressFlagSet ? "l" : "-" - let d = isDirectFlagSet ? "d" : "-" - - return "\(W)\(R) \(c)\(t)\(i)\(C)\(D)\(l)\(d)" - } -} - -fileprivate extension Reachability { - - func reachabilityChanged() { - guard previousFlags != flags else { return } - - let block = connection != .none ? whenReachable : whenUnreachable - - DispatchQueue.main.async { - block?(self) - self.notificationCenter.post(name: .reachabilityChanged, object: self) - } - - previousFlags = flags - } - - var isOnWWANFlagSet: Bool { - #if os(iOS) - return flags.contains(.isWWAN) - #else - return false - #endif - } - var isReachableFlagSet: Bool { - return flags.contains(.reachable) - } - var isConnectionRequiredFlagSet: Bool { - return flags.contains(.connectionRequired) - } - var isInterventionRequiredFlagSet: Bool { - return flags.contains(.interventionRequired) - } - var isConnectionOnTrafficFlagSet: Bool { - return flags.contains(.connectionOnTraffic) - } - var isConnectionOnDemandFlagSet: Bool { - return flags.contains(.connectionOnDemand) - } - var isConnectionOnTrafficOrDemandFlagSet: Bool { - return !flags.isDisjoint(with: ([.connectionOnTraffic, .connectionOnDemand])) - } - var isTransientConnectionFlagSet: Bool { - return flags.contains(.transientConnection) - } - var isLocalAddressFlagSet: Bool { - return flags.contains(.isLocalAddress) - } - var isDirectFlagSet: Bool { - return flags.contains(.isDirect) - } - var isConnectionRequiredAndTransientFlagSet: Bool { - return flags.intersection( - [.connectionRequired, .transientConnection] - ) == [.connectionRequired, .transientConnection] - } - - var flags: SCNetworkReachabilityFlags { - var flags = SCNetworkReachabilityFlags() - if SCNetworkReachabilityGetFlags(reachabilityRef, &flags) { - return flags - } else { - return SCNetworkReachabilityFlags() - } - } -} diff --git a/TCAT/Network/ReachabilityManager.swift b/TCAT/Network/ReachabilityManager.swift deleted file mode 100644 index 853a788a..00000000 --- a/TCAT/Network/ReachabilityManager.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// ReachabilityManager.swift -// TCAT -// -// Created by Daniel Vebman on 11/6/19. -// Copyright © 2019 cuappdev. All rights reserved. -// - -import Foundation - -class ReachabilityManager: NSObject { - - static let shared: ReachabilityManager = ReachabilityManager() - - private let reachability = Reachability() - private var listeners: [Pair] = [] - - typealias Listener = AnyObject - typealias Closure = (Reachability.Connection) -> Void - - private struct Pair { - weak var listener: Listener? - var closure: Closure - } - - override private init() { - super.init() - - do { - try reachability?.startNotifier() - } catch { - print("[ReachabilityManager] init: Could not start reachability notifier.") - } - - NotificationCenter.default.addObserver( - self, - selector: #selector(reachabilityChanged(_:)), - name: .reachabilityChanged, - object: reachability - ) - } - - /// Adds a listener to reachability updates. - /// Reminder: Be sure to begin the closure with `[weak self]`. - func addListener(_ listener: Listener, _ closure: @escaping Closure) { - listeners.append(Pair(listener: listener, closure: closure)) - } - - @objc func reachabilityChanged(_ notification: Notification) { - guard let reachability = reachability else { return } - listeners = listeners.filter { pair -> Bool in - pair.closure(reachability.connection) // call the closures - return pair.listener != nil // remove closures for deinitialized listeners - } - } - -} diff --git a/TCAT/Services/Network/ApiEndpoint.swift b/TCAT/Services/Network/ApiEndpoint.swift new file mode 100644 index 00000000..19323ea7 --- /dev/null +++ b/TCAT/Services/Network/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/Services/Network/ApiErrorHandler.swift b/TCAT/Services/Network/ApiErrorHandler.swift new file mode 100644 index 00000000..12edbc1b --- /dev/null +++ b/TCAT/Services/Network/ApiErrorHandler.swift @@ -0,0 +1,67 @@ +// +// ApiErrorHandler.swift +// TCAT +// +// Created by Jayson Hahn on 9/16/24. +// Copyright © 2024 Cornell AppDev. All rights reserved. +// + +import Foundation + +/// Represents an API error with optional code and message. +struct ApiError: Codable { + let code: String? + let message: String? +} + +/// Enum to handle various API errors and provide localized error descriptions. +enum ApiErrorHandler: LocalizedError { + /// Custom API error with associated `ApiError` object. + case customApiError(ApiError) + + /// Error indicating that the request failed. + case requestFailed + + /// Normal error with associated `Error` object. + case normalError(Error) + + /// Error indicating an empty response with a specific status code. + case emptyErrorWithStatusCode(String) + + /// Error indicating that no search results were found. + case noSearchResultsFound + + /// Provides a localized description for each error case. + var errorDescription: String { + switch self { + case .customApiError(let apiError): + var errorComponents = [String]() + + if let code = apiError.code, !code.isEmpty { + errorComponents.append("Code: \(code)") + } + + if let message = apiError.message, !message.isEmpty { + errorComponents.append("Message: \(message)") + } + + if errorComponents.isEmpty { + return "Internal error!" + } + + return errorComponents.joined(separator: "\n") + + case .requestFailed: + return "Request failed" + + case .normalError(let error): + return error.localizedDescription + + case .emptyErrorWithStatusCode(let status): + return "Empty response with status code: \(status)" + + case .noSearchResultsFound: + return "No search results found" + } + } +} diff --git a/TCAT/Services/Network/NetworkManager.swift b/TCAT/Services/Network/NetworkManager.swift new file mode 100644 index 00000000..01af7232 --- /dev/null +++ b/TCAT/Services/Network/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/Services/Network/NetworkMonitor.swift b/TCAT/Services/Network/NetworkMonitor.swift new file mode 100644 index 00000000..0309d0fc --- /dev/null +++ b/TCAT/Services/Network/NetworkMonitor.swift @@ -0,0 +1,75 @@ +// +// NetworkMonitor.swift +// TCAT +// +// Created by Jayson Hahn on 10/9/24. +// Copyright © 2024 Cornell AppDev. All rights reserved. +// + +import Network +import Foundation + +/// A singleton class that monitors the network status using `NWPathMonitor`. +final class NetworkMonitor { + + /// The shared instance of `NetworkMonitor`. + static let shared = NetworkMonitor() + + /// A network path monitor that observes changes in network status. + /// This instance is used to monitor the network connectivity status of the device. + private let monitor = NWPathMonitor() + private var status: NWPath.Status = .requiresConnection + + /// Indicates whether the current connection is cellular. + public var isCellular: Bool = false + + /// Indicates whether the network is reachable. + public var isReachable: Bool { status == .satisfied } + + /// Optional handler that gets called when the network becomes reachable. + public var whenReachable: (() -> Void)? + + /// Optional handler that gets called when the network becomes unreachable. + public var whenUnreachable: (() -> Void)? + + private init() {} + + public func startMonitoring() { + monitor.pathUpdateHandler = { [weak self] path in + self?.status = path.status + self?.isCellular = path.isExpensive + + // Notify handlers and observers based on connection status + if path.status == .satisfied { + print("Connected to the network.") + self?.whenReachable?() + NotificationCenter.default.post(name: .reachabilityChanged, object: self) + } else { + print("No network connection.") + self?.whenUnreachable?() + NotificationCenter.default.post(name: .reachabilityChanged, object: self) + } + + if path.usesInterfaceType(.wifi) { + print("We're connected over Wifi!") + } else if path.usesInterfaceType(.cellular) { + print("We're connected over Cellular!") + } else { + print("We're connected over other network!") + } + } + + let queue = DispatchQueue.global(qos: .background) + monitor.start(queue: queue) + } + + /// Stops monitoring the network status. + public func stopMonitoring() { + monitor.cancel() + } +} + +extension Notification.Name { + /// Notification name for reachability changes. + static let reachabilityChanged = Notification.Name("reachabilityChanged") +} diff --git a/TCAT/Network/Models.swift b/TCAT/Services/Network/RequestModels.swift similarity index 81% rename from TCAT/Network/Models.swift rename to TCAT/Services/Network/RequestModels.swift index c701dc6a..a8f8e50b 100644 --- a/TCAT/Network/Models.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,13 +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? } -// Response -struct Response: Codable { +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 new file mode 100644 index 00000000..ac6c6f28 --- /dev/null +++ b/TCAT/Services/Transit/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 departureNotification(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, .departureNotification, .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, .departureNotification, .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 .departureNotification: + 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 .departureNotification( + 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/Services/Transit/TransitService.swift b/TCAT/Services/Transit/TransitService.swift new file mode 100644 index 00000000..07899d6a --- /dev/null +++ b/TCAT/Services/Transit/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.departureNotification(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/Supporting/Constants.swift b/TCAT/Supporting/Constants.swift index f186c2fa..cfe3b0bf 100644 --- a/TCAT/Supporting/Constants.swift +++ b/TCAT/Supporting/Constants.swift @@ -166,12 +166,13 @@ 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" - static let multiRoute = "/multiroute" - static let placeIDCoordinates = "/placeIDCoordinates" - static let routeSelected = "/routeSelected" } struct Footers { @@ -198,7 +199,7 @@ struct Constants { static let favorites = "Your Favorites." static let liveTracking = "Live Tracking." static let searchAnywhere = "Search Anywhere." - static let welcome = "Welcome to Ithaca Transit." + static let welcome = "Welcome to Navi." /// Detail label messages static let favoritesMessage = "All of your favorite destinations are just one tap away." @@ -246,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/Info.plist b/TCAT/Supporting/Info.plist index d54da69f..2e3c25cb 100755 --- a/TCAT/Supporting/Info.plist +++ b/TCAT/Supporting/Info.plist @@ -69,7 +69,9 @@ UIBackgroundModes + fetch location + remote-notification UILaunchStoryboardName LaunchScreen diff --git a/TCAT/Supporting/TCAT.entitlements b/TCAT/Supporting/TCAT.entitlements index 410c063f..531c4668 100644 --- a/TCAT/Supporting/TCAT.entitlements +++ b/TCAT/Supporting/TCAT.entitlements @@ -2,6 +2,8 @@ + aps-environment + development com.apple.security.application-groups group.tcat 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/TCATDebug.entitlements b/TCAT/TCATDebug.entitlements new file mode 100644 index 00000000..531c4668 --- /dev/null +++ b/TCAT/TCATDebug.entitlements @@ -0,0 +1,12 @@ + + + + + aps-environment + development + com.apple.security.application-groups + + group.tcat + + + diff --git a/TCAT/TCATLocal.entitlements b/TCAT/TCATLocal.entitlements new file mode 100644 index 00000000..531c4668 --- /dev/null +++ b/TCAT/TCATLocal.entitlements @@ -0,0 +1,12 @@ + + + + + aps-environment + development + com.apple.security.application-groups + + group.tcat + + + diff --git a/TCAT/Utils/Extensions+App.swift b/TCAT/Utils/Extensions+App.swift index 76543ba4..d28e86c9 100755 --- a/TCAT/Utils/Extensions+App.swift +++ b/TCAT/Utils/Extensions+App.swift @@ -230,17 +230,25 @@ extension Array where Element: Comparable { } /// Present a share sheet for a route in any context. -func presentShareSheet(from view: UIView, for route: Route, with image: UIImage? = nil) { - - let shareText = route.summaryDescription - let promotionalText = "Download Ithaca Transit on the App Store! \(Constants.App.appStoreLink)" +func presentShareSheet( + from view: UIView, + for destination: Place, + with image: UIImage? = nil +) { + + let lat: Double = destination.latitude + let long: Double = destination.longitude + let thirdParamName: String = ( + destination.type == .busStop + ) ? "stopName" : "destinationName" + let destType = ( + destination.type == .busStop + ) ? "busStop" : "applePlace" + let dest = destination.name + let formattedDestination = dest.split(separator: " ").joined(separator: "%") + let promotionalText = "ithaca-transit://getRoutes?lat=\(lat)&long=\(long)&\(thirdParamName)=\(formattedDestination)&destinationType=\(destType)" var activityItems: [Any] = [promotionalText] - if let shareImage = image { - activityItems.insert(shareImage, at: 0) - } else { - activityItems.insert(shareText, at: 0) - } let activityVC = UIActivityViewController(activityItems: activityItems, applicationActivities: nil) activityVC.excludedActivityTypes = [.print, .assignToContact, .openInIBooks, .addToReadingList] @@ -268,8 +276,11 @@ infix operator ???: NilCoalescingPrecedence public func ??? (optional: T?, defaultValue: @autoclosure () -> String) -> String { switch optional { - case let value?: return String(describing: value) - case nil: return defaultValue() + case let value?: + return String(describing: value) + + case nil: + return defaultValue() } } diff --git a/TCAT/Utils/Extensions+Shared.swift b/TCAT/Utils/Extensions+Shared.swift index eec7e14b..3b436690 100644 --- a/TCAT/Utils/Extensions+Shared.swift +++ b/TCAT/Utils/Extensions+Shared.swift @@ -245,3 +245,30 @@ extension NSObject { } } + +extension UserDefaults { + + /// Initializes user defaults with default values if they don't exist + func initialize(with defaults: [(key: String, defaultValue: Any)]) { + for (key, defaultValue) in defaults where !hasValue(forKey: key) { + set(defaultValue, forKey: key) + } + } + + /// Creates and sets a unique identifier. If the device identifier changes, updates it. + func setupUniqueIdentifier() { + guard let uid = UIDevice.current.identifierForVendor?.uuidString else { + return + } + + if uid != self.string(forKey: Constants.UserDefaults.uid) { + self.set(uid, forKey: Constants.UserDefaults.uid) + } + } + + /// Checks if a value exists for a given key + private func hasValue(forKey key: String) -> Bool { + return object(forKey: key) != nil + } + +} diff --git a/TCAT/Utils/JSONFileManager.swift b/TCAT/Utils/JSONFileManager.swift index a2c144db..ae85f6e2 100644 --- a/TCAT/Utils/JSONFileManager.swift +++ b/TCAT/Utils/JSONFileManager.swift @@ -18,6 +18,7 @@ enum JSONType { switch self { case .routeJSON: return "routeJSON" + case .delayJSON: return "delayJSON" } diff --git a/TCAT/Utils/SearchTableViewHelpers.swift b/TCAT/Utils/SearchTableViewHelpers.swift index 03b4674b..2d495c12 100755 --- a/TCAT/Utils/SearchTableViewHelpers.swift +++ b/TCAT/Utils/SearchTableViewHelpers.swift @@ -17,16 +17,8 @@ class Global { static let shared = Global() func retrievePlaces(for key: String) -> [Place] { - if key == Constants.UserDefaults.favorites { - if let storedPlaces = sharedUserDefaults?.value(forKey: key) as? Data, - let favorites = try? decoder.decode([Place].self, from: storedPlaces) { - return favorites - } - - } else if - let storedPlaces = userDefaults.value(forKey: key) as? Data, - let places = try? decoder.decode([Place].self, from: storedPlaces) - { + if let storedPlaces = userDefaults.value(forKey: key) as? Data, + let places = try? decoder.decode([Place].self, from: storedPlaces) { return places } return [Place]() @@ -37,7 +29,7 @@ class Global { let newFavoritesList = allFavorites.filter { favorite != $0 } do { let data = try encoder.encode(newFavoritesList) - sharedUserDefaults?.set(data, forKey: Constants.UserDefaults.favorites) + userDefaults.set(data, forKey: Constants.UserDefaults.favorites) AppShortcuts.shared.updateShortcutItems() } catch let error { print(error) @@ -86,11 +78,7 @@ class Global { do { let data = try encoder.encode(places) - if key == Constants.UserDefaults.favorites { - sharedUserDefaults?.set(data, forKey: key) - } else { - userDefaults.set(data, forKey: key) - } + userDefaults.set(data, forKey: key) AppShortcuts.shared.updateShortcutItems() } catch let error { print(error) diff --git a/TCAT/Utils/Shared.swift b/TCAT/Utils/Shared.swift index d6f24184..8edc08b0 100644 --- a/TCAT/Utils/Shared.swift +++ b/TCAT/Utils/Shared.swift @@ -10,9 +10,6 @@ import Foundation /// This class is for shared enums between TCAT and the Today Extension. -/// This is used for favorites between targets (e.g. TCAT.app, Today Extension) -let sharedUserDefaults = UserDefaults.init(suiteName: Constants.UserDefaults.group) - enum SearchType: String { case arriveBy, leaveAt, leaveNow } diff --git a/TCAT/Utils/StoreReviewHelper.swift b/TCAT/Utils/StoreReviewHelper.swift index 64e3dfe2..4243a908 100644 --- a/TCAT/Utils/StoreReviewHelper.swift +++ b/TCAT/Utils/StoreReviewHelper.swift @@ -57,6 +57,7 @@ class StoreReviewHelper { switch appOpenCount { case firstRequestLaunchCount, secondRequestLaunchCount, thirdRequestLaunchCount: StoreReviewHelper.shared.requestReview() + case _ where appOpenCount % futureRequestInterval == 0: StoreReviewHelper.shared.requestReview() default: diff --git a/TCAT/Utils/Styles.swift b/TCAT/Utils/Styles.swift index 730d872b..028324ee 100644 --- a/TCAT/Utils/Styles.swift +++ b/TCAT/Utils/Styles.swift @@ -66,17 +66,31 @@ extension UIFont { var fontString: String if size >= 14 { switch name { - case .regular: fontString = Fonts.SanFrancisco.ProDisplay.regular - case .medium: fontString = Fonts.SanFrancisco.ProDisplay.medium - case .semibold: fontString = Fonts.SanFrancisco.ProDisplay.semibold - case .bold: fontString = Fonts.SanFrancisco.ProDisplay.bold + case .regular: + fontString = Fonts.SanFrancisco.ProDisplay.regular + + case .medium: + fontString = Fonts.SanFrancisco.ProDisplay.medium + + case .semibold: + fontString = Fonts.SanFrancisco.ProDisplay.semibold + + case .bold: + fontString = Fonts.SanFrancisco.ProDisplay.bold } } else { switch name { - case .regular: fontString = Fonts.SanFrancisco.ProText.regular - case .medium: fontString = Fonts.SanFrancisco.ProText.medium - case .semibold: fontString = Fonts.SanFrancisco.ProText.semibold - case .bold: fontString = Fonts.SanFrancisco.ProText.bold + case .regular: + fontString = Fonts.SanFrancisco.ProText.regular + + case .medium: + fontString = Fonts.SanFrancisco.ProText.medium + + case .semibold: + fontString = Fonts.SanFrancisco.ProText.semibold + + case .bold: + fontString = Fonts.SanFrancisco.ProText.bold } } return UIFont(name: fontString, size: size)! diff --git a/TCAT/Views/BusIcon.swift b/TCAT/Views/BusIcon.swift index 8152c8e3..91710967 100755 --- a/TCAT/Views/BusIcon.swift +++ b/TCAT/Views/BusIcon.swift @@ -16,8 +16,10 @@ enum BusIconType: String { switch self { case .blueBannerSmall, .directionSmall, .redBannerSmall: return 48 + case .directionLarge: return 72 + case .liveTracking: return 72 } @@ -28,8 +30,10 @@ enum BusIconType: String { switch self { case .blueBannerSmall, .directionSmall, .redBannerSmall: return 24 + case .directionLarge: return 36 + case .liveTracking: return 30 } @@ -40,6 +44,7 @@ enum BusIconType: String { switch self { case .directionLarge: return 8 + default: return 4 } @@ -49,6 +54,7 @@ enum BusIconType: String { switch self { case .blueBannerSmall, .redBannerSmall: return Colors.white + case .directionLarge, .directionSmall, .liveTracking: return Colors.tcatBlue } @@ -58,8 +64,10 @@ enum BusIconType: String { switch self { case .blueBannerSmall: return Colors.tcatBlue + case .directionLarge, .directionSmall, .liveTracking: return Colors.white + case .redBannerSmall: return Colors.lateRed } @@ -88,9 +96,14 @@ class BusIcon: UIView { var fontSize: CGFloat switch type { - case .blueBannerSmall, .directionSmall, .redBannerSmall: fontSize = 14 - case .directionLarge: fontSize = 20 - case .liveTracking: fontSize = 16 + case .blueBannerSmall, .directionSmall, .redBannerSmall: + fontSize = 14 + + case .directionLarge: + fontSize = 20 + + case .liveTracking: + fontSize = 16 } backgroundColor = .clear @@ -122,9 +135,14 @@ class BusIcon: UIView { var constant: CGFloat switch type { - case .blueBannerSmall, .directionSmall, .redBannerSmall: constant = 0.75 - case .directionLarge: constant = 1 - case .liveTracking: constant = 0.87 + case .blueBannerSmall, .directionSmall, .redBannerSmall: + constant = 0.75 + + case .directionLarge: + constant = 1 + + case .liveTracking: + constant = 0.87 } let imageSize = CGSize(width: image.frame.width * constant, height: image.frame.height * constant) diff --git a/TCAT/Views/Circle.swift b/TCAT/Views/Circle.swift index bbf00e63..4b98b4d3 100755 --- a/TCAT/Views/Circle.swift +++ b/TCAT/Views/Circle.swift @@ -46,6 +46,7 @@ class Circle: UIView { switch style { case .solid: backgroundColor = color + case .bordered: backgroundColor = Colors.white layer.borderColor = color.cgColor @@ -64,6 +65,7 @@ class Circle: UIView { make.centerX.centerY.equalToSuperview() make.size.equalTo(CGSize(width: solidCircleDiameter, height: solidCircleDiameter)) } + case .outline: backgroundColor = Colors.white layer.borderColor = color.cgColor diff --git a/TCAT/Views/DatePickerView.swift b/TCAT/Views/DatePickerView.swift index 1b0bafe2..7f08faf3 100755 --- a/TCAT/Views/DatePickerView.swift +++ b/TCAT/Views/DatePickerView.swift @@ -82,7 +82,7 @@ class DatePickerView: UIView { private func setupTimeTypeSegmentedControl() { styleSegmentedControl(timeTypeSegmentedControl) - setSegmentedControlOptions(timeTypeSegmentedControl, options: [leaveNowElement.title,leaveAtElement.title, arriveByElement.title]) + setSegmentedControlOptions(timeTypeSegmentedControl, options: [leaveNowElement.title, leaveAtElement.title, arriveByElement.title]) timeTypeSegmentedControl.selectedSegmentIndex = leaveNowElement.index addSubview(timeTypeSegmentedControl) @@ -157,6 +157,7 @@ class DatePickerView: UIView { switch searchTimeType { case .leaveAt, .leaveNow: timeTypeSegmentedControl.selectedSegmentIndex = leaveAtElement.index + case .arriveBy: timeTypeSegmentedControl.selectedSegmentIndex = arriveByElement.index } @@ -170,8 +171,10 @@ class DatePickerView: UIView { switch timeTypeSegmentedControl.selectedSegmentIndex { case arriveByElement.index: searchTimeType = .arriveBy + case leaveAtElement.index: searchTimeType = .leaveAt + default: break } diff --git a/TCAT/Views/DetailIconView.swift b/TCAT/Views/DetailIconView.swift index ae9dde0f..4fca643d 100755 --- a/TCAT/Views/DetailIconView.swift +++ b/TCAT/Views/DetailIconView.swift @@ -154,9 +154,21 @@ class DetailIconView: UIView { setTimeLabelTexts(for: direction, isLastStep: isLast) if direction.type == .walk { - scheduledTimeLabel.textColor = Colors.primaryText - centerScheduledLabel() - hideDelayedLabel() + if let delay = direction.delay { + if delay < 60 { + scheduledTimeLabel.textColor = Colors.liveGreen + centerScheduledLabel() + hideDelayedLabel() + } else { + scheduledTimeLabel.textColor = Colors.primaryText + showDelayedLabel() + offsetScheduledLabel() + } + } else { + scheduledTimeLabel.textColor = Colors.primaryText + hideDelayedLabel() + centerScheduledLabel() + } } else { if let delay = direction.delay { if delay < 60 { diff --git a/TCAT/Views/HeaderView.swift b/TCAT/Views/HeaderView.swift index 9cae955a..d1a977cd 100644 --- a/TCAT/Views/HeaderView.swift +++ b/TCAT/Views/HeaderView.swift @@ -77,6 +77,7 @@ class HeaderView: UITableViewHeaderFooterView { case .clear: button?.setTitle(Constants.Buttons.clear, for: .normal) button?.addTarget(self, action: #selector(clearRecentSearches), for: .touchUpInside) + default: return } diff --git a/TCAT/Views/NotificationBannerView.swift b/TCAT/Views/NotificationBannerView.swift index 482e0428..52528b2b 100644 --- a/TCAT/Views/NotificationBannerView.swift +++ b/TCAT/Views/NotificationBannerView.swift @@ -16,6 +16,8 @@ enum NotificationType { switch self { case .beforeBoarding: return Constants.Notification.notifyBeforeBoarding + + case .delay: return Constants.Notification.notifyDelay } @@ -25,13 +27,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 } } @@ -81,8 +84,14 @@ class NotificationBannerView: UIView { switch type { case .beforeBoardingConfirmation: beginningText = Constants.Notification.beforeBoardingConfirmation + + case .delayConfirmation: beginningText = Constants.Notification.delayConfirmation + + case .unableToConfirmBeforeBoarding: + beginningText = Constants.Notification.unableToConfirmBeforeBoarding + default: beginningText = "" }