diff --git a/GeoBus.xcodeproj/project.pbxproj b/GeoBus.xcodeproj/project.pbxproj index 6fe8a5d3..cf90877b 100644 --- a/GeoBus.xcodeproj/project.pbxproj +++ b/GeoBus.xcodeproj/project.pbxproj @@ -11,6 +11,8 @@ CF05F61A28CD09A000B4AD58 /* NavBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF05F61928CD09A000B4AD58 /* NavBar.swift */; }; CF05F62028CD337200B4AD58 /* AppVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF05F61F28CD337200B4AD58 /* AppVersion.swift */; }; CF0C256A290211EF00B03052 /* DataProvidersCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF0C2569290211EF00B03052 /* DataProvidersCard.swift */; }; + CF0C256C29031B2C00B03052 /* CarrisCommunityAPIModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF0C256B29031B2C00B03052 /* CarrisCommunityAPIModel.swift */; }; + CF0C256E290324A600B03052 /* CarrisCommunityAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF0C256D290324A600B03052 /* CarrisCommunityAPI.swift */; }; CF181FE628CCB7D600248F72 /* GeoBusApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF181FE528CCB7D600248F72 /* GeoBusApp.swift */; }; CF181FE828CCB7D600248F72 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF181FE728CCB7D600248F72 /* ContentView.swift */; }; CF181FEA28CCB7D700248F72 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CF181FE928CCB7D700248F72 /* Assets.xcassets */; }; @@ -24,7 +26,6 @@ CF18207028CCBD2300248F72 /* RouteDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF18206128CCBD2300248F72 /* RouteDetailsView.swift */; }; CF18207228CCBD2300248F72 /* VariantPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF18206528CCBD2300248F72 /* VariantPicker.swift */; }; CF18207328CCBD2300248F72 /* VariantButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF18206628CCBD2300248F72 /* VariantButton.swift */; }; - CF18207428CCBD2300248F72 /* CircularVariantInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF18206728CCBD2300248F72 /* CircularVariantInfo.swift */; }; CF18207528CCBD2300248F72 /* VariantWarning.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF18206828CCBD2300248F72 /* VariantWarning.swift */; }; CF18207628CCBD2300248F72 /* ConnectionsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF18206928CCBD2300248F72 /* ConnectionsList.swift */; }; CF18207728CCBD2300248F72 /* RouteDetailsAddToFavorites.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF18206A28CCBD2300248F72 /* RouteDetailsAddToFavorites.swift */; }; @@ -38,6 +39,7 @@ CF18209B28CCBD5000248F72 /* MapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF18209A28CCBD5000248F72 /* MapView.swift */; }; CF47994D28D3315E00B56D4B /* Appstate.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF47994C28D3315E00B56D4B /* Appstate.swift */; }; CF47994F28D33E1900B56D4B /* Disclaimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF47994E28D33E1900B56D4B /* Disclaimer.swift */; }; + CF47FCB529035D8300AE33B0 /* PresentedSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF47FCB429035D8300AE33B0 /* PresentedSheetView.swift */; }; CF5094C528FC279A00EDD320 /* Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF5094C428FC279A00EDD320 /* Array.swift */; }; CF5094C928FC50AC00EDD320 /* CarrisNetworkController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF5094C828FC50AC00EDD320 /* CarrisNetworkController.swift */; }; CF5094CB28FC50E900EDD320 /* CarrisNetworkModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF5094CA28FC50E900EDD320 /* CarrisNetworkModel.swift */; }; @@ -55,13 +57,21 @@ CF82BB0928D7F19A007F0CDB /* LocationCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF82BB0828D7F19A007F0CDB /* LocationCard.swift */; }; CF82BB0B28D7F1C6007F0CDB /* ShareCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF82BB0A28D7F1C6007F0CDB /* ShareCard.swift */; }; CF82BB0D28D7F202007F0CDB /* ContactsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF82BB0C28D7F202007F0CDB /* ContactsCard.swift */; }; + CF88E97A291B170200E22E82 /* NewMapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF88E979291B170200E22E82 /* NewMapView.swift */; }; CFAF0E7A28CE84F400DDAD5B /* MapAnnotations.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFAF0E7928CE84F400DDAD5B /* MapAnnotations.swift */; }; CFB5D45728EEFE21002368BC /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = CFB5D45528EEFE21002368BC /* InfoPlist.strings */; }; CFB5D45A28EEFE6B002368BC /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = CFB5D45828EEFE6B002368BC /* Localizable.strings */; }; + CFB71F82290CAFB500B37E69 /* LoadingSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFB71F81290CAFB500B37E69 /* LoadingSheet.swift */; }; + CFB71F84290E8ECF00B37E69 /* PlaceholderAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFB71F83290E8ECF00B37E69 /* PlaceholderAnimation.swift */; }; + CFB71F86290E913F00B37E69 /* Chip.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFB71F85290E913F00B37E69 /* Chip.swift */; }; + CFB71F88290F1AB100B37E69 /* SheetErrorScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFB71F87290F1AB100B37E69 /* SheetErrorScreen.swift */; }; CFDC15EF28D292FB00A4BE49 /* ViewSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFDC15EE28D292FB00A4BE49 /* ViewSize.swift */; }; CFDD014928D5114D0070FE4B /* SyncStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFDD014828D5114D0070FE4B /* SyncStatus.swift */; }; CFDD014B28D535370070FE4B /* Card.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFDD014A28D535370070FE4B /* Card.swift */; }; CFDD014D28D66D9B0070FE4B /* CloseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFDD014C28D66D9B0070FE4B /* CloseButton.swift */; }; + CFED5F8A2919B7820062045B /* SheetController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFED5F892919B7820062045B /* SheetController.swift */; }; + CFED5F8C2919CBBD0062045B /* Debounce.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFED5F8B2919CBBD0062045B /* Debounce.swift */; }; + CFEE4EA229202637005DFA1B /* BackgroundThreads.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFEE4EA129202637005DFA1B /* BackgroundThreads.swift */; }; CFEF85C228D34E4F00A29526 /* crowdin.yml in Resources */ = {isa = PBXBuildFile; fileRef = CFEF85C128D34E4F00A29526 /* crowdin.yml */; }; CFEF85C528D34E6300A29526 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = CFEF85C328D34E6300A29526 /* README.md */; }; CFEF85C628D34E6300A29526 /* LICENSE in Resources */ = {isa = PBXBuildFile; fileRef = CFEF85C428D34E6300A29526 /* LICENSE */; }; @@ -80,6 +90,8 @@ CF05F61928CD09A000B4AD58 /* NavBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavBar.swift; sourceTree = ""; }; CF05F61F28CD337200B4AD58 /* AppVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppVersion.swift; sourceTree = ""; }; CF0C2569290211EF00B03052 /* DataProvidersCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataProvidersCard.swift; sourceTree = ""; }; + CF0C256B29031B2C00B03052 /* CarrisCommunityAPIModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarrisCommunityAPIModel.swift; sourceTree = ""; }; + CF0C256D290324A600B03052 /* CarrisCommunityAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarrisCommunityAPI.swift; sourceTree = ""; }; CF181FE228CCB7D600248F72 /* GeoBus.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = GeoBus.app; sourceTree = BUILT_PRODUCTS_DIR; }; CF181FE528CCB7D600248F72 /* GeoBusApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoBusApp.swift; sourceTree = ""; }; CF181FE728CCB7D600248F72 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -94,7 +106,6 @@ CF18206128CCBD2300248F72 /* RouteDetailsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RouteDetailsView.swift; sourceTree = ""; }; CF18206528CCBD2300248F72 /* VariantPicker.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VariantPicker.swift; sourceTree = ""; }; CF18206628CCBD2300248F72 /* VariantButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VariantButton.swift; sourceTree = ""; }; - CF18206728CCBD2300248F72 /* CircularVariantInfo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularVariantInfo.swift; sourceTree = ""; }; CF18206828CCBD2300248F72 /* VariantWarning.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VariantWarning.swift; sourceTree = ""; }; CF18206928CCBD2300248F72 /* ConnectionsList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConnectionsList.swift; sourceTree = ""; }; CF18206A28CCBD2300248F72 /* RouteDetailsAddToFavorites.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RouteDetailsAddToFavorites.swift; sourceTree = ""; }; @@ -108,6 +119,7 @@ CF18209A28CCBD5000248F72 /* MapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapView.swift; sourceTree = ""; }; CF47994C28D3315E00B56D4B /* Appstate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Appstate.swift; sourceTree = ""; }; CF47994E28D33E1900B56D4B /* Disclaimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Disclaimer.swift; sourceTree = ""; }; + CF47FCB429035D8300AE33B0 /* PresentedSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresentedSheetView.swift; sourceTree = ""; }; CF5094C428FC279A00EDD320 /* Array.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Array.swift; sourceTree = ""; }; CF5094C828FC50AC00EDD320 /* CarrisNetworkController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarrisNetworkController.swift; sourceTree = ""; }; CF5094CA28FC50E900EDD320 /* CarrisNetworkModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarrisNetworkModel.swift; sourceTree = ""; }; @@ -126,6 +138,7 @@ CF82BB0828D7F19A007F0CDB /* LocationCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationCard.swift; sourceTree = ""; }; CF82BB0A28D7F1C6007F0CDB /* ShareCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareCard.swift; sourceTree = ""; }; CF82BB0C28D7F202007F0CDB /* ContactsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsCard.swift; sourceTree = ""; }; + CF88E979291B170200E22E82 /* NewMapView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewMapView.swift; sourceTree = ""; }; CFAF0E7928CE84F400DDAD5B /* MapAnnotations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapAnnotations.swift; sourceTree = ""; }; CFAF0E7B28CEC78C00DDAD5B /* GeoBus.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GeoBus.entitlements; sourceTree = ""; }; CFB5D45628EEFE21002368BC /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -136,6 +149,10 @@ CFB5D46028EEFEF1002368BC /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; CFB5D46128EEFF2C002368BC /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; CFB5D46228EEFF2D002368BC /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/InfoPlist.strings; sourceTree = ""; }; + CFB71F81290CAFB500B37E69 /* LoadingSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingSheet.swift; sourceTree = ""; }; + CFB71F83290E8ECF00B37E69 /* PlaceholderAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaceholderAnimation.swift; sourceTree = ""; }; + CFB71F85290E913F00B37E69 /* Chip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chip.swift; sourceTree = ""; }; + CFB71F87290F1AB100B37E69 /* SheetErrorScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetErrorScreen.swift; sourceTree = ""; }; CFCED4F628EF5CB900963640 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/Localizable.strings; sourceTree = ""; }; CFCED4F728EF5CB900963640 /* pl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pl; path = pl.lproj/InfoPlist.strings; sourceTree = ""; }; CFCED4F828EF5CD500963640 /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = fa.lproj/Localizable.strings; sourceTree = ""; }; @@ -144,6 +161,9 @@ CFDD014828D5114D0070FE4B /* SyncStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatus.swift; sourceTree = ""; }; CFDD014A28D535370070FE4B /* Card.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Card.swift; sourceTree = ""; }; CFDD014C28D66D9B0070FE4B /* CloseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloseButton.swift; sourceTree = ""; }; + CFED5F892919B7820062045B /* SheetController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheetController.swift; sourceTree = ""; }; + CFED5F8B2919CBBD0062045B /* Debounce.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debounce.swift; sourceTree = ""; }; + CFEE4EA129202637005DFA1B /* BackgroundThreads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundThreads.swift; sourceTree = ""; }; CFEF85C128D34E4F00A29526 /* crowdin.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = crowdin.yml; sourceTree = ""; }; CFEF85C328D34E6300A29526 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; CFEF85C428D34E6300A29526 /* LICENSE */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; @@ -176,6 +196,8 @@ children = ( CF181FE728CCB7D600248F72 /* ContentView.swift */, CF05F61928CD09A000B4AD58 /* NavBar.swift */, + CF47FCB429035D8300AE33B0 /* PresentedSheetView.swift */, + CF88E978291B170200E22E82 /* NewMap */, CF18208828CCBD4600248F72 /* Map */, CF05F62528CD60BD00B4AD58 /* SelectRoute */, CF05F61828CD097700B4AD58 /* RouteDetails */, @@ -196,7 +218,6 @@ CF18206528CCBD2300248F72 /* VariantPicker.swift */, CF18206828CCBD2300248F72 /* VariantWarning.swift */, CF18206628CCBD2300248F72 /* VariantButton.swift */, - CF18206728CCBD2300248F72 /* CircularVariantInfo.swift */, CF18206928CCBD2300248F72 /* ConnectionsList.swift */, ); path = RouteDetails; @@ -207,10 +228,13 @@ children = ( CF18204328CCBCC500248F72 /* SheetHeader.swift */, CF6C917D28D3ED0A006C3F61 /* SquareButton.swift */, + CFB71F87290F1AB100B37E69 /* SheetErrorScreen.swift */, CF18204128CCBCC500248F72 /* RouteBadgePill.swift */, CF18204228CCBCC500248F72 /* RouteBadgeSquare.swift */, CFFFAD7828F4AD0400DFD5FD /* TimeLeft.swift */, + CFB71F85290E913F00B37E69 /* Chip.swift */, CFFFAD7A28F4D8D000DFD5FD /* StopIcon.swift */, + CFB71F81290CAFB500B37E69 /* LoadingSheet.swift */, CF548FF528D14BA400668CB6 /* VehicleIdentifier.swift */, CF03D6AD28F3B00F0077299B /* VehicleDestination.swift */, CFDD014A28D535370070FE4B /* Card.swift */, @@ -225,6 +249,7 @@ children = ( CF47994C28D3315E00B56D4B /* Appstate.swift */, CFFFAD8028F64E2000DFD5FD /* Analytics.swift */, + CFED5F892919B7820062045B /* SheetController.swift */, CF6C918128D3F1C6006C3F61 /* MapController.swift */, CF5094C028FB992D00EDD320 /* Networks */, ); @@ -304,6 +329,8 @@ CFFFAD8228F6754400DFD5FD /* Helpers.swift */, CFDC15EE28D292FB00A4BE49 /* ViewSize.swift */, CF5094C428FC279A00EDD320 /* Array.swift */, + CFED5F8B2919CBBD0062045B /* Debounce.swift */, + CFEE4EA129202637005DFA1B /* BackgroundThreads.swift */, ); path = Extensions; sourceTree = ""; @@ -313,6 +340,7 @@ children = ( CFFFAD8A28F8F33200DFD5FD /* Pulse.swift */, CFFFAD8828F8ECDE00DFD5FD /* Spinner.swift */, + CFB71F83290E8ECF00B37E69 /* PlaceholderAnimation.swift */, ); path = Animations; sourceTree = ""; @@ -352,6 +380,8 @@ children = ( CFFFAD8428F7A21100DFD5FD /* CarrisAPI.swift */, CF5094CC28FCB9E400EDD320 /* CarrisAPIModel.swift */, + CF0C256D290324A600B03052 /* CarrisCommunityAPI.swift */, + CF0C256B29031B2C00B03052 /* CarrisCommunityAPIModel.swift */, CF5094CA28FC50E900EDD320 /* CarrisNetworkModel.swift */, CF5094C828FC50AC00EDD320 /* CarrisNetworkController.swift */, ); @@ -373,11 +403,20 @@ path = VehicleDetails; sourceTree = ""; }; + CF88E978291B170200E22E82 /* NewMap */ = { + isa = PBXGroup; + children = ( + CF88E979291B170200E22E82 /* NewMapView.swift */, + ); + path = NewMap; + sourceTree = ""; + }; CFF4875F28D3D35F00E2C13D /* About */ = { isa = PBXGroup; children = ( CF6C918728D3FAF8006C3F61 /* AboutGeoBus.swift */, CFDD014828D5114D0070FE4B /* SyncStatus.swift */, + CF0C2569290211EF00B03052 /* DataProvidersCard.swift */, CF82BB0628D7F166007F0CDB /* LiveDataCard.swift */, CF82BB0828D7F19A007F0CDB /* LocationCard.swift */, CF82BB0A28D7F1C6007F0CDB /* ShareCard.swift */, @@ -385,7 +424,6 @@ CFDD014C28D66D9B0070FE4B /* CloseButton.swift */, CF05F61F28CD337200B4AD58 /* AppVersion.swift */, CF47994E28D33E1900B56D4B /* Disclaimer.swift */, - CF0C2569290211EF00B03052 /* DataProvidersCard.swift */, ); path = About; sourceTree = ""; @@ -509,7 +547,6 @@ CF03D6AE28F3B00F0077299B /* VehicleDestination.swift in Sources */, CF18207328CCBD2300248F72 /* VariantButton.swift in Sources */, CF18209B28CCBD5000248F72 /* MapView.swift in Sources */, - CF18207428CCBD2300248F72 /* CircularVariantInfo.swift in Sources */, CF18208228CCBD3A00248F72 /* SelectRouteView.swift in Sources */, CF18208328CCBD3A00248F72 /* SelectRouteSheet.swift in Sources */, CF5094C528FC279A00EDD320 /* Array.swift in Sources */, @@ -517,31 +554,41 @@ CF18207728CCBD2300248F72 /* RouteDetailsAddToFavorites.swift in Sources */, CF6C918428D3F2C9006C3F61 /* UserLocation.swift in Sources */, CFFFAD8928F8ECDE00DFD5FD /* Spinner.swift in Sources */, + CF88E97A291B170200E22E82 /* NewMapView.swift in Sources */, CF18207528CCBD2300248F72 /* VariantWarning.swift in Sources */, CF6C918C28D4B452006C3F61 /* SearchStopInput.swift in Sources */, CF18207628CCBD2300248F72 /* ConnectionsList.swift in Sources */, + CFB71F88290F1AB100B37E69 /* SheetErrorScreen.swift in Sources */, CF181FE828CCB7D600248F72 /* ContentView.swift in Sources */, CF05F62028CD337200B4AD58 /* AppVersion.swift in Sources */, CF6C918628D3F8C8006C3F61 /* StopSearch.swift in Sources */, + CFED5F8A2919B7820062045B /* SheetController.swift in Sources */, CF18207228CCBD2300248F72 /* VariantPicker.swift in Sources */, CFAF0E7A28CE84F400DDAD5B /* MapAnnotations.swift in Sources */, CF548FF328D129B000668CB6 /* VehicleDetailsView.swift in Sources */, CFFFAD7B28F4D8D000DFD5FD /* StopIcon.swift in Sources */, + CFB71F82290CAFB500B37E69 /* LoadingSheet.swift in Sources */, + CF0C256C29031B2C00B03052 /* CarrisCommunityAPIModel.swift in Sources */, CF82BB0928D7F19A007F0CDB /* LocationCard.swift in Sources */, + CFEE4EA229202637005DFA1B /* BackgroundThreads.swift in Sources */, CF18207928CCBD2300248F72 /* RouteDetailsVehiclesQuantity.swift in Sources */, CF181FE628CCB7D600248F72 /* GeoBusApp.swift in Sources */, CF18205028CCBCE900248F72 /* OpenExternalLinkButton.swift in Sources */, CF18204928CCBCC500248F72 /* SheetHeader.swift in Sources */, CF18205728CCBD0400248F72 /* StopEstimations.swift in Sources */, + CFB71F84290E8ECF00B37E69 /* PlaceholderAnimation.swift in Sources */, CF5094CD28FCB9E400EDD320 /* CarrisAPIModel.swift in Sources */, CF5094C928FC50AC00EDD320 /* CarrisNetworkController.swift in Sources */, CFFFAD8528F7A21100DFD5FD /* CarrisAPI.swift in Sources */, CF05F61A28CD09A000B4AD58 /* NavBar.swift in Sources */, + CFB71F86290E913F00B37E69 /* Chip.swift in Sources */, CFDD014B28D535370070FE4B /* Card.swift in Sources */, CF18202928CCBBDC00248F72 /* TapticEngine.swift in Sources */, CF548FF628D14BA400668CB6 /* VehicleIdentifier.swift in Sources */, + CFED5F8C2919CBBD0062045B /* Debounce.swift in Sources */, CF5094CB28FC50E900EDD320 /* CarrisNetworkModel.swift in Sources */, CF18208728CCBD3A00248F72 /* SelectRouteInput.swift in Sources */, + CF0C256E290324A600B03052 /* CarrisCommunityAPI.swift in Sources */, CF18204728CCBCC500248F72 /* RouteBadgePill.swift in Sources */, CF0C256A290211EF00B03052 /* DataProvidersCard.swift in Sources */, CF18204828CCBCC500248F72 /* RouteBadgeSquare.swift in Sources */, @@ -550,6 +597,7 @@ CFFFAD8B28F8F33200DFD5FD /* Pulse.swift in Sources */, CF6C918828D3FAF9006C3F61 /* AboutGeoBus.swift in Sources */, CF6C917E28D3ED0A006C3F61 /* SquareButton.swift in Sources */, + CF47FCB529035D8300AE33B0 /* PresentedSheetView.swift in Sources */, CF82BB0B28D7F1C6007F0CDB /* ShareCard.swift in Sources */, CF47994F28D33E1900B56D4B /* Disclaimer.swift in Sources */, CF47994D28D3315E00B56D4B /* Appstate.swift in Sources */, diff --git a/GeoBus/App/Components/About/AboutGeoBus.swift b/GeoBus/App/Components/About/AboutGeoBus.swift index c764db67..ac90be34 100644 --- a/GeoBus/App/Components/About/AboutGeoBus.swift +++ b/GeoBus/App/Components/About/AboutGeoBus.swift @@ -9,7 +9,7 @@ import SwiftUI struct AboutGeoBus: View { - @EnvironmentObject var carrisNetworkController: CarrisNetworkController + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared @State private var showInfoSheet: Bool = false @@ -46,7 +46,7 @@ struct AboutGeoBus: View { .padding(.top, 70) .padding(.bottom, 15) SyncStatus() -// DataProvidersCard() + DataProvidersCard() } .padding(.horizontal) diff --git a/GeoBus/App/Components/About/CloseButton.swift b/GeoBus/App/Components/About/CloseButton.swift index 977a4ac2..5a4bda5b 100644 --- a/GeoBus/App/Components/About/CloseButton.swift +++ b/GeoBus/App/Components/About/CloseButton.swift @@ -9,8 +9,8 @@ import SwiftUI struct CloseButton: View { - @EnvironmentObject var appstate: Appstate - @EnvironmentObject var carrisNetworkController: CarrisNetworkController + @ObservedObject private var appstate = Appstate.shared + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared @Binding var isPresenting: Bool diff --git a/GeoBus/App/Components/About/DataProvidersCard.swift b/GeoBus/App/Components/About/DataProvidersCard.swift index 9cea7036..08361239 100644 --- a/GeoBus/App/Components/About/DataProvidersCard.swift +++ b/GeoBus/App/Components/About/DataProvidersCard.swift @@ -9,73 +9,61 @@ import SwiftUI struct DataProvidersCard: View { - private let cardColor: Color = Color(.systemTeal) - - @State var communityProviderIsOn: Bool = false - - - var providerToggle: some View { - Toggle(isOn: $communityProviderIsOn) { - HStack { - Image(systemName: "staroflife.circle") - .renderingMode(.template) - .font(Font.system(size: 25)) - .foregroundColor(cardColor) - Text("Community ETAs") - .font(Font.system(size: 18, weight: .bold)) - .foregroundColor(cardColor) - .padding(.leading, 5) - } - .onAppear() { -// if (estimationsController.estimationsProvider == .carris) { -// communityProviderIsOn = false -// } else { -// communityProviderIsOn = true -// } - } - } - .padding() - .frame(maxWidth: .infinity) - .tint(cardColor) - .background(cardColor.opacity(0.05)) - .cornerRadius(10) -// .onChange(of: estimationsController.estimationsProvider) { value in -// if (value == .carris) { -// communityProviderIsOn = false -// } else { -// communityProviderIsOn = true -// } -// } - .onChange(of: communityProviderIsOn) { value in - if (value) { -// estimationsController.setProvider(selection: .community) - } else { -// estimationsController.setProvider(selection: .carris) - } - } - } - + private let accentColor: Color = Color(.systemTeal) var body: some View { Card { Image(systemName: "clock.arrow.2.circlepath") .font(Font.system(size: 30, weight: .regular)) - .foregroundColor(cardColor) - Text("ETA Provider") + .foregroundColor(accentColor) + Text("Community Data") .font(.title) .fontWeight(.bold) - .foregroundColor(cardColor) - Text("Select your prefered Data provider.") + .foregroundColor(accentColor) + Text("Try an experimental feature made in partnership with people interested in improving transportation in Lisbon.") .multilineTextAlignment(.center) .font(.headline) .fontWeight(.semibold) .foregroundColor(Color(.label)) - Text("Ainda não faz nada.") + Text("This includes better arrival time estimates for all stops, more precise vehicle locations and additional route information.") .multilineTextAlignment(.center) .font(.headline) .fontWeight(.semibold) .foregroundColor(Color(.secondaryLabel)) - providerToggle + CommunityProviderToggle() + } + } + +} + + + +struct CommunityProviderToggle: View { + + private let accentColor: Color = Color(.systemTeal) + + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared + + var body: some View { + Toggle(isOn: $carrisNetworkController.communityDataProviderStatus) { + HStack { + Image(systemName: "staroflife.circle") + .renderingMode(.template) + .font(Font.system(size: 25)) + .foregroundColor(accentColor) + Text("Community Data") + .font(Font.system(size: 18, weight: .bold)) + .foregroundColor(accentColor) + .padding(.leading, 5) + } + } + .padding() + .frame(maxWidth: .infinity) + .tint(accentColor) + .background(accentColor.opacity(0.05)) + .cornerRadius(10) + .onChange(of: carrisNetworkController.communityDataProviderStatus) { value in + carrisNetworkController.toggleCommunityDataProviderStatus(to: value) } } diff --git a/GeoBus/App/Components/About/EstimationsProviderCard.swift b/GeoBus/App/Components/About/EstimationsProviderCard.swift new file mode 100644 index 00000000..ff5b08f6 --- /dev/null +++ b/GeoBus/App/Components/About/EstimationsProviderCard.swift @@ -0,0 +1,84 @@ +// +// AboutLiveData.swift +// GeoBus +// +// Created by João de Vasconcelos on 19/09/2022. +// + +import SwiftUI + +struct EstimationsProviderCard: View { + + @EnvironmentObject var estimationsController: EstimationsController + + private let cardColor: Color = Color(.systemTeal) + + @State var communityProviderIsOn: Bool = false + + + var providerToggle: some View { + Toggle(isOn: $communityProviderIsOn) { + HStack { + Image(systemName: "staroflife.circle") + .renderingMode(.template) + .font(Font.system(size: 25)) + .foregroundColor(cardColor) + Text("Community ETAs") + .font(Font.system(size: 18, weight: .bold)) + .foregroundColor(cardColor) + .padding(.leading, 5) + } + .onAppear() { + if (estimationsController.estimationsProvider == .carris) { + communityProviderIsOn = false + } else { + communityProviderIsOn = true + } + } + } + .padding() + .frame(maxWidth: .infinity) + .tint(cardColor) + .background(cardColor.opacity(0.05)) + .cornerRadius(10) + .onChange(of: estimationsController.estimationsProvider) { value in + if (value == .carris) { + communityProviderIsOn = false + } else { + communityProviderIsOn = true + } + } + .onChange(of: communityProviderIsOn) { value in + if (value) { + estimationsController.setProvider(selection: .community) + } else { + estimationsController.setProvider(selection: .carris) + } + } + } + + + var body: some View { + Card { + Image(systemName: "clock.arrow.2.circlepath") + .font(Font.system(size: 30, weight: .regular)) + .foregroundColor(cardColor) + Text("ETA Provider") + .font(.title) + .fontWeight(.bold) + .foregroundColor(cardColor) + Text("Select your prefered Time of Arrival provider.") + .multilineTextAlignment(.center) + .font(.headline) + .fontWeight(.semibold) + .foregroundColor(Color(.label)) + Text("Text about differences in provider.") + .multilineTextAlignment(.center) + .font(.headline) + .fontWeight(.semibold) + .foregroundColor(Color(.secondaryLabel)) + providerToggle + } + } + +} diff --git a/GeoBus/App/Components/About/SyncStatus.swift b/GeoBus/App/Components/About/SyncStatus.swift index 77d4b8e2..68393881 100644 --- a/GeoBus/App/Components/About/SyncStatus.swift +++ b/GeoBus/App/Components/About/SyncStatus.swift @@ -9,8 +9,8 @@ import SwiftUI struct SyncStatus: View { - @EnvironmentObject var appstate: Appstate - @EnvironmentObject var carrisNetworkController: CarrisNetworkController + @ObservedObject private var appstate = Appstate.shared + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared var syncError: some View { diff --git a/GeoBus/App/Components/ContentView.swift b/GeoBus/App/Components/ContentView.swift index 5c9dd42e..92fcc77f 100644 --- a/GeoBus/App/Components/ContentView.swift +++ b/GeoBus/App/Components/ContentView.swift @@ -7,18 +7,88 @@ // import SwiftUI +import MapKit struct ContentView: View { + @StateObject private var mapController = MapController.shared + @StateObject private var carrisNetworkController = CarrisNetworkController.shared + var body: some View { - - VStack(alignment: .trailing, spacing: 0) { - + VStack(spacing: 0) { ZStack(alignment: .topTrailing) { - - MapView() - .edgesIgnoringSafeArea(.vertical) - + MapViewSwiftUI( + region: $mapController.region, + camera: $mapController.mapCamera, + annotations: $mapController.allAnnotations, + overlays: $mapController.allOverlays + ) + .edgesIgnoringSafeArea(.vertical) + .onReceive(carrisNetworkController.$activeVariant) { newVariant in + if (newVariant != nil) { + if (newVariant?.circularShape != nil) { + var tempLinePoints: [CLLocationCoordinate2D] = [] + do { + let dataStringData = Data(newVariant!.circularShape!.utf8) + let dataObject = try JSONDecoder().decode(CarrisAPIModel.Shape2.self, from: dataStringData) + print("HJBUYY/TGUYINUHBGVFCTVYGBUHNJHBGVFCDVTGYBHUN") + print(dataObject) + for point in dataObject.coordinates { + tempLinePoints.append( + CLLocationCoordinate2D(latitude: point[1], longitude: point[0]) + ) + } + } catch { + print(error) + } + self.mapController.allOverlays.append( + MKPolyline(coordinates: tempLinePoints, count: tempLinePoints.count) + ) + } + } + } + .onReceive(carrisNetworkController.$activeVehicles) { newVehiclesList in + var tempNewAnnotations: [GeoBusMKAnnotation] = [] + for vehicle in carrisNetworkController.activeVehicles { + tempNewAnnotations.append( + GeoBusMKAnnotation( + type: .vehicle, + id: vehicle.id, + coordinate: CLLocationCoordinate2D(latitude: vehicle.lat ?? 0, longitude: vehicle.lng ?? 0) + ) + ) + } + mapController.add(annotations: tempNewAnnotations, ofType: .vehicle) + } + .onReceive(mapController.$region) { newRegion in + var tempNewAnnotations: [GeoBusMKAnnotation] = [] + if (newRegion.span.latitudeDelta < 0.01 || newRegion.span.longitudeDelta < 0.01) { + + let latTop = newRegion.center.latitude + newRegion.span.latitudeDelta + 0.01 + let latBottom = newRegion.center.latitude - newRegion.span.latitudeDelta - 0.01 + + let lngRight = newRegion.center.longitude + newRegion.span.longitudeDelta + 0.01 + let lngLeft = newRegion.center.longitude - newRegion.span.longitudeDelta - 0.01 + + for stop in carrisNetworkController.allStops { + + let isBetweenLats = stop.lat > latBottom && stop.lat < latTop + let isBetweenLngs = stop.lng > lngLeft && stop.lng < lngRight + + if (isBetweenLats && isBetweenLngs) { + tempNewAnnotations.append( + GeoBusMKAnnotation( + type: .stop, + id: stop.id, + coordinate: CLLocationCoordinate2D(latitude: stop.lat, longitude: stop.lng) + ) + ) + } + + } + } + mapController.add(annotations: tempNewAnnotations, ofType: .stop) + } VStack(spacing: 15) { AboutGeoBus() Spacer() @@ -26,14 +96,10 @@ struct ContentView: View { UserLocation() } .padding() - } - NavBar() .edgesIgnoringSafeArea(.vertical) - } - } - + } diff --git a/GeoBus/App/Components/Map/MapAnnotations.swift b/GeoBus/App/Components/Map/MapAnnotations.swift index c948fe05..48304666 100644 --- a/GeoBus/App/Components/Map/MapAnnotations.swift +++ b/GeoBus/App/Components/Map/MapAnnotations.swift @@ -13,82 +13,51 @@ import SwiftUI struct GenericMapAnnotation: Identifiable { - let id: Int + let id: UUID var location: CLLocationCoordinate2D var item: AnnotationItem enum AnnotationItem { - case carris_stop(CarrisNetworkModel.Stop) + case stop(CarrisNetworkModel.Stop) case carris_connection(CarrisNetworkModel.Connection) - case carris_vehicle(CarrisNetworkModel.Vehicle) + case vehicle(CarrisNetworkModel.Vehicle) } } - - -struct CarrisStopAnnotationView: View { - - public let stop: CarrisNetworkModel.Stop - - @State private var isAnnotationSelected: Bool = false - - - var body: some View { - Button(action: { - self.isAnnotationSelected = true - TapticEngine.impact.feedback(.light) - }) { - StopIcon( - orderInRoute: 0, - direction: .circular, - isSelected: self.isAnnotationSelected - ) - } - .frame(width: 40, height: 40, alignment: .center) - .onAppear() { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.isAnnotationSelected = true - } - } - .sheet(isPresented: $isAnnotationSelected, onDismiss: { - withAnimation(.easeInOut(duration: 0.1)) { - self.isAnnotationSelected = false - } - }) { - StopDetailsView(stop: self.stop) - } - } - -} - - struct CarrisConnectionAnnotationView: View { public let connection: CarrisNetworkModel.Connection - @State private var isAnnotationSelected: Bool = false + @ObservedObject private var sheetController = SheetController.shared + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared + + var body2: some View { + EmptyView() + } var body: some View { Button(action: { - self.isAnnotationSelected = true TapticEngine.impact.feedback(.light) + carrisNetworkController.select(connection: self.connection) + sheetController.present(sheet: .ConnectionDetails) }) { - StopIcon( - orderInRoute: self.connection.orderInRoute, - direction: self.connection.direction, - isSelected: self.isAnnotationSelected - ) + if (carrisNetworkController.activeConnection?.id == self.connection.id) { + StopIcon(style: .selected, orderInRoute: self.connection.orderInRoute) + } else if (self.connection.direction == .ascending) { + StopIcon(style: .ascending, orderInRoute: self.connection.orderInRoute) + } else if (self.connection.direction == .descending) { + StopIcon(style: .descending, orderInRoute: self.connection.orderInRoute) + } else if (self.connection.direction == .circular) { + StopIcon(style: .circular, orderInRoute: self.connection.orderInRoute) + } else { + StopIcon(style: .standard) + } } .frame(width: 40, height: 40, alignment: .center) - .sheet(isPresented: $isAnnotationSelected, onDismiss: { - self.isAnnotationSelected = false - }) { - ConnectionDetailsView(connection: self.connection) - } } } @@ -101,14 +70,20 @@ struct CarrisVehicleAnnotationView: View { let vehicle: CarrisNetworkModel.Vehicle - @State private var isPresented: Bool = false - @State private var viewSize = CGSize() + @ObservedObject private var sheetController = SheetController.shared + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared + + + var body2: some View { + EmptyView() + } var body: some View { Button(action: { - self.isPresented = true TapticEngine.impact.feedback(.light) + carrisNetworkController.select(vehicle: vehicle.id) + sheetController.present(sheet: .VehicleDetails) }) { ZStack(alignment: .init(horizontal: .leading, vertical: .center)) { switch (vehicle.kind) { @@ -116,30 +91,56 @@ struct CarrisVehicleAnnotationView: View { Image("Tram") case .neighborhood, .night, .regular, .none: Image("RegularService") - Text(verbatim: String(vehicle.id)) - .font(Font.system(size: 10, weight: .bold, design: .monospaced)) - .tracking(1) - .foregroundColor(.white) - .padding(.leading, 12) } } } .frame(width: 40, height: 40, alignment: .center) .rotationEffect(.radians(vehicle.angleInRadians ?? 0)) .animation(.default, value: vehicle.angleInRadians) - .sheet(isPresented: $isPresented) { - VStack(alignment: .leading) { - VehicleDetailsView(vehicle: self.vehicle) - .padding(.bottom, 20) - Disclaimer() - .padding(.horizontal) - .padding(.bottom, 10) - } - .readSize { size in - viewSize = size + } + +} + + + + + +struct StopAnnotationView: View { + + public let stop: CarrisNetworkModel.Stop + + @StateObject private var sheetController = SheetController.shared + @StateObject private var mapController = MapController.shared + @StateObject private var carrisNetworkController = CarrisNetworkController.shared + + var body: some View { + VStack { + if (carrisNetworkController.activeStop?.id == self.stop.id) { + StopIcon(style: .selected) + } else if (mapController.region.span.latitudeDelta < 0.0025 || mapController.region.span.longitudeDelta < 0.0025) { + StopIcon(style: .standard) + } else { + StopIcon(style: .mini) } - .presentationDetents([.height(viewSize.height)]) + } + .onTapGesture { + TapticEngine.impact.feedback(.light) + carrisNetworkController.select(stop: self.stop) + sheetController.present(sheet: .StopDetails) + // withAnimation(.easeIn(duration: 0.5)) { + // mapController.centerMapOnCoordinates(lat: self.stop.lat, lng: self.stop.lng) + // } } } } + + + + + + + + + + diff --git a/GeoBus/App/Components/Map/MapView.swift b/GeoBus/App/Components/Map/MapView.swift index bbbeab31..83e20b3b 100644 --- a/GeoBus/App/Components/Map/MapView.swift +++ b/GeoBus/App/Components/Map/MapView.swift @@ -12,8 +12,8 @@ import MapKit struct MapView: View { - @EnvironmentObject var mapController: MapController - @EnvironmentObject var carrisNetworkController: CarrisNetworkController + @StateObject private var mapController = MapController.shared + @StateObject private var carrisNetworkController = CarrisNetworkController.shared var body: some View { @@ -26,29 +26,27 @@ struct MapView: View { MapAnnotation(coordinate: annotation.location) { switch (annotation.item) { - case .carris_stop(let item): - CarrisStopAnnotationView(stop: item) + case .stop(let item): + StopAnnotationView(stop: item) case .carris_connection(let item): CarrisConnectionAnnotationView(connection: item) - case .carris_vehicle(let item): + case .vehicle(let item): CarrisVehicleAnnotationView(vehicle: item) } } } - .onChange(of: carrisNetworkController.activeStop) { newStop in - if (newStop != nil) { - self.mapController.updateAnnotations(with: newStop!) - } - } - .onChange(of: carrisNetworkController.activeVariant) { newVariant in + .onReceive(carrisNetworkController.$activeVariant) { newVariant in if (newVariant != nil) { self.mapController.updateAnnotations(with: newVariant!) } } - .onChange(of: carrisNetworkController.activeVehicles) { newVehiclesList in + .onReceive(carrisNetworkController.$activeVehicles) { newVehiclesList in self.mapController.updateAnnotations(with: newVehiclesList) } + .onReceive(mapController.$region.debounce(for: .seconds(0.1), scheduler: DispatchQueue.main)) { newRegion in + self.mapController.updateAnnotations(for: newRegion, with: carrisNetworkController.allStops) + } } diff --git a/GeoBus/App/Components/Map/UserLocation.swift b/GeoBus/App/Components/Map/UserLocation.swift index e09ac2d9..bce0d456 100644 --- a/GeoBus/App/Components/Map/UserLocation.swift +++ b/GeoBus/App/Components/Map/UserLocation.swift @@ -9,7 +9,7 @@ import SwiftUI struct UserLocation: View { - @EnvironmentObject var mapController: MapController + @ObservedObject private var mapController = MapController.shared var body: some View { SquareButton(icon: "location.fill", size: 22) diff --git a/GeoBus/App/Components/NavBar.swift b/GeoBus/App/Components/NavBar.swift index a65e9f55..406e802c 100644 --- a/GeoBus/App/Components/NavBar.swift +++ b/GeoBus/App/Components/NavBar.swift @@ -9,44 +9,39 @@ import SwiftUI import Combine struct NavBar: View { - - @EnvironmentObject var carrisNetworkController: CarrisNetworkController - + + @ObservedObject private var sheetController = SheetController.shared + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared + @State var showSelectRouteSheet: Bool = false @State var showRouteDetailsSheet: Bool = false - - + + // This is the route button, the left side of the NavBar. // Depending on the state, the button conveys different information. var routeSelector: some View { Button(action: { - self.showSelectRouteSheet = true + sheetController.present(sheet: .RouteSelector) }) { SelectRouteView() } - .sheet(isPresented: $showSelectRouteSheet) { - SelectRouteSheet(isPresentingSheet: $showSelectRouteSheet) - } } - + // This is the route details panel, the right side of the NavBar. // If a route is selected, it's details appear here. If no route is selected, // then it acts as button to choose a route. var routeDetails: some View { Button(action: { if (carrisNetworkController.activeRoute != nil) { - showRouteDetailsSheet = true + sheetController.present(sheet: .RouteDetails) } else { - showSelectRouteSheet = true + sheetController.present(sheet: .RouteSelector) } }) { RouteDetailsView() } - .sheet(isPresented: $showRouteDetailsSheet) { - RouteDetailsSheet(showRouteDetailsSheet: $showRouteDetailsSheet) - } } - + // The composed final view for the NavBar. // It encompasses the route selector and the route details panel, // as well as the current app version. @@ -64,5 +59,5 @@ struct NavBar: View { .frame(height: 120) .background(Color("BackgroundSecondary")) } - + } diff --git a/GeoBus/App/Components/NewMap/NewMapView.swift b/GeoBus/App/Components/NewMap/NewMapView.swift new file mode 100644 index 00000000..2ef8a6c5 --- /dev/null +++ b/GeoBus/App/Components/NewMap/NewMapView.swift @@ -0,0 +1,396 @@ +// +// MapView.swift +// GeoBus +// +// Created by João de Vasconcelos on 14/04/2020. +// Copyright © 2020 João de Vasconcelos. All rights reserved. +// + + + +import SwiftUI +import MapKit + +struct MapViewSwiftUI: UIViewRepresentable { + + private let mapView = MKMapView() + + @Binding var region: MKCoordinateRegion + @Binding var camera: MKMapCamera + @Binding var annotations: [GeoBusMKAnnotation] + @Binding var overlays: [MKPolyline] + + @StateObject var carrisNetworkController = CarrisNetworkController.shared + + + func makeUIView(context: UIViewRepresentableContext) -> MKMapView { + mapView.delegate = context.coordinator + mapView.region = self.region + mapView.showsUserLocation = true + mapView.userTrackingMode = .follow + mapView.preferredConfiguration = MKStandardMapConfiguration(elevationStyle: .realistic, emphasisStyle: .muted) + + mapView.register(StopMKAnnotationView.self, forAnnotationViewWithReuseIdentifier: StopMKAnnotationView.reuseIdentifier) + mapView.register(VehicleMKAnnotationView.self, forAnnotationViewWithReuseIdentifier: VehicleMKAnnotationView.reuseIdentifier) + + return mapView + } + + + func updateUIView(_ uiView: MKMapView, context: Context) { + + var tempAnnotationsToAdd: [GeoBusMKAnnotation] = [] + var tempAnnotationsToRemove: [GeoBusMKAnnotation] = [] + + let tempCurrentAnnotations: [GeoBusMKAnnotation] = uiView.annotations.compactMap({ + // Make sure we are dealing with annotation of type GeoBusMKAnnotation + return $0 as? GeoBusMKAnnotation + }) + + // Find out which annotations should be added to the map + for newAnnotation in annotations { + // If this annotation is not yet on the view, add it + let indexOfThisNewAnnotationInUiView = tempCurrentAnnotations.firstIndex(of: newAnnotation) + if (indexOfThisNewAnnotationInUiView == nil) { + tempAnnotationsToAdd.append(newAnnotation) + } + } + + // Find out the excess annotations that should be removed from the map + for currentAnnotation in tempCurrentAnnotations { + // If this visible annotation is not in [annotations], remove it + let indexOfThisCurrentAnnotationInNextAnnotations = annotations.firstIndex(of: currentAnnotation) + if (indexOfThisCurrentAnnotationInNextAnnotations == nil) { + tempAnnotationsToRemove.append(currentAnnotation) + } + } + + + // Update the view with annotations + uiView.removeAnnotations(tempAnnotationsToRemove) + uiView.addAnnotations(tempAnnotationsToAdd) + + + + // OVERLAYS + + var tempOverlaysToAdd: [MKPolyline] = [] + + for overlay in overlays { + tempOverlaysToAdd.append(overlay) + } + + uiView.addOverlays(tempOverlaysToAdd) + + + print("HOWMANY currentAnnotations: \(tempCurrentAnnotations.count)") + print("HOWMANY tempAnnotationsToAdd: \(tempAnnotationsToAdd.count)") + print("HOWMANY tempAnnotationsToRemove: \(tempAnnotationsToRemove.count)") + print("HOWMANY uiView displayed annotations: \(uiView.annotations.count)") + print("HOWMANY ----------") + } + + + func makeCoordinator() -> MapViewSwiftUICoordinator { + MapViewSwiftUICoordinator(self) + } + +} + + + +final class MapViewSwiftUICoordinator: NSObject, MKMapViewDelegate { + + var parentSwiftUIView: MapViewSwiftUI + + init(_ parentSwiftUIView: MapViewSwiftUI) { + self.parentSwiftUIView = parentSwiftUIView + } + + + @MainActor func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { + DispatchQueue.main.async { [self] in + self.parentSwiftUIView.region = mapView.region + self.parentSwiftUIView.camera = mapView.camera + } + } + + @MainActor func mapViewDidChangeVisibleRegion(_ mapView: MKMapView) { + DispatchQueue.main.async { [self] in + // self.parentSwiftUIView.region = mapView.region + self.parentSwiftUIView.camera = mapView.camera + } + } + + + func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? { + guard let annotation = annotation as? GeoBusMKAnnotation else { return nil } + + switch annotation.type { + case .stop: + return StopMKAnnotationView(annotation: annotation, reuseIdentifier: StopMKAnnotationView.reuseIdentifier) + case .vehicle: + return VehicleMKAnnotationView(annotation: annotation, reuseIdentifier: VehicleMKAnnotationView.reuseIdentifier) + } + + } + + + @MainActor func mapView(_ mapView: MKMapView, didSelect annotation: MKAnnotation) { + guard let annotation = annotation as? GeoBusMKAnnotation else { return } + + switch annotation.type { + case .stop: + DispatchQueue.main.async { [self] in + TapticEngine.impact.feedback(.light) + _ = parentSwiftUIView.carrisNetworkController.select(stop: annotation.id) + SheetController.shared.present(sheet: .StopDetails) + } + case .vehicle: + DispatchQueue.main.async { [self] in + TapticEngine.impact.feedback(.light) + _ = parentSwiftUIView.carrisNetworkController.select(vehicle: annotation.id) + SheetController.shared.present(sheet: .VehicleDetails) + } + } + } + + + + @MainActor func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer { + print("HETERERERERERE : \(overlay)") + if let polyline = overlay as? MKPolyline { + print("HETERERERERERE27e53672") + var testlineRenderer = MKPolylineRenderer(polyline: polyline) + testlineRenderer.strokeColor = .systemPink + testlineRenderer.lineWidth = 2.0 + return testlineRenderer + } + fatalError("Something wrong...") + } + + +} + + + + + + +final class GeoBusMKAnnotation: NSObject, MKAnnotation { + + let type: AnnotationType + + let id: Int + dynamic var coordinate: CLLocationCoordinate2D + + enum AnnotationType { + case stop + case vehicle + } + + init(type: AnnotationType, id: Int, coordinate: CLLocationCoordinate2D) { + self.id = id + self.coordinate = coordinate + self.type = type + } + + func update(coordinate newCoordinate: CLLocationCoordinate2D) { + self.coordinate = newCoordinate + } + + override func isEqual(_ object: Any?) -> Bool { + if let annot = object as? GeoBusMKAnnotation{ + // Add your defintion of equality here. i.e what determines if two Annotations are equal. + let equalId = annot.id == self.id + let equalCoordinates = annot.coordinate == self.coordinate + let equalType = annot.type == self.type + return equalId && equalCoordinates && equalType + } else { + return false + } + } + +} + + + + +final class GeoBusMKOverlay: NSObject, MKOverlay { + + let type: OverlayType + + let id: Int + var coordinate: CLLocationCoordinate2D + var boundingMapRect: MKMapRect + + enum OverlayType { + case route + } + + init(type: OverlayType, id: Int, coordinate: CLLocationCoordinate2D, boundingMapRect: MKMapRect) { + self.type = type + self.id = id + self.coordinate = coordinate + self.boundingMapRect = boundingMapRect + } + + override func isEqual(_ object: Any?) -> Bool { + if let annot = object as? GeoBusMKAnnotation{ + // Add your defintion of equality here. i.e what determines if two Annotations are equal. + // let equalCoordinates = annot.coordinate == self.coordinate + // let equalType = annot.type == self.type + // return equalCoordinates && equalType + return annot.id == self.id + } else { + return false + } + } + +} + + + + + + + + + + + + + + +// STOP ANNOTATIONS + +final class StopMKAnnotationView: MKAnnotationView { + + static let reuseIdentifier = "stop" + + override var annotation: MKAnnotation? { + willSet { + guard let newValue = newValue as? GeoBusMKAnnotation else { return } + + canShowCallout = false + + zPriority = MKAnnotationViewZPriority(0) + + let swiftUIView = StopSwiftUIAnnotationView(stopId: newValue.id) + let uiKitView = UIHostingController(rootView: swiftUIView) + addSubview(uiKitView.view) + } + } + +} + + +struct StopSwiftUIAnnotationView: View { + + public let stopId: Int + + @StateObject private var carrisNetworkController = CarrisNetworkController.shared + + var body: some View { + VStack { + if (carrisNetworkController.activeStop?.id == self.stopId) { + StopIcon(style: .selected) + } else { + StopIcon(style: .mini) + } + } + } + +} + + + + + + + + + + + +// VEHICLE ANNOTATIONS + + +final class VehicleMKAnnotationView: MKAnnotationView { + + static let reuseIdentifier = "vehicle" + + override var annotation: MKAnnotation? { + willSet { + guard let newValue = newValue as? GeoBusMKAnnotation else { return } + + canShowCallout = false + + zPriority = MKAnnotationViewZPriority(1) + + let swiftUIView = VehicleSwiftUIAnnotationView(vehicleId: newValue.id) + let uiKitView = UIHostingController(rootView: swiftUIView) + addSubview(uiKitView.view) + } + } + +} + + +struct VehicleSwiftUIAnnotationView: View { + + public let vehicleId: Int + + @StateObject private var mapController = MapController.shared + @StateObject private var carrisNetworkController = CarrisNetworkController.shared + + @State private var vehicle: CarrisNetworkModel.Vehicle? + + var body: some View { + VStack { + switch (vehicle?.kind) { + case .tram, .elevator: + Image("Tram") + case .neighborhood, .night, .regular, .none: + Image("RegularService") + } + } + .rotationEffect(.radians(vehicle?.angleInRadians ?? 0) + .degrees(-mapController.mapCamera.heading)) + .animation(.default, value: vehicle?.angleInRadians) + .onAppear() { + self.vehicle = carrisNetworkController.find(vehicle: self.vehicleId) + } + } + +} + + + + + + + + + +// ROUTE VARIANT OVERLAY RENDERER + + +//final class VehicleMKAnnotationView: MKOverlayRenderer { +// +// static let reuseIdentifier = "vehicle" +// +// override var annotation: MKAnnotation? { +// willSet { +// guard let newValue = newValue as? GeoBusMKAnnotation else { return } +// +// canShowCallout = false +// +// zPriority = MKAnnotationViewZPriority(1) +// +// let swiftUIView = VehicleSwiftUIAnnotationView(vehicleId: newValue.id) +// let uiKitView = UIHostingController(rootView: swiftUIView) +// addSubview(uiKitView.view) +// } +// } +// +//} diff --git a/GeoBus/App/Components/PresentedSheetView.swift b/GeoBus/App/Components/PresentedSheetView.swift new file mode 100644 index 00000000..b9f0fb4f --- /dev/null +++ b/GeoBus/App/Components/PresentedSheetView.swift @@ -0,0 +1,63 @@ +// +// PresentedSheetView.swift +// GeoBus +// +// Created by João de Vasconcelos on 22/10/2022. +// + +import SwiftUI + +struct PresentedSheetView: View { + + @ObservedObject private var sheetController = SheetController.shared + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared + + var body: some View { + switch sheetController.currentlyPresentedSheetView { + + case .RouteSelector: + SelectRouteSheet() + .presentationDetents([.large]) + .presentationDragIndicator(.hidden) + + case .RouteDetails: + RouteDetailsSheet() + .presentationDetents([.large]) + .presentationDragIndicator(.hidden) + + case .StopSelector: + StopSearchView() + .presentationDetents([.medium]) + .presentationDragIndicator(.hidden) + + case .StopDetails: + StopSheetView() + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.hidden) + .onDisappear() { + carrisNetworkController.deselect([.stop]) + } + + case .ConnectionDetails: + ConnectionSheetView() + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.hidden) + .onDisappear() { + carrisNetworkController.deselect([.connection]) + } + + case .VehicleDetails: + CarrisVehicleSheetView() + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.hidden) + .onDisappear() { + carrisNetworkController.deselect([.vehicle]) + } + + case .none: + EmptyView() + + } + } + +} diff --git a/GeoBus/App/Components/RouteDetails/CircularVariantInfo.swift b/GeoBus/App/Components/RouteDetails/CircularVariantInfo.swift deleted file mode 100644 index 0df56475..00000000 --- a/GeoBus/App/Components/RouteDetails/CircularVariantInfo.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// RouteVariantWarning.swift -// GeoBus -// -// Created by João on 23/04/2020. -// Copyright © 2020 João. All rights reserved. -// - -import SwiftUI - -struct RouteCircularVariantInfo: View { - var body: some View { - VStack { - HStack { - Image(systemName: "repeat") - .font(.callout) - Text("This is a circular route.") - .font(.callout) - .fixedSize(horizontal: true, vertical: true) - Spacer() - } - } - .padding() - .foregroundColor(Color(.systemBlue)) - .background(Color(.systemBlue).opacity(0.2)) - .cornerRadius(10) - } -} diff --git a/GeoBus/App/Components/RouteDetails/ConnectionsList.swift b/GeoBus/App/Components/RouteDetails/ConnectionsList.swift index cccc6997..23e097dd 100644 --- a/GeoBus/App/Components/RouteDetails/ConnectionsList.swift +++ b/GeoBus/App/Components/RouteDetails/ConnectionsList.swift @@ -16,9 +16,9 @@ struct ConnectionsList: View { var body: some View { VStack(alignment: .leading, spacing: 15) { ForEach(connections) { connection in - ConnectionDetailsView2( + StopDetailsView( canToggle: true, - publicId: connection.stop.id, + stopId: connection.stop.id, name: connection.stop.name, orderInRoute: connection.orderInRoute, direction: connection.direction diff --git a/GeoBus/App/Components/RouteDetails/RouteDetailsAddToFavorites.swift b/GeoBus/App/Components/RouteDetails/RouteDetailsAddToFavorites.swift index 275cb520..76bb4fed 100644 --- a/GeoBus/App/Components/RouteDetails/RouteDetailsAddToFavorites.swift +++ b/GeoBus/App/Components/RouteDetails/RouteDetailsAddToFavorites.swift @@ -10,7 +10,7 @@ import SwiftUI struct RouteDetailsAddToFavorites: View { - @EnvironmentObject var carrisNetworkController: CarrisNetworkController + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared var body: some View { diff --git a/GeoBus/App/Components/RouteDetails/RouteDetailsSheet.swift b/GeoBus/App/Components/RouteDetails/RouteDetailsSheet.swift index 3289efdf..2c2a024d 100644 --- a/GeoBus/App/Components/RouteDetails/RouteDetailsSheet.swift +++ b/GeoBus/App/Components/RouteDetails/RouteDetailsSheet.swift @@ -10,9 +10,8 @@ import SwiftUI struct RouteDetailsSheet: View { - @EnvironmentObject var carrisNetworkController: CarrisNetworkController - - @Binding var showRouteDetailsSheet: Bool + @ObservedObject private var appstate = Appstate.shared + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared @State var routeDirection: Int = 0 @State var routeDirectionPicker: Int = 0 @@ -22,7 +21,7 @@ struct RouteDetailsSheet: View { VStack(spacing: 15) { - SheetHeader(title: Text("Route Details"), toggle: $showRouteDetailsSheet) + SheetHeader(title: Text("Route Details")) HStack(spacing: 25) { RouteBadgeSquare(routeNumber: carrisNetworkController.activeRoute!.number) @@ -55,7 +54,7 @@ struct RouteDetailsSheet: View { VStack(spacing: 15) { if (carrisNetworkController.activeVariant?.circularItinerary != nil) { - RouteCircularVariantInfo() + Chip(icon: Image(systemName: "repeat"), text: Text("This is a circular route."), color: Color(.systemBlue)) ConnectionsList(connections: carrisNetworkController.activeVariant!.circularItinerary!) } else if (carrisNetworkController.activeVariant?.ascendingItinerary != nil && carrisNetworkController.activeVariant?.descendingItinerary != nil) { @@ -86,7 +85,7 @@ struct RouteDetailsSheet: View { var body: some View { - ScrollView(.vertical, showsIndicators: true) { + ScrollView(showsIndicators: true) { VStack(spacing: 5) { liveInfo .padding() diff --git a/GeoBus/App/Components/RouteDetails/RouteDetailsView.swift b/GeoBus/App/Components/RouteDetails/RouteDetailsView.swift index 15d9ee54..64be92da 100644 --- a/GeoBus/App/Components/RouteDetails/RouteDetailsView.swift +++ b/GeoBus/App/Components/RouteDetails/RouteDetailsView.swift @@ -10,9 +10,11 @@ import SwiftUI struct RouteDetailsView: View { - @EnvironmentObject var appstate: Appstate - @EnvironmentObject var carrisNetworkController: CarrisNetworkController + @ObservedObject private var appstate = Appstate.shared + @ObservedObject private var mapController = MapController.shared + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared + @State var mapspan: Double = 0.0 // Initial screen simply explaining how to select a route, // also with app version and build. @@ -21,9 +23,13 @@ struct RouteDetailsView: View { VStack(alignment: .leading) { Spacer() HStack { - Text("← Choose a Route") +// Text("← Choose a Route") + Text("Span: \(mapspan)") .font(Font.system(size: 15, weight: .bold, design: .default)) .foregroundColor(Color(.secondaryLabel)) + .onReceive(mapController.$region, perform: { newRegion in + self.mapspan = newRegion.span.latitudeDelta + }) Spacer() } Spacer() diff --git a/GeoBus/App/Components/RouteDetails/VariantPicker.swift b/GeoBus/App/Components/RouteDetails/VariantPicker.swift index db3264f1..c427e7c6 100644 --- a/GeoBus/App/Components/RouteDetails/VariantPicker.swift +++ b/GeoBus/App/Components/RouteDetails/VariantPicker.swift @@ -10,7 +10,7 @@ import SwiftUI struct VariantPicker: View { - @EnvironmentObject var carrisNetworkController: CarrisNetworkController + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared var body: some View { diff --git a/GeoBus/App/Components/RouteDetails/VariantWarning.swift b/GeoBus/App/Components/RouteDetails/VariantWarning.swift index 4fc98f71..f51d6469 100644 --- a/GeoBus/App/Components/RouteDetails/VariantWarning.swift +++ b/GeoBus/App/Components/RouteDetails/VariantWarning.swift @@ -9,23 +9,15 @@ import SwiftUI struct VariantWarning: View { - - var qty: Int - - var body: some View { - VStack { - HStack { - Image(systemName: "info.circle.fill") - .font(.callout) - Text("This route may have \(qty) alternative paths.") - .font(.callout) - .fixedSize(horizontal: true, vertical: true) - } - } - .padding() - .foregroundColor(Color(.systemOrange)) - .background(Color(.systemOrange).opacity(0.2)) - .cornerRadius(10) - - } + + var qty: Int + + var body: some View { + Chip( + icon: Image(systemName: "info.circle.fill"), + text: Text("This route may have \(qty) alternative paths."), + color: Color(.systemOrange) + ) + } + } diff --git a/GeoBus/App/Components/SelectRoute/FavoriteRoutes.swift b/GeoBus/App/Components/SelectRoute/FavoriteRoutes.swift index a44a7a51..e84191df 100644 --- a/GeoBus/App/Components/SelectRoute/FavoriteRoutes.swift +++ b/GeoBus/App/Components/SelectRoute/FavoriteRoutes.swift @@ -12,9 +12,8 @@ struct FavoriteRoutes: View { @Environment(\.colorScheme) var colorScheme: ColorScheme - @EnvironmentObject var carrisNetworkController: CarrisNetworkController - - @Binding var showSelectRouteSheet: Bool + @ObservedObject private var sheetController = SheetController.shared + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared @State var routes: [CarrisNetworkModel.Route] = [] @@ -38,7 +37,7 @@ struct FavoriteRoutes: View { Button(action: { _ = self.carrisNetworkController.select(route: route.number) Analytics.shared.capture(event: .Routes_Select_FromFavorites, properties: ["routeNumber": route.number]) - self.showSelectRouteSheet = false + sheetController.dismiss() }){ RouteBadgeSquare(routeNumber: route.number) } diff --git a/GeoBus/App/Components/SelectRoute/SelectRouteInput.swift b/GeoBus/App/Components/SelectRoute/SelectRouteInput.swift index 168a3a6e..9f62d754 100644 --- a/GeoBus/App/Components/SelectRoute/SelectRouteInput.swift +++ b/GeoBus/App/Components/SelectRoute/SelectRouteInput.swift @@ -10,9 +10,8 @@ import SwiftUI struct SelectRouteInput: View { - @EnvironmentObject var carrisNetworkController: CarrisNetworkController - - @Binding var showSheet: Bool + @ObservedObject private var sheetController = SheetController.shared + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared @State var showErrorLabel: Bool = false @@ -33,7 +32,7 @@ struct SelectRouteInput: View { let success = carrisNetworkController.select(route: self.routeNumber.uppercased()) if success { Analytics.shared.capture(event: .Routes_Select_FromTextInput, properties: ["routeNumber": self.routeNumber.uppercased()]) - self.showSheet = false + sheetController.dismiss() } else { self.showErrorLabel = true } diff --git a/GeoBus/App/Components/SelectRoute/SelectRouteSheet.swift b/GeoBus/App/Components/SelectRoute/SelectRouteSheet.swift index 3880eefb..c12d3339 100644 --- a/GeoBus/App/Components/SelectRoute/SelectRouteSheet.swift +++ b/GeoBus/App/Components/SelectRoute/SelectRouteSheet.swift @@ -10,28 +10,26 @@ import SwiftUI struct SelectRouteSheet: View { - @Binding var isPresentingSheet: Bool - - + var body: some View { - ScrollView(.vertical, showsIndicators: true) { + ScrollView(showsIndicators: true) { VStack(spacing: 30) { - SheetHeader(title: Text("Find Routes"), toggle: $isPresentingSheet) + SheetHeader(title: Text("Find Routes")) - SelectRouteInput(showSheet: $isPresentingSheet) + SelectRouteInput() .padding(.horizontal) Divider() VStack(spacing: 30) { - FavoriteRoutes(showSelectRouteSheet: $isPresentingSheet) - SetOfRoutes(title: Text("Trams"), kind: .tram, showSheet: $isPresentingSheet) - SetOfRoutes(title: Text("Neighborhood Buses"), kind: .neighborhood, showSheet: $isPresentingSheet) - SetOfRoutes(title: Text("Night Buses"), kind: .night, showSheet: $isPresentingSheet) - SetOfRoutes(title: Text("Regular Service"), kind: .regular, showSheet: $isPresentingSheet) + FavoriteRoutes() + SetOfRoutes(title: Text("Trams"), kind: .tram) + SetOfRoutes(title: Text("Neighborhood Buses"), kind: .neighborhood) + SetOfRoutes(title: Text("Night Buses"), kind: .night) + SetOfRoutes(title: Text("Regular Service"), kind: .regular) } .padding(.horizontal) diff --git a/GeoBus/App/Components/SelectRoute/SelectRouteView.swift b/GeoBus/App/Components/SelectRoute/SelectRouteView.swift index 203eecc6..e840fb42 100644 --- a/GeoBus/App/Components/SelectRoute/SelectRouteView.swift +++ b/GeoBus/App/Components/SelectRoute/SelectRouteView.swift @@ -12,8 +12,8 @@ struct SelectRouteView: View { @Environment(\.colorScheme) var colorScheme: ColorScheme - @EnvironmentObject var appstate: Appstate - @EnvironmentObject var carrisNetworkController: CarrisNetworkController + @ObservedObject private var appstate = Appstate.shared + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared var body: some View { diff --git a/GeoBus/App/Components/SelectRoute/SetOfRoutes.swift b/GeoBus/App/Components/SelectRoute/SetOfRoutes.swift index 60c7a4a9..e35c78c0 100644 --- a/GeoBus/App/Components/SelectRoute/SetOfRoutes.swift +++ b/GeoBus/App/Components/SelectRoute/SetOfRoutes.swift @@ -10,12 +10,12 @@ import SwiftUI struct SetOfRoutes: View { - @EnvironmentObject var carrisNetworkController: CarrisNetworkController + @ObservedObject private var sheetController = SheetController.shared + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared - var title: Text - var kind: CarrisNetworkModel.Kind + let title: Text + let kind: CarrisNetworkModel.Kind - @Binding var showSheet: Bool @State private var routes: [CarrisNetworkModel.Route] = [] var body: some View { @@ -35,7 +35,7 @@ struct SetOfRoutes: View { Button(action: { _ = self.carrisNetworkController.select(route: route.number) Analytics.shared.capture(event: .Routes_Select_FromList, properties: ["routeNumber": route.number]) - self.showSheet = false + sheetController.dismiss() }){ RouteBadgeSquare(routeNumber: route.number) } diff --git a/GeoBus/App/Components/StopDetails/SearchStopInput.swift b/GeoBus/App/Components/StopDetails/SearchStopInput.swift index db57c1e7..f83a53d5 100644 --- a/GeoBus/App/Components/StopDetails/SearchStopInput.swift +++ b/GeoBus/App/Components/StopDetails/SearchStopInput.swift @@ -10,9 +10,10 @@ import SwiftUI struct SearchStopInput: View { - @EnvironmentObject var carrisNetworkController: CarrisNetworkController + @ObservedObject private var appstate = Appstate.shared + @ObservedObject private var sheetController = SheetController.shared + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared - @Binding var showSheet: Bool @FocusState private var stopIdInputIsFocused: Bool @State private var showErrorLabel: Bool = false @@ -42,8 +43,8 @@ struct SearchStopInput: View { Button(action: { let success = self.carrisNetworkController.select(stop: Int(self.stopPublicId.uppercased()) ?? -1) if success { - self.showSheet = false Analytics.shared.capture(event: .Stops_Select_FromTextInput, properties: ["stopPublicId": self.stopPublicId.uppercased()]) + sheetController.present(sheet: .StopDetails) } else { self.showErrorLabel = true } diff --git a/GeoBus/App/Components/StopDetails/StopDetailsView.swift b/GeoBus/App/Components/StopDetails/StopDetailsView.swift index 4c65c494..5b642341 100644 --- a/GeoBus/App/Components/StopDetails/StopDetailsView.swift +++ b/GeoBus/App/Components/StopDetails/StopDetailsView.swift @@ -7,136 +7,106 @@ import SwiftUI -struct ConnectionDetailsView: View { - - let connection: CarrisNetworkModel.Connection + +struct ConnectionSheetView: View { - @State private var viewSize = CGSize() + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared - var body: some View { - VStack(alignment: .leading) { - ConnectionDetailsView2( + var body: some View { + ScrollView { + StopDetailsView( canToggle: false, - publicId: connection.stop.id, - name: connection.stop.name, - orderInRoute: connection.orderInRoute, - direction: connection.direction - ) - .padding(.bottom, 20) - Disclaimer() - .padding(.horizontal) - .padding(.bottom, 10) - } - .readSize { size in - viewSize = size - } - .presentationDetents([.height(viewSize.height)]) - } + stopId: carrisNetworkController.activeConnection?.stop.id ?? 0, + name: carrisNetworkController.activeConnection?.stop.name ?? "-", + orderInRoute: carrisNetworkController.activeConnection?.orderInRoute, + direction: carrisNetworkController.activeConnection?.direction + ) + } + } + } -struct StopDetailsView: View { - - let stop: CarrisNetworkModel.Stop +struct StopSheetView: View { - @State private var viewSize = CGSize() + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared var body: some View { - VStack(alignment: .leading) { - ConnectionDetailsView2( + ScrollView { + StopDetailsView( canToggle: false, - publicId: stop.id, - name: stop.name, - orderInRoute: 0, - direction: .circular + stopId: carrisNetworkController.activeStop?.id ?? 0, + name: carrisNetworkController.activeStop?.name ?? "-", + orderInRoute: nil, + direction: nil ) - .padding(.bottom, 20) - Disclaimer() - .padding(.horizontal) - .padding(.bottom, 10) } - .readSize { size in - viewSize = size - } - .presentationDetents([.height(viewSize.height)]) } + } - - - -struct ConnectionDetailsView2: View { - - @Environment(\.colorScheme) var colorScheme: ColorScheme - - @EnvironmentObject var carrisNetworkController: CarrisNetworkController - - let refreshTimer = Timer.publish(every: 60 /* seconds */, on: .main, in: .common).autoconnect() +struct StopDetailsHeader: View { - let canToggle: Bool - let publicId: Int + let stopId: Int let name: String let orderInRoute: Int? let direction: CarrisNetworkModel.Direction? - @State private var isOpen = false - @State private var estimations: [CarrisNetworkModel.Estimation]? = nil - - - func getEstimationsFromController() { - Task { - self.estimations = await carrisNetworkController.getEstimation(for: self.publicId) - } - } - - - var fixedHeader: some View { + var body: some View { HStack(spacing: 15) { - StopIcon(orderInRoute: self.orderInRoute ?? 0, direction: self.direction ?? .circular) + StopIcon(orderInRoute: self.orderInRoute, direction: self.direction) Text(name) .fontWeight(.medium) .foregroundColor(Color(.label)) .multilineTextAlignment(.leading) Spacer() - Text(String(publicId)) + Text(String(self.stopId)) .font(Font.system(size: 12, weight: .medium, design: .default) ) .foregroundColor(Color(.secondaryLabel)) .padding(.vertical, 2) .padding(.horizontal, 7) - .background(colorScheme == .dark ? Color(.secondarySystemFill) : Color(.secondarySystemBackground)) + .background(Color(.secondarySystemFill)) .cornerRadius(10) } + .padding() } +} + + + + +struct StopDetailsView: View { + let canToggle: Bool - var content: some View { - StopEstimations(estimations: self.estimations) - .onAppear() { - // Get estimations when view appears - self.getEstimationsFromController() - } - .onReceive(refreshTimer) { event in - // Update estimations on timer call - self.getEstimationsFromController() - } - } + let stopId: Int + let name: String + let orderInRoute: Int? + let direction: CarrisNetworkModel.Direction? + + @State private var isOpen = false + + @Environment(\.colorScheme) var colorScheme: ColorScheme var body: some View { - VStack(spacing: 0) { - // The header of the view is always visible - fixedHeader - .padding() - // Estimations are visible only when the view is opened + VStack(alignment: .leading, spacing: 0) { + Button(action: { + if (canToggle) { + self.isOpen = !self.isOpen + TapticEngine.impact.feedback(.medium) + } + }, label: { + StopDetailsHeader(stopId: self.stopId, name: self.name, orderInRoute: self.orderInRoute, direction: self.direction) + }) if (isOpen || !canToggle) { Divider() - content - .padding([.horizontal, .bottom]) - .padding(.top, 7) + EstimationsContainer(stopId: self.stopId) + .padding() } } .background( @@ -147,13 +117,7 @@ struct ConnectionDetailsView2: View { : Color.clear ) .cornerRadius(10) - .onTapGesture { - if (canToggle) { - // If the view can be opened and closed - self.isOpen = !self.isOpen - TapticEngine.impact.feedback(.medium) - } - } } } + diff --git a/GeoBus/App/Components/StopDetails/StopEstimations.swift b/GeoBus/App/Components/StopDetails/StopEstimations.swift index 0a14922d..3157be5a 100644 --- a/GeoBus/App/Components/StopDetails/StopEstimations.swift +++ b/GeoBus/App/Components/StopDetails/StopEstimations.swift @@ -8,24 +8,80 @@ import SwiftUI -struct StopEstimations: View { +struct EstimationsContainer: View { - @EnvironmentObject var appstate: Appstate + let stopId: Int - let estimations: [CarrisNetworkModel.Estimation]? + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared + + @State var estimations: [CarrisNetworkModel.Estimation]? + + let refreshTimer = Timer.publish(every: 30 /* seconds */, on: .main, in: .common).autoconnect() + + + func getEstimationsFromController(_ value: Any?) { + Task { + self.estimations = await carrisNetworkController.getEstimation(for: self.stopId) + } + } + + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + EstimationsHeader() + EstimationsList(estimations: self.estimations) + .onAppear() { self.getEstimationsFromController(nil) } + .onReceive(refreshTimer, perform: self.getEstimationsFromController(_:)) + .onChange(of: carrisNetworkController.communityDataProviderStatus) { value in + self.estimations = nil + self.getEstimationsFromController(nil) + } + CommunityProviderToggle() + .padding(.vertical) + Disclaimer() + .padding(.vertical) + } + } - var fixedInfo: some View { + +} + + + + +struct EstimationsHeader: View { + + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared + + var body: some View { HStack { Text("Next on this stop:") .font(Font.system(size: 10, weight: .bold, design: .default) ) .textCase(.uppercase) .foregroundColor(Color(.tertiaryLabel)) Spacer() - PulseLabel(accent: .orange, label: Text("Estimated")) + if (carrisNetworkController.communityDataProviderStatus) { + PulseLabel(accent: Color(.systemTeal), label: Text("Community")) + } else { + PulseLabel(accent: Color(.systemOrange), label: Text("Estimated")) + } } } +} + + + + + + + +struct EstimationsList: View { + + let estimations: [CarrisNetworkModel.Estimation]? + + var loadingScreen: some View { HStack(spacing: 3) { ProgressView() @@ -37,17 +93,6 @@ struct StopEstimations: View { } } - var estimationsList: some View { - VStack(spacing: 12) { - ForEach(estimations!) { estimation in - HStack(spacing: 5) { - VehicleDestination(routeNumber: estimation.routeNumber, destination: estimation.destination) - Spacer() - TimeLeft(time: estimation.eta) - } - } - } - } var noResultsScreen: some View { Text("No estimations available for this stop.") @@ -55,24 +100,24 @@ struct StopEstimations: View { .foregroundColor(Color(.secondaryLabel)) } - var errorScreen: some View { - Text("Carris API is unavailable.") - .font(Font.system(size: 13, weight: .medium, design: .default) ) - .foregroundColor(Color(.secondaryLabel)) + + var estimationsList: some View { + VStack(spacing: 12) { + ForEach(estimations!) { estimation in + EstimationContainer(estimation: estimation) + } + } } var body: some View { VStack(alignment: .leading, spacing: 10) { - fixedInfo if (estimations != nil) { if (estimations!.count > 0) { estimationsList } else { noResultsScreen } - } else if (appstate.estimations == .error) { - errorScreen } else { loadingScreen } @@ -82,3 +127,42 @@ struct StopEstimations: View { } + + + + +struct EstimationContainer: View { + + let estimation: CarrisNetworkModel.Estimation + + @ObservedObject private var sheetController = SheetController.shared + @ObservedObject private var mapController = MapController.shared + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared + + + var estimationLine: some View { + HStack(spacing: 4) { + RouteBadgePill(routeNumber: estimation.routeNumber) + DestinationText(destination: estimation.destination) + Spacer(minLength: 5) + TimeLeft(time: estimation.eta) + } + } + + + var body: some View { + + if (estimation.busNumber != nil) { + Button(action: { + carrisNetworkController.select(vehicle: estimation.busNumber) + // mapController.moveMap(to:) + sheetController.present(sheet: .VehicleDetails) + }, label: { + estimationLine + }) + } else { + estimationLine + } + } +} + diff --git a/GeoBus/App/Components/StopDetails/StopSearch.swift b/GeoBus/App/Components/StopDetails/StopSearch.swift index 923ecc3d..9ca5cf31 100644 --- a/GeoBus/App/Components/StopDetails/StopSearch.swift +++ b/GeoBus/App/Components/StopDetails/StopSearch.swift @@ -9,32 +9,32 @@ import SwiftUI struct StopSearch: View { - @State var showSearchStopSheet: Bool = false - @State private var viewSize = CGSize() - + @ObservedObject private var sheetController = SheetController.shared var body: some View { SquareButton(icon: "mail.and.text.magnifyingglass", size: 26) .onTapGesture() { TapticEngine.impact.feedback(.medium) - self.showSearchStopSheet = true + sheetController.present(sheet: .StopSelector) } - .sheet(isPresented: self.$showSearchStopSheet) { - ScrollView() { - VStack { - Text("Search Stop") - .font(.largeTitle) - .fontWeight(.bold) - .padding(.vertical, 30) - SearchStopInput(showSheet: $showSearchStopSheet) - } - .padding() - .readSize { size in - viewSize = size - } - } - .background(Color("BackgroundPrimary")) - .presentationDetents([.height(viewSize.height)]) + } +} + + +struct StopSearchView: View { + + var body: some View { + ScrollView() { + VStack { + Text("Search Stop") + .font(.largeTitle) + .fontWeight(.bold) + .padding(.vertical, 30) + SearchStopInput() } + .padding() + } + .background(Color("BackgroundPrimary")) } + } diff --git a/GeoBus/App/Components/VehicleDetails/VehicleDetailsView.swift b/GeoBus/App/Components/VehicleDetails/VehicleDetailsView.swift index 54acd7cb..4b08cf85 100644 --- a/GeoBus/App/Components/VehicleDetails/VehicleDetailsView.swift +++ b/GeoBus/App/Components/VehicleDetails/VehicleDetailsView.swift @@ -7,95 +7,623 @@ // import SwiftUI import Combine +import MapKit -struct VehicleDetailsView: View { + +struct CarrisVehicleSheetView: View { + + @ObservedObject private var appstate = Appstate.shared + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared + + + var body: some View { + VStack(spacing: 0) { + CarrisVehicleSheetHeader(vehicle: carrisNetworkController.activeVehicle) + ScrollView { + VStack(alignment: .leading, spacing: 15) { + if (appstate.carris_vehicleDetails == .error) { + SheetErrorScreen() + } else { + HStack(spacing: 15) { + CarrisVehicleLastSeenTime(vehicle: carrisNetworkController.activeVehicle) + CarrisVehicleToggleFollowOnMap() + } + } + CarrisVehicleRouteSummary(vehicle: carrisNetworkController.activeVehicle) + Disclaimer() + } + .padding() + } + } + } + +} + + + + + +struct CarrisVehicleSheetHeader: View { - let vehicle: CarrisNetworkModel.Vehicle + public let vehicle: CarrisNetworkModel.Vehicle? - @EnvironmentObject var appstate: Appstate - @EnvironmentObject var carrisNetworkController: CarrisNetworkController + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 4) { + RouteBadgePill(routeNumber: vehicle?.routeNumber) + DestinationText(destination: vehicle?.lastStopOnVoyageName) + Spacer(minLength: 15) + VehicleIdentifier(busNumber: vehicle?.id, vehiclePlate: vehicle?.vehiclePlate) + } + .padding() + Divider() + } + } - let refreshTimer = Timer.publish(every: 20 /* seconds */, on: .main, in: .common).autoconnect() - let lastSeenTimeTimer = Timer.publish(every: 1 /* seconds */, on: .main, in: .common).autoconnect() +} + + + + + + +struct CarrisVehicleLastSeenTime: View { - @State var lastSeenTime: String = "-" + public let vehicle: CarrisNetworkModel.Vehicle? + private let lastSeenTimeTimer = Timer.publish(every: 1 /* seconds */, on: .main, in: .common).autoconnect() -// init(vehicle: CarrisNetworkModel.Vehicle) { -// self.vehicle = carrisNetworkController.find(vehicle: vehicle.id) -// } + @State private var lastSeenTime: String? = nil - var loadingScreen: some View { - HStack(spacing: 3) { - ProgressView() - .scaleEffect(0.55) - Text("Loading...") - .font(Font.system(size: 13, weight: .medium, design: .default) ) - .foregroundColor(Color(.tertiaryLabel)) - Spacer() + func updateLastSeenTime(_ value: Any) { + if (vehicle?.lastGpsTime != nil) { + self.lastSeenTime = Helpers.getTimeString(for: vehicle!.lastGpsTime!, in: .past, style: .short, units: [.hour, .minute, .second]) } } - var errorScreen: some View { - Text("Carris API is unavailable.") - .font(Font.system(size: 13, weight: .medium, design: .default) ) - .foregroundColor(Color(.secondaryLabel)) + + var body: some View { + Chip( + icon: Image(systemName: "antenna.radiowaves.left.and.right"), + text: Text("GPS updated \(lastSeenTime ?? "-") ago."), + color: Color(.secondaryLabel), + showContent: lastSeenTime != nil + ) + .onChange(of: vehicle, perform: updateLastSeenTime(_:)) + .onReceive(lastSeenTimeTimer, perform: updateLastSeenTime(_:)) + } + +} + + + + +struct CarrisVehicleToggleFollowOnMap: View { + + private let storageKeyForShouldFollowOnMap: String = "ui_shouldFollowOnMap" + + @ObservedObject private var appstate = Appstate.shared + @ObservedObject private var mapController = MapController.shared + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared + + @State private var shouldFollowOnMap: Bool + + + init() { + self.shouldFollowOnMap = UserDefaults.standard.bool(forKey: storageKeyForShouldFollowOnMap) } - var vehicleDetailsHeader: some View { - HStack(spacing: 15) { - VehicleDestination(routeNumber: vehicle.routeNumber ?? "-", destination: vehicle.lastStopOnVoyageName ?? "-") - Spacer() - VehicleIdentifier(busNumber: vehicle.id, vehiclePlate: vehicle.vehiclePlate) + + func centerMapOnActiveVehicle() { + if (shouldFollowOnMap && carrisNetworkController.activeVehicle != nil) { + mapController.moveMap(to: MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: carrisNetworkController.activeVehicle?.lat ?? 0, longitude: carrisNetworkController.activeVehicle?.lng ?? 0), + latitudinalMeters: CLLocationDistance(1500), longitudinalMeters: CLLocationDistance(1500) + )) } } + var body: some View { + Button(action: { + TapticEngine.impact.feedback(.medium) + self.shouldFollowOnMap = !shouldFollowOnMap + UserDefaults.standard.set(shouldFollowOnMap, forKey: storageKeyForShouldFollowOnMap) + self.centerMapOnActiveVehicle() + }, label: { + VStack { + Image(systemName: shouldFollowOnMap ? "location.circle.fill" : "location.slash.circle") + .font(.system(size: 20, weight: .medium)) + } + .padding() + .foregroundColor(shouldFollowOnMap ? .white : Color(.systemBlue)) + .background(shouldFollowOnMap ? Color(.systemBlue) : Color(.secondaryLabel).opacity(0.1)) + .cornerRadius(10) + .onAppear(perform: centerMapOnActiveVehicle) + .onChange(of: [carrisNetworkController.activeVehicle?.lat, carrisNetworkController.activeVehicle?.lng]) { _ in + centerMapOnActiveVehicle() + } + }) + } - var vehicleDetailsScreen: some View { - VStack(alignment: .leading) { - HStack(alignment: .center, spacing: 5) { - Image(systemName: "antenna.radiowaves.left.and.right") - .font(.system(size: 12, weight: .bold, design: .default)) - .foregroundColor(Color(.secondaryLabel)) - Text("GPS updated \(lastSeenTime) ago") - .font(.system(size: 12, weight: .bold, design: .default)) - .foregroundColor(Color(.secondaryLabel)) - .onAppear() { - self.lastSeenTime = Helpers.getTimeString(for: vehicle.lastGpsTime ?? "", in: .past, style: .full, units: [.hour, .minute, .second]) - } - .onReceive(lastSeenTimeTimer) { event in - self.lastSeenTime = Helpers.getTimeString(for: vehicle.lastGpsTime ?? "", in: .past, style: .full, units: [.hour, .minute, .second]) + + +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +struct CarrisVehicleRouteSummary: View { + + let vehicle: CarrisNetworkModel.Vehicle? + + @ObservedObject private var appstate = Appstate.shared + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared + + + func findNextStop() -> Int? { + if (vehicle?.routeOverview != nil) { + + if let previousStop = vehicle!.routeOverview!.lastIndex(where: { + $0.hasArrived ?? false + }) { + if (previousStop + 1 < vehicle!.routeOverview!.count) { + let nextStop = vehicle!.routeOverview![previousStop + 1] + return nextStop.stopId + } + } + + } + return nil + } + + + func findNextStopIndex() -> Int? { + if (vehicle?.routeOverview != nil) { + if let previousStop = vehicle!.routeOverview!.lastIndex(where: { + $0.hasArrived ?? false + }) { + return previousStop + 1 + } + } + return nil + } + + + + @State var nextStopIndex = 0 + @State var showAllStops = false + + + + + var allStopsToggle: some View { + VStack(alignment: .leading, spacing: 5) { + HStack { + Button(action: { + TapticEngine.impact.feedback(.medium) + self.showAllStops = !showAllStops + }, label: { + if (showAllStops) { + Image(systemName: "minus.square") + .frame(width: 25) + Text("Hide Previous Stops") + } else { + Image(systemName: "plus.square") + .frame(width: 25) + Text("Show All Stops") } + }) Spacer() + PulseLabel(accent: .teal, label: Text("Community")) + } + .font(Font.system(size: 15, weight: .medium)) + .foregroundColor(Color(.secondaryLabel)) + } + } + + @State private var placeholderOpacity: Double = 1 + + + + var content: some View { + VStack(alignment: .leading, spacing: 0) { + + allStopsToggle + .padding() + + Divider() + + VStack(alignment: .leading, spacing: 0) { + ForEach(nextStopIndex.. 0) { + Rectangle() + .frame(width: 3, height: 30) + .foregroundColor(Color("StopMutedBackground")) + .padding(.horizontal, 11) + } + + HStack(alignment: .center, spacing: 10) { + StopIcon(style: .muted, orderInRoute: thisStopIndex+1) + Text(carrisNetworkController.find(stop: estimationLine.stopId)?.name ?? "") + .font(Font.system(size: 17, weight: .medium)) + .lineLimit(1) + .foregroundColor(estimationLine.hasArrived ?? false ? Color("StopMutedText") : .black) + Spacer(minLength: 5) + Image(systemName: "checkmark.circle") + .font(Font.system(size: 15, weight: .medium)) + .foregroundColor(Color("StopMutedText")) + } + + } else if (nextStopId == estimationLine.stopId) { + + VStack(alignment: .center, spacing: -2) { + Rectangle() + .frame(width: 3, height: 30) + .foregroundColor(Color("StopMutedBackground")) + Image(systemName: "arrowtriangle.down.circle.fill") + .font(Font.system(size: 15, weight: .medium)) + .foregroundColor(Color(.systemBlue)) + Rectangle() + .frame(width: 5, height: 30) + .foregroundColor(Color(.systemBlue).opacity(0.5)) + } + .frame(width: 25) + + HStack(alignment: .center, spacing: 10) { + StopIcon(style: .circular, orderInRoute: thisStopIndex+1) + Text(carrisNetworkController.find(stop: estimationLine.stopId)?.name ?? "") + .font(Font.system(size: 17, weight: .medium)) + .lineLimit(1) + .foregroundColor(Color(.label)) + Spacer(minLength: 5) + TimeLeft(time: estimationLine.eta) + } + } else { - vehicleDetailsHeader - .padding() - Divider() - vehicleDetailsScreen - .padding() + + if (thisStopIndex > 0) { + Rectangle() + .frame(width: 5, height: 30) + .foregroundColor(Color(.systemBlue)) + .padding(.horizontal, 10) + } + + HStack(alignment: .center, spacing: 10) { + StopIcon(style: .circular, orderInRoute: thisStopIndex+1) + Text(carrisNetworkController.find(stop: estimationLine.stopId)?.name ?? "") + .font(Font.system(size: 17, weight: .medium)) + .lineLimit(1) + .foregroundColor(Color(.label)) + Spacer(minLength: 5) + TimeLeft(time: estimationLine.eta) + } + + } + + + } + + } + + +} + + + + + + + + + + + + + + + + + + + + + +struct CarrisVehicleRouteOverview: View { + + let vehicle: CarrisNetworkModel.Vehicle? + + @ObservedObject private var appstate = Appstate.shared + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared + + + func findNextStop() -> Int? { + if (vehicle?.routeOverview != nil) { + + if let previousStop = vehicle!.routeOverview!.lastIndex(where: { + $0.hasArrived ?? false + }) { + if (previousStop + 1 < vehicle!.routeOverview!.count) { + let nextStop = vehicle!.routeOverview![previousStop + 1] + return nextStop.stopId + } } + } - .onAppear() { - carrisNetworkController.getAdditionalDetailsFor(vehicle: self.vehicle.id) + return nil + } + + + func findNextStopIndex() -> Int? { + if (vehicle?.routeOverview != nil) { + if let previousStop = vehicle!.routeOverview!.lastIndex(where: { + $0.hasArrived ?? false + }) { + return previousStop + 1 + } } - .onReceive(refreshTimer) { event in - carrisNetworkController.getAdditionalDetailsFor(vehicle: self.vehicle.id) + return nil + } + + + + + + var content: some View { + ScrollViewReader { value in + Button("Jump to #8") { + if (findNextStopIndex() != nil) { + value.scrollTo(findNextStopIndex()) + } + } + + VStack(alignment: .leading, spacing: 0) { + if (vehicle?.routeOverview != nil) { + + ForEach(Array(vehicle!.routeOverview!.enumerated()), id: \.offset) { index, element in + VStack(alignment: .leading, spacing: 0) { + + if (element.hasArrived ?? false) { + + if (index > 0) { + Rectangle() + .frame(width: 3, height: 30) + .foregroundColor(Color("StopMutedBackground")) + .padding(.horizontal, 11) + } + + HStack(alignment: .center, spacing: 10) { + StopIcon(style: .muted, orderInRoute: index+1) + Text(carrisNetworkController.find(stop: element.stopId)?.name ?? "") + .font(Font.system(size: 17, weight: .medium)) + .lineLimit(1) + .foregroundColor(element.hasArrived ?? false ? Color("StopMutedText") : .black) + Spacer(minLength: 5) + Image(systemName: "checkmark.circle") + .font(Font.system(size: 15, weight: .medium)) + .foregroundColor(Color("StopMutedText")) + } + + } else if (findNextStop() == element.stopId) { + + VStack(spacing: 0) { + Rectangle() + .frame(width: 3, height: 30) + .foregroundColor(Color("StopMutedBackground")) + .padding(.horizontal, 11) + Image(systemName: "arrowtriangle.down.circle.fill") + .font(Font.system(size: 15, weight: .medium)) + .foregroundColor(Color(.systemBlue)) + .padding(.vertical, -2) + Rectangle() + .frame(width: 5, height: 30) + .foregroundColor(Color(.systemBlue)) + .padding(.horizontal, 10) + } + + HStack(alignment: .center, spacing: 10) { + StopIcon(style: .standard, orderInRoute: index+1) + Text(carrisNetworkController.find(stop: element.stopId)?.name ?? "") + .font(Font.system(size: 17, weight: .medium)) + .lineLimit(1) + .foregroundColor(Color(.label)) + Spacer(minLength: 5) + TimeLeft(time: element.eta) + } + + } else { + + if (index > 0) { + Rectangle() + .frame(width: 5, height: 30) + .foregroundColor(Color(.systemBlue)) + .padding(.horizontal, 10) + } + + HStack(alignment: .center, spacing: 10) { + StopIcon(style: .standard, orderInRoute: index+1) + Text(carrisNetworkController.find(stop: element.stopId)?.name ?? "") + .font(Font.system(size: 17, weight: .medium)) + .lineLimit(1) + .foregroundColor(Color(.label)) + Spacer(minLength: 5) + TimeLeft(time: element.eta) + } + + } + + + } + } + + + + } else { + Text("Is nil, loading?") + } + + } } - + .padding() + .frame(maxWidth: .infinity) + .background(Color(.secondaryLabel).opacity(0.1)) + .cornerRadius(10) + } + + + + var body: some View { + content } + } + + diff --git a/GeoBus/App/Controllers/Analytics.swift b/GeoBus/App/Controllers/Analytics.swift index 2ca44d0e..6d600cc1 100644 --- a/GeoBus/App/Controllers/Analytics.swift +++ b/GeoBus/App/Controllers/Analytics.swift @@ -1,13 +1,7 @@ -// -// Analytics.swift -// GeoBus -// -// Created by João de Vasconcelos on 11/09/2022. -// - import Foundation import PostHog + /* * */ /* MARK: - ANALYTICS */ /* Wrapper to Posthog. For anyone reading this, this framework was selected */ @@ -16,6 +10,7 @@ import PostHog /* The point is to only record general events, without identifiers, to understand */ /* if calls to the API are working, which routes are selected and how many people are using it. */ + final class Analytics { /* * */ diff --git a/GeoBus/App/Controllers/Appstate.swift b/GeoBus/App/Controllers/Appstate.swift index 5531d1fc..0a9dd9e5 100644 --- a/GeoBus/App/Controllers/Appstate.swift +++ b/GeoBus/App/Controllers/Appstate.swift @@ -1,10 +1,3 @@ -// -// Appstate.swift -// GeoBus -// -// Created by João de Vasconcelos on 11/09/2022. -// - import Foundation @@ -40,27 +33,13 @@ final class Appstate: ObservableObject { case routes case vehicles case estimations + case carris_vehicleDetails } /* * */ - /* MARK: - SECTION 3: ERROR TYPES */ - /* Modules can publish more information on the particular error it encountered. */ - /* This functionality is planned to be expanded sometime in the future. */ - - enum ModuleError: Error { - - // For Carris API - case carris_unauthorized - case carris_unavailable - - } - - - - /* * */ - /* MARK: - SECTION 4: SHARED INSTANCE */ + /* MARK: - SECTION 3: SHARED INSTANCE */ /* To allow the same instance of this class to be available accross the whole app, */ /* we create a Singleton. More info here: https://www.hackingwithswift.com/example-code/language/what-is-a-singleton */ /* Adding a private initializer is important because it stops other code from creating a new class instance. */ @@ -72,7 +51,7 @@ final class Appstate: ObservableObject { /* * */ - /* MARK: - SECTION 5: PUBLISHED VARIABLES */ + /* MARK: - SECTION 4: PUBLISHED VARIABLES */ /* Here are all the @Published variables refering to the above modules that can be consumed */ /* by the UI. It is important to keep the names of this variables short, but descriptive, */ /* to avoid clutter on the interface code. */ @@ -85,10 +64,12 @@ final class Appstate: ObservableObject { @Published var vehicles: State = .idle @Published var estimations: State = .idle + @Published var carris_vehicleDetails: State = .idle + /* * */ - /* MARK: - SECTION 6: CHANGE STATE */ + /* MARK: - SECTION 5: CHANGE STATE */ /* Dispatch the change to the main queue to ensure UI updates happen smoothly and without interruptions. */ /* After the change, follow the set rules to also update the .global state. This might change in the future. */ @@ -106,6 +87,8 @@ final class Appstate: ObservableObject { self.vehicles = newState case .estimations: self.estimations = newState + case .carris_vehicleDetails: + self.carris_vehicleDetails = newState } // Change state of global module if (self.auth == .idle && self.vehicles == .idle) { diff --git a/GeoBus/App/Controllers/CarrisNetworkController.swift b/GeoBus/App/Controllers/CarrisNetworkController.swift new file mode 100644 index 00000000..b97797cf --- /dev/null +++ b/GeoBus/App/Controllers/CarrisNetworkController.swift @@ -0,0 +1,843 @@ +// +// RoutesController.swift +// GeoBus +// +// Created by João de Vasconcelos on 08/09/2022. +// Copyright © 2022 João de Vasconcelos. All rights reserved. +// + +import Foundation +import Combine + + +/* * */ +/* MARK: - CARRIS NETWORK CONTROLLER */ +/* This class controls all things Network related. Keeping logic centralized */ +/* allows for code reuse, less plumbing passing object from one class to another */ +/* and less clutter overall. If the data is provided by Carris, it should be controlled */ +/* by this class. Next follows an overview of this class and its sections: */ +/* › SECTION 1: SETTINGS */ +/* › SECTION 2: PUBLISHED VARIABLES */ +/* › SECTION 3: INITIALIZER */ +/* › SECTION 4: APPSTATE, ANALYTICS & AUTHENTICATION */ +/* › SECTION 5: TITLE */ +/* › SECTION 6: TITLE */ +/* › SECTION 7: TITLE */ +/* › SECTION 8: TITLE */ +/* › SECTION 9: TITLE */ +/* › SECTION 10: TITLE */ +/* › SECTION 11: TITLE */ + + +@MainActor +class CarrisNetworkController: ObservableObject { + + /* * */ + /* MARK: - SECTION 1: SETTINGS */ + /* In this section one can find private constants for update intervals, */ + /* storage keys and more. Change these values with caution because they can */ + /* trigger updates on the users devices, which can take a long time or fail. */ + + private let network_updateInterval: Int = 86400 * 5 // 5 days + private let network_storageKeyForSavedStops: String = "network_savedStops" + private let network_storageKeyForSavedRoutes: String = "network_savedRoutes" + private let network_storageKeyForLastUpdated: String = "network_lastUpdated" + + private let routes_storageKeyForFavoriteRoutes: String = "routes_favoriteRoutes" + + private let stops_storageKeyForFavoriteStops: String = "stops_favoriteStops" + + private let api_carrisEndpoint: String = "https://gateway.carris.pt/gateway/xtranpassengerapi/api/v2.10" + private let api_communityEndpoint: String = "https://api.carril.workers.dev" + + + + + + /* * */ + /* MARK: - SECTION 2: PUBLISHED VARIABLES */ + /* Here are all the @Published variables that can be consumed by the app views. */ + /* It is important to keep the names of this variables short, but descriptive, */ + /* to avoid clutter on the interface code. */ + + @Published var network_allRoutes: [Route_NEW] = [] + @Published var network_allStops: [Stop_NEW] = [] + @Published var network_allVehicles: [Vehicle] = [] + + @Published var network_lastUpdated: String? = nil + @Published var network_updateProgress: Int? = nil + + @Published var network_selectedRoute: Route_NEW? = nil + @Published var network_selectedVariant: Variant_NEW? = nil + @Published var network_selectedConnection: Connection_NEW? = nil + @Published var network_selectedStop: Stop_NEW? = nil + + @Published var favorites_routes: [Route_NEW] = [] + @Published var favorites_stops: [Stop_NEW] = [] + + + + + + /* * */ + /* MARK: - SECTION 3: INITIALIZER */ + /* When this class is initialized, data stored on the users device must be retrieved */ + /* from UserDefaults to avoid requesting a new update to the APIs. Do not call other */ + /* functions yet because Appstate and Authentication must be passed first. */ + + init() { + + // Unwrap and Decode Stops from Storage + if let unwrappedSavedNetworkStops = UserDefaults.standard.data(forKey: network_storageKeyForSavedStops) { + if let decodedSavedNetworkStops = try? JSONDecoder().decode([Stop_NEW].self, from: unwrappedSavedNetworkStops) { + self.network_allStops = decodedSavedNetworkStops + } + } + + // Unwrap and Decode Routes from Storage + if let unwrappedSavedNetworkRoutes = UserDefaults.standard.data(forKey: network_storageKeyForSavedRoutes) { + if let decodedSavedNetworkRoutes = try? JSONDecoder().decode([Route_NEW].self, from: unwrappedSavedNetworkRoutes) { + self.network_allRoutes = decodedSavedNetworkRoutes + } + } + + // Unwrap last timestamp from Storage + if let unwrappedLastUpdatedNetwork = UserDefaults.standard.string(forKey: network_storageKeyForLastUpdated) { + self.network_lastUpdated = unwrappedLastUpdatedNetwork + } + + } + + + + + + /* * */ + /* MARK: - SECTION 5: UPDATE NETWORK FROM CARRIS API */ + /* This function decides whether to update the complete network model */ + /* if it is considered outdated or is inexistent on device storage. */ + /* Provide a convenience method to allow user-requested updates from the UI. */ + + func resetAndUpdateNetwork() { + self.start(withForcedUpdate: true) + } + + func start(withForcedUpdate forceUpdate: Bool = false) { + + // Conditions to update + let lastUpdateIsLongerThanInterval = Helpers.getSecondsFromISO8601DateString(network_lastUpdated ?? "") > network_updateInterval + let savedNetworkDataIsEmpty = network_allRoutes.isEmpty || network_allStops.isEmpty + let updateIsForcedByCaller = forceUpdate + + // Proceed if at least one condition is true + if (lastUpdateIsLongerThanInterval || savedNetworkDataIsEmpty || updateIsForcedByCaller) { + Task { + await fetchStopsFromCarrisAPI() + await fetchRoutesFromCarrisAPI() + } + // Replace timestamp in storage with current time + let timestampOfCurrentUpdate = ISO8601DateFormatter().string(from: Date.now) + UserDefaults.standard.set(timestampOfCurrentUpdate, forKey: network_storageKeyForLastUpdated) + } + + + // Retrieve favorites at app launch + // self.retrieveFavorites() + + } + + + + + + /* * */ + /* MARK: - SECTION F: FETCH & FORMAT ROUTES FROM CARRIS API */ + /* This function first fetches the Routes List, which is an object */ + /* that contains all the available routes from the API. */ + /* The information for each Route is very short, so it is necessary to retrieve */ + /* the details for each route. Here, we only care about the publicy available routes. */ + /* After, for each route, it's details are formatted and transformed into a Route. */ + + func fetchRoutesFromCarrisAPI() async { + + Analytics.shared.capture(event: .Routes_Sync_START) + Appstate.shared.change(to: .loading, for: .routes) + + print("GB: Fetching Routes: Starting...") + + do { + // Request API Routes List + var requestCarrisAPIRoutesList = URLRequest(url: URL(string: "\(api_carrisEndpoint)/Routes")!) + requestCarrisAPIRoutesList.addValue("application/json", forHTTPHeaderField: "Content-Type") + requestCarrisAPIRoutesList.addValue("application/json", forHTTPHeaderField: "Accept") + requestCarrisAPIRoutesList.setValue("Bearer \(CarrisAuthentication.shared.authToken ?? "invalid_token")", forHTTPHeaderField: "Authorization") + let (rawDataCarrisAPIRoutesList, rawResponseCarrisAPIRoutesList) = try await URLSession.shared.data(for: requestCarrisAPIRoutesList) + let responseCarrisAPIRoutesList = rawResponseCarrisAPIRoutesList as? HTTPURLResponse + + // Check status of response + if (responseCarrisAPIRoutesList?.statusCode == 401) { + await CarrisAuthentication.shared.authenticate() + await self.fetchRoutesFromCarrisAPI() + return + } else if (responseCarrisAPIRoutesList?.statusCode != 200) { + print(responseCarrisAPIRoutesList as Any) + throw Appstate.ModuleError.carris_unavailable + } + + let decodedCarrisAPIRoutesList = try JSONDecoder().decode([APIRoutesList].self, from: rawDataCarrisAPIRoutesList) + + self.network_updateProgress = decodedCarrisAPIRoutesList.count + + // Define a temporary variable to store routes + // before saving them to the device storage. + var tempAllRoutes: [Route_NEW] = [] + + // For each available route in the API, + for availableRoute in decodedCarrisAPIRoutesList { + + if (availableRoute.isPublicVisible ?? false) { + + print("Route: \(String(describing: availableRoute.routeNumber)) starting...") + + // Request Route Detail for ‹routeNumber› + var requestAPIRouteDetail = URLRequest(url: URL(string: "\(api_carrisEndpoint)/Routes/\(availableRoute.routeNumber ?? "invalid-route-number")")!) + requestAPIRouteDetail.addValue("application/json", forHTTPHeaderField: "Content-Type") + requestAPIRouteDetail.addValue("application/json", forHTTPHeaderField: "Accept") + requestAPIRouteDetail.setValue("Bearer \(CarrisAuthentication.shared.authToken ?? "invalid_token")", forHTTPHeaderField: "Authorization") + let (rawDataAPIRouteDetail, rawResponseAPIRouteDetail) = try await URLSession.shared.data(for: requestAPIRouteDetail) + let responseAPIRouteDetail = rawResponseAPIRouteDetail as? HTTPURLResponse + + // Check status of response + if (responseAPIRouteDetail?.statusCode == 401) { + await CarrisAuthentication.shared.authenticate() + await self.fetchRoutesFromCarrisAPI() + return + } else if (responseAPIRouteDetail?.statusCode != 200) { + print(responseAPIRouteDetail as Any) + throw Appstate.ModuleError.carris_unavailable + } + + let decodedAPIRouteDetail = try JSONDecoder().decode(APIRoute.self, from: rawDataAPIRouteDetail) + + // Define a temporary variable to store formatted route variants + var tempFormattedRouteVariants: [Variant_NEW] = [] + + // For each variant in route, + // check if it is currently active, format it + // and append the result to the temporary variable. + for apiRouteVariant in decodedAPIRouteDetail.variants ?? [] { + if (apiRouteVariant.isActive ?? false) { + tempFormattedRouteVariants.append( + formatRawRouteVariant(rawVariant: apiRouteVariant) + ) + } + } + + // Build the formatted route object + let formattedRoute = Route_NEW( + number: decodedAPIRouteDetail.routeNumber ?? "-", + name: decodedAPIRouteDetail.name ?? "-", + kind: Helpers.getKind(by: decodedAPIRouteDetail.routeNumber ?? "-"), + variants: tempFormattedRouteVariants + ) + + // Save the formatted route object in the allRoutes temporary variable + tempAllRoutes.append(formattedRoute) + + self.network_updateProgress! -= 1 + + print("Route: Route.\(String(describing: formattedRoute.number)) complete.") + + try await Task.sleep(nanoseconds: 100_000_000) + + } + + } + + // Finally, save the temporary variables into storage, + // while removing the previous, old ones. + self.network_allRoutes.removeAll() + self.network_allRoutes.append(contentsOf: tempAllRoutes) + if let encodedAllRoutes = try? JSONEncoder().encode(self.network_allRoutes) { + UserDefaults.standard.set(encodedAllRoutes, forKey: network_storageKeyForSavedRoutes) + } + + print("Fetching Routes: Complete!") + + Analytics.shared.capture(event: .Routes_Sync_OK) + Appstate.shared.change(to: .idle, for: .routes) + + } catch { + Analytics.shared.capture(event: .Routes_Sync_ERROR) + Appstate.shared.change(to: .error, for: .routes) + print("Fetching Routes: Error!") + print(error) + print("************") + } + + } + + + + + func formatConnections(rawConnections: [APIRouteVariantItineraryConnection]) -> [Connection_NEW] { + + var tempConnections: [Connection_NEW] = [] + + // For each connection, + // convert the nested objects into a simplified RouteStop object + for rawConnection in rawConnections { + + // Append new values to the temporary variable property directly + tempConnections.append( + Connection_NEW( + orderInRoute: rawConnection.orderNum ?? -1, + stop: Stop_NEW( + publicId: rawConnection.busStop?.publicId ?? "-", + name: rawConnection.busStop?.name ?? "-", + lat: rawConnection.busStop?.lat ?? 0, + lng: rawConnection.busStop?.lng ?? 0 + ) + ) + ) + + } + + // Sort the stops + tempConnections.sort(by: { $0.orderInRoute < $1.orderInRoute }) + + return tempConnections + + } + + + /* MARK: - Format Route Variants */ + // Parse and simplify the data model for variants + func formatRawRouteVariant(rawVariant: APIRouteVariant) -> Variant_NEW { + + // For each Itinerary type, + // check if it is defined (not nil) in the raw object + var tempItineraries: [Itinerary_NEW] = [] + + // For UpItinerary: + if (rawVariant.upItinerary != nil) { + tempItineraries.append( + Itinerary_NEW( + direction: .ascending, + connections: formatConnections(rawConnections: rawVariant.upItinerary!.connections ?? []) + ) + ) + } + + // For DownItinerary: + if (rawVariant.downItinerary != nil) { + tempItineraries.append( + Itinerary_NEW( + direction: .descending, + connections: formatConnections(rawConnections: rawVariant.downItinerary!.connections ?? []) + ) + ) + } + + // For CircItinerary: + if (rawVariant.circItinerary != nil) { + tempItineraries.append( + Itinerary_NEW( + direction: .circular, + connections: formatConnections(rawConnections: rawVariant.circItinerary!.connections ?? []) + ) + ) + } + + +// if (formattedVariant.isCircular) { +// formattedVariant.name = getTerminalStopNameForVariant(variant: formattedVariant, direction: .circular) +// } else { +// let firstStop = getTerminalStopNameForVariant(variant: formattedVariant, direction: .ascending) +// let lastStop = getTerminalStopNameForVariant(variant: formattedVariant, direction: .descending) +// formattedVariant.name = "\(firstStop) ⇄ \(lastStop)" +// } + + // Finally, return the temporary variable to the caller + return Variant_NEW( + number: rawVariant.variantNumber ?? -1, + name: "in-progress", + itineraries: tempItineraries + ) + + } + + + /* MARK: - Get Terminal Stop Name for Variant */ + // This function returns the provided variant's terminal stop for the provided direction. + func getTerminalStopNameForVariant(variant: Variant, direction: Direction) -> String { + switch direction { + case .circular: + return variant.circItinerary?.first?.name ?? "-" + case .ascending: + return variant.upItinerary?.last?.name ?? (variant.upItinerary?.first?.name ?? "-") + case .descending: + return variant.downItinerary?.last?.name ?? (variant.downItinerary?.first?.name ?? "-") + } + } + + + func fetchStopsFromCarrisAPI() async { + + Analytics.shared.capture(event: .Stops_Sync_START) + Appstate.shared.change(to: .loading, for: .stops) + + print("Fetching Stops: Starting...") + + do { + // Request API Routes List + var requestCarrisAPIStopsList = URLRequest(url: URL(string: "\(api_carrisEndpoint)/busstops")!) + requestCarrisAPIStopsList.addValue("application/json", forHTTPHeaderField: "Content-Type") + requestCarrisAPIStopsList.addValue("application/json", forHTTPHeaderField: "Accept") + requestCarrisAPIStopsList.setValue("Bearer \(CarrisAuthentication.shared.authToken ?? "invalid_token")", forHTTPHeaderField: "Authorization") + let (rawDataCarrisAPIStopsList, rawResponseCarrisAPIStopsList) = try await URLSession.shared.data(for: requestCarrisAPIStopsList) + let responseCarrisAPIStopsList = rawResponseCarrisAPIStopsList as? HTTPURLResponse + + // Check status of response + if (responseCarrisAPIStopsList?.statusCode == 401) { + await CarrisAuthentication.shared.authenticate() + await self.fetchStopsFromCarrisAPI() + return + } else if (responseCarrisAPIStopsList?.statusCode != 200) { + print(responseCarrisAPIStopsList as Any) + throw Appstate.ModuleError.carris_unavailable + } + + let decodedCarrisAPIStopsList = try JSONDecoder().decode([APIStop].self, from: rawDataCarrisAPIStopsList) + + // Define a temporary variable to store routes + // before saving them to the device storage. + var tempAllStops: [Stop_NEW] = [] + + // For each available route in the API, + for availableStop in decodedCarrisAPIStopsList { + if (availableStop.isPublicVisible ?? false) { + // Save the formatted route object in the allRoutes temporary variable + tempAllStops.append( + Stop_NEW( + publicId: availableStop.publicId ?? "0", + name: availableStop.name ?? "-", + lat: availableStop.lat ?? 0, + lng: availableStop.lng ?? 0 + ) + ) + } + } + + // Finally, save the temporary variables into storage, + // while removing the previous, old ones. + self.network_allStops.removeAll() + self.network_allStops.append(contentsOf: tempAllStops) + if let encodedAllStops = try? JSONEncoder().encode(self.network_allStops) { + UserDefaults.standard.set(encodedAllStops, forKey: network_storageKeyForSavedStops) + } + + print("[GB-Debug] Fetching Stops: Complete!") + + Analytics.shared.capture(event: .Stops_Sync_OK) + Appstate.shared.change(to: .idle, for: .stops) + + } catch { + Analytics.shared.capture(event: .Stops_Sync_ERROR) + Appstate.shared.change(to: .error, for: .stops) + print("Fetching Stops: Error!") + print(error) + print("************") + } + + } + + /* MARK: - Find Route by RouteNumber */ + // This function searches for the provided routeNumber in all routes array, + // and returns it if found. If not found, returns nil. + func findRoute(by routeNumber: String) -> Route_NEW? { + + // Find index of route matching requested routeNumber + let indexOfRouteInArray = network_allRoutes.firstIndex(where: { (route) -> Bool in + route.number == routeNumber // test if this is the item we're looking for + }) ?? nil // If the item does not exist, return default value nil + + // If a match is found... + if (indexOfRouteInArray != nil) { + return network_allRoutes[indexOfRouteInArray!] + } else { + return nil + } + + } + + + + + + + + /* * */ + /* MARK: - SECTION B: SELECTORS */ + /* Lorem ipsum dolor sit amet consectetur adipisicing elit. Maxime mollitia, */ + /* molestiae quas vel sint commodi repudiandae consequuntur voluptatum laborum */ + /* numquam blanditiis harum quisquam eius sed odit fugiat */ + + // Routes + + public func select(route routeNumber: String) { + if let route = self.findRoute(by: routeNumber) { + self.select(route: route) + } + } + + func select(route routeNumber: String, returnResult: Bool) -> Bool { + if let route = self.findRoute(by: routeNumber) { + self.select(route: route) + return true + } else { + return false + } + } + + private func select(route: Route_NEW) { + self.network_selectedRoute = route + self.select(variant: route.variants[0]) + } + + public func select(variant: Variant_NEW) { + self.network_selectedVariant = variant + } + + + public func deselect() { + self.network_selectedRoute = nil + self.network_selectedVariant = nil + self.network_selectedConnection = nil + self.network_selectedStop = nil + } + + + // Stops + + private func select(stop: Stop_NEW) { + self.network_selectedStop = stop + } + + func select(stop stopPublicId: String) { + let stop = self.findStop(by: stopPublicId) + if (stop != nil) { + self.select(stop: stop!) + } + } + + func select(stop stopPublicId: String, returnResult: Bool) -> Bool { + let stop = self.findStop(by: stopPublicId) + if (stop != nil) { + self.select(stop: stop!) + return true + } else { + return false + } + } + + + /* MARK: - Find Stop by Public ID */ + // This function searches for the provided routeNumber in all routes array, + // and returns it if found. If not found, returns nil. + func findStop(by stopPublicId: String) -> Stop_NEW? { + + let parsedStopPublicId = Int(stopPublicId) ?? 0 + + // Find index of route matching requested routeNumber + let indexOfStopInArray = network_allStops.firstIndex(where: { (stop) -> Bool in + stop.publicId == String(parsedStopPublicId) // test if this is the item we're looking for + }) ?? nil // If the item does not exist, return default value -1 + + // If a match is found... + if (indexOfStopInArray != nil) { + return network_allStops[indexOfStopInArray!] + } else { + return nil + } + + } + + + /* MARK: - UPDATE VEHICLES */ + + // This function decides whether to update available routes + + enum VehicleUpdateScope { + case summary + case detail + case community + } + + func update(scope: VehicleUpdateScope, for busNumber: Int? = nil) { + + switch scope { + + case .summary: + Task { + await fetchVehiclesListFromCarrisAPI_NEW() + } + + case .detail: + if (busNumber != nil) { + Task { + await fetchVehicleDetailsFromCarrisAPI_NEW(for: busNumber!) + } + } + + case .community: + if (busNumber != nil) { + Task { + await fetchVehicleFromCommunityAPI_NEW(for: busNumber!) + } + } + + } + + } + + + + /* MARK: - FETCH VEHICLES SUMMARY FROM CARRIS API */ + + // This function calls the GeoBus API and receives vehicle metadata, + // including positions, for the set route number, while storing them + // in the vehicles array. It also formats VehicleAnnotations and stores + // them in the annotations array. It must have @objc flag because Timer + // is written in Objective-C. + + func fetchVehiclesListFromCarrisAPI_NEW() async { + + Appstate.shared.change(to: .loading, for: .vehicles) + + do { + // Request all Vehicles from API + var requestCarrisAPIVehiclesList = URLRequest(url: URL(string: "https://gateway.carris.pt/gateway/xtranpassengerapi/api/v2.10/vehicleStatuses")!) // /routeNumber/\(routeNumber!) + requestCarrisAPIVehiclesList.addValue("application/json", forHTTPHeaderField: "Content-Type") + requestCarrisAPIVehiclesList.addValue("application/json", forHTTPHeaderField: "Accept") + requestCarrisAPIVehiclesList.setValue("Bearer \(CarrisAuthentication.shared.authToken ?? "invalid_token")", forHTTPHeaderField: "Authorization") + let (rawDataCarrisAPIVehiclesList, rawResponseCarrisAPIVehiclesList) = try await URLSession.shared.data(for: requestCarrisAPIVehiclesList) + let responseCarrisAPIVehiclesList = rawResponseCarrisAPIVehiclesList as? HTTPURLResponse + + // Check status of response + if (responseCarrisAPIVehiclesList?.statusCode == 401) { + await CarrisAuthentication.shared.authenticate() + await self.fetchVehiclesListFromCarrisAPI_NEW() + return + } else if (responseCarrisAPIVehiclesList?.statusCode != 200) { + print(responseCarrisAPIVehiclesList as Any) + throw Appstate.ModuleError.carris_unavailable + } + + let decodedCarrisAPIVehiclesList = try JSONDecoder().decode([CarrisAPIVehicleSummary].self, from: rawDataCarrisAPIVehiclesList) + + + for vehicleSummary in decodedCarrisAPIVehiclesList { + + let indexOfVehicleInArray = self.network_allVehicles.firstIndex(where: { + $0.id == vehicleSummary.busNumber + }) + + if (indexOfVehicleInArray != nil) { + network_allVehicles[indexOfVehicleInArray!].routeNumber = vehicleSummary.routeNumber ?? "-" + network_allVehicles[indexOfVehicleInArray!].kind = Helpers.getKind(by: vehicleSummary.routeNumber ?? "-") + network_allVehicles[indexOfVehicleInArray!].lat = vehicleSummary.lat ?? 0 + network_allVehicles[indexOfVehicleInArray!].lng = vehicleSummary.lng ?? 0 + network_allVehicles[indexOfVehicleInArray!].previousLatitude = vehicleSummary.previousLatitude ?? 0 + network_allVehicles[indexOfVehicleInArray!].previousLongitude = vehicleSummary.previousLongitude ?? 0 + network_allVehicles[indexOfVehicleInArray!].lastGpsTime = vehicleSummary.lastGpsTime ?? "" + network_allVehicles[indexOfVehicleInArray!].angleInRadians = self.getAngleInRadians( + prevLat: vehicleSummary.previousLatitude ?? 0, + prevLng: vehicleSummary.previousLongitude ?? 0, + currLat: vehicleSummary.lat ?? 0, + currLng: vehicleSummary.lng ?? 0 + ) + } else { + self.network_allVehicles.append( + Vehicle( + busNumber: vehicleSummary.busNumber ?? 0, + routeNumber: vehicleSummary.routeNumber ?? "-", + kind: Helpers.getKind(by: vehicleSummary.routeNumber ?? "-"), + lat: vehicleSummary.lat ?? 0, + lng: vehicleSummary.lng ?? 0, + previousLatitude: vehicleSummary.previousLatitude ?? 0, + previousLongitude: vehicleSummary.previousLongitude ?? 0, + lastGpsTime: vehicleSummary.lastGpsTime ?? "", + angleInRadians: self.getAngleInRadians( + prevLat: vehicleSummary.previousLatitude ?? 0, + prevLng: vehicleSummary.previousLongitude ?? 0, + currLat: vehicleSummary.lat ?? 0, + currLng: vehicleSummary.lng ?? 0 + ) + ) + ) + } + + } + + Appstate.shared.change(to: .idle, for: .vehicles) + + } catch { + Appstate.shared.change(to: .error, for: .vehicles) + print("ERROR IN VEHICLES: \(error)") + return + } + + } + + + + + + + + + /* MARK: - FETCH VEHICLE DETAILS FROM CARRIS API */ + + // This function calls the GeoBus API and receives vehicle metadata, + // including positions, for the set route number, while storing them + // in the vehicles array. It also formats VehicleAnnotations and stores + // them in the annotations array. It must have @objc flag because Timer + // is written in Objective-C. + + func fetchVehicleDetailsFromCarrisAPI_NEW(for busNumber: Int) async { + + // 1. Check if Vehicle exists in array + guard let indexOfVehicleInArray = network_allVehicles.firstIndex(where: { $0.id == busNumber }) else { + return + } + + Appstate.shared.change(to: .loading, for: .vehicles) + + do { + + // Request Vehicle Detail (SGO) + var requestCarrisAPIVehicleDetail = URLRequest(url: URL(string: "\(api_carrisEndpoint)/SGO/busNumber/\(busNumber)")!) + requestCarrisAPIVehicleDetail.addValue("application/json", forHTTPHeaderField: "Content-Type") + requestCarrisAPIVehicleDetail.addValue("application/json", forHTTPHeaderField: "Accept") + requestCarrisAPIVehicleDetail.setValue("Bearer \(CarrisAuthentication.shared.authToken ?? "invalid_token")", forHTTPHeaderField: "Authorization") + let (rawDataCarrisAPIVehicleDetail, rawResponseCarrisAPIVehicleDetail) = try await URLSession.shared.data(for: requestCarrisAPIVehicleDetail) + let responseCarrisAPIVehicleDetail = rawResponseCarrisAPIVehicleDetail as? HTTPURLResponse + + // Check status of response + if (responseCarrisAPIVehicleDetail?.statusCode == 401) { + await CarrisAuthentication.shared.authenticate() + await self.fetchVehicleDetailsFromCarrisAPI_NEW(for: busNumber) + return + } else if (responseCarrisAPIVehicleDetail?.statusCode != 200) { + print(responseCarrisAPIVehicleDetail as Any) + throw Appstate.ModuleError.carris_unavailable + } + + let decodedCarrisAPIVehicleDetail = try JSONDecoder().decode(CarrisAPIVehicleDetail.self, from: rawDataCarrisAPIVehicleDetail) + + // Update details of Vehicle + network_allVehicles[indexOfVehicleInArray].vehiclePlate = decodedCarrisAPIVehicleDetail.vehiclePlate ?? "-" + network_allVehicles[indexOfVehicleInArray].lastStopOnVoyageName = decodedCarrisAPIVehicleDetail.lastStopOnVoyageName ?? "-" + + Appstate.shared.change(to: .idle, for: .vehicles) + + } catch { + Appstate.shared.change(to: .error, for: .vehicles) + print("ERROR IN VEHICLE DETAILS: \(error)") + return + } + + } + + + + /* MARK: - FETCH VEHICLE FROM COMMUNITY API */ + + // This function calls the GeoBus API and receives vehicle metadata, + // including positions, for the set route number, while storing them + // in the vehicles array. It also formats VehicleAnnotations and stores + // them in the annotations array. It must have @objc flag because Timer + // is written in Objective-C. + + func fetchVehicleFromCommunityAPI_NEW(for busNumber: Int) async { + + // 1. Check if Vehicle exists in array + guard let indexOfVehicleInArray = network_allVehicles.firstIndex(where: { $0.id == busNumber }) else { + return + } + + Appstate.shared.change(to: .loading, for: .vehicles) + + do { + + // Request Vehicle Detail (SGO) + var requestCommunityAPIVehicle = URLRequest(url: URL(string: "\(api_communityEndpoint)/estbus?busNumber=\(busNumber)")!) + requestCommunityAPIVehicle.addValue("application/json", forHTTPHeaderField: "Content-Type") + requestCommunityAPIVehicle.addValue("application/json", forHTTPHeaderField: "Accept") + let (rawDataCommunityAPIVehicle, rawResponseCommunityAPIVehicle) = try await URLSession.shared.data(for: requestCommunityAPIVehicle) + let responseCommunityAPIVehicle = rawResponseCommunityAPIVehicle as? HTTPURLResponse + + // Check status of response + if (responseCommunityAPIVehicle?.statusCode != 200) { + print(responseCommunityAPIVehicle as Any) + throw Appstate.ModuleError.community_unavailable + } + + let decodedCommunityAPIVehicle = try JSONDecoder().decode([CommunityAPIVehicle].self, from: rawDataCommunityAPIVehicle) + + // Update details of Vehicle + network_allVehicles[indexOfVehicleInArray].estimatedTimeofArrivalCorrected = decodedCommunityAPIVehicle[0].estimatedTimeofArrivalCorrected + + Appstate.shared.change(to: .idle, for: .vehicles) + + } catch { + Appstate.shared.change(to: .error, for: .vehicles) + print("GB: ERROR IN 'fetchVehicleFromCommunityAPI_NEW': \(error)") + return + } + + } + + + + + + func getVehicle(by busNumber: Int) -> Vehicle? { + let indexInArray = self.network_allVehicles.firstIndex(where: { + $0.busNumber == busNumber + }) + + if (indexInArray != nil) { + return network_allVehicles[indexInArray!] + } else { + return nil + } + } + + + + /* MARK: - CALCULATE ANGLE IN RADIANS FOR VEHICLE DIRECTION */ + + // This function calls the GeoBus API and receives vehicle metadata, + + func getAngleInRadians(prevLat: Double, prevLng: Double, currLat: Double, currLng: Double) -> Double { + // and return response to the caller + let x = currLat - prevLat; + let y = currLng - prevLng; + + var teta: Double; + // Angle is calculated with the arctan of ( y / x ) + if (x == 0){ teta = .pi / 2 } + else { teta = atan(y / x) } + + // If x is negative, then the angle is in the symetric quadrant + if (x < 0) { teta += .pi } + + return teta - (.pi / 2) // Correction cuz Apple rotates clockwise + + } + + + +} diff --git a/GeoBus/App/Controllers/MapController.swift b/GeoBus/App/Controllers/MapController.swift index a63cf570..eddfea64 100644 --- a/GeoBus/App/Controllers/MapController.swift +++ b/GeoBus/App/Controllers/MapController.swift @@ -1,16 +1,15 @@ -// -// MapController.swift -// GeoBus -// -// Created by João de Vasconcelos on 16/09/2022. -// - import Foundation import MapKit -import SwiftUI + + +/* * */ +/* MARK: - MAP CONTROLLER */ +/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi rutrum lectus */ +/* non interdum imperdiet. In hendrerit ligula velit, ac porta augue volutpat id. */ + @MainActor -class MapController: ObservableObject { +final class MapController: ObservableObject { /* * */ /* MARK: - SECTION 1: SETTINGS */ @@ -27,7 +26,14 @@ class MapController: ObservableObject { /* MARK: - SECTION 2: PUBLISHED PROPERTIES */ /* Here are all the @Published variables that can be consumed by the app views. */ - @Published var region = MKCoordinateRegion() + @Published var region = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 38.721917, longitude: -9.137732), + span: MKCoordinateSpan(latitudeDelta: CLLocationDistance(15000), longitudeDelta: CLLocationDistance(15000)) + ) + + + @Published var mapCamera: MKMapCamera = MKMapCamera() + @Published var locationManager = CLLocationManager() @Published var showLocationNotAllowedAlert: Bool = false @@ -35,13 +41,16 @@ class MapController: ObservableObject { @Published var visibleAnnotations: [GenericMapAnnotation] = [] + @Published var allAnnotations: [GeoBusMKAnnotation] = [] + @Published var allOverlays: [MKPolyline] = [] + /* * */ /* MARK: - SECTION 3: SHARED INSTANCE */ /* To allow the same instance of this class to be available accross the whole app, */ /* we create a Singleton. More info here: https://www.hackingwithswift.com/example-code/language/what-is-a-singleton */ - static let shared = MapController() + public static let shared = MapController() @@ -51,8 +60,54 @@ class MapController: ObservableObject { /* Setup the initial map region on init. */ private init() { - self.region = MKCoordinateRegion(center: initialMapRegion, latitudinalMeters: initialMapZoom, longitudinalMeters: initialMapZoom) + self.region = MKCoordinateRegion( + center: self.initialMapRegion, + latitudinalMeters: self.initialMapZoom, + longitudinalMeters: self.initialMapZoom + ) } + + + + + + + // ADD ANNOTATIONS + func add(annotations newAnnotationsArray: [GeoBusMKAnnotation], ofType annotationsType: GeoBusMKAnnotation.AnnotationType) { + self.allAnnotations.removeAll(where: { $0.type == annotationsType }) + self.allAnnotations.append(contentsOf: newAnnotationsArray) + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -61,11 +116,20 @@ class MapController: ObservableObject { /* Helper function to animate the Map changing region. */ func moveMap(to newRegion: MKCoordinateRegion) { - DispatchQueue.main.async { - withAnimation(.easeIn(duration: 0.5)) { - self.region = newRegion - } - } + self.region = newRegion + } + + + + /* * */ + /* MARK: - SECTION 6: CENTER MAP ON COORDINATES */ + /* Lorem ipsum dolor sit amet consectetur adipisicing elit. */ + + func centerMapOnCoordinates(lat: Double, lng: Double, andZoom: Bool = false) { + self.region = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: lat, longitude: lng), + span: self.region.span + ) } @@ -136,26 +200,28 @@ class MapController: ObservableObject { /* MARK: - SECTION 8: UPDATE ANNOTATIONS WITH SELECTED CARRIS STOP */ /* Lorem ipsum dolor sit amet consectetur adipisicing elit. */ - func updateAnnotations(with activeStop: CarrisNetworkModel.Stop) { - - visibleAnnotations.removeAll(where: { - switch $0.item { - case .carris_stop(_), .carris_connection(_), .carris_vehicle(_): - return true - } - }) - - visibleAnnotations.append( - GenericMapAnnotation( - id: activeStop.id, - location: CLLocationCoordinate2D(latitude: activeStop.lat, longitude: activeStop.lng), - item: .carris_stop(activeStop) - ) - ) - - zoomToFitMapAnnotations(annotations: visibleAnnotations) - - } +// func updateAnnotations(with activeStop: CarrisNetworkModel.Stop) { +// +// visibleAnnotations.removeAll(where: { +// switch $0.item { +// case .stop(_), .carris_connection(_), .vehicle(_: +// return true +// } +// }) +// +// var tempNewAnnotations: [GenericMapAnnotation] = [] +// +// tempNewAnnotations.append( +// GenericMapAnnotation( +// id: UUID(), +// location: CLLocationCoordinate2D(latitude: activeStop.lat, longitude: activeStop.lng), +// item: .stop(activeStop) +// ) +// ) +// +// self.addAnnotations(tempNewAnnotations, zoom: true) +// +// } @@ -167,18 +233,22 @@ class MapController: ObservableObject { visibleAnnotations.removeAll(where: { switch $0.item { - case .carris_connection(_), .carris_stop(_): + case .carris_connection(_), .stop(_): return true - case .carris_vehicle(_): + case .vehicle(_): return false } }) + + var tempNewAnnotations: [GenericMapAnnotation] = [] + + if (activeVariant.circularItinerary != nil) { for connection in activeVariant.circularItinerary! { - visibleAnnotations.append( + tempNewAnnotations.append( GenericMapAnnotation( - id: connection.stop.id, + id: UUID(), location: CLLocationCoordinate2D(latitude: connection.stop.lat, longitude: connection.stop.lng), item: .carris_connection(connection) ) @@ -188,9 +258,9 @@ class MapController: ObservableObject { if (activeVariant.ascendingItinerary != nil) { for connection in activeVariant.ascendingItinerary! { - visibleAnnotations.append( + tempNewAnnotations.append( GenericMapAnnotation( - id: connection.stop.id, + id: UUID(), location: CLLocationCoordinate2D(latitude: connection.stop.lat, longitude: connection.stop.lng), item: .carris_connection(connection) ) @@ -200,9 +270,9 @@ class MapController: ObservableObject { if (activeVariant.descendingItinerary != nil) { for connection in activeVariant.descendingItinerary! { - visibleAnnotations.append( + tempNewAnnotations.append( GenericMapAnnotation( - id: connection.stop.id, + id: UUID(), location: CLLocationCoordinate2D(latitude: connection.stop.lat, longitude: connection.stop.lng), item: .carris_connection(connection) ) @@ -210,41 +280,247 @@ class MapController: ObservableObject { } } - // Remove annotations with duplicate IDs (same stop on different itineraries) - visibleAnnotations.uniqueInPlace(for: \.id) - - zoomToFitMapAnnotations(annotations: visibleAnnotations) + self.addAnnotations(tempNewAnnotations, zoom: true) } /* * */ - /* MARK: - SECTION 10: UPDATE ANNOTATIONS WITH ACTIVE CARRIS VEHICLES */ + /* MARK: - SECTION 10: UPDATE ANNOTATIONS WITH LIST OF ACTIVE CARRIS VEHICLES */ /* Lorem ipsum dolor sit amet consectetur adipisicing elit. */ func updateAnnotations(with activeVehiclesList: [CarrisNetworkModel.Vehicle]) { visibleAnnotations.removeAll(where: { switch $0.item { - case .carris_vehicle(_), .carris_stop(_): + case .vehicle(_), .stop(_): return true case .carris_connection(_): return false } }) + + var tempNewAnnotations: [GenericMapAnnotation] = [] + for vehicle in activeVehiclesList { - visibleAnnotations.append( + tempNewAnnotations.append( GenericMapAnnotation( - id: vehicle.id, + id: UUID(), location: vehicle.coordinate, - item: .carris_vehicle(vehicle) + item: .vehicle(vehicle) + ) + ) + } + + self.addAnnotations(tempNewAnnotations) + + } + + + + /* * */ + /* MARK: - SECTION 10: UPDATE ANNOTATIONS WITH SINGLE ACTIVE CARRIS VEHICLE */ + /* Lorem ipsum dolor sit amet consectetur adipisicing elit. */ + + func updateAnnotations(with activeVehicle: CarrisNetworkModel.Vehicle) { + +// let indexOfVehicleInArray = allVehicles.firstIndex(where: { +// $0.id == vehicleId +// }) + + + if let activeVehicleAnnotation = visibleAnnotations.first(where: { + switch $0.item { + case .vehicle(let item): + if (item.id == activeVehicle.id) { + return true + } else { + return false + } + case .carris_connection(_), .stop(_): + return false + } + }) { + + self.zoomToFitMapAnnotations(annotations: [activeVehicleAnnotation]) + + } else { + + var tempNewAnnotations: [GenericMapAnnotation] = [] + + tempNewAnnotations.append( + GenericMapAnnotation( + id: UUID(), + location: activeVehicle.coordinate, + item: .vehicle(activeVehicle) ) ) + + self.addAnnotations(tempNewAnnotations, zoom: true) + } } + + + + + + + + + private func addAnnotations(_ newAnnotations: [GenericMapAnnotation], zoom: Bool = false) { + DispatchQueue.main.async { + // Add the annotations to the map + self.visibleAnnotations.append(contentsOf: newAnnotations) + // Remove annotations with duplicate IDs (ex: same stop on different itineraries) + self.visibleAnnotations.uniqueInPlace(for: \.id) + // Adjust map region to annotations + if (zoom) { + self.zoomToFitMapAnnotations(annotations: newAnnotations) + } + } + } + + +// private func removeAnnotations(ofType annotationTypes: [GenericMapAnnotation.AnnotationItem]) { +// visibleAnnotations.removeAll(where: { +// for type in annotationTypes { +// if ($0.item == type) { +// return true +// } +// } +// return false +// }) +// } + + + + + + /* * */ + /* MARK: - SECTION 8: UPDATE ANNOTATIONS WITH SELECTED CARRIS STOP */ + /* Lorem ipsum dolor sit amet consectetur adipisicing elit. */ + + func updateAnnotations(ministop: [CarrisNetworkModel.Stop]) { + + visibleAnnotations.removeAll(where: { + switch $0.item { + case .stop(_): + return true + case .vehicle(_), .carris_connection(_): + return false + } + }) + + var tempNewAnnotations: [GenericMapAnnotation] = [] + + for stop in ministop { + tempNewAnnotations.append( + GenericMapAnnotation( + id: UUID(), + location: CLLocationCoordinate2D(latitude: stop.lat, longitude: stop.lng), + item: .stop(stop) + ) + ) + } + + self.addAnnotations(tempNewAnnotations, zoom: true) + + } + + + /* * */ + /* MARK: - SECTION 8: UPDATE ANNOTATIONS WITH SELECTED CARRIS STOP */ + /* Lorem ipsum dolor sit amet consectetur adipisicing elit. */ + + private var allStopAnnotations: [GenericMapAnnotation] = [] + + func updateAnnotations(for newMapRegion: MKCoordinateRegion?, with allStops: [CarrisNetworkModel.Stop]) { + + if (allStopAnnotations.isEmpty) { + for stop in allStops { + allStopAnnotations.append( + GenericMapAnnotation( + id: UUID(), + location: CLLocationCoordinate2D(latitude: stop.lat, longitude: stop.lng), + item: .stop(stop) + ) + ) + } + } + + + if (region.span.latitudeDelta < 0.005 || region.span.longitudeDelta < 0.005) { + + let latTop = self.region.center.latitude + self.region.span.latitudeDelta + let latBottom = self.region.center.latitude - self.region.span.latitudeDelta + + let lngRight = self.region.center.longitude + self.region.span.longitudeDelta + let lngLeft = self.region.center.longitude - self.region.span.longitudeDelta + + + for annotation in allStopAnnotations { + + // Checks + let isBetweenLats = annotation.location.latitude > latBottom && annotation.location.latitude < latTop + let isBetweenLngs = annotation.location.longitude > lngLeft && annotation.location.longitude < lngRight + + if (isBetweenLats && isBetweenLngs) { + if visibleAnnotations.firstIndex(where: { + $0.id == annotation.id + }) == nil { + visibleAnnotations.append(annotation) + } + } else { + if let indexOfAnnotation = visibleAnnotations.firstIndex(where: { + $0.id == annotation.id + }) { + visibleAnnotations.remove(at: indexOfAnnotation) + } + } + + } + + } else { + visibleAnnotations.removeAll(where: { + switch $0.item { + case .stop(_): + return true; + case .carris_connection(_), .vehicle(_): + return false; + } + }) + } + + } + + +} + + + + + +extension MKCoordinateRegion: Equatable { + public static func == (lhs: MKCoordinateRegion, rhs: MKCoordinateRegion) -> Bool { + if (lhs.center.latitude != rhs.center.latitude) { return false } + if (lhs.center.longitude != rhs.center.longitude) { return false } + if (lhs.span.latitudeDelta != rhs.span.latitudeDelta) { return false } + if (lhs.span.longitudeDelta != rhs.span.longitudeDelta) { return false } + return true + } +} + + +extension CLLocationCoordinate2D: Equatable { + public static func == (lhs: CLLocationCoordinate2D, rhs: CLLocationCoordinate2D) -> Bool { + if (lhs.latitude != rhs.latitude) { return false } + if (lhs.longitude != rhs.longitude) { return false } + return true + } } diff --git a/GeoBus/App/Controllers/Networks/Carris/CarrisAPI.swift b/GeoBus/App/Controllers/Networks/Carris/CarrisAPI.swift index 36db1488..f66f85be 100644 --- a/GeoBus/App/Controllers/Networks/Carris/CarrisAPI.swift +++ b/GeoBus/App/Controllers/Networks/Carris/CarrisAPI.swift @@ -114,8 +114,8 @@ final class CarrisAPI { /* while loop, the same method is not repeated and the flow gets stuck in an infinite loop. This could be caused */ /* due to the way authentication in Carris API is implemented: the system returns invalid tokens for an expired key. */ /* This means that the only way to check if the tokens fetched from the current apiKey are valid is to perform the */ - /* the request and look for the response status. Keeping this centralized in one single ‹request()› functions */ - /* allows for a lot of code reuse. Also, if the response is not equal to 200 or 401, then throw an error immediately. */ + /* request and look for the response status. Keeping this centralized in one single ‹request()› function */ + /* allows for a lot of code reuse. Also, if the response is not equal to 200 or 401, throw an error immediately. */ /* If all is well, then return the raw data response to the parent caller. */ public func request(for service: String) async throws -> Data { diff --git a/GeoBus/App/Controllers/Networks/Carris/CarrisAPIModel.swift b/GeoBus/App/Controllers/Networks/Carris/CarrisAPIModel.swift index 97d73970..b0927c43 100644 --- a/GeoBus/App/Controllers/Networks/Carris/CarrisAPIModel.swift +++ b/GeoBus/App/Controllers/Networks/Carris/CarrisAPIModel.swift @@ -1,10 +1,3 @@ -// -// Routes.swift -// GeoBus -// -// Created by João de Vasconcelos on 09/09/2022. -// Copyright © 2022 João de Vasconcelos. All rights reserved. -// import Foundation @@ -13,6 +6,7 @@ import Foundation /* Data model as provided by Carris API. */ /* Schema is available at https://joaodcp.github.io/Carris-API */ + struct CarrisAPIModel { struct RoutesList: Decodable { @@ -43,9 +37,21 @@ struct CarrisAPIModel { struct Itinerary: Decodable { let id: Int? let type: String? + let shape: String? let connections: [Connection]? } + + struct Shape: Decodable { + let type: String? + let coordinates: String? + } + + struct Shape2: Decodable { + let type: String? + let coordinates: [[Double]] + } + struct Connection: Decodable { let id, distance, orderNum: Int? let busStop: Stop? diff --git a/GeoBus/App/Controllers/Networks/Carris/CarrisCommunityAPI.swift b/GeoBus/App/Controllers/Networks/Carris/CarrisCommunityAPI.swift new file mode 100644 index 00000000..8fbc4da9 --- /dev/null +++ b/GeoBus/App/Controllers/Networks/Carris/CarrisCommunityAPI.swift @@ -0,0 +1,92 @@ +import Foundation + + +/* * */ +/* MARK: - CARRIS API WRAPPER */ +/* A series of self contained steps to request and authenticate with Carris API. */ + + +final class CarrisCommunityAPI { + + /* * */ + /* MARK: - SECTION 1: SETTINGS */ + /* In this section private constants for update intervals and storage keys are defined. */ + /* ‹totalAuthenticationAttemptsBeforeFailing› should be more than 3 to make sure any error in the auth server */ + /* is not passed on to the user. */ + + private let apiEndpoint = "https://api.carril.workers.dev/" + + + + /* * */ + /* MARK: - SECTION 4: CARRIS API AUTHENTICATION MODELS */ + /* Data models as provided by the authentication APIs. */ + /* Example request for ‹CarrisAPICredential› is available at https://joao.earth/api/geobus/carris_auth */ + /* Schema for ‹CarrisAPIAuthorization› is available at https://joaodcp.github.io/Carris-API */ + + private enum CarrisCommunityAPIError: Error { + case unavailable + } + + + + /* * */ + /* MARK: - SECTION 5: SHARED INSTANCE */ + /* To allow the same instance of this class to be available accross the whole app, */ + /* we create a Singleton. More info here: https://www.hackingwithswift.com/example-code/language/what-is-a-singleton */ + + static let shared = CarrisCommunityAPI() + + private init() { } + + + + /* * */ + /* MARK: - SECTION 7: REQUEST */ + /* This function makes GET requests to Carris API and agregates all the steps required for authentication. */ + + public func request(for service: String) async throws -> Data { + + let (data, response) = try await makeGETRequest(url: self.apiEndpoint + service) + + if (response.statusCode != 200) { + print("GeoBus: Carris API: Routes: Unknown error. More info: \(response as Any)") + print("********************************************************") + throw CarrisCommunityAPIError.unavailable + } else { + return data + } + + } + + + + /* * */ + /* MARK: - SECTION 10: MAKE REQUEST TO URL */ + /* Convenience function to format, perform and return a GET or POST request to an URL. */ + /* Provide the option to authenticate the request, and for POST the body. */ + + private func makeGETRequest(url requestURL: String) async throws -> (Data, HTTPURLResponse) { + + // Format the request + var carrisAPIGETRequest = URLRequest(url: URL(string: requestURL)!) + + carrisAPIGETRequest.httpMethod = "GET" + carrisAPIGETRequest.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") + carrisAPIGETRequest.addValue("application/json", forHTTPHeaderField: "Accept") + + // Perform the request + let (carrisAPIGETRequestRawData, carrisAPIGETRequestRawResponse) = try await URLSession.shared.data(for: carrisAPIGETRequest) + + // If cast to HTTPResponse is valid return, else throw an error + if let carrisAPIGETRequestHTTPResponse = carrisAPIGETRequestRawResponse as? HTTPURLResponse { + return (carrisAPIGETRequestRawData, carrisAPIGETRequestHTTPResponse) + } else { + throw CarrisCommunityAPIError.unavailable + } + + } + + + +} diff --git a/GeoBus/App/Controllers/Networks/Carris/CarrisCommunityAPIModel.swift b/GeoBus/App/Controllers/Networks/Carris/CarrisCommunityAPIModel.swift new file mode 100644 index 00000000..16e767fe --- /dev/null +++ b/GeoBus/App/Controllers/Networks/Carris/CarrisCommunityAPIModel.swift @@ -0,0 +1,137 @@ +import Foundation + + +/* * */ +/* MARK: - CARRIS COMMUNITY API DATA MODEL */ +/* Data model as provided by Community API for Carris network. */ +/* Schema is available at https://github.com/ricardojorgerm/carril */ + + +struct CarrisCommunityAPIModel { + + struct Vehicle: Decodable { + // let busNumber: Int? + // let dataServico: String? + // let direction: String? + // let enrichedAvgRouteSpeed: Double? + // let enrichedBusSpeed: Double? + // let enrichedDbStartup: Double? + // let enrichedEstRouteKm: Double? + // let enrichedGeohash300m: String? + // let enrichedGeohash80m: String? + // let enrichedGeohashPrev300m: String? + // let enrichedGeohashPrev80m: String? + // let enrichedPreviousStopId: String? + // let enrichedPreviousStopList: [String]? + // let enrichedPreviousStopMax: Int? + // let enrichedPreviousStopOrderIdx: Double? + // let enrichedQueryTime: Double? + // let enrichedRouteCoords: [Double]? + // let enrichedRouteDirection: Double? + // let enrichedRouteDoneKm: Double? + // let enrichedRouteLengthKm: Double? + // let enrichedSequenceNo: Int? + // let enrichedStartLat: Double? + // let enrichedStartLng: Double? + // let enrichedStartTime: String? + // let enrichedTimeHash30m: String? + // let enrichedTimeHashDay30m: String? + // let estimatedDebug: [String]? + // let estimatedRouteItinerary: [String]? + let estimatedRouteResults: [EstimatedRouteResult]? + // let lastGpsTime: String? + // let lastReportTime: String? + // let lat: Double? + // let lng: Double? + // let plateNumber: String? + // let previousLatitude: Double? + // let previousLongitude: Double? + // let previousReportTime: String? + // let routeNumber: String? + // let state: String? + // let timeStamp: String? + // let variantNumber: Int? + // let voyageNumber: Int? + } + + struct EstimatedRouteResult: Decodable { + // let estimatedFeatures: [EstimatedFeature]? + let estimatedPreviouslyArrived: Bool? + // let estimatedRecentlyArrived: Bool? + let estimatedRouteStopId: String? + // let estimatedRouteStopPosition: Double? + // let estimatedTimeofArrival: String? + let estimatedTimeofArrivalCorrected: String? + // let estimatedUncertainty: String? + } + + + + struct Estimation: Decodable { + let busNumber: Int? + // let dataServico: String? + let direction: String? + // let enrichedAvgRouteSpeed: Double? + // let enrichedBusSpeed: Double? + // let enrichedDbStartup: Double? + // let enrichedEstRouteKm: Double? + // let enrichedGeohash300m: String? + // let enrichedGeohash80m: String? + // let enrichedGeohashPrev300m: String? + // let enrichedGeohashPrev80m: String? + // let enrichedPreviousStopId: String? + // let enrichedPreviousStopList: [String]? + // let enrichedPreviousStopMax: Int? + // let enrichedPreviousStopOrderIdx: Double? + // let enrichedQueryTime: Double? + // let enrichedRouteCoords: [Double]? + // let enrichedRouteDirection: Double? + // let enrichedRouteDoneKm: Double? + // let enrichedRouteLengthKm: Double? + // let enrichedSequenceNo: Int? + // let enrichedStartLat: Double? + // let enrichedStartLng: Double? + // let enrichedStartTime: String? + // let enrichedTimeHash30m: String? + // let enrichedTimeHashDay30m: String? + // let estimatedDebug: [String]? + // let estimatedFeatures: EstimatedFeature? + // let estimatedPreviouslyArrived: Bool? + // let estimatedRecentlyArrived: Bool? + // let estimatedRouteStopId: String? + // let estimatedRouteStopPosition: Double? + // let estimatedTimeofArrival: String? + let estimatedTimeofArrivalCorrected: String? + // let estimatedUncertainty: String? + // let lastGpsTime: String? + // let lastReportTime: String? + // let lat: Double? + // let lng: Double? + // let plateNumber: String? + // let previousLatitude: Double? + // let previousLongitude: Double? + // let previousReportTime: String? + let routeNumber: String? + // let state: String? + // let timeStamp: String? + let variantNumber: Int? + // let voyageNumber: Int? + } + + + + struct EstimatedFeature: Decodable { + // let avgHistorDeltaDistanceKm: Double? + // let avgHistorDeltaSeconds: Double? + // let avgHistorDeltaSeqNo: Double? + // let avgHistorInstSpeedAtPositionKmh: Double? + // let avgHistorLongtermSpeedAtPositionKmh: Double? + // let correctionFactorLongTerm: Double? + // let correctionFactorShortTerm: Double? + // let maxHistorDelta: Int? + // let minHistorDelta: Int? + // let noHistorSamples: Int? + // let stdHistorDeltaSeconds: Double? + } + +} diff --git a/GeoBus/App/Controllers/Networks/Carris/CarrisNetworkController.swift b/GeoBus/App/Controllers/Networks/Carris/CarrisNetworkController.swift index 3bd5bf46..3f3906dc 100644 --- a/GeoBus/App/Controllers/Networks/Carris/CarrisNetworkController.swift +++ b/GeoBus/App/Controllers/Networks/Carris/CarrisNetworkController.swift @@ -8,11 +8,12 @@ import Combine /* allows for code reuse, less plumbing passing objects from one class to another and less */ /* clutter overall. If the data is provided by Carris, it should be controlled by this class. */ + @MainActor class CarrisNetworkController: ObservableObject { /* * */ - /* MARK: - SECTION 1: SETTINGS */ + /* MARK: - 1. SETTINGS */ /* In this section one can find private constants for update intervals, */ /* storage keys and more. Change these values with caution because they can */ /* trigger updates on the users devices, which can take a long time or fail. */ @@ -26,11 +27,12 @@ class CarrisNetworkController: ObservableObject { private let storageKeyForFavoriteStops: String = "carris_favoriteStops" private let storageKeyForSavedRoutes: String = "carris_savedRoutes" private let storageKeyForFavoriteRoutes: String = "carris_favoriteRoutes" + private let storageKeyForCommunityDataProviderStatus: String = "carris_communityDataProviderStatus" /* * */ - /* MARK: - SECTION 2: PUBLISHED VARIABLES */ + /* MARK: - 2. PUBLISHED VARIABLES */ /* Here are all the @Published variables that can be consumed by the app views. */ /* It is important to keep the names of this variables short, but descriptive, */ /* to avoid clutter on the interface code. */ @@ -47,29 +49,53 @@ class CarrisNetworkController: ObservableObject { @Published var activeConnection: CarrisNetworkModel.Connection? = nil @Published var activeStop: CarrisNetworkModel.Stop? = nil @Published var activeVehicles: [CarrisNetworkModel.Vehicle] = [] + @Published var activeVehicle: CarrisNetworkModel.Vehicle? = nil @Published var favorites_routes: [CarrisNetworkModel.Route] = [] @Published var favorites_stops: [CarrisNetworkModel.Stop] = [] + @Published var communityDataProviderStatus: Bool = false + + + + + + + + + + + /* * */ - /* MARK: - SECTION 3: SHARED INSTANCE */ + /* MARK: - 3. INITIALIZER */ + /* Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi ut ornare ipsum. */ + /* Nunc neque nulla, pretium ac lectus id, scelerisque facilisis est. */ + + + /* * */ + /* MARK: - 3.1. SHARED INSTANCE */ /* To allow the same instance of this class to be available accross the whole app, */ /* we create a Singleton. More info here: https://www.hackingwithswift.com/example-code/language/what-is-a-singleton */ - static let shared = CarrisNetworkController() + public static let shared = CarrisNetworkController() /* * */ - /* MARK: - SECTION 4: INITIALIZER */ + /* MARK: - 3.2. INITIALIZE CLASS */ /* When this class is initialized, data stored on the users device must be retrieved */ /* from UserDefaults to avoid requesting a new update to the APIs. After this, check if */ /* this stored data needs an update or not. */ private init() { + // Unwrap last timestamp from Storage + if let unwrappedLastUpdatedNetwork = UserDefaults.standard.string(forKey: storageKeyForLastUpdatedCarrisNetwork) { + self.lastUpdatedNetwork = unwrappedLastUpdatedNetwork + } + // Unwrap and Decode Stops from Storage if let unwrappedSavedNetworkStops = UserDefaults.standard.data(forKey: storageKeyForSavedStops) { if let decodedSavedNetworkStops = try? JSONDecoder().decode([CarrisNetworkModel.Stop].self, from: unwrappedSavedNetworkStops) { @@ -84,10 +110,8 @@ class CarrisNetworkController: ObservableObject { } } - // Unwrap last timestamp from Storage - if let unwrappedLastUpdatedNetwork = UserDefaults.standard.string(forKey: storageKeyForLastUpdatedCarrisNetwork) { - self.lastUpdatedNetwork = unwrappedLastUpdatedNetwork - } + // Unwrap Community Provider Status from Storage + self.communityDataProviderStatus = UserDefaults.standard.bool(forKey: storageKeyForCommunityDataProviderStatus) // Check if network needs an update self.update(reset: false) @@ -96,8 +120,24 @@ class CarrisNetworkController: ObservableObject { + + + + + + + + + + + /* * */ + /* MARK: - 4. UPDATE DATA */ + /* Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi ut ornare ipsum. */ + /* Nunc neque nulla, pretium ac lectus id, scelerisque facilisis est. */ + + /* * */ - /* MARK: - SECTION 5.1: UPDATE NETWORK FROM CARRIS API */ + /* MARK: - 4.1. UPDATE NETWORK MODEL */ /* This function decides whether to update the complete network model */ /* if it is considered outdated or is inexistent on device storage. */ /* Provide a convenience method to allow user-requested updates from the UI. */ @@ -122,6 +162,9 @@ class CarrisNetworkController: ObservableObject { UserDefaults.standard.removeObject(forKey: key) } + // Restore deleted stored values + UserDefaults.standard.set(communityDataProviderStatus, forKey: storageKeyForCommunityDataProviderStatus) + // Fetch the updated network from the API await fetchStopsFromCarrisAPI() await fetchRoutesFromCarrisAPI() @@ -132,6 +175,9 @@ class CarrisNetworkController: ObservableObject { } + // Get favorites from KVS + self.retrieveFavoritesFromKVS() + // Always update vehicles and favorites self.refresh() @@ -141,22 +187,73 @@ class CarrisNetworkController: ObservableObject { /* * */ - /* MARK: - SECTION 5.2: REFRESH DATA */ + /* MARK: - 4.2. REFRESH DATA */ /* This function initiates vehicles refresh from Carris API, updates the ‹activeVehicles› array */ /* and syncronizes favorites with iCloud, to ensure changes are always up to date. */ public func refresh() { Task { + // Update all vehicles from Carris API await self.fetchVehiclesListFromCarrisAPI() + + // DEBUG ! +// if (self.activeVehicle == nil) { +// self.select(vehicle: self.allVehicles[1].id) +// Appstate.shared.present(sheet: .carris_vehicleDetails) +// } + // ! DEBUG + + // DEBUG ! +// if (self.activeRoute == nil) { +// _ = self.select(route: "758") +// // Appstate.shared.present(sheet: .carris_vehicleDetails) +// } + // ! DEBUG + + // If there is an active vehicle, also refresh it's details + if (self.activeVehicle != nil) { + await self.fetchVehicleDetailsFromCarrisAPI(for: self.activeVehicle!.id) + await self.fetchVehicleDetailsFromCommunityAPI(for: self.activeVehicle!.id) + } + // Update the list of active vehicles (the current selected route) self.populateActiveVehicles() - self.retrieveFavoritesFromKVS() } } /* * */ - /* MARK: - SECTION 6: FETCH & FORMAT STOPS FROM CARRIS API */ + /* MARK: - 4.3. COMMUNITY PROVIDER */ + /* Call this function to switch Community Data ON or OFF. */ + /* This switches in memory for the current session, and stores the new setting in storage. */ + + public func toggleCommunityDataProviderStatus(to newStatus: Bool) { + self.communityDataProviderStatus = newStatus + UserDefaults.standard.set(newStatus, forKey: storageKeyForCommunityDataProviderStatus) + print("GeoBus: Carris API: ‹toggleCommunityDataProviderTo()› Community Data switched \(newStatus ? "ON" : "OFF")") + self.refresh() + } + + + + + + + + + + + + + + /* * */ + /* MARK: - 5. FORMAT NETWORK */ + /* Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi ut ornare ipsum. */ + /* Nunc neque nulla, pretium ac lectus id, scelerisque facilisis est. */ + + + /* * */ + /* MARK: - 5.1. STOPS: FETCH & FORMAT FROM CARRIS API */ /* Call Carris API and retrieve all stops. Format them to the app model. */ private func fetchStopsFromCarrisAPI() async { @@ -183,7 +280,7 @@ class CarrisNetworkController: ObservableObject { // Save the formatted route object in the allRoutes temporary variable tempAllStops.append( CarrisNetworkModel.Stop( - id: availableStop.id ?? -1, + id: Int(availableStop.publicId ?? "-1") ?? -1, name: availableStop.name ?? "-", lat: availableStop.lat ?? 0, lng: availableStop.lng ?? 0 @@ -216,7 +313,7 @@ class CarrisNetworkController: ObservableObject { /* * */ - /* MARK: - SECTION 7: FETCH & FORMAT ROUTES FROM CARRIS API */ + /* MARK: - 5.2. ROUTES: FETCH & FORMAT FROM CARRIS API */ /* This function first fetches the Routes List from Carris API, */ /* which is an object that contains all the available routes. */ /* The information for each Route is very short, so it is necessary to retrieve */ @@ -251,6 +348,9 @@ class CarrisNetworkController: ObservableObject { // Request Route Detail for ‹routeNumber› let rawDataCarrisAPIRouteDetail = try await CarrisAPI.shared.request(for: "Routes/\(availableRoute.routeNumber ?? "-")") + + print("rawDataCarrisAPIRouteDetail: \(String(decoding: rawDataCarrisAPIRouteDetail, as: UTF8.self))") + let decodedAPIRouteDetail = try JSONDecoder().decode(CarrisAPIModel.Route.self, from: rawDataCarrisAPIRouteDetail) // Define a temporary variable to store formatted route variants @@ -313,7 +413,7 @@ class CarrisNetworkController: ObservableObject { /* * */ - /* MARK: - SECTION 7.1: FORMAT CARRIS ROUTE VARIANTS */ + /* MARK: - 5.3. VARIANTS: FORMAT CARRIS ROUTE VARIANTS */ /* Parse and simplify the data model for variants. Variants contain */ /* one or more itineraries, each with a direction. Each itinerary is composed */ /* of a series of connections, in which each contains a stop. */ @@ -360,7 +460,8 @@ class CarrisNetworkController: ObservableObject { name: tempVariantName, circularItinerary: tempCircularConnections, ascendingItinerary: tempAscendingConnections, - descendingItinerary: tempDescendingConnections + descendingItinerary: tempDescendingConnections, + circularShape: rawVariant.circItinerary?.shape ) } @@ -368,7 +469,7 @@ class CarrisNetworkController: ObservableObject { /* * */ - /* MARK: - SECTION 7.2: FORMAT CARRIS CONNECTIONS */ + /* MARK: - 5.4. CONNECTIONS: FORMAT CARRIS CONNECTIONS */ /* Each itinerary is composed of a series of connections, in which each */ /* has a single stop. Connections contain the property ‹orderInRoute›. */ @@ -400,8 +501,20 @@ class CarrisNetworkController: ObservableObject { + + + + + + + + /* * */ + /* MARK: - 6. FAVORITES */ + /* This section holds the logic to deal with favorites from iCloud Key-Value-Storage. */ + + /* * */ - /* MARK: - SECTION 8.1: RETRIEVE FAVORITES FROM ICLOUD KVS */ + /* MARK: - 6.1. RETRIEVE FAVORITES FROM ICLOUD KVS */ /* This function retrieves favorites from iCloud Key-Value-Storage. */ private func retrieveFavoritesFromKVS() { @@ -435,7 +548,7 @@ class CarrisNetworkController: ObservableObject { /* * */ - /* MARK: - SECTION 8.2: SAVE FAVORITES TO ICLOUD KVS */ + /* MARK: - 6.2. SAVE FAVORITES TO ICLOUD KVS */ /* This function saves a representation of the objects stored in the favorites arrays */ /* to iCloud Key-Value-Store. This function should be called whenever a change */ /* in favorites occurs, to ensure consistency across devices. */ @@ -465,7 +578,7 @@ class CarrisNetworkController: ObservableObject { /* * */ - /* MARK: - SECTION 8.3: TOGGLE ROUTES AND STOPS AS FAVORITES */ + /* MARK: - 6.3. TOGGLE ROUTES AND STOPS AS FAVORITES */ /* These functions mark an object as favorite if it is not, and remove it from favorites if it is. */ public func toggleFavorite(route: CarrisNetworkModel.Route) { @@ -506,7 +619,7 @@ class CarrisNetworkController: ObservableObject { /* * */ - /* MARK: - SECTION 8.4: IS FAVORITE CHECKER */ + /* MARK: - 6.4: IS FAVORITE CHECKER */ /* These functions check if an object is marked as favorite. */ public func isFavourite(route: CarrisNetworkModel.Route?) -> Bool { @@ -536,11 +649,35 @@ class CarrisNetworkController: ObservableObject { + + + + + + + + + + + /* * */ + /* MARK: - 7. FINDERS */ + /* Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi ut ornare ipsum. */ + /* Nunc neque nulla, pretium ac lectus id, scelerisque facilisis est. */ + + /* * */ - /* MARK: - SECTION 9: FIND OBJECTS BY IDENTIFIER */ + /* MARK: - 7.1. FIND OBJECTS BY IDENTIFIER */ /* These functions search for the provided object identifier in the storage arrays */ /* and return it if found or nil if not found. */ + public func find(vehicle vehicleId: Int) -> CarrisNetworkModel.Vehicle? { + if let requestedVehicleObject = self.allVehicles[withId: vehicleId] { + return requestedVehicleObject + } else { + return nil + } + } + private func find(route routeNumber: String) -> CarrisNetworkModel.Route? { if let requestedRouteObject = self.allRoutes[withId: routeNumber] { return requestedRouteObject @@ -549,7 +686,7 @@ class CarrisNetworkController: ObservableObject { } } - private func find(stop stopId: Int) -> CarrisNetworkModel.Stop? { + public func find(stop stopId: Int) -> CarrisNetworkModel.Stop? { if let requestedStopObject = self.allStops[withId: stopId] { return requestedStopObject } else { @@ -557,32 +694,98 @@ class CarrisNetworkController: ObservableObject { } } - private func find(vehicle vehicleId: Int) -> CarrisNetworkModel.Vehicle? { - if let requestedVehicleObject = self.allVehicles[withId: vehicleId] { - return requestedVehicleObject - } else { + private func find(route routeNumber: String, variant: Int, direction: String) -> CarrisNetworkModel.Stop? { + guard let requestedRouteObject = self.find(route: routeNumber) else { + return nil + } + + if (variant >= requestedRouteObject.variants.count) { return nil } + + let requestedVariantObject = requestedRouteObject.variants[variant] + + switch direction { + case "ASC": + return requestedVariantObject.ascendingItinerary?.last?.stop + case "DESC": + return requestedVariantObject.descendingItinerary?.last?.stop + case "CIRC": + return requestedVariantObject.circularItinerary?.last?.stop + default: + return nil + } + + } + + + public func getDirectionFrom(string directionString: String?) -> CarrisNetworkModel.Direction? { + switch directionString { + case "ASC": + return .ascending + case "DESC": + return .descending + case "CIRC": + return .circular + default: + return nil + } } + + + + + + + + /* * */ - /* MARK: - SECTION 10: OBJECT SELECTORS */ + /* MARK: - 8. SELECTORS */ + /* Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi ut ornare ipsum. */ + /* Nunc neque nulla, pretium ac lectus id, scelerisque facilisis est. */ + + + /* * */ + /* MARK: - 8.1: OBJECT SELECTORS */ /* These functions select and deselect the currently active objects. */ /* Provide public functions to more easily select object by their identifier. */ - private func deselect() { - self.activeRoute = nil - self.activeVariant = nil - self.activeConnection = nil - self.activeStop = nil - self.activeVehicles = [] + + enum SelectableObject { + case route + case variant + case connection + case vehicle + case stop + case all + } + + + public func deselect(_ objectType: [SelectableObject]) { + for type in objectType { + switch type { + case .route: + self.activeRoute = nil + case .variant: + self.activeVariant = nil + case .connection: + self.activeConnection = nil + case .vehicle: + self.activeVehicle = nil + case .stop: + self.activeStop = nil + case .all: + self.deselect([.route, .variant, .connection, .vehicle, .stop]) + } + } } private func select(route: CarrisNetworkModel.Route) { - self.deselect() + self.deselect([.all]) self.activeRoute = route self.select(variant: route.variants[0]) self.populateActiveVehicles() @@ -600,24 +803,27 @@ class CarrisNetworkController: ObservableObject { public func select(variant: CarrisNetworkModel.Variant) { self.activeVariant = variant +// print("rawVariant.circItinerary?.shape: \(variant.circularShape)") } - private func select(connection: CarrisNetworkModel.Connection) { - self.deselect() + public func deselect(connection: CarrisNetworkModel.Connection) { self.activeConnection = connection } + public func select(connection: CarrisNetworkModel.Connection) { + self.activeConnection = connection + } - private func select(stop: CarrisNetworkModel.Stop) { - self.deselect() + public func select(stop: CarrisNetworkModel.Stop) { self.activeStop = stop } public func select(stop stopId: Int) -> Bool { let stop = self.find(stop: stopId) if (stop != nil) { - self.select(stop: stop!) + self.activeStop = stop +// self.select(stop: stop!) return true } else { return false @@ -625,9 +831,40 @@ class CarrisNetworkController: ObservableObject { } + public func select(vehicle vehicleId: Int?) { + if (vehicleId != nil) { + if let foundVehicle = self.find(vehicle: vehicleId!) { + Task { + await self.fetchVehicleDetailsFromCarrisAPI(for: foundVehicle.id) + if (self.communityDataProviderStatus) { + await self.fetchVehicleDetailsFromCommunityAPI(for: foundVehicle.id) + } + self.activeVehicle = foundVehicle + } + } + } + } + + + + + + + + + + + + + + /* * */ + /* MARK: - 9. VEHICLES */ + /* Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi ut ornare ipsum. */ + /* Nunc neque nulla, pretium ac lectus id, scelerisque facilisis est. */ + /* * */ - /* MARK: - SECTION 11: SET ACTIVE VEHICLES */ + /* MARK: - 9.1. VEHICLES: SET ACTIVE VEHICLES */ /* This function compares the currently active route number with all vehicles */ /* appending the ones that match to the ‹activeVehicles› array. It also checks */ /* if vehicles have an up-to-date location. */ @@ -663,7 +900,7 @@ class CarrisNetworkController: ObservableObject { /* * */ - /* MARK: - SECTION 12: FETCH ALL VEHICLES FROM CARRIS API */ + /* MARK: - 9.2. FETCH ALL VEHICLES FROM CARRIS API */ /* This function calls the Carris API and receives vehicle metadata, */ /* including positions, for all currently active vehicles, */ /* and stores them in the ‹allVehicles› array. */ @@ -700,6 +937,7 @@ class CarrisNetworkController: ObservableObject { allVehicles[indexOfVehicleInArray!].previousLatitude = vehicleSummary.previousLatitude ?? 0 allVehicles[indexOfVehicleInArray!].previousLongitude = vehicleSummary.previousLongitude ?? 0 allVehicles[indexOfVehicleInArray!].lastGpsTime = vehicleSummary.lastGpsTime ?? "" + allVehicles[indexOfVehicleInArray!].direction = getDirectionFrom(string: vehicleSummary.direction) } else { self.allVehicles.append( @@ -734,22 +972,15 @@ class CarrisNetworkController: ObservableObject { /* * */ - /* MARK: - SECTION 12: FETCH VEHICLE DETAILS FROM CARRIS API */ + /* MARK: - 9.3. FETCH VEHICLE DETAILS FROM CARRIS API */ /* This function calls the Carris API SGO endpoint to retrieve additional vehicle metadata, */ /* such as location, license plate number and last stop on the current trip. Provide a convenience */ /* function to allow the UI to request this information only when necessary. After retrieving the new details */ /* fromt the API, re-populate the activeVehicles array to trigger an update in the UI. */ - public func getAdditionalDetailsFor(vehicle vehicleId: Int) { - Task { - await self.fetchVehicleDetailsFromCarrisAPI(for: vehicleId) - self.populateActiveVehicles() - } - } - private func fetchVehicleDetailsFromCarrisAPI(for vehicleId: Int) async { - Appstate.shared.change(to: .loading, for: .vehicles) + Appstate.shared.change(to: .loading, for: .carris_vehicleDetails) print("GeoBus: Carris API: Vehicle Details: Starting update...") @@ -771,14 +1002,15 @@ class CarrisNetworkController: ObservableObject { allVehicles[indexOfVehicleInArray!].vehiclePlate = decodedCarrisAPIVehicleDetail.vehiclePlate allVehicles[indexOfVehicleInArray!].lastStopOnVoyageId = decodedCarrisAPIVehicleDetail.lastStopOnVoyageId allVehicles[indexOfVehicleInArray!].lastStopOnVoyageName = decodedCarrisAPIVehicleDetail.lastStopOnVoyageName + allVehicles[indexOfVehicleInArray!].hasLoadedCarrisDetails = true } print("GeoBus: Carris API: Vehicle Details: Update complete!") - Appstate.shared.change(to: .idle, for: .vehicles) + Appstate.shared.change(to: .idle, for: .carris_vehicleDetails) } catch { - Appstate.shared.change(to: .error, for: .vehicles) + Appstate.shared.change(to: .error, for: .carris_vehicleDetails) print("GeoBus: Carris API: Vehicles Details: Error found while updating. More info: \(error)") return } @@ -787,15 +1019,101 @@ class CarrisNetworkController: ObservableObject { - /* MARK: - Get Estimations */ + /* * */ + /* MARK: - 9.4. FETCH VEHICLE DETAILS FROM COMMUNITY API */ + /* Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi ut ornare ipsum. */ + /* Nunc neque nulla, pretium ac lectus id, scelerisque facilisis est. */ + + private func fetchVehicleDetailsFromCommunityAPI(for vehicleId: Int) async { + + Appstate.shared.change(to: .loading, for: .carris_vehicleDetails) + + print("GeoBus: Community API: Vehicle Details: Starting update...") + + do { + + // Request Vehicle Detail (SGO) + let rawDataCarrisCommunityAPIVehicleDetail = try await CarrisCommunityAPI.shared.request(for: "estbus?busNumber=\(vehicleId)") + + let decodedCarrisCommunityAPIVehicleDetail = try JSONDecoder().decode([CarrisCommunityAPIModel.Vehicle].self, from: rawDataCarrisCommunityAPIVehicleDetail) + + + if (decodedCarrisCommunityAPIVehicleDetail[0].estimatedRouteResults != nil) { + + var tempRouteOverview: [CarrisNetworkModel.Estimation] = [] + + for routeResult in decodedCarrisCommunityAPIVehicleDetail[0].estimatedRouteResults! { + tempRouteOverview.append( + CarrisNetworkModel.Estimation( + stopId: Int(routeResult.estimatedRouteStopId ?? "-1") ?? -1, + routeNumber: "", + destination: "", + eta: routeResult.estimatedTimeofArrivalCorrected, + hasArrived: routeResult.estimatedPreviouslyArrived + ) + ) + } + + + let indexOfVehicleInArray = allVehicles.firstIndex(where: { + $0.id == vehicleId + }) + + if (indexOfVehicleInArray != nil) { + allVehicles[indexOfVehicleInArray!].routeOverview = tempRouteOverview + allVehicles[indexOfVehicleInArray!].hasLoadedCommunityDetails = true + } + + } + + print("GeoBus: Community API: Vehicle Details: Update complete!") + + Appstate.shared.change(to: .idle, for: .carris_vehicleDetails) + + } catch { + Appstate.shared.change(to: .error, for: .carris_vehicleDetails) + print("GeoBus: Community API: Vehicles Details: Error found while updating. More info: \(error)") + return + } + + } + + + + + + + + + + + + + + /* * */ + /* MARK: - 10. ESTIMATES */ + /* Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi ut ornare ipsum. */ + /* Nunc neque nulla, pretium ac lectus id, scelerisque facilisis est. */ + + + /* MARK: - 10.1. GET ESTIMATION */ // This function calls the API to retrieve estimations for the provided stop 'publicId'. // It formats and returns the results to the caller. public func getEstimation(for stopId: Int) async -> [CarrisNetworkModel.Estimation] { + if (!communityDataProviderStatus) { return await self.fetchEstimationsFromCarrisAPI(for: stopId) -// self.populateActiveVehicles() + } else { + return await self.fetchEstimationsFromCommunityAPI(for: stopId) + } } + + + /* MARK: - 10.2. GET CARRIS ESTIMATIONS */ + // This function calls the API to retrieve estimations for the provided stop 'publicId'. + // It formats and returns the results to the caller. + public func fetchEstimationsFromCarrisAPI(for stopId: Int) async -> [CarrisNetworkModel.Estimation] { Appstate.shared.change(to: .loading, for: .estimations) @@ -807,7 +1125,6 @@ class CarrisNetworkController: ObservableObject { let rawDataCarrisAPIEstimations = try await CarrisAPI.shared.request(for: "Estimations/busStop/\(stopId)/top/5") let decodedCarrisAPIEstimations = try JSONDecoder().decode([CarrisAPIModel.Estimation].self, from: rawDataCarrisAPIEstimations) - var tempFormattedEstimations: [CarrisNetworkModel.Estimation] = [] @@ -816,10 +1133,10 @@ class CarrisNetworkController: ObservableObject { tempFormattedEstimations.append( CarrisNetworkModel.Estimation( stopId: Int(apiEstimation.publicId ?? "-1") ?? -1, - routeNumber: apiEstimation.routeNumber ?? "-", - destination: apiEstimation.destination ?? "-", + routeNumber: apiEstimation.routeNumber, + destination: apiEstimation.destination, eta: apiEstimation.time ?? "", - busNumber: Int(apiEstimation.busNumber ?? "-1") + busNumber: Int(apiEstimation.busNumber ?? "") ) ) } @@ -840,4 +1157,58 @@ class CarrisNetworkController: ObservableObject { + /* MARK: - 10.3. GET COMMUNITY ESTIMATIONS */ + // This function calls the API to retrieve estimations for the provided stop 'publicId'. + // It formats and returns the results to the caller. + + public func fetchEstimationsFromCommunityAPI(for stopId: Int) async -> [CarrisNetworkModel.Estimation] { + + Appstate.shared.change(to: .loading, for: .estimations) + + print("GeoBus: Carris API: Estimations: Starting update...") + + do { + // Request API Estimations List + let rawDataCarrisCommunityAPIEstimations = try await CarrisCommunityAPI.shared.request(for: "eststop?busStop=\(stopId)") + let decodedCarrisCommunityAPIEstimations = try JSONDecoder().decode([CarrisCommunityAPIModel.Estimation].self, from: rawDataCarrisCommunityAPIEstimations) + + + var tempFormattedEstimations: [CarrisNetworkModel.Estimation] = [] + + + // For each available vehicles in the API + for apiEstimation in decodedCarrisCommunityAPIEstimations { + + let destinationStop = find( + route: apiEstimation.routeNumber ?? "-", + variant: apiEstimation.variantNumber ?? -1, + direction: apiEstimation.direction ?? "-" + ) + + tempFormattedEstimations.append( + CarrisNetworkModel.Estimation( + stopId: stopId, + routeNumber: apiEstimation.routeNumber ?? "-", + destination: destinationStop?.name ?? "-", + eta: apiEstimation.estimatedTimeofArrivalCorrected ?? "", + busNumber: apiEstimation.busNumber ?? -1 + ) + ) + } + + print("GeoBus: Carris API: Estimations: Update complete!") + + Appstate.shared.change(to: .idle, for: .estimations) + + return tempFormattedEstimations + + } catch { + Appstate.shared.change(to: .error, for: .estimations) + print("GeoBus: Carris API: Estimations: Error found while updating. More info: \(error)") + return [] + } + + } + + } diff --git a/GeoBus/App/Controllers/Networks/Carris/CarrisNetworkModel.swift b/GeoBus/App/Controllers/Networks/Carris/CarrisNetworkModel.swift index 7dd0d85a..6fe82a5a 100644 --- a/GeoBus/App/Controllers/Networks/Carris/CarrisNetworkModel.swift +++ b/GeoBus/App/Controllers/Networks/Carris/CarrisNetworkModel.swift @@ -1,10 +1,3 @@ -// -// Routes.swift -// GeoBus -// -// Created by João de Vasconcelos on 09/09/2022. -// Copyright © 2022 João de Vasconcelos. All rights reserved. -// import Foundation import CoreLocation @@ -16,6 +9,7 @@ import CoreLocation /* For this app, the goal is to simplify and build upon this network model */ /* to prevent duplicated data and increase flexibility on updates to the views. */ + struct CarrisNetworkModel { enum Kind: Codable, Equatable { @@ -67,13 +61,16 @@ struct CarrisNetworkModel { let ascendingItinerary: [Connection]? let descendingItinerary: [Connection]? - init(number: Int, name: String, circularItinerary: [Connection]? = nil, ascendingItinerary: [Connection]? = nil, descendingItinerary: [Connection]? = nil) { + let circularShape: String? + + init(number: Int, name: String, circularItinerary: [Connection]? = nil, ascendingItinerary: [Connection]? = nil, descendingItinerary: [Connection]? = nil, circularShape: String?) { self.id = number self.number = number self.name = name self.circularItinerary = circularItinerary self.ascendingItinerary = ascendingItinerary self.descendingItinerary = descendingItinerary + self.circularShape = circularShape } } @@ -120,18 +117,20 @@ struct CarrisNetworkModel { struct Estimation: Codable, Identifiable, Equatable { let id: UUID let stopId: Int - let routeNumber: String - let destination: String - let eta: String + let routeNumber: String? + let destination: String? + let eta: String? + let hasArrived: Bool? let busNumber: Int? - init(stopId: Int, routeNumber: String, destination: String, eta: String, busNumber: Int? = nil) { + init(stopId: Int, routeNumber: String?, destination: String?, eta: String?, busNumber: Int? = nil, hasArrived: Bool? = nil) { self.id = UUID() self.stopId = stopId self.routeNumber = routeNumber self.destination = destination self.busNumber = busNumber self.eta = eta + self.hasArrived = hasArrived } } @@ -141,13 +140,28 @@ struct CarrisNetworkModel { /* MARK: - CARRIS VEHICLE */ /* Lorem ipsum dolor sit amet consectetur adipisicing elit. Maxime mollitia. */ - struct Vehicle: Identifiable, Equatable { + class Vehicle: Identifiable, Equatable { + + static func == (lhs: CarrisNetworkModel.Vehicle, rhs: CarrisNetworkModel.Vehicle) -> Bool { + return false + } + // IDENTIFIER // The unique identifier for this model. let id: Int // Bus Number + init(id: Int, routeNumber: String, lat: Double, lng: Double, previousLatitude: Double, previousLongitude: Double, lastGpsTime: String) { + self.id = id + self.routeNumber = routeNumber + self.lat = lat + self.lng = lng + self.previousLatitude = previousLatitude + self.previousLongitude = previousLongitude + self.lastGpsTime = lastGpsTime + } + /* * */ @@ -162,14 +176,17 @@ struct CarrisNetworkModel { var previousLatitude: Double? var previousLongitude: Double? var lastGpsTime: String? + var direction: Direction? // Carris API › Vehicle Details var vehiclePlate: String? var lastStopOnVoyageId: Int? var lastStopOnVoyageName: String? + var hasLoadedCarrisDetails: Bool = false // Community API - var estimatedTimeofArrivalCorrected: [String]? + var routeOverview: [Estimation]? + var hasLoadedCommunityDetails: Bool = false diff --git a/GeoBus/App/Controllers/SheetController.swift b/GeoBus/App/Controllers/SheetController.swift new file mode 100644 index 00000000..a8b04afc --- /dev/null +++ b/GeoBus/App/Controllers/SheetController.swift @@ -0,0 +1,79 @@ +import Foundation + + +/* * */ +/* MARK: - SHEET CONTROLLER */ +/* Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi rutrum lectus */ +/* non interdum imperdiet. In hendrerit ligula velit, ac porta augue volutpat id. */ + + +final class SheetController: ObservableObject { + + /* * */ + /* MARK: - 1: PRESENTABLE SHEET VIEWS */ + /* These are the available views that can be presented inside the sheet. */ + + enum PresentableSheetView { + case RouteSelector + case RouteDetails + case StopSelector + case StopDetails + case ConnectionDetails + case VehicleDetails + } + + + + /* * */ + /* MARK: - SECTION 2: PUBLISHED VARIABLES */ + /* Here are all the @Published variables refering to the above modules that can be consumed */ + /* by the UI. It is important to keep the names of this variables short, but descriptive, */ + /* to avoid clutter on the interface code. */ + + @Published var sheetIsPresented: Bool = false + @Published var currentlyPresentedSheetView: PresentableSheetView? = nil + + + + /* * */ + /* MARK: - 3: PRESENT SHEET */ + /* Call this function to present the view. If a sheet is already visible, */ + /* then dismiss it and after a delay present the desired sheet. */ + + public func present(sheet: PresentableSheetView) { + if (sheetIsPresented) { + self.sheetIsPresented = false + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + self.currentlyPresentedSheetView = sheet + self.sheetIsPresented = true + } + } else { + self.currentlyPresentedSheetView = sheet + self.sheetIsPresented = true + } + } + + + + /* * */ + /* MARK: - 4: DISMISS SHEET */ + /* Call this function to dismiss the sheet. */ + + public func dismiss() { + self.sheetIsPresented = false + } + + + + /* * */ + /* MARK: - SECTION 3: SHARED INSTANCE */ + /* To allow the same instance of this class to be available accross the whole app, */ + /* we create a Singleton. More info here: https://www.hackingwithswift.com/example-code/language/what-is-a-singleton */ + /* Adding a private initializer is important because it stops other code from creating a new class instance. */ + + static let shared = SheetController() + + private init() { } + + +} diff --git a/GeoBus/App/Extensions/BackgroundThreads.swift b/GeoBus/App/Extensions/BackgroundThreads.swift new file mode 100644 index 00000000..50d3b80b --- /dev/null +++ b/GeoBus/App/Extensions/BackgroundThreads.swift @@ -0,0 +1,40 @@ +// +// BackgroundThreads.swift +// GeoBus +// +// Created by João de Vasconcelos on 12/11/2022. +// + +import Foundation + +extension DispatchQueue { + + static func background(delay: Double = 0.0, background: (()->Void)? = nil, completion: (() -> Void)? = nil) { + DispatchQueue.global(qos: .background).async { + background?() + if let completion = completion { + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: { + completion() + }) + } + } + } +} + +// USAGE + +//DispatchQueue.background(delay: 3.0, background: { +// // do something in background +//}, completion: { +// // when background job finishes, wait 3 seconds and do something in main thread +//}) +// +//DispatchQueue.background(background: { +// // do something in background +//}, completion:{ +// // when background job finished, do something in main thread +//}) +// +//DispatchQueue.background(delay: 3.0, completion:{ +// // do something in main thread after 3 seconds +//}) diff --git a/GeoBus/App/Extensions/Debounce.swift b/GeoBus/App/Extensions/Debounce.swift new file mode 100644 index 00000000..225c2315 --- /dev/null +++ b/GeoBus/App/Extensions/Debounce.swift @@ -0,0 +1,20 @@ +// +// Debounce.swift +// GeoBus +// +// Created by @quickthyme (https://stackoverflow.com/a/59296478) +// + +import Dispatch + +class Debounce { + + private init() {} + + static func input(_ input: T, comparedAgainst current: @escaping @autoclosure () -> (T), perform: @escaping (T) -> ()) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if input == current() { perform(input) } + } + } + +} diff --git a/GeoBus/App/Extensions/Helpers.swift b/GeoBus/App/Extensions/Helpers.swift index 7b7160eb..b55083c7 100644 --- a/GeoBus/App/Extensions/Helpers.swift +++ b/GeoBus/App/Extensions/Helpers.swift @@ -93,18 +93,37 @@ open class Helpers { } - static func getTimeString(for isoDateString: String, in timeRelation: TimeRelativeToNow, style: DateComponentsFormatter.UnitsStyle, units: NSCalendar.Unit) -> String { - + static func getTimeInterval(for isoDateString: String, in timeRelation: TimeRelativeToNow) -> Double { + // Setup Date Formatter let dateFormatter = DateFormatter() dateFormatter.locale = Locale(identifier: "en_US_POSIX") dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" - + // Parse ISO Timestamp using the Date Formatter let now = Date() let dateObj = dateFormatter.date(from: isoDateString) ?? now let seconds = now.timeIntervalSince(dateObj) // in seconds - + + // Use the configured Date Components Formatter to generate the string. + switch timeRelation { + case .past: + return seconds + case .future: + return -seconds + } + + } + + + static func getTimeString(for isoDateString: String, in timeRelation: TimeRelativeToNow, style: DateComponentsFormatter.UnitsStyle, units: NSCalendar.Unit, alwaysPositive: Bool = false) -> String { + + var seconds = self.getTimeInterval(for: isoDateString, in: timeRelation) + + if (alwaysPositive && seconds < 60) { + seconds = 60.1 // Do not let it be 0 + } + // Setup Date Components Formatter let dateComponentsFormatter = DateComponentsFormatter() dateComponentsFormatter.unitsStyle = style @@ -114,26 +133,33 @@ open class Helpers { dateComponentsFormatter.allowsFractionalUnits = false // Use the configured Date Components Formatter to generate the string. - switch timeRelation { - case .past: - return dateComponentsFormatter.string(from: seconds) ?? "?" - case .future: - return dateComponentsFormatter.string(from: -seconds) ?? "?" - } + return dateComponentsFormatter.string(from: seconds) ?? "?" } - static func getLastSeenTime(since lastGpsTime: String) -> Int { + + + + + + + + + + + + static func getLastSeenTime(since isoDateString: String) -> Int { - let formatter = DateFormatter() - formatter.locale = Locale(identifier: "en_US_POSIX") - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + // Setup Date Formatter + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + // Parse ISO Timestamp using the Date Formatter let now = Date() - let estimation = formatter.date(from: lastGpsTime) ?? now - - let seconds = now.timeIntervalSince(estimation) + let dateObj = dateFormatter.date(from: isoDateString) ?? now + let seconds = now.timeIntervalSince(dateObj) // in seconds return Int(seconds) diff --git a/GeoBus/App/GeoBusApp.swift b/GeoBus/App/GeoBusApp.swift index c43b1f39..b67cfce2 100644 --- a/GeoBus/App/GeoBusApp.swift +++ b/GeoBus/App/GeoBusApp.swift @@ -1,30 +1,31 @@ import SwiftUI + /* MARK: - GEOBUS */ + @main struct GeoBusApp: App { - @StateObject private var appstate = Appstate.shared - @StateObject private var mapController = MapController.shared - @StateObject private var carrisNetworkController = CarrisNetworkController.shared - // @StateObject private var tcbNetworkController = TCBNetworkController.shared + @ObservedObject private var sheetController = SheetController.shared + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared + // @ObservedObject private var tcbNetworkController = TCBNetworkController.shared - private let updateIntervalTimer = Timer.publish(every: 20 /* seconds */, on: .main, in: .common).autoconnect() + private let appRefreshTimer = Timer.publish(every: 20 /* seconds */, on: .main, in: .common).autoconnect() var body: some Scene { WindowGroup { ContentView() - .environmentObject(appstate) - .environmentObject(mapController) - .environmentObject(carrisNetworkController) .onAppear(perform: { Analytics.shared.capture(event: .App_Session_Start) }) - .onReceive(updateIntervalTimer) { event in + .onReceive(appRefreshTimer) { event in carrisNetworkController.refresh() Analytics.shared.capture(event: .App_Session_Ping) } + .sheet(isPresented: $sheetController.sheetIsPresented) { + PresentedSheetView() + } } } diff --git a/GeoBus/App/Layout/Animations/PlaceholderAnimation.swift b/GeoBus/App/Layout/Animations/PlaceholderAnimation.swift new file mode 100644 index 00000000..5840d210 --- /dev/null +++ b/GeoBus/App/Layout/Animations/PlaceholderAnimation.swift @@ -0,0 +1,24 @@ +// +// PlaceholderAnimation.swift +// GeoBus +// +// Created by João de Vasconcelos on 30/10/2022. +// + +import SwiftUI + +// Create an immediate, looping animation +extension View { + func animatePlaceholder(binding: Binding) -> some View { + + let minOpacity: Double = 0.5 + let animationSpeed: Double = 1 + + return onAppear { + withAnimation(.easeInOut(duration: animationSpeed).repeatForever(autoreverses: true)) { + binding.wrappedValue = minOpacity + } + } + + } +} diff --git a/GeoBus/App/Layout/Animations/Spinner.swift b/GeoBus/App/Layout/Animations/Spinner.swift index 98d72e89..b177af7f 100644 --- a/GeoBus/App/Layout/Animations/Spinner.swift +++ b/GeoBus/App/Layout/Animations/Spinner.swift @@ -9,10 +9,10 @@ import SwiftUI struct Spinner: View { - @Environment(\.colorScheme) var colorScheme: ColorScheme + public var size: CGFloat = 20.0 + public var timing: Double = 0.5 - private let timing: Double = 0.5 - private let size: CGFloat = 20.0 + @Environment(\.colorScheme) var colorScheme: ColorScheme @State var trim: Double = 0.4 @State var rotationAngle: Double = 0.0 diff --git a/GeoBus/App/Layout/Chip.swift b/GeoBus/App/Layout/Chip.swift new file mode 100644 index 00000000..6cdd3657 --- /dev/null +++ b/GeoBus/App/Layout/Chip.swift @@ -0,0 +1,82 @@ +// +// Chip.swift +// GeoBus +// +// Created by João de Vasconcelos on 30/10/2022. +// + +import SwiftUI + +struct Chip: View { + + let icon: Image + let text: Text + let color: Color + let showContent: Bool + + let customContent: CustomContent + + @State private var placeholderOpacity: Double = 1 + + + init(icon: Image, text: Text, color: Color, showContent: Bool = true, customContent: () -> CustomContent = { EmptyView() }) { + self.icon = icon + self.text = text + self.color = color + self.showContent = showContent + self.customContent = customContent() + } + + var actualContent: some View { + VStack(alignment: .leading, spacing: 0) { + + HStack(alignment: .center) { + icon + text + Spacer() + } + .font(Font.system(size: 15, weight: .medium)) + .foregroundColor(color) + .padding() + + if (type(of: customContent) != EmptyView.self) { + Divider() + customContent + .padding() + } + + } + .frame(maxWidth: .infinity) + .background(color.opacity(0.1)) + .cornerRadius(10) + } + + + var placeholder: some View { + HStack(alignment: .center) { + Circle() + .frame(width: 15, height: 15) + Rectangle() + .frame(width: 90, height: 12) + Spacer() + } + .font(Font.system(size: 15, weight: .medium)) + .padding() + .foregroundColor(Color("PlaceholderShape")) + .frame(maxWidth: .infinity) + .background(Color("PlaceholderShape").opacity(0.3)) + .cornerRadius(10) + .opacity(placeholderOpacity) + .animatePlaceholder(binding: $placeholderOpacity) + } + + + var body: some View { + if (showContent) { + actualContent + } else { + placeholder + } + } + +} diff --git a/GeoBus/App/Layout/LoadingSheet.swift b/GeoBus/App/Layout/LoadingSheet.swift new file mode 100644 index 00000000..af64c58c --- /dev/null +++ b/GeoBus/App/Layout/LoadingSheet.swift @@ -0,0 +1,15 @@ +// +// LoadingSheet.swift +// GeoBus +// +// Created by João de Vasconcelos on 29/10/2022. +// + +import SwiftUI + +struct LoadingSheet: View { + var body: some View { + ProgressView() + .scaleEffect(5) + } +} diff --git a/GeoBus/App/Layout/RouteBadgePill.swift b/GeoBus/App/Layout/RouteBadgePill.swift index 07214274..999bfdfe 100644 --- a/GeoBus/App/Layout/RouteBadgePill.swift +++ b/GeoBus/App/Layout/RouteBadgePill.swift @@ -9,23 +9,55 @@ import SwiftUI struct RouteBadgePill: View { - - let routeNumber: String - - var body: some View { - + + public let routeNumber: String? + + private let fontSize: CGFloat = 13 + private let fontWeight: Font.Weight = .heavy + private let cornerRadius: CGFloat = 10 + private let paddingHorizontal: CGFloat = 7 + private let paddingVertical: CGFloat = 2 + + @State private var placeholderOpacity: Double = 1 + private let placeholderColor: Color = Color("PlaceholderShape") + + + var placeholder: some View { VStack { - Text(routeNumber) - .font(.footnote) - .fontWeight(.heavy) + Text("000") + .font(Font.system(size: fontSize, weight: fontWeight)) .lineLimit(1) - .foregroundColor(Helpers.getForegroundColor(for: routeNumber)) - .padding(.horizontal, 7) - .padding(.vertical, 2) + .foregroundColor(.clear) + .padding(.horizontal, paddingHorizontal) + .padding(.vertical, paddingVertical) } - .background(Helpers.getBackgroundColor(for: routeNumber)) - .cornerRadius(10) - + .background(placeholderColor) + .cornerRadius(cornerRadius) + .opacity(placeholderOpacity) + .animatePlaceholder(binding: $placeholderOpacity) } - + + + var actualContent: some View { + VStack { + Text(routeNumber!) + .font(Font.system(size: fontSize, weight: fontWeight)) + .lineLimit(1) + .foregroundColor(Helpers.getForegroundColor(for: routeNumber!)) + .padding(.horizontal, paddingHorizontal) + .padding(.vertical, paddingVertical) + } + .background(Helpers.getBackgroundColor(for: routeNumber!)) + .cornerRadius(cornerRadius) + } + + + var body: some View { + if (routeNumber != nil) { + actualContent + } else { + placeholder + } + } + } diff --git a/GeoBus/App/Layout/SheetErrorScreen.swift b/GeoBus/App/Layout/SheetErrorScreen.swift new file mode 100644 index 00000000..7ed8f953 --- /dev/null +++ b/GeoBus/App/Layout/SheetErrorScreen.swift @@ -0,0 +1,54 @@ +// +// SheetErrorScreen.swift +// GeoBus +// +// Created by João de Vasconcelos on 30/10/2022. +// + +import SwiftUI + +struct SheetErrorScreen: View { + + @ObservedObject private var carrisNetworkController = CarrisNetworkController.shared + + var body: some View { + Chip( + icon: Image(systemName: "exclamationmark.triangle.fill"), + text: Text("Error fetching data from server."), + color: Color(.systemOrange) + ) + } + + + + + + var bodyAlt: some View { + VStack(alignment: .center, spacing: 15) { + Image(systemName: "exclamationmark.octagon.fill") + .foregroundColor(Color(.systemRed)) + .font(Font.system(size: 50, weight: .bold)) + Text("Connection Error") + .font(Font.system(size: 24, weight: .bold, design: .default) ) + .foregroundColor(Color(.label)) + Text("A connection to the API was not possible.") + .font(Font.system(size: 18, weight: .medium, design: .default) ) + .foregroundColor(Color(.secondaryLabel)) + Button(action: { + carrisNetworkController.refresh() + }, label: { + Text("Try Again") + .font(Font.system(size: 18, weight: .bold)) + .foregroundColor(.white) + .padding(.vertical, 10) + .padding(.horizontal, 20) + .frame(maxWidth: .infinity) + .background(Color(.systemBlue)) + .cornerRadius(10) + .padding(.top, 30) + }) + } + .padding() + .frame(maxWidth: .infinity) + } +} diff --git a/GeoBus/App/Layout/SheetHeader.swift b/GeoBus/App/Layout/SheetHeader.swift index 256769aa..a4c44db8 100644 --- a/GeoBus/App/Layout/SheetHeader.swift +++ b/GeoBus/App/Layout/SheetHeader.swift @@ -9,24 +9,27 @@ import SwiftUI struct SheetHeader: View { - - let title: Text - @Binding var toggle: Bool - - var body: some View { - VStack { - HStack { - Spacer() - Button(action: { self.toggle = false }) { - Text("Close") + + @ObservedObject private var sheetController = SheetController.shared + + let title: Text + + var body: some View { + VStack { + HStack { + Spacer() + Button(action: { + sheetController.dismiss() + }) { + Text("Close") + .fontWeight(.bold) + } + .padding(25) + } + title + .font(.largeTitle) .fontWeight(.bold) - } - .padding(25) } - title - .font(.largeTitle) - .fontWeight(.bold) - } - .padding(.bottom, 20) - } + .padding(.bottom, 20) + } } diff --git a/GeoBus/App/Layout/StopIcon.swift b/GeoBus/App/Layout/StopIcon.swift index ccc674b1..aa26f93a 100644 --- a/GeoBus/App/Layout/StopIcon.swift +++ b/GeoBus/App/Layout/StopIcon.swift @@ -9,92 +9,152 @@ import SwiftUI struct StopIcon: View { - public let orderInRoute: Int - public let direction: CarrisNetworkModel.Direction - public let isSelected: Bool + public let style: Style + public let orderInRoute: Int? - init(orderInRoute: Int, direction: CarrisNetworkModel.Direction) { - self.orderInRoute = orderInRoute - self.direction = direction - self.isSelected = false - } + @ObservedObject private var mapController = MapController.shared - init(orderInRoute: Int, direction: CarrisNetworkModel.Direction, isSelected: Bool) { + init(style: Style = .standard, orderInRoute: Int? = nil, direction: CarrisNetworkModel.Direction? = nil) { self.orderInRoute = orderInRoute - self.direction = direction - self.isSelected = isSelected + + switch direction { + case .ascending: + self.style = .ascending + case .descending: + self.style = .descending + case .circular: + self.style = .circular + case .none: + self.style = style + } + } - // Properties: - // The defaults for the icon - private let size: CGFloat = 25 - private let multiplier: Double = 1.5 + + enum Style { + case standard + case mini + case circular + case ascending + case descending + case selected + case muted + } + private var viewSize: CGFloat { - if (self.isSelected) { - return self.size * self.multiplier - } else { - return self.size + switch style { + case .standard, .ascending, .descending, .circular, .muted: + return 25 + case .mini: + let mapZoom = mapController.region.span.latitudeDelta + var size = -440.0 * mapZoom + 12 + if (size < 0) { size = 0 } + return size + case .selected: + return 25 * 1.5 } } + + private var borderWidth: CGFloat { return self.viewSize - self.viewSize / 5 } + + private var textSize: CGFloat { return self.viewSize / 2 } + + private var borderColor: Color { - if (self.isSelected) { - return Color("StopSelectedBorder") - } else { - switch direction { - case .ascending: - return Color("StopAscendingBorder") - case .descending: - return Color("StopDescendingBorder") - case .circular: - return Color("StopCircularBorder") - } + switch style { + case .standard: + return Color("StopStandardBorder") + case .mini: + return Color("StopMiniBorder") + case .ascending: + return Color("StopAscendingBorder") + case .descending: + return Color("StopDescendingBorder") + case .circular: + return Color("StopCircularBorder") + case .selected: + return Color("StopSelectedBorder") + case .muted: + return Color("StopMutedBorder") } } + + private var backgroundColor: Color { - if (self.isSelected) { - return Color("StopSelectedBackground") - } else { - switch direction { - case .ascending: - return Color("StopAscendingBackground") - case .descending: - return Color("StopDescendingBackground") - case .circular: - return Color("StopCircularBackground") - } + switch style { + case .standard: + return Color("StopStandardBackground") + case .mini: + return Color("StopMiniBackground") + case .ascending: + return Color("StopAscendingBackground") + case .descending: + return Color("StopDescendingBackground") + case .circular: + return Color("StopCircularBackground") + case .selected: + return Color("StopSelectedBackground") + case .muted: + return Color("StopMutedBackground") } } + + private var textColor: Color { + switch style { + case .standard: + return Color("StopStandardText") + case .mini: + return Color("StopMiniText") + case .ascending: + return Color("StopAscendingText") + case .descending: + return Color("StopDescendingText") + case .circular: + return Color("StopCircularText") + case .selected: + return Color("StopSelectedText") + case .muted: + return Color("StopMutedText") + } + } + + + var body: some View { ZStack { Circle() .foregroundColor(self.borderColor) .frame(width: self.viewSize, height: self.viewSize) - .animation(.default, value: self.borderColor) - .animation(.default, value: self.viewSize) Circle() .foregroundColor(self.backgroundColor) .frame(width: self.borderWidth, height: self.borderWidth) - .animation(.default, value: self.backgroundColor) - .animation(.default, value: self.borderWidth) - Text(String(self.orderInRoute)) - .font(.system(size: self.textSize, weight: .bold)) - .foregroundColor(.white) - .animation(.default, value: self.textSize) + switch style { + case .standard, .selected: + Image(systemName: "mappin") + .font(.system(size: self.textSize, weight: .bold)) + .foregroundColor(textColor) + case .ascending, .descending, .circular, .muted: + Text(String(self.orderInRoute!)) + .font(.system(size: self.textSize, weight: .bold)) + .foregroundColor(textColor) + case .mini: + EmptyView() + } } } diff --git a/GeoBus/App/Layout/TimeLeft.swift b/GeoBus/App/Layout/TimeLeft.swift index 2b839b5d..b026d161 100644 --- a/GeoBus/App/Layout/TimeLeft.swift +++ b/GeoBus/App/Layout/TimeLeft.swift @@ -9,42 +9,77 @@ import SwiftUI struct TimeLeft: View { - public let time: String? + public let timeString: String? + + private let countdownUnits: NSCalendar.Unit private let countdownTimer = Timer.publish(every: 1 /* seconds */, on: .main, in: .common).autoconnect() - @State var timeLeftString: String = "" + @State private var countdownValue: Double = 0 + @State private var countdownString: String? + + + init(time: String?, units: NSCalendar.Unit = [.hour, .minute]) { + self.timeString = time + self.countdownUnits = units + } + - var loading: some View { - HStack(spacing: 3) { - ProgressView() - .scaleEffect(0.55) + func setCountdownString(_ value: Any?) { + if (timeString != nil) { + self.countdownValue = Helpers.getTimeInterval(for: self.timeString!, in: .future) + self.countdownString = Helpers.getTimeString(for: self.timeString!, in: .future, style: .short, units: self.countdownUnits, alwaysPositive: true) } } - var content: some View { + + var positiveTime: some View { HStack(spacing: 5) { Image(systemName: "plusminus") .font(.footnote) .foregroundColor(Color(.tertiaryLabel)) - Text(self.timeLeftString) + Text(self.countdownString!) .font(.body) .fontWeight(.medium) .foregroundColor(Color(.label)) - .onAppear() { - self.timeLeftString = Helpers.getTimeString(for: self.time ?? "", in: .future, style: .short, units: [.hour, .minute]) - } - .onReceive(countdownTimer) { event in - self.timeLeftString = Helpers.getTimeString(for: self.time ?? "", in: .future, style: .short, units: [.hour, .minute]) - } } } + + var negativeTime: some View { + HStack(spacing: 5) { + Image(systemName: "lessthan") + .font(.footnote) + .foregroundColor(Color(.tertiaryLabel)) + Text(self.countdownString!) + .font(.body) + .fontWeight(.medium) + .foregroundColor(Color(.label)) + } + } + + + var invalidValue: some View { + Image(systemName: "circle.dashed") + .font(Font.system(size: 15, weight: .medium)) + .foregroundColor(Color(.secondaryLabel)) + } + + var body: some View { - if (time != nil) { - content - } else { - loading + VStack { + if (countdownString != nil) { + if (countdownValue > 0) { + positiveTime + } else { + negativeTime + } + } else { + invalidValue + } } + .onAppear() { setCountdownString(nil) } + .onChange(of: timeString, perform: setCountdownString) + .onReceive(countdownTimer, perform: setCountdownString) } } diff --git a/GeoBus/App/Layout/VehicleDestination.swift b/GeoBus/App/Layout/VehicleDestination.swift index d6a7ddf5..ee6b5379 100644 --- a/GeoBus/App/Layout/VehicleDestination.swift +++ b/GeoBus/App/Layout/VehicleDestination.swift @@ -7,18 +7,37 @@ import SwiftUI -struct VehicleDestination: View { + + + + +struct DestinationText: View { - public let routeNumber: String - public let destination: String + let destination: String? - var body: some View { + @State private var placeholderOpacity: Double = 1 + + + var placeholder: some View { + HStack(alignment: .center, spacing: 4) { + Image(systemName: "arrow.forward") + .font(.system(size: 8, weight: .bold, design: .default)) + .foregroundColor(Color("PlaceholderText")) + Rectangle() + .frame(width: 80, height: 12) + .foregroundColor(Color("PlaceholderShape")) + } + .opacity(self.placeholderOpacity) + .animatePlaceholder(binding: self.$placeholderOpacity) + } + + + var actualContent: some View { HStack(spacing: 4) { - RouteBadgePill(routeNumber: self.routeNumber) Image(systemName: "arrow.forward") .font(.system(size: 8, weight: .bold, design: .default)) .foregroundColor(Color(.tertiaryLabel)) - Text(self.destination) + Text(self.destination!) .font(.body) .fontWeight(.medium) .foregroundColor(Color(.label)) @@ -26,5 +45,13 @@ struct VehicleDestination: View { } } + + var body: some View { + if (self.destination != nil) { + actualContent + } else { + placeholder + } + } + } - diff --git a/GeoBus/App/Layout/VehicleIdentifier.swift b/GeoBus/App/Layout/VehicleIdentifier.swift index 2de59b76..5c791d02 100644 --- a/GeoBus/App/Layout/VehicleIdentifier.swift +++ b/GeoBus/App/Layout/VehicleIdentifier.swift @@ -10,14 +10,29 @@ import SwiftUI struct VehicleIdentifier: View { - let busNumber: Int + let busNumber: Int? let vehiclePlate: String? @State var toggleIdentifier: Bool = false + @State var placeholderOpacity: Double = 1 + + var placeholder: some View { + Text("00000") + .font(Font.system(size: 12, weight: .bold, design: .monospaced) ) + .foregroundColor(.clear) + .padding(.vertical, 2) + .padding(.horizontal, 7) + .background(Color("PlaceholderShape")) + .cornerRadius(5) + .opacity(placeholderOpacity) + .animatePlaceholder(binding: $placeholderOpacity) + } + + var busNumberView: some View { - Text(String(busNumber)) + Text(String(busNumber!)) .font(Font.system(size: 12, weight: .bold, design: .monospaced) ) .foregroundColor(.primary) .padding(.vertical, 2) @@ -51,24 +66,26 @@ struct VehicleIdentifier: View { var body: some View { - // If vehiclePlate is not available, - // then show only the busNumber - if (vehiclePlate != nil) { - // If both are available, allow them to be toggled - VStack { - if (toggleIdentifier) { - busNumberView - } else { - licensePlateView + if (busNumber != nil) { + + if (vehiclePlate != nil) { + VStack { + if (toggleIdentifier) { + busNumberView + } else { + licensePlateView + } } + .onTapGesture { + TapticEngine.impact.feedback(.light) + self.toggleIdentifier = !toggleIdentifier + } + } else { + busNumberView } - .onTapGesture { - TapticEngine.impact.feedback(.light) - self.toggleIdentifier = !toggleIdentifier - } - + } else { - busNumberView + placeholder } } diff --git a/GeoBus/App/Models/Estimations.swift b/GeoBus/App/Models/Estimations.swift new file mode 100644 index 00000000..cd616abf --- /dev/null +++ b/GeoBus/App/Models/Estimations.swift @@ -0,0 +1,82 @@ +// +// Estimations.swift +// GeoBus +// +// Created by João on 16/04/2020. +// Copyright © 2020 João de Vasconcelos. All rights reserved. +// + +import Foundation + +/* MARK: - Estimations Provider */ + +// Different providers for Estimations. + +enum EstimationsProvider: String { + case carris + case community +} + + +/* MARK: - API Estimation */ + +// Data model as provided by Carris API. +// Schema is available at https://joaodcp.github.io/Carris-API + +struct CarrisAPIEstimation: Decodable { + let routeNumber: String? + let routeName: String? + let destination: String? + let time: String? // Expected time of arrival + let busNumber: String? + let plateNumber: String? + let voyageNumber: Int + let publicId: String? +} + + +// Data model as provided by Community API. +// Schema is currently not available... + +struct CommunityAPIEstimation: Decodable { + let busNumber: Int? + let enrichedAvgRouteSpeed: Double? + let enrichedBusSpeed: Double? + let enrichedEstRouteKm: Double? + let enrichedQueryTime: Int? + let enrichedSequenceNo: Int? + let enrichedStartTime: String? +// "estimatedDebug":[ +// "Info: currentBusStopArrivals: This bus is NOT expected to have passed this stop already in the current route.", +// "Info: timeDeltaList: corrected estimate computed with speed correction factors 0.8543648255362379 and 0.6507523601990874.", +// "Info: timeDeltaList: estimate computed from 28 historical samples." +// ] + let estimatedRecentlyArrived: Bool? + let estimatedTimeofArrival: String? + let estimatedTimeofArrivalCorrected: String? + let estimatedTimeofArrivalCorrected_debug_alternative: String? + let estimatedUncertainty: String? + let lastReportTime: String? + let lat, long: Double? + let previousLatitude, previousLongitude: Double? + let variantNumber: Int? +} + + + +/* MARK: - Estimation */ + +// Data model adjusted for the app. + +struct Estimation: Codable, Identifiable, Equatable { + let routeNumber: String + let destination: String + let publicId: String + let busNumber: String? + let eta: String + + var id: String { + return UUID().uuidString + } + +} diff --git a/GeoBus/App/Models/Network.swift b/GeoBus/App/Models/Network.swift new file mode 100644 index 00000000..493971e2 --- /dev/null +++ b/GeoBus/App/Models/Network.swift @@ -0,0 +1,108 @@ +// +// Routes.swift +// GeoBus +// +// Created by João de Vasconcelos on 09/09/2022. +// Copyright © 2022 João de Vasconcelos. All rights reserved. +// + +import Foundation + + +/* * */ +/* MARK: - NETWORK DATA MODEL */ +/* Data provided by the Carris API consists of a list of separate endpoints */ +/* from which it is possible to retrieve information from routes and stops. */ +/* For this app, the goal is to simplify and build upon this network model */ +/* to prevent duplicated and increase flexibility on updates to the views. */ + + +// ROUTE +// Routes are identified by its ‹routeNumber›, have a name, +// a kind (tram, nightBus, etc.) and can have several variants. +struct Route_NEW: Codable, Equatable, Identifiable { + let id: String + let number: String + let name: String + let kind: Kind + let variants: [Variant_NEW] + + init(number: String, name: String, kind: Kind, variants: [Variant_NEW]) { + self.id = number + self.number = number + self.name = name + self.kind = kind + self.variants = variants + } +} + + +// VARIANT +// Variants are alternative paths the same route can have, +// like segments of a full route during peak hours. +// Variants are identified by its number inside each route, +// and they can be circular or in a straight line. +struct Variant_NEW: Codable, Equatable, Identifiable { + let id: Int + let number: Int + let name: String + let itineraries: [Itinerary_NEW] + + init(number: Int, name: String, itineraries: [Itinerary_NEW]) { + self.id = number + self.number = number + self.name = name + self.itineraries = itineraries + } +} + + +// ITINERARY +// Itineraries hold the list of connections (stops) for each variant. +// They are identified by their direction (ascending, descending, circular). +struct Itinerary_NEW: Codable, Equatable, Identifiable { + let id: Direction + let direction: Direction + let connections: [Connection_NEW] + + init(direction: Direction, connections: [Connection_NEW]) { + self.id = direction + self.direction = direction + self.connections = connections + } +} + + +// CONNECTION +// Connections are a thin wrapper before stops in order to be able +// to hold a ‹orderInRoute› number. Connections are identified by this value. +struct Connection_NEW: Codable, Equatable, Identifiable { + let id: Int + let orderInRoute: Int + let stop: Stop_NEW + + init(orderInRoute: Int, stop: Stop_NEW) { + self.id = orderInRoute + self.orderInRoute = orderInRoute + self.stop = stop + } +} + + +// STOP +// Stops are identified by its ‹publicId› value. +// They have a name and a location. +struct Stop_NEW: Codable, Equatable, Identifiable { + let id: String + let publicId: String + let name: String + let lat, lng: Double + + init(publicId: String, name: String, lat: Double, lng: Double) { + self.id = publicId + self.publicId = publicId + self.name = name + self.lat = lat + self.lng = lng + } +} diff --git a/GeoBus/App/Models/Vehicles.swift b/GeoBus/App/Models/Vehicles.swift new file mode 100644 index 00000000..921c66c8 --- /dev/null +++ b/GeoBus/App/Models/Vehicles.swift @@ -0,0 +1,158 @@ +// +// Vehicles.swift +// GeoBus +// +// Created by João on 16/04/2020. +// Copyright © 2020 João de Vasconcelos. All rights reserved. +// + +import Foundation + +/* MARK: - API Vehicle */ + +// Data model as provided by the API. +// Schema is available at https://joaodcp.github.io/Carris-API + +struct CarrisAPIVehicleSummary: Decodable { + let busNumber: Int? + let state: String? + let lastGpsTime: String? + let lastReportTime: String? + let lat: Double? + let lng: Double? + let routeNumber: String? + let direction: String? + let plateNumber: String? + let timeStamp: String? + let dataServico: String? + let previousReportTime: String? + let previousLatitude: Double? + let previousLongitude: Double? +} + +struct CarrisAPIVehicleDetail: Codable { + let vehiclePlate: String? + let routeNumber: String? + let plateNumber: String? + let direction: String? + let lastStopOnVoyageId: Int? + let lastStopOnVoyageName: String? + let parkingStopId: Int? + let parkingStopName: String? + let driverNumber: String? + let lat: Double? + let lng: Double? +} + + +struct CommunityAPIVehicle: Codable { + let busNumber: Int? + let dataServico: String? + let direction: String? + let enrichedAvgRouteSpeed: Double? + let enrichedBusSpeed: Double? + let enrichedDbStartup: Double? + let enrichedEstRouteKm: Double? + let enrichedGeohash300m: String? + let enrichedGeohash80m: String? + let enrichedGeohashPrev300m: String? + let enrichedGeohashPrev80m: String? + let enrichedPreviousStopId: String? + // enrichedPreviousStopList: [] + let enrichedPreviousStopMax: Int? + let enrichedPreviousStopOrderIdx: Double? + let enrichedQueryTime: Int? + let enrichedRouteDoneKm: Double? + let enrichedRouteLengthKm: Double? + let enrichedSequenceNo: Int? + let enrichedStartLat: Double? + let enrichedStartLng: Double? + let enrichedStartTime: String? + let enrichedTimeHash30m: String? + let enrichedTimeHashDay30m: String? + // estimatedDebug": [ + // "Warning: currentBusStopArrivals: This bus is expected to either arrive or have arrived in the past minute. Halting computation." + // ] + let estimatedRecentlyArrived: Bool? + let estimatedTimeofArrival: [String] + let estimatedTimeofArrivalCorrected: [String] + let lastGpsTime: String? + let lastReportTime: String? + let lat: Double? + let lng: Double? + let plateNumber: String? + let previousLatitude: Double? + let previousLongitude: Double? + let previousReportTime: String? + let routeNumber: String? + let state: String? + let timeStamp: String? + let variantNumber: Int? + let voyageNumber: Int? +} + + + +/* MARK: - Vehicle */ + +// Data model adjusted for the app. + +struct Vehicle: Codable, Equatable, Identifiable, Hashable { + + let busNumber: Int + var id: Int { + return self.busNumber + } + + // Carris API › Vehicle Summary + var routeNumber: String? + var kind: Kind? + var lat: Double? + var lng: Double? + var previousLatitude: Double? + var previousLongitude: Double? + var lastGpsTime: String? + var angleInRadians: Double? + + // Carris API › Vehicle Details + var vehiclePlate: String? + var lastStopOnVoyageName: String? + + // Community API + var estimatedTimeofArrivalCorrected: [String]? + +} + + + + +struct VehicleSummary: Codable, Equatable, Identifiable { + let busNumber: Int + let state: String + let routeNumber: String + let kind: Kind + let lat: Double + let lng: Double + let previousLatitude: Double? + let previousLongitude: Double? + let lastGpsTime: String + let angleInRadians: Double + + var id: Int { + return self.busNumber + } + +} + + +struct VehicleDetails: Codable, Equatable, Identifiable { + let busNumber: Int + let vehiclePlate: String + let lastStopOnVoyageName: String + + var id: String { +// return self.busNumber + self.lastStopOnVoyageName + return UUID().uuidString + } + +} diff --git a/GeoBus/Assets.xcassets/Colors/Placeholder/Contents.json b/GeoBus/Assets.xcassets/Colors/Placeholder/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/GeoBus/Assets.xcassets/Colors/Placeholder/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GeoBus/Assets.xcassets/Colors/Placeholder/PlaceholderShape.colorset/Contents.json b/GeoBus/Assets.xcassets/Colors/Placeholder/PlaceholderShape.colorset/Contents.json new file mode 100644 index 00000000..f33a61e7 --- /dev/null +++ b/GeoBus/Assets.xcassets/Colors/Placeholder/PlaceholderShape.colorset/Contents.json @@ -0,0 +1,34 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "extended-gray", + "components" : { + "alpha" : "1.000", + "white" : "235" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "extended-gray", + "components" : { + "alpha" : "1.000", + "white" : "64" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GeoBus/Assets.xcassets/Colors/Placeholder/PlaceholderText.colorset/Contents.json b/GeoBus/Assets.xcassets/Colors/Placeholder/PlaceholderText.colorset/Contents.json new file mode 100644 index 00000000..f2912710 --- /dev/null +++ b/GeoBus/Assets.xcassets/Colors/Placeholder/PlaceholderText.colorset/Contents.json @@ -0,0 +1,34 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "extended-gray", + "components" : { + "alpha" : "1.000", + "white" : "220" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "extended-gray", + "components" : { + "alpha" : "1.000", + "white" : "0.350" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GeoBus/Assets.xcassets/Colors/Stops/Ascending/StopAscendingText.colorset/Contents.json b/GeoBus/Assets.xcassets/Colors/Stops/Ascending/StopAscendingText.colorset/Contents.json new file mode 100644 index 00000000..c60cea5b --- /dev/null +++ b/GeoBus/Assets.xcassets/Colors/Stops/Ascending/StopAscendingText.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "255", + "green" : "255", + "red" : "255" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "255", + "green" : "255", + "red" : "255" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GeoBus/Assets.xcassets/Colors/Stops/Circular/StopCircularText.colorset/Contents.json b/GeoBus/Assets.xcassets/Colors/Stops/Circular/StopCircularText.colorset/Contents.json new file mode 100644 index 00000000..22c4bb0a --- /dev/null +++ b/GeoBus/Assets.xcassets/Colors/Stops/Circular/StopCircularText.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GeoBus/Assets.xcassets/Colors/Stops/Descending/StopDescendingText.colorset/Contents.json b/GeoBus/Assets.xcassets/Colors/Stops/Descending/StopDescendingText.colorset/Contents.json new file mode 100644 index 00000000..22c4bb0a --- /dev/null +++ b/GeoBus/Assets.xcassets/Colors/Stops/Descending/StopDescendingText.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GeoBus/Assets.xcassets/Colors/Stops/Mini/Contents.json b/GeoBus/Assets.xcassets/Colors/Stops/Mini/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/GeoBus/Assets.xcassets/Colors/Stops/Mini/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GeoBus/Assets.xcassets/Colors/Stops/Mini/StopMiniBackground.colorset/Contents.json b/GeoBus/Assets.xcassets/Colors/Stops/Mini/StopMiniBackground.colorset/Contents.json new file mode 100644 index 00000000..c27ad0fe --- /dev/null +++ b/GeoBus/Assets.xcassets/Colors/Stops/Mini/StopMiniBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.510", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.667", + "green" : "0.235", + "red" : "0.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GeoBus/Assets.xcassets/Colors/Stops/Mini/StopMiniBorder.colorset/Contents.json b/GeoBus/Assets.xcassets/Colors/Stops/Mini/StopMiniBorder.colorset/Contents.json new file mode 100644 index 00000000..91ecaedc --- /dev/null +++ b/GeoBus/Assets.xcassets/Colors/Stops/Mini/StopMiniBorder.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.863", + "green" : "0.431", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.510", + "red" : "0.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GeoBus/Assets.xcassets/Colors/Stops/Mini/StopMiniText.colorset/Contents.json b/GeoBus/Assets.xcassets/Colors/Stops/Mini/StopMiniText.colorset/Contents.json new file mode 100644 index 00000000..22c4bb0a --- /dev/null +++ b/GeoBus/Assets.xcassets/Colors/Stops/Mini/StopMiniText.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GeoBus/Assets.xcassets/Colors/Stops/Muted/Contents.json b/GeoBus/Assets.xcassets/Colors/Stops/Muted/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/GeoBus/Assets.xcassets/Colors/Stops/Muted/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GeoBus/Assets.xcassets/Colors/Stops/Muted/StopMutedBackground.colorset/Contents.json b/GeoBus/Assets.xcassets/Colors/Stops/Muted/StopMutedBackground.colorset/Contents.json new file mode 100644 index 00000000..085f942e --- /dev/null +++ b/GeoBus/Assets.xcassets/Colors/Stops/Muted/StopMutedBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "230", + "green" : "235", + "red" : "235" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "120", + "green" : "115", + "red" : "110" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GeoBus/Assets.xcassets/Colors/Stops/Muted/StopMutedBorder.colorset/Contents.json b/GeoBus/Assets.xcassets/Colors/Stops/Muted/StopMutedBorder.colorset/Contents.json new file mode 100644 index 00000000..cf4d4a3e --- /dev/null +++ b/GeoBus/Assets.xcassets/Colors/Stops/Muted/StopMutedBorder.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "215", + "green" : "215", + "red" : "215" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "160", + "green" : "155", + "red" : "150" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GeoBus/Assets.xcassets/Colors/Stops/Muted/StopMutedText.colorset/Contents.json b/GeoBus/Assets.xcassets/Colors/Stops/Muted/StopMutedText.colorset/Contents.json new file mode 100644 index 00000000..19bc36fa --- /dev/null +++ b/GeoBus/Assets.xcassets/Colors/Stops/Muted/StopMutedText.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "180", + "green" : "180", + "red" : "180" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GeoBus/Assets.xcassets/Colors/Stops/Selected/StopSelectedText.colorset/Contents.json b/GeoBus/Assets.xcassets/Colors/Stops/Selected/StopSelectedText.colorset/Contents.json new file mode 100644 index 00000000..22c4bb0a --- /dev/null +++ b/GeoBus/Assets.xcassets/Colors/Stops/Selected/StopSelectedText.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GeoBus/Assets.xcassets/Colors/Stops/Standard/Contents.json b/GeoBus/Assets.xcassets/Colors/Stops/Standard/Contents.json new file mode 100644 index 00000000..73c00596 --- /dev/null +++ b/GeoBus/Assets.xcassets/Colors/Stops/Standard/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GeoBus/Assets.xcassets/Colors/Stops/Standard/StopStandardBackground.colorset/Contents.json b/GeoBus/Assets.xcassets/Colors/Stops/Standard/StopStandardBackground.colorset/Contents.json new file mode 100644 index 00000000..c27ad0fe --- /dev/null +++ b/GeoBus/Assets.xcassets/Colors/Stops/Standard/StopStandardBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.510", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.667", + "green" : "0.235", + "red" : "0.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GeoBus/Assets.xcassets/Colors/Stops/Standard/StopStandardBorder.colorset/Contents.json b/GeoBus/Assets.xcassets/Colors/Stops/Standard/StopStandardBorder.colorset/Contents.json new file mode 100644 index 00000000..91ecaedc --- /dev/null +++ b/GeoBus/Assets.xcassets/Colors/Stops/Standard/StopStandardBorder.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.863", + "green" : "0.431", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.510", + "red" : "0.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GeoBus/Assets.xcassets/Colors/Stops/Standard/StopStandardText.colorset/Contents.json b/GeoBus/Assets.xcassets/Colors/Stops/Standard/StopStandardText.colorset/Contents.json new file mode 100644 index 00000000..22c4bb0a --- /dev/null +++ b/GeoBus/Assets.xcassets/Colors/Stops/Standard/StopStandardText.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/Contents.json b/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/Contents.json index 27c242f0..28937d23 100644 --- a/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/Contents.json +++ b/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "regular-service-light.png", + "filename" : "regular-service-active-light.png", "idiom" : "universal", "scale" : "1x" }, @@ -12,12 +12,12 @@ "value" : "dark" } ], - "filename" : "regular-service-dark.png", + "filename" : "regular-service-active-dark.png", "idiom" : "universal", "scale" : "1x" }, { - "filename" : "regular-service-light@2x.png", + "filename" : "regular-service-active-light@2x.png", "idiom" : "universal", "scale" : "2x" }, @@ -28,12 +28,12 @@ "value" : "dark" } ], - "filename" : "regular-service-dark@2x.png", + "filename" : "regular-service-active-dark@2x.png", "idiom" : "universal", "scale" : "2x" }, { - "filename" : "regular-service-light@3x.png", + "filename" : "regular-service-active-light@3x.png", "idiom" : "universal", "scale" : "3x" }, @@ -44,7 +44,7 @@ "value" : "dark" } ], - "filename" : "regular-service-dark@3x.png", + "filename" : "regular-service-active-dark@3x.png", "idiom" : "universal", "scale" : "3x" } diff --git a/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-active-dark.png b/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-active-dark.png new file mode 100644 index 00000000..5746aed1 Binary files /dev/null and b/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-active-dark.png differ diff --git a/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-active-dark@2x.png b/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-active-dark@2x.png new file mode 100644 index 00000000..6f45e826 Binary files /dev/null and b/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-active-dark@2x.png differ diff --git a/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-active-dark@3x.png b/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-active-dark@3x.png new file mode 100644 index 00000000..003c3fc9 Binary files /dev/null and b/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-active-dark@3x.png differ diff --git a/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-active-light.png b/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-active-light.png new file mode 100644 index 00000000..4c71ce07 Binary files /dev/null and b/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-active-light.png differ diff --git a/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-active-light@2x.png b/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-active-light@2x.png new file mode 100644 index 00000000..2cee8d53 Binary files /dev/null and b/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-active-light@2x.png differ diff --git a/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-active-light@3x.png b/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-active-light@3x.png new file mode 100644 index 00000000..bbe99edd Binary files /dev/null and b/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-active-light@3x.png differ diff --git a/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-dark.png b/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-dark.png deleted file mode 100644 index 64ff52ef..00000000 Binary files a/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-dark.png and /dev/null differ diff --git a/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-dark@2x.png b/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-dark@2x.png deleted file mode 100644 index 1e958752..00000000 Binary files a/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-dark@2x.png and /dev/null differ diff --git a/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-dark@3x.png b/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-dark@3x.png deleted file mode 100644 index 73712b9c..00000000 Binary files a/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-dark@3x.png and /dev/null differ diff --git a/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-light.png b/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-light.png deleted file mode 100644 index 9e40efa6..00000000 Binary files a/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-light.png and /dev/null differ diff --git a/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-light@2x.png b/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-light@2x.png deleted file mode 100644 index fe4b7759..00000000 Binary files a/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-light@2x.png and /dev/null differ diff --git a/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-light@3x.png b/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-light@3x.png deleted file mode 100644 index cef5228d..00000000 Binary files a/GeoBus/Assets.xcassets/VehicleMarkers/RegularService.imageset/regular-service-light@3x.png and /dev/null differ