From 086d80010a14f91b93120d911d835711ee8f234c Mon Sep 17 00:00:00 2001 From: hilmyveradin Date: Tue, 13 Aug 2024 17:24:24 +0700 Subject: [PATCH 1/6] feat: modify OTPKit to swift pacakge --- .gitignore | 4 - .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../OTPKitDemo.xcodeproj/project.pbxproj | 374 ++++++++++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/swiftpm/Package.resolved | 14 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 + .../OTPKitDemo/Assets.xcassets/Contents.json | 6 + Examples/OTPKitDemo/OTPKitDemo/MapView.swift | 42 ++ .../OTPKitDemo/OTPKitDemo/OTPKitDemoApp.swift | 36 ++ .../Preview Assets.xcassets/Contents.json | 6 + Package.swift | 27 ++ Sources/OTPKit/Controls/PageHeaderView.swift | 39 ++ Sources/OTPKit/Controls/SearchView.swift | 32 ++ .../OTPKit/Controls/SectionHeaderView.swift | 39 ++ .../MapExtension/MapMarkingView.swift | 59 +++ .../OriginDestination/FavoriteView.swift | 52 +++ .../OriginDestinationSheetEnvironment.swift | 47 ++ .../OriginDestinationView.swift | 67 +++ .../Sheets/AddFavoriteLocationsSheet.swift | 112 +++++ .../Sheets/FavoriteLocationDetailSheet.swift | 62 +++ .../Sheets/MoreFavoriteLocationsSheet.swift | 48 +++ .../Sheets/MoreRecentLocationsSheet.swift | 38 ++ .../Sheets/OriginDestinationSheetView.swift | 253 +++++++++++ .../DirectionLegOriginDestinationView.swift | 42 ++ .../Direction/DirectionLegUnknownView.swift | 37 ++ .../Direction/DirectionLegVehicleView.swift | 51 +++ .../Direction/DirectionLegWalkView.swift | 39 ++ .../Direction/DirectionSheetView.swift | 112 +++++ .../ItineraryLegUnknownView.swift | 29 ++ .../ItineraryLegVehicleView.swift | 52 +++ .../ItineraryLegWalkView.swift | 30 ++ .../TripPlannerSheetView.swift | 103 +++++ .../TripPlanner/TripPlannerView.swift | 50 +++ Sources/OTPKit/Miscellaneous/FlowLayout.swift | 45 ++ Sources/OTPKit/Miscellaneous/Formatters.swift | 49 +++ .../Miscellaneous/PresentationManager.swift | 25 ++ Sources/OTPKit/Miscellaneous/Utilities.swift | 10 + .../Helpers/DebugDescriptionBuilder.swift | 45 ++ .../Models/MapExtension/MarkerItem.swift | 15 + .../OriginDestinationState.swift | 17 + Sources/OTPKit/Models/Polyline/Polyline.swift | 387 +++++++++++++++++ .../Models/TripPlanner/ErrorResponse.swift | 28 ++ .../OTPKit/Models/TripPlanner/Itinerary.swift | 64 +++ Sources/OTPKit/Models/TripPlanner/Leg.swift | 78 ++++ .../Models/TripPlanner/LegGeometry.swift | 17 + .../OTPKit/Models/TripPlanner/Location.swift | 25 ++ .../Models/TripPlanner/OTPResponse.swift | 29 ++ Sources/OTPKit/Models/TripPlanner/Place.swift | 32 ++ Sources/OTPKit/Models/TripPlanner/Plan.swift | 36 ++ .../TripPlanner/RequestParameters.swift | 44 ++ Sources/OTPKit/Models/TripPlanner/Step.swift | 38 ++ Sources/OTPKit/Network/RestAPI.swift | 107 +++++ Sources/OTPKit/Network/URLDataLoader.swift | 27 ++ Sources/OTPKit/OTPKit.h | 18 + Sources/OTPKit/Previews/PreviewHelpers.swift | 41 ++ .../OTPKit/Services/TripPlannerService.swift | 401 ++++++++++++++++++ .../Services/UserDefaultsServices.swift | 124 ++++++ Sources/OTPKit/TripPlannerExtensionView.swift | 114 +++++ Tests/OTPKitTests/OTPKitTests.swift | 12 + project.yml | 65 --- 63 files changed, 3780 insertions(+), 69 deletions(-) create mode 100644 .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata create mode 100644 .swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.pbxproj create mode 100644 Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Examples/OTPKitDemo/OTPKitDemo/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Examples/OTPKitDemo/OTPKitDemo/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Examples/OTPKitDemo/OTPKitDemo/Assets.xcassets/Contents.json create mode 100644 Examples/OTPKitDemo/OTPKitDemo/MapView.swift create mode 100644 Examples/OTPKitDemo/OTPKitDemo/OTPKitDemoApp.swift create mode 100644 Examples/OTPKitDemo/OTPKitDemo/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 Package.swift create mode 100644 Sources/OTPKit/Controls/PageHeaderView.swift create mode 100644 Sources/OTPKit/Controls/SearchView.swift create mode 100644 Sources/OTPKit/Controls/SectionHeaderView.swift create mode 100644 Sources/OTPKit/Features/MapExtension/MapMarkingView.swift create mode 100644 Sources/OTPKit/Features/OriginDestination/FavoriteView.swift create mode 100644 Sources/OTPKit/Features/OriginDestination/OriginDestinationSheetEnvironment.swift create mode 100644 Sources/OTPKit/Features/OriginDestination/OriginDestinationView.swift create mode 100644 Sources/OTPKit/Features/OriginDestination/Sheets/AddFavoriteLocationsSheet.swift create mode 100644 Sources/OTPKit/Features/OriginDestination/Sheets/FavoriteLocationDetailSheet.swift create mode 100644 Sources/OTPKit/Features/OriginDestination/Sheets/MoreFavoriteLocationsSheet.swift create mode 100644 Sources/OTPKit/Features/OriginDestination/Sheets/MoreRecentLocationsSheet.swift create mode 100644 Sources/OTPKit/Features/OriginDestination/Sheets/OriginDestinationSheetView.swift create mode 100644 Sources/OTPKit/Features/TripPlanner/Direction/DirectionLegOriginDestinationView.swift create mode 100644 Sources/OTPKit/Features/TripPlanner/Direction/DirectionLegUnknownView.swift create mode 100644 Sources/OTPKit/Features/TripPlanner/Direction/DirectionLegVehicleView.swift create mode 100644 Sources/OTPKit/Features/TripPlanner/Direction/DirectionLegWalkView.swift create mode 100644 Sources/OTPKit/Features/TripPlanner/Direction/DirectionSheetView.swift create mode 100644 Sources/OTPKit/Features/TripPlanner/SelectItenerary/ItineraryLegUnknownView.swift create mode 100644 Sources/OTPKit/Features/TripPlanner/SelectItenerary/ItineraryLegVehicleView.swift create mode 100644 Sources/OTPKit/Features/TripPlanner/SelectItenerary/ItineraryLegWalkView.swift create mode 100644 Sources/OTPKit/Features/TripPlanner/SelectItenerary/TripPlannerSheetView.swift create mode 100644 Sources/OTPKit/Features/TripPlanner/TripPlannerView.swift create mode 100644 Sources/OTPKit/Miscellaneous/FlowLayout.swift create mode 100644 Sources/OTPKit/Miscellaneous/Formatters.swift create mode 100644 Sources/OTPKit/Miscellaneous/PresentationManager.swift create mode 100644 Sources/OTPKit/Miscellaneous/Utilities.swift create mode 100644 Sources/OTPKit/Models/Helpers/DebugDescriptionBuilder.swift create mode 100644 Sources/OTPKit/Models/MapExtension/MarkerItem.swift create mode 100644 Sources/OTPKit/Models/OriginDestination/OriginDestinationState.swift create mode 100644 Sources/OTPKit/Models/Polyline/Polyline.swift create mode 100644 Sources/OTPKit/Models/TripPlanner/ErrorResponse.swift create mode 100644 Sources/OTPKit/Models/TripPlanner/Itinerary.swift create mode 100644 Sources/OTPKit/Models/TripPlanner/Leg.swift create mode 100644 Sources/OTPKit/Models/TripPlanner/LegGeometry.swift create mode 100644 Sources/OTPKit/Models/TripPlanner/Location.swift create mode 100644 Sources/OTPKit/Models/TripPlanner/OTPResponse.swift create mode 100644 Sources/OTPKit/Models/TripPlanner/Place.swift create mode 100644 Sources/OTPKit/Models/TripPlanner/Plan.swift create mode 100644 Sources/OTPKit/Models/TripPlanner/RequestParameters.swift create mode 100644 Sources/OTPKit/Models/TripPlanner/Step.swift create mode 100644 Sources/OTPKit/Network/RestAPI.swift create mode 100644 Sources/OTPKit/Network/URLDataLoader.swift create mode 100644 Sources/OTPKit/OTPKit.h create mode 100644 Sources/OTPKit/Previews/PreviewHelpers.swift create mode 100644 Sources/OTPKit/Services/TripPlannerService.swift create mode 100644 Sources/OTPKit/Services/UserDefaultsServices.swift create mode 100644 Sources/OTPKit/TripPlannerExtensionView.swift create mode 100644 Tests/OTPKitTests/OTPKitTests.swift delete mode 100644 project.yml diff --git a/.gitignore b/.gitignore index c890a39..5e2a856 100644 --- a/.gitignore +++ b/.gitignore @@ -89,8 +89,4 @@ fastlane/test_output iOSInjectionProject/ -OTPKit.xcodeproj -OTPKitDemo/Info.plist -OTPKit/Info.plist -OTPKitTests/Info.plist *.xcresult \ No newline at end of file diff --git a/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.pbxproj b/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.pbxproj new file mode 100644 index 0000000..a0cbe64 --- /dev/null +++ b/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.pbxproj @@ -0,0 +1,374 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 01AA80542C6B6A7500D4038A /* OTPKitDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01AA80532C6B6A7500D4038A /* OTPKitDemoApp.swift */; }; + 01AA80562C6B6A7500D4038A /* MapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01AA80552C6B6A7500D4038A /* MapView.swift */; }; + 01AA80582C6B6A7600D4038A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 01AA80572C6B6A7600D4038A /* Assets.xcassets */; }; + 01AA805B2C6B6A7600D4038A /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 01AA805A2C6B6A7600D4038A /* Preview Assets.xcassets */; }; + 01CF11752C6B6AB0005B6B47 /* OTPKit in Frameworks */ = {isa = PBXBuildFile; productRef = 01CF11742C6B6AB0005B6B47 /* OTPKit */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 01AA80502C6B6A7500D4038A /* OTPKitDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OTPKitDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 01AA80532C6B6A7500D4038A /* OTPKitDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPKitDemoApp.swift; sourceTree = ""; }; + 01AA80552C6B6A7500D4038A /* MapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapView.swift; sourceTree = ""; }; + 01AA80572C6B6A7600D4038A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 01AA805A2C6B6A7600D4038A /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 01AA804D2C6B6A7500D4038A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 01CF11752C6B6AB0005B6B47 /* OTPKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 01AA80472C6B6A7500D4038A = { + isa = PBXGroup; + children = ( + 01AA80522C6B6A7500D4038A /* OTPKitDemo */, + 01AA80512C6B6A7500D4038A /* Products */, + ); + sourceTree = ""; + }; + 01AA80512C6B6A7500D4038A /* Products */ = { + isa = PBXGroup; + children = ( + 01AA80502C6B6A7500D4038A /* OTPKitDemo.app */, + ); + name = Products; + sourceTree = ""; + }; + 01AA80522C6B6A7500D4038A /* OTPKitDemo */ = { + isa = PBXGroup; + children = ( + 01AA80532C6B6A7500D4038A /* OTPKitDemoApp.swift */, + 01AA80552C6B6A7500D4038A /* MapView.swift */, + 01AA80572C6B6A7600D4038A /* Assets.xcassets */, + 01AA80592C6B6A7600D4038A /* Preview Content */, + ); + path = OTPKitDemo; + sourceTree = ""; + }; + 01AA80592C6B6A7600D4038A /* Preview Content */ = { + isa = PBXGroup; + children = ( + 01AA805A2C6B6A7600D4038A /* Preview Assets.xcassets */, + ); + path = "Preview Content"; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 01AA804F2C6B6A7500D4038A /* OTPKitDemo */ = { + isa = PBXNativeTarget; + buildConfigurationList = 01AA805E2C6B6A7600D4038A /* Build configuration list for PBXNativeTarget "OTPKitDemo" */; + buildPhases = ( + 01AA804C2C6B6A7500D4038A /* Sources */, + 01AA804D2C6B6A7500D4038A /* Frameworks */, + 01AA804E2C6B6A7500D4038A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = OTPKitDemo; + packageProductDependencies = ( + 01CF11742C6B6AB0005B6B47 /* OTPKit */, + ); + productName = OTPKitDemo; + productReference = 01AA80502C6B6A7500D4038A /* OTPKitDemo.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 01AA80482C6B6A7500D4038A /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1520; + LastUpgradeCheck = 1520; + TargetAttributes = { + 01AA804F2C6B6A7500D4038A = { + CreatedOnToolsVersion = 15.2; + }; + }; + }; + buildConfigurationList = 01AA804B2C6B6A7500D4038A /* Build configuration list for PBXProject "OTPKitDemo" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 01AA80472C6B6A7500D4038A; + packageReferences = ( + 01CF11732C6B6AB0005B6B47 /* XCRemoteSwiftPackageReference "otpkit-test" */, + ); + productRefGroup = 01AA80512C6B6A7500D4038A /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 01AA804F2C6B6A7500D4038A /* OTPKitDemo */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 01AA804E2C6B6A7500D4038A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 01AA805B2C6B6A7600D4038A /* Preview Assets.xcassets in Resources */, + 01AA80582C6B6A7600D4038A /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 01AA804C2C6B6A7500D4038A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 01AA80562C6B6A7500D4038A /* MapView.swift in Sources */, + 01AA80542C6B6A7500D4038A /* OTPKitDemoApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 01AA805C2C6B6A7600D4038A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 01AA805D2C6B6A7600D4038A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 01AA805F2C6B6A7600D4038A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"OTPKitDemo/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.onebusaway.otpkitdemo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 01AA80602C6B6A7600D4038A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = "\"OTPKitDemo/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.onebusaway.otpkitdemo; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 01AA804B2C6B6A7500D4038A /* Build configuration list for PBXProject "OTPKitDemo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 01AA805C2C6B6A7600D4038A /* Debug */, + 01AA805D2C6B6A7600D4038A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 01AA805E2C6B6A7600D4038A /* Build configuration list for PBXNativeTarget "OTPKitDemo" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 01AA805F2C6B6A7600D4038A /* Debug */, + 01AA80602C6B6A7600D4038A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 01CF11732C6B6AB0005B6B47 /* XCRemoteSwiftPackageReference "otpkit-test" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/hilmyveradin/otpkit-test"; + requirement = { + branch = master; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 01CF11742C6B6AB0005B6B47 /* OTPKit */ = { + isa = XCSwiftPackageProductDependency; + package = 01CF11732C6B6AB0005B6B47 /* XCRemoteSwiftPackageReference "otpkit-test" */; + productName = OTPKit; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 01AA80482C6B6A7500D4038A /* Project object */; +} diff --git a/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..bc0b173 --- /dev/null +++ b/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "otpkit-test", + "kind" : "remoteSourceControl", + "location" : "https://github.com/hilmyveradin/otpkit-test", + "state" : { + "branch" : "master", + "revision" : "e1010be7e016a65eeec614a7e832051abc3b7cea" + } + } + ], + "version" : 2 +} diff --git a/Examples/OTPKitDemo/OTPKitDemo/Assets.xcassets/AccentColor.colorset/Contents.json b/Examples/OTPKitDemo/OTPKitDemo/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Examples/OTPKitDemo/OTPKitDemo/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/OTPKitDemo/OTPKitDemo/Assets.xcassets/AppIcon.appiconset/Contents.json b/Examples/OTPKitDemo/OTPKitDemo/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/Examples/OTPKitDemo/OTPKitDemo/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/OTPKitDemo/OTPKitDemo/Assets.xcassets/Contents.json b/Examples/OTPKitDemo/OTPKitDemo/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Examples/OTPKitDemo/OTPKitDemo/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Examples/OTPKitDemo/OTPKitDemo/MapView.swift b/Examples/OTPKitDemo/OTPKitDemo/MapView.swift new file mode 100644 index 0000000..372057a --- /dev/null +++ b/Examples/OTPKitDemo/OTPKitDemo/MapView.swift @@ -0,0 +1,42 @@ +// +// MapView.swift +// OTPKitDemo +// +// Created by Hilmy Veradin on 25/06/24. +// + +import MapKit +import OTPKit +import SwiftUI + +struct MapView: View { + @EnvironmentObject private var tripPlanner: TripPlannerService + + var body: some View { + TripPlannerExtensionView { + Map(position: $tripPlanner.currentCameraPosition, interactionModes: .all) { + tripPlanner.generateMarkers() + tripPlanner.generateMapPolyline() + .stroke(.blue, lineWidth: 5) + } + .mapControls { + if !tripPlanner.isMapMarkingMode { + MapUserLocationButton() + MapPitchToggle() + } + } + } + .environmentObject(tripPlanner) + } +} + +#Preview { + let planner = TripPlannerService( + apiClient: RestAPI(baseURL: URL(string: "https://otp.prod.sound.obaweb.org/otp/routers/default/")!), + locationManager: CLLocationManager(), + searchCompleter: MKLocalSearchCompleter() + ) + + return MapView() + .environmentObject(planner) +} diff --git a/Examples/OTPKitDemo/OTPKitDemo/OTPKitDemoApp.swift b/Examples/OTPKitDemo/OTPKitDemo/OTPKitDemoApp.swift new file mode 100644 index 0000000..13cd143 --- /dev/null +++ b/Examples/OTPKitDemo/OTPKitDemo/OTPKitDemoApp.swift @@ -0,0 +1,36 @@ +/* + * Copyright (C) Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import CoreLocation +import MapKit +import OTPKit +import SwiftUI + +@main +struct OTPKitDemoApp: App { + let tripPlannerService = TripPlannerService( + apiClient: RestAPI(baseURL: URL(string: "https://otp.prod.sound.obaweb.org/otp/routers/default/")!), + locationManager: CLLocationManager(), + searchCompleter: MKLocalSearchCompleter() + ) + + var body: some Scene { + WindowGroup { + MapView() + .environmentObject(tripPlannerService) + } + } +} diff --git a/Examples/OTPKitDemo/OTPKitDemo/Preview Content/Preview Assets.xcassets/Contents.json b/Examples/OTPKitDemo/OTPKitDemo/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Examples/OTPKitDemo/OTPKitDemo/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..6932d29 --- /dev/null +++ b/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "OTPKit", + platforms: [ + .iOS(.v17) + ], + + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "OTPKit", + targets: ["OTPKit"]), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "OTPKit"), + .testTarget( + name: "OTPKitTests", + dependencies: ["OTPKit"]), + ] +) diff --git a/Sources/OTPKit/Controls/PageHeaderView.swift b/Sources/OTPKit/Controls/PageHeaderView.swift new file mode 100644 index 0000000..1fb5742 --- /dev/null +++ b/Sources/OTPKit/Controls/PageHeaderView.swift @@ -0,0 +1,39 @@ +// +// PageHeaderView.swift +// OTPKit +// +// Created by Aaron Brethorst on 7/31/24. +// + +import SwiftUI + +/// Appears at the top of UI pages with a title and close button. +struct PageHeaderView: View { + private let text: String + private let action: VoidBlock? + + init(text: String, action: VoidBlock? = nil) { + self.text = text + self.action = action + } + + var body: some View { + HStack { + Text(text) + .font(.title2) + .fontWeight(.bold) + Spacer() + Button(action: { + action?() + }, label: { + Image(systemName: "xmark.circle.fill") + .font(.title2) + .foregroundColor(.gray) + }) + } + } +} + +#Preview { + PageHeaderView(text: "Favorites") +} diff --git a/Sources/OTPKit/Controls/SearchView.swift b/Sources/OTPKit/Controls/SearchView.swift new file mode 100644 index 0000000..ec2bda1 --- /dev/null +++ b/Sources/OTPKit/Controls/SearchView.swift @@ -0,0 +1,32 @@ +// +// SearchView.swift +// OTPKit +// +// Created by Aaron Brethorst on 8/11/24. +// + +import SwiftUI + +/// A reusable Search control suitable for displaying in the header of a view. +struct SearchView: View { + var placeholder: String + @Binding var searchText: String + @FocusState var isSearchFocused: Bool + + var body: some View { + HStack { + Image(systemName: "magnifyingglass") + TextField(placeholder, text: $searchText) + .autocorrectionDisabled() + .focused($isSearchFocused) + } + .padding(.vertical, 8) + .padding(.horizontal, 12) + .background(Color.gray.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +// #Preview { +// SearchView() +// } diff --git a/Sources/OTPKit/Controls/SectionHeaderView.swift b/Sources/OTPKit/Controls/SectionHeaderView.swift new file mode 100644 index 0000000..317f5e0 --- /dev/null +++ b/Sources/OTPKit/Controls/SectionHeaderView.swift @@ -0,0 +1,39 @@ +// +// SectionHeaderView.swift +// OTPKit +// +// Created by Aaron Brethorst on 7/31/24. +// + +import SwiftUI + +/// The view that appears above a section on the `OriginDestinationSheetView`. +/// For instance, the header for the Recents and Favorites sections. +struct SectionHeaderView: View { + private let text: String + private let action: VoidBlock? + + init(text: String, action: VoidBlock? = nil) { + self.text = text + self.action = action + } + + var body: some View { + HStack { + Text(text) + .textCase(.none) + Spacer() + Button(action: { + action?() + }, label: { + Text("More") + .textCase(.none) + .font(.subheadline) + }) + } + } +} + +#Preview { + SectionHeaderView(text: "Hello, world!") +} diff --git a/Sources/OTPKit/Features/MapExtension/MapMarkingView.swift b/Sources/OTPKit/Features/MapExtension/MapMarkingView.swift new file mode 100644 index 0000000..e2de0a1 --- /dev/null +++ b/Sources/OTPKit/Features/MapExtension/MapMarkingView.swift @@ -0,0 +1,59 @@ +// +// MapMarkingView.swift +// OTPKit +// +// Created by Hilmy Veradin on 18/07/24. +// + +import SwiftUI + + +/// View for Map Marking Mode +/// User able to add Marking directly from the map +public struct MapMarkingView: View { + @EnvironmentObject private var tripPlanner: TripPlannerService + + public init() {} + public var body: some View { + VStack { + Spacer() + + Text("Tap on the map to add a pin.") + .padding(16) + .background(.regularMaterial) + .cornerRadius(16) + + HStack(spacing: 16) { + Button { + tripPlanner.toggleMapMarkingMode(false) + tripPlanner.selectAndRefreshCoordinate() + tripPlanner.removeOriginDestinationData() + } label: { + Text("Cancel") + .padding(8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + + Button { + tripPlanner.toggleMapMarkingMode(false) + tripPlanner.addOriginDestinationData() + tripPlanner.selectAndRefreshCoordinate() + } label: { + Text("Add Pin") + .padding(8) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + } + .frame(maxWidth: .infinity) + .padding(16) + } + .padding(.bottom, 24) + } +} + +#Preview { + MapMarkingView() + .environmentObject(PreviewHelpers.buildTripPlannerService()) +} diff --git a/Sources/OTPKit/Features/OriginDestination/FavoriteView.swift b/Sources/OTPKit/Features/OriginDestination/FavoriteView.swift new file mode 100644 index 0000000..ce4d3a4 --- /dev/null +++ b/Sources/OTPKit/Features/OriginDestination/FavoriteView.swift @@ -0,0 +1,52 @@ +// +// FavoriteView.swift +// OTPKit +// +// Created by Aaron Brethorst on 7/31/24. +// + +import SwiftUI + +/// A button that wraps a circle with an icon above a line of text. +struct FavoriteView: View { + private let title: String + private let imageName: String + private let tapAction: VoidBlock? + private let longTapAction: VoidBlock? + + init(title: String, imageName: String, tapAction: VoidBlock? = nil, longTapAction: VoidBlock? = nil) { + self.title = title + self.imageName = imageName + self.tapAction = tapAction + self.longTapAction = longTapAction + } + + var body: some View { + Button(action: {}, label: { + VStack(alignment: .center) { + Image(systemName: imageName) + .frame(width: 48, height: 48) + .background(Color.gray.opacity(0.5)) + .clipShape(Circle()) + + Text(title) + .font(.caption) + .frame(width: 64) + .lineLimit(1) + .truncationMode(.tail) + } + .padding(.all, 4) + .foregroundStyle(.foreground) + }) + .simultaneousGesture(LongPressGesture().onEnded { _ in + longTapAction?() + }) + .simultaneousGesture(TapGesture().onEnded { + tapAction?() + }) + } +} + +#Preview { + FavoriteView(title: "Hello, world!", imageName: "mappin") +} diff --git a/Sources/OTPKit/Features/OriginDestination/OriginDestinationSheetEnvironment.swift b/Sources/OTPKit/Features/OriginDestination/OriginDestinationSheetEnvironment.swift new file mode 100644 index 0000000..7eede29 --- /dev/null +++ b/Sources/OTPKit/Features/OriginDestination/OriginDestinationSheetEnvironment.swift @@ -0,0 +1,47 @@ +// +// OriginDestinationSheetEnvironment.swift +// OTPKitDemo +// +// Created by Hilmy Veradin on 25/06/24. +// + +import Foundation +import SwiftUI + +/// OriginDestinationSheetEnvironment responsible for manage the environment of `OriginDestination` features +/// - sheetState: responsible for managing shown sheet in `OriginDestinationView` +/// - selectedValue: responsible for managing selected value when user taped the list in `OriginDestinationSheetView` +public final class OriginDestinationSheetEnvironment: ObservableObject { + @Published public var isSheetOpened = false + @Published public var selectedValue: String = "" + + // This responsible for showing favorite locations and recent locations in sheets + @Published public var favoriteLocations: [Location] = [] + @Published public var recentLocations: [Location] = [] + + /// Selected detail favorite locations that will be shown in `FavoriteLocationDetailSheet` + @Published public var selectedDetailFavoriteLocation: Location? + + // Public initializer + public init() {} + + /// Refresh favorite locations data from user defaults + func refreshFavoriteLocations() { + switch UserDefaultsServices.shared.getFavoriteLocationsData() { + case let .success(locations): + favoriteLocations = locations + case let .failure(error): + print("Failed to refresh favorite locations: \(error)") + } + } + + /// Refresh recent locations data from user defaults + func refreshRecentLocations() { + switch UserDefaultsServices.shared.getRecentLocations() { + case let .success(locations): + recentLocations = locations + case let .failure(error): + print("Failed to refresh favorite locations: \(error)") + } + } +} diff --git a/Sources/OTPKit/Features/OriginDestination/OriginDestinationView.swift b/Sources/OTPKit/Features/OriginDestination/OriginDestinationView.swift new file mode 100644 index 0000000..8e90079 --- /dev/null +++ b/Sources/OTPKit/Features/OriginDestination/OriginDestinationView.swift @@ -0,0 +1,67 @@ +// +// OriginDestinationView.swift +// OTPKit +// +// Created by Hilmy Veradin on 05/07/24. +// + +import MapKit +import SwiftUI + +/// OriginDestinationView is the main view for setting up Origin/Destination in OTPKit. +/// It consists a list of Origin and Destination along with the `MapKit` +public struct OriginDestinationView: View { + @EnvironmentObject private var sheetEnvironment: OriginDestinationSheetEnvironment + @EnvironmentObject private var tripPlanner: TripPlannerService + @State private var isSheetOpened = false + + // Public Initializer + public init() {} + + public var body: some View { + VStack { + List { + Button(action: { + sheetEnvironment.isSheetOpened.toggle() + tripPlanner.originDestinationState = .origin + }, label: { + HStack(spacing: 16) { + Image(systemName: "paperplane.fill") + .background( + Circle() + .fill(Color.green) + .frame(width: 30, height: 30) + ) + Text(tripPlanner.originName) + } + }) + .foregroundStyle(.foreground) + + Button(action: { + sheetEnvironment.isSheetOpened.toggle() + tripPlanner.originDestinationState = .destination + }, label: { + HStack(spacing: 16) { + Image(systemName: "mappin") + .background( + Circle() + .fill(Color.green) + .frame(width: 30, height: 30) + ) + Text(tripPlanner.destinationName) + } + }) + .foregroundStyle(.foreground) + } + .frame(height: 135) + .scrollContentBackground(.hidden) + .scrollDisabled(true) + .padding(.bottom, 24) + } + } +} + +#Preview { + OriginDestinationView() + .environmentObject(PreviewHelpers.buildTripPlannerService()) +} diff --git a/Sources/OTPKit/Features/OriginDestination/Sheets/AddFavoriteLocationsSheet.swift b/Sources/OTPKit/Features/OriginDestination/Sheets/AddFavoriteLocationsSheet.swift new file mode 100644 index 0000000..881e612 --- /dev/null +++ b/Sources/OTPKit/Features/OriginDestination/Sheets/AddFavoriteLocationsSheet.swift @@ -0,0 +1,112 @@ +// +// AddFavoriteLocationsSheet.swift +// OTPKitDemo +// +// Created by Hilmy Veradin on 03/07/24. +// + +import SwiftUI + +/// This sheet responsible to add a new favorite location. +/// Users can search and add their favorite locations +public struct AddFavoriteLocationsSheet: View { + @Environment(\.dismiss) var dismiss + @EnvironmentObject private var sheetEnvironment: OriginDestinationSheetEnvironment + @EnvironmentObject private var tripPlanner: TripPlannerService + + @State private var search = "" + + @FocusState private var isSearchFocused: Bool + + private var filteredCompletions: [Location] { + let favorites = sheetEnvironment.favoriteLocations + return tripPlanner.completions.filter { completion in + !favorites.contains { favorite in + favorite.title == completion.title && + favorite.subTitle == completion.subTitle + } + } + } + + private func currentUserSection() -> some View { + if search.isEmpty, let userLocation = tripPlanner.currentLocation { + AnyView( + Button(action: { + switch UserDefaultsServices.shared.saveFavoriteLocationData(data: userLocation) { + case .success: + sheetEnvironment.refreshFavoriteLocations() + dismiss() + case let .failure(error): + print(error) + } + }, label: { + HStack { + VStack(alignment: .leading) { + Text(userLocation.title) + .font(.headline) + Text(userLocation.subTitle) + }.foregroundStyle(.foreground) + + Spacer() + + Image(systemName: "plus") + } + + }) + ) + + } else { + AnyView(EmptyView()) + } + } + + private func searchedResultsSection() -> some View { + ForEach(filteredCompletions) { location in + Button(action: { + switch UserDefaultsServices.shared.saveFavoriteLocationData(data: location) { + case .success: + sheetEnvironment.refreshFavoriteLocations() + dismiss() + case let .failure(error): + print(error) + } + }, label: { + HStack { + VStack(alignment: .leading) { + Text(location.title) + .font(.headline) + Text(location.subTitle) + }.foregroundStyle(.foreground) + + Spacer() + + Image(systemName: "plus") + } + + }) + } + } + + public var body: some View { + VStack { + PageHeaderView(text: "Add Favorite") { + dismiss() + } + .padding() + SearchView(placeholder: "Search for a place", searchText: $search, isSearchFocused: _isSearchFocused) + .padding(.horizontal, 16) + List { + currentUserSection() + searchedResultsSection() + } + .onChange(of: search) { _, searchValue in + tripPlanner.updateQuery(queryFragment: searchValue) + } + } + } +} + +#Preview { + AddFavoriteLocationsSheet() + .environmentObject(PreviewHelpers.buildTripPlannerService()) +} diff --git a/Sources/OTPKit/Features/OriginDestination/Sheets/FavoriteLocationDetailSheet.swift b/Sources/OTPKit/Features/OriginDestination/Sheets/FavoriteLocationDetailSheet.swift new file mode 100644 index 0000000..cdbc0e6 --- /dev/null +++ b/Sources/OTPKit/Features/OriginDestination/Sheets/FavoriteLocationDetailSheet.swift @@ -0,0 +1,62 @@ +// +// FavoriteLocationDetailSheet.swift +// OTPKitDemo +// +// Created by Hilmy Veradin on 04/07/24. +// + +import SwiftUI + +/// This responsible for showing the details of favorite locations +/// Users can see the details and delete the location sheet +public struct FavoriteLocationDetailSheet: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var sheetEnvironment: OriginDestinationSheetEnvironment + + @State private var isShowErrorAlert = false + @State private var errorMessage = "" + + public var body: some View { + VStack { + PageHeaderView(text: "Favorite Location Detail") { + sheetEnvironment.selectedDetailFavoriteLocation = nil + dismiss() + } + .padding(.vertical) + + Text("\(sheetEnvironment.selectedDetailFavoriteLocation?.title ?? "")") + .font(.headline) + Text("\(sheetEnvironment.selectedDetailFavoriteLocation?.subTitle ?? "")") + + Button(action: { + guard let uid = sheetEnvironment.selectedDetailFavoriteLocation?.id else { + return + } + switch UserDefaultsServices.shared.deleteFavoriteLocationData(with: uid) { + case .success: + sheetEnvironment.selectedDetailFavoriteLocation = nil + sheetEnvironment.refreshFavoriteLocations() + dismiss() + case let .failure(failure): + errorMessage = failure.localizedDescription + isShowErrorAlert.toggle() + } + }, label: { + Text("Delete Location") + }) + .padding() + + Spacer() + } + .padding() + .alert(isPresented: $isShowErrorAlert) { + Alert(title: Text("Error Delete Favorite Location"), + message: Text(errorMessage), + dismissButton: .cancel(Text("Ok"))) + } + } +} + +#Preview { + FavoriteLocationDetailSheet() +} diff --git a/Sources/OTPKit/Features/OriginDestination/Sheets/MoreFavoriteLocationsSheet.swift b/Sources/OTPKit/Features/OriginDestination/Sheets/MoreFavoriteLocationsSheet.swift new file mode 100644 index 0000000..03a6a32 --- /dev/null +++ b/Sources/OTPKit/Features/OriginDestination/Sheets/MoreFavoriteLocationsSheet.swift @@ -0,0 +1,48 @@ +// +// MoreFavoriteLocationsSheet.swift +// OTPKitDemo +// +// Created by Hilmy Veradin on 03/07/24. +// + +import SwiftUI + +/// Show all the lists of favorite locations +public struct MoreFavoriteLocationsSheet: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var sheetEnvironment: OriginDestinationSheetEnvironment + @EnvironmentObject private var tripPlanner: TripPlannerService + + @State private var isDetailSheetOpened = false + + public var body: some View { + VStack { + PageHeaderView(text: "Favorites") { + dismiss() + } + .padding() + + List { + ForEach(sheetEnvironment.favoriteLocations) { location in + Button(action: {}, label: { + VStack(alignment: .leading) { + Text(location.title) + .font(.headline) + Text(location.subTitle) + } + .foregroundStyle(.foreground) + }) + } + } + .sheet(isPresented: $isDetailSheetOpened, content: { + FavoriteLocationDetailSheet() + .environmentObject(sheetEnvironment) + }) + } + } +} + +#Preview { + MoreFavoriteLocationsSheet() + .environmentObject(PreviewHelpers.buildTripPlannerService()) +} diff --git a/Sources/OTPKit/Features/OriginDestination/Sheets/MoreRecentLocationsSheet.swift b/Sources/OTPKit/Features/OriginDestination/Sheets/MoreRecentLocationsSheet.swift new file mode 100644 index 0000000..a718f41 --- /dev/null +++ b/Sources/OTPKit/Features/OriginDestination/Sheets/MoreRecentLocationsSheet.swift @@ -0,0 +1,38 @@ +// +// MoreRecentLocationsSheet.swift +// OTPKitDemo +// +// Created by Hilmy Veradin on 03/07/24. +// + +import SwiftUI + +/// Show all the lists of all recent locations +public struct MoreRecentLocationsSheet: View { + @Environment(\.dismiss) var dismiss + + @EnvironmentObject private var sheetEnvironment: OriginDestinationSheetEnvironment + + public var body: some View { + VStack { + PageHeaderView(text: "Recents") { + dismiss() + } + .padding() + + List { + ForEach(sheetEnvironment.recentLocations) { location in + VStack(alignment: .leading) { + Text(location.title) + .font(.headline) + Text(location.subTitle) + } + } + } + } + } +} + +#Preview { + MoreRecentLocationsSheet() +} diff --git a/Sources/OTPKit/Features/OriginDestination/Sheets/OriginDestinationSheetView.swift b/Sources/OTPKit/Features/OriginDestination/Sheets/OriginDestinationSheetView.swift new file mode 100644 index 0000000..915a453 --- /dev/null +++ b/Sources/OTPKit/Features/OriginDestination/Sheets/OriginDestinationSheetView.swift @@ -0,0 +1,253 @@ +import MapKit +import SwiftUI + +/// OriginDestinationSheetView responsible for showing sheets +/// consists of available origin/destination of OriginDestinationView +/// - Attributes: +/// - sheetEnvironment responsible for manage sheet states across the view. See `OriginDestinationSheetEnvironment` +/// - locationService responsible for manage autocompletion of origin/destination search bar. See `LocationService` +/// +public struct OriginDestinationSheetView: View { + @Environment(\.dismiss) var dismiss + @EnvironmentObject var sheetEnvironment: OriginDestinationSheetEnvironment + @EnvironmentObject private var tripPlanner: TripPlannerService + + @State private var search: String = "" + + // Sheet States + private enum Modals: Identifiable { + case addFavoriteSheet + case moreFavoritesSheet + case favoriteDetailsSheet + case moreRecentsSheet + + var id: Self { self } + } + + @StateObject private var presentationManager = PresentationManager() + + @State private var isShowFavoriteConfirmationDialog = false + + // Alert States + @State private var isShowErrorAlert = false + @State private var errorTitle = "" + @State private var errorMessage = "" + + @FocusState private var isSearchFocused: Bool + + // Public initializer + public init() {} + + private func favoriteSectionConfirmationDialog() -> some View { + Group { + Button(action: { + presentationManager.present(.favoriteDetailsSheet) + }, label: { + Text("Show Details") + }) + + Button(role: .destructive, action: { + guard let uid = sheetEnvironment.selectedDetailFavoriteLocation?.id else { + return + } + switch UserDefaultsServices.shared.deleteFavoriteLocationData(with: uid) { + case .success: + sheetEnvironment.selectedDetailFavoriteLocation = nil + sheetEnvironment.refreshFavoriteLocations() + case let .failure(failure): + errorTitle = "Failed to Delete Favorite Location" + errorMessage = failure.localizedDescription + isShowErrorAlert.toggle() + } + }, label: { + Text("Delete") + }) + } + } + + private func favoritesSection() -> some View { + Section(content: { + ScrollView(.horizontal) { + HStack { + ForEach(sheetEnvironment.favoriteLocations, content: { location in + FavoriteView(title: location.title, imageName: "mappin", tapAction: { + tripPlanner.appendMarker(location: location) + tripPlanner.addOriginDestinationData() + dismiss() + }, longTapAction: { + isShowFavoriteConfirmationDialog = true + sheetEnvironment.selectedDetailFavoriteLocation = location + }) + }) + + FavoriteView(title: "Add", imageName: "plus", tapAction: { + presentationManager.present(.addFavoriteSheet) + }) + } + } + }, header: { + SectionHeaderView(text: "Favorites") { + presentationManager.present(.moreFavoritesSheet) + } + }) + } + + private func recentsSection() -> some View { + guard sheetEnvironment.recentLocations.count > 0 else { + return AnyView(EmptyView()) + } + + return AnyView( + Section(content: { + ForEach(Array(sheetEnvironment.recentLocations.prefix(5)), content: { location in + Button { + tripPlanner.appendMarker(location: location) + tripPlanner.addOriginDestinationData() + dismiss() + } label: { + VStack(alignment: .leading) { + Text(location.title) + .font(.headline) + Text(location.subTitle) + } + .foregroundColor(.primary) + } + }) + }, header: { + SectionHeaderView(text: "Recents") { + presentationManager.present(.moreRecentsSheet) + } + }) + ) + } + + private func searchResultsSection() -> some View { + Group { + ForEach(tripPlanner.completions) { location in + Button(action: { + tripPlanner.appendMarker(location: location) + tripPlanner.addOriginDestinationData() + switch UserDefaultsServices.shared.saveRecentLocations(data: location) { + case .success: + dismiss() + case .failure: + break + } + + }, label: { + VStack(alignment: .leading) { + Text(location.title) + .font(.headline) + Text(location.subTitle) + } + }) + .buttonStyle(PlainButtonStyle()) + } + } + } + + private func currentUserSection() -> some View { + Group { + if let userLocation = tripPlanner.currentLocation { + Button(action: { + tripPlanner.appendMarker(location: userLocation) + tripPlanner.addOriginDestinationData() + switch UserDefaultsServices.shared.saveRecentLocations(data: userLocation) { + case .success: + dismiss() + case .failure: + break + } + + }, label: { + VStack(alignment: .leading) { + Text("My Location") + .font(.headline) + Text("Your current location") + } + }) + .buttonStyle(PlainButtonStyle()) + } else { + EmptyView() + } + } + } + + private func selectLocationBasedOnMap() -> some View { + Button(action: { + tripPlanner.toggleMapMarkingMode(true) + dismiss() + }, label: { + HStack { + Image(systemName: "mappin") + Text("Choose on Map") + } + }) + .buttonStyle(PlainButtonStyle()) + } + + public var body: some View { + VStack { + PageHeaderView(text: "Change Stop") { + dismiss() + } + .padding() + + SearchView(placeholder: "Search for a place", searchText: $search, isSearchFocused: _isSearchFocused) + .padding(.horizontal, 16) + + List { + if search.isEmpty, isSearchFocused { + currentUserSection() + } else if search.isEmpty { + selectLocationBasedOnMap() + favoritesSection() + recentsSection() + } else { + searchResultsSection() + } + } + .onChange(of: search) { _, searchValue in + tripPlanner.updateQuery(queryFragment: searchValue) + } + + Spacer() + } + .onAppear { + sheetEnvironment.refreshFavoriteLocations() + sheetEnvironment.refreshRecentLocations() + } + .sheet(item: $presentationManager.activePresentation) { presentation in + switch presentation { + case .addFavoriteSheet: + AddFavoriteLocationsSheet() + .environmentObject(sheetEnvironment) + .environmentObject(tripPlanner) + case .moreFavoritesSheet: + MoreFavoriteLocationsSheet() + .environmentObject(sheetEnvironment) + .environmentObject(tripPlanner) + case .favoriteDetailsSheet: + FavoriteLocationDetailSheet() + .environmentObject(sheetEnvironment) + case .moreRecentsSheet: + MoreRecentLocationsSheet() + .environmentObject(sheetEnvironment) + } + } + .alert(isPresented: $isShowErrorAlert) { + Alert(title: Text(errorTitle), + message: Text(errorMessage), + dismissButton: .cancel(Text("OK"))) + } + .confirmationDialog("", isPresented: $isShowFavoriteConfirmationDialog, actions: { + favoriteSectionConfirmationDialog() + }) + } +} + +#Preview { + OriginDestinationSheetView() + .environmentObject(OriginDestinationSheetEnvironment()) + .environmentObject(PreviewHelpers.buildTripPlannerService()) +} diff --git a/Sources/OTPKit/Features/TripPlanner/Direction/DirectionLegOriginDestinationView.swift b/Sources/OTPKit/Features/TripPlanner/Direction/DirectionLegOriginDestinationView.swift new file mode 100644 index 0000000..973abe2 --- /dev/null +++ b/Sources/OTPKit/Features/TripPlanner/Direction/DirectionLegOriginDestinationView.swift @@ -0,0 +1,42 @@ +// +// DirectionLegOriginDestinationView.swift +// OTPKit +// +// Created by Hilmy Veradin on 08/08/24. +// + +import SwiftUI + +struct DirectionLegOriginDestinationView: View { + private let title: String + private let description: String + + init(title: String, description: String) { + self.title = title + self.description = description + } + + var body: some View { + HStack(spacing: 24) { + Image(systemName: "mappin") + .font(.system(size: 24)) + .padding(8) + .background(Color.red.opacity(0.8)) + .clipShape(Circle()) + .frame(width: 40) + .padding(.bottom, 16) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.title3) + .fontWeight(.bold) + Text(description) + .foregroundStyle(.gray) + } + } + } +} + +#Preview { + DirectionLegOriginDestinationView(title: "Origin", description: "Unknown Location") +} diff --git a/Sources/OTPKit/Features/TripPlanner/Direction/DirectionLegUnknownView.swift b/Sources/OTPKit/Features/TripPlanner/Direction/DirectionLegUnknownView.swift new file mode 100644 index 0000000..1d44c1b --- /dev/null +++ b/Sources/OTPKit/Features/TripPlanner/Direction/DirectionLegUnknownView.swift @@ -0,0 +1,37 @@ +// +// DirectionLegUnknownView.swift +// OTPKit +// +// Created by Hilmy Veradin on 08/08/24. +// + +import SwiftUI + +struct DirectionLegUnknownView: View { + let leg: Leg + + var body: some View { + Image(systemName: "questionmark.circle") + .font(.system(size: 24)) + .padding() + .frame(width: 40) + + VStack(alignment: .leading, spacing: 4) { + Text("To \(leg.to.name)") + .font(.title3) + .fontWeight(.bold) + .fixedSize(horizontal: false, vertical: true) + Text( + Formatters.formatDistance(Int(leg.distance)) + + ", about " + + Formatters.formatTimeDuration(leg.duration) + ) + .foregroundStyle(.gray) + .fixedSize(horizontal: false, vertical: true) + } + } +} + +#Preview { + DirectionLegUnknownView(leg: PreviewHelpers.buildLeg()) +} diff --git a/Sources/OTPKit/Features/TripPlanner/Direction/DirectionLegVehicleView.swift b/Sources/OTPKit/Features/TripPlanner/Direction/DirectionLegVehicleView.swift new file mode 100644 index 0000000..b3c7a75 --- /dev/null +++ b/Sources/OTPKit/Features/TripPlanner/Direction/DirectionLegVehicleView.swift @@ -0,0 +1,51 @@ +// +// DirectionLegVehicleView.swift +// OTPKit +// +// Created by Hilmy Veradin on 08/08/24. +// + +import SwiftUI + +struct DirectionLegVehicleView: View { + let leg: Leg + + var body: some View { + HStack(spacing: 24) { + Text(leg.route ?? "") + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(backgroundColor) + .foregroundStyle(.foreground) + .font(.caption) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .frame(width: 40) + + VStack(alignment: .leading, spacing: 4) { + Text("Board to \(leg.agencyName ?? "")") + .font(.title3) + .fontWeight(.bold) + .fixedSize(horizontal: false, vertical: true) + Text("\(leg.headsign ?? "")") + .foregroundStyle(.gray) + .fixedSize(horizontal: false, vertical: true) + Text("Scheduled at \(Formatters.formatDateToTime(leg.startTime))") + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private var backgroundColor: Color { + if leg.mode == "TRAM" { + Color.blue + } else if leg.mode == "BUS" { + Color.green + } else { + Color.pink + } + } +} + +#Preview { + DirectionLegVehicleView(leg: PreviewHelpers.buildLeg()) +} diff --git a/Sources/OTPKit/Features/TripPlanner/Direction/DirectionLegWalkView.swift b/Sources/OTPKit/Features/TripPlanner/Direction/DirectionLegWalkView.swift new file mode 100644 index 0000000..f4bf184 --- /dev/null +++ b/Sources/OTPKit/Features/TripPlanner/Direction/DirectionLegWalkView.swift @@ -0,0 +1,39 @@ +// +// DirectionLegWalkView.swift +// OTPKit +// +// Created by Hilmy Veradin on 08/08/24. +// + +import SwiftUI + +struct DirectionLegWalkView: View { + let leg: Leg + + var body: some View { + HStack(spacing: 24) { + Image(systemName: "figure.walk") + .font(.system(size: 24)) + .padding() + .frame(width: 40) + + VStack(alignment: .leading, spacing: 4) { + Text("Walk to \(leg.to.name)") + .font(.title3) + .fontWeight(.bold) + .fixedSize(horizontal: false, vertical: true) + Text( + Formatters.formatDistance(Int(leg.distance)) + + ", about " + + Formatters.formatTimeDuration(leg.duration) + ) + .foregroundStyle(.gray) + .fixedSize(horizontal: false, vertical: true) + } + } + } +} + +#Preview { + DirectionLegWalkView(leg: PreviewHelpers.buildLeg()) +} diff --git a/Sources/OTPKit/Features/TripPlanner/Direction/DirectionSheetView.swift b/Sources/OTPKit/Features/TripPlanner/Direction/DirectionSheetView.swift new file mode 100644 index 0000000..8ff78ee --- /dev/null +++ b/Sources/OTPKit/Features/TripPlanner/Direction/DirectionSheetView.swift @@ -0,0 +1,112 @@ +import MapKit +import SwiftUI + +public struct DirectionSheetView: View { + @EnvironmentObject private var tripPlanner: TripPlannerService + @Environment(\.dismiss) private var dismiss + @Binding var sheetDetent: PresentationDetent + @State private var scrollToItem: String? + + public init(sheetDetent: Binding) { + _sheetDetent = sheetDetent + } + + private func generateLegView(leg: Leg) -> some View { + Group { + switch leg.mode { + case "BUS", "TRAM": + DirectionLegVehicleView(leg: leg) + case "WALK": + DirectionLegWalkView(leg: leg) + default: + DirectionLegUnknownView(leg: leg) + } + } + } + + private func handleTap(coordinate: CLLocationCoordinate2D, itemId: String) { + let placemark = MKPlacemark(coordinate: coordinate) + let item = MKMapItem(placemark: placemark) + tripPlanner.changeMapCamera(item) + scrollToItem = itemId + sheetDetent = .fraction(0.2) + } + + public var body: some View { + ScrollViewReader { proxy in + List { + Section { + PageHeaderView(text: "\(tripPlanner.destinationName)") { + tripPlanner.resetTripPlanner() + dismiss() + } + .frame(height: 50) + .listRowInsets(EdgeInsets()) + } + + if let itinerary = tripPlanner.selectedItinerary { + Section { + createOriginView(itinerary: itinerary) + createLegsView(itinerary: itinerary) + createDestinationView(itinerary: itinerary) + } + } + } + .padding(.horizontal, 12) + .padding(.top, 16) + .listStyle(PlainListStyle()) + .onChange(of: scrollToItem) { + if let itemId = scrollToItem { + withAnimation { + proxy.scrollTo(itemId, anchor: .top) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + scrollToItem = nil + } + } + } + } + } + + private func createOriginView(itinerary _: Itinerary) -> some View { + DirectionLegOriginDestinationView( + title: "Origin", + description: tripPlanner.originName + ) + .id("item-0") + .onTapGesture { + if let originCoordinate = tripPlanner.originCoordinate { + handleTap(coordinate: originCoordinate, itemId: "item-0") + } + } + } + + private func createLegsView(itinerary: Itinerary) -> some View { + ForEach(Array(itinerary.legs.enumerated()), id: \.offset) { index, leg in + generateLegView(leg: leg) + .id("item-\(index + 1)") + .onTapGesture { + let coordinate = CLLocationCoordinate2D(latitude: leg.to.lat, longitude: leg.to.lon) + handleTap(coordinate: coordinate, itemId: "item-\(index + 1)") + } + } + } + + private func createDestinationView(itinerary: Itinerary) -> some View { + DirectionLegOriginDestinationView( + title: "Destination", + description: tripPlanner.destinationName + ) + .id("item-\(itinerary.legs.count + 1)") + .onTapGesture { + if let destinationCoordinate = tripPlanner.destinationCoordinate { + handleTap(coordinate: destinationCoordinate, itemId: "item-\(itinerary.legs.count + 1)") + } + } + } +} + +#Preview { + DirectionSheetView(sheetDetent: .constant(.fraction(0.2))) + .environmentObject(PreviewHelpers.buildTripPlannerService()) +} diff --git a/Sources/OTPKit/Features/TripPlanner/SelectItenerary/ItineraryLegUnknownView.swift b/Sources/OTPKit/Features/TripPlanner/SelectItenerary/ItineraryLegUnknownView.swift new file mode 100644 index 0000000..a3867e7 --- /dev/null +++ b/Sources/OTPKit/Features/TripPlanner/SelectItenerary/ItineraryLegUnknownView.swift @@ -0,0 +1,29 @@ +// +// ItineraryLegUnknownView.swift +// OTPKit +// +// Created by Aaron Brethorst on 8/5/24. +// + +import SwiftUI + +/// Represents an itinerary leg that uses an unknown method of conveyance. +struct ItineraryLegUnknownView: View { + let leg: Leg + + var body: some View { + HStack(spacing: 4) { + Text("\(leg.mode): \(Formatters.formatTimeDuration(leg.duration))") + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.gray.opacity(0.2)) + .foregroundStyle(.gray) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(height: 40) + } +} + +#Preview { + ItineraryLegUnknownView(leg: PreviewHelpers.buildLeg()) +} diff --git a/Sources/OTPKit/Features/TripPlanner/SelectItenerary/ItineraryLegVehicleView.swift b/Sources/OTPKit/Features/TripPlanner/SelectItenerary/ItineraryLegVehicleView.swift new file mode 100644 index 0000000..fddc50e --- /dev/null +++ b/Sources/OTPKit/Features/TripPlanner/SelectItenerary/ItineraryLegVehicleView.swift @@ -0,0 +1,52 @@ +// +// ItineraryLegVehicleView.swift +// OTPKit +// +// Created by Aaron Brethorst on 8/5/24. +// + +import SwiftUI + +/// Represents an itinerary leg that uses a vehicular method of conveyance. +struct ItineraryLegVehicleView: View { + let leg: Leg + + var body: some View { + HStack(spacing: 4) { + Text(leg.route ?? "") + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(backgroundColor) + .foregroundStyle(.foreground) + .font(.caption) + .clipShape(RoundedRectangle(cornerRadius: 4)) + + Image(systemName: imageName) + .foregroundStyle(.foreground) + }.frame(height: 40) + } + + private var imageName: String { + if leg.mode == "TRAM" { + "tram" + } else if leg.mode == "BUS" { + "bus" + } else { + "" + } + } + + private var backgroundColor: Color { + if leg.mode == "TRAM" { + Color.blue + } else if leg.mode == "BUS" { + Color.green + } else { + Color.pink + } + } +} + +#Preview { + ItineraryLegVehicleView(leg: PreviewHelpers.buildLeg()) +} diff --git a/Sources/OTPKit/Features/TripPlanner/SelectItenerary/ItineraryLegWalkView.swift b/Sources/OTPKit/Features/TripPlanner/SelectItenerary/ItineraryLegWalkView.swift new file mode 100644 index 0000000..3217183 --- /dev/null +++ b/Sources/OTPKit/Features/TripPlanner/SelectItenerary/ItineraryLegWalkView.swift @@ -0,0 +1,30 @@ +// +// ItineraryLegWalkView.swift +// OTPKit +// +// Created by Aaron Brethorst on 8/5/24. +// + +import SwiftUI + +/// Represents an itinerary leg that uses a walking method of conveyance. +struct ItineraryLegWalkView: View { + let leg: Leg + + var body: some View { + HStack(spacing: 4) { + Image(systemName: "figure.walk") + Text(Formatters.formatTimeDuration(leg.duration)) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.gray.opacity(0.2)) + .foregroundStyle(.gray) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(height: 40) + } +} + +#Preview { + ItineraryLegWalkView(leg: PreviewHelpers.buildLeg()) +} diff --git a/Sources/OTPKit/Features/TripPlanner/SelectItenerary/TripPlannerSheetView.swift b/Sources/OTPKit/Features/TripPlanner/SelectItenerary/TripPlannerSheetView.swift new file mode 100644 index 0000000..c7066ea --- /dev/null +++ b/Sources/OTPKit/Features/TripPlanner/SelectItenerary/TripPlannerSheetView.swift @@ -0,0 +1,103 @@ +// +// TripPlannerSheetView.swift +// OTPKit +// +// Created by Hilmy Veradin on 25/07/24. +// + +import SwiftUI + +public struct TripPlannerSheetView: View { + @EnvironmentObject private var tripPlanner: TripPlannerService + @Environment(\.dismiss) var dismiss + + public init() {} + + private func generateLegView(leg: Leg) -> some View { + Group { + switch leg.mode { + case "BUS", "TRAM": + ItineraryLegVehicleView(leg: leg) + case "WALK": + ItineraryLegWalkView(leg: leg) + default: + ItineraryLegUnknownView(leg: leg) + } + } + } + + public var body: some View { + VStack { + if let itineraries = tripPlanner.planResponse?.plan?.itineraries { + List(itineraries, id: \.self) { itinerary in + Button(action: { + tripPlanner.selectedItinerary = itinerary + tripPlanner.planResponse = nil + dismiss() + }, label: { + HStack(spacing: 20) { + VStack(alignment: .leading) { + Text(Formatters.formatTimeDuration(itinerary.duration)) + .font(.title) + .fontWeight(.bold) + .foregroundStyle(.foreground) + Text("Bus scheduled at \(Formatters.formatDateToTime(itinerary.startTime))") + .foregroundStyle(.gray) + + FlowLayout { + ForEach(Array(zip(itinerary.legs.indices, itinerary.legs)), id: \.1) { index, leg in + + generateLegView(leg: leg) + + if index < itinerary.legs.count - 1 { + VStack { + Image(systemName: "chevron.right.circle.fill") + .frame(width: 8, height: 16) + }.frame(height: 40) + } + } + } + } + + Button(action: { + tripPlanner.selectedItinerary = itinerary + tripPlanner.planResponse = nil + tripPlanner.adjustOriginDestinationCamera() + dismiss() + }, label: { + Text("Preview") + .padding(30) + .background(Color.green) + .foregroundStyle(.foreground) + .fontWeight(.bold) + .clipShape(RoundedRectangle(cornerRadius: 12)) + }) + } + + }) + .foregroundStyle(.foreground) + } + } else { + Text("Can't find trip planner. Please try another pin point") + } + + Button(action: { + tripPlanner.resetTripPlanner() + dismiss() + }, label: { + Text("Cancel") + .frame(maxWidth: .infinity) + .padding() + .background(Color.gray) + .foregroundStyle(.foreground) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal, 16) + }) + } + } +} + +#Preview { + TripPlannerSheetView() + .environmentObject(PreviewHelpers.buildTripPlannerService()) +} diff --git a/Sources/OTPKit/Features/TripPlanner/TripPlannerView.swift b/Sources/OTPKit/Features/TripPlanner/TripPlannerView.swift new file mode 100644 index 0000000..4ac397e --- /dev/null +++ b/Sources/OTPKit/Features/TripPlanner/TripPlannerView.swift @@ -0,0 +1,50 @@ +// +// TripPlannerView.swift +// OTPKit +// +// Created by Hilmy Veradin on 30/07/24. +// + +import SwiftUI + +public struct TripPlannerView: View { + @EnvironmentObject private var tripPlanner: TripPlannerService + + public init(text: String) { + self.text = text + } + + private let text: String + + public var body: some View { + VStack(alignment: .leading, spacing: 0) { + Text(text) + .fontWeight(.semibold) + .padding(16) + HStack { + Button(action: { + tripPlanner.resetTripPlanner() + }, label: { + Text("Cancel") + .frame(maxWidth: .infinity) + }) + .buttonStyle(BorderedButtonStyle()) + + Button(action: { + tripPlanner.isStepsViewPresented = true + }, label: { + Text("Start") + .frame(maxWidth: .infinity) + }) + .buttonStyle(BorderedProminentButtonStyle()) + } + .padding() + } + .background(.thickMaterial) + } +} + +#Preview { + TripPlannerView(text: "43 minutes, departs at 4:15 PM") + .environmentObject(PreviewHelpers.buildTripPlannerService()) +} diff --git a/Sources/OTPKit/Miscellaneous/FlowLayout.swift b/Sources/OTPKit/Miscellaneous/FlowLayout.swift new file mode 100644 index 0000000..9944568 --- /dev/null +++ b/Sources/OTPKit/Miscellaneous/FlowLayout.swift @@ -0,0 +1,45 @@ +// +// FlowLayout.swift +// OTPKit +// +// Created by Hilmy Veradin on 04/08/24. +// + +import SwiftUI + +/// Extension to make adaptive layout +struct FlowLayout: Layout { + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) -> CGSize { + let sizes = subviews.map { $0.sizeThatFits(.unspecified) } + return layout(sizes: sizes, proposal: proposal).size + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) { + let sizes = subviews.map { $0.sizeThatFits(.unspecified) } + let offsets = layout(sizes: sizes, proposal: proposal).offsets + + for (offset, subview) in zip(offsets, subviews) { + subview.place(at: CGPoint(x: bounds.minX + offset.x, y: bounds.minY + offset.y), proposal: .unspecified) + } + } + + private func layout(sizes: [CGSize], proposal: ProposedViewSize) -> (offsets: [CGPoint], size: CGSize) { + let verticalSpacing: CGFloat = 4 + let horizontalSpacing: CGFloat = 8 + var result: [CGPoint] = [] + var currentPosition: CGPoint = .zero + var maxY: CGFloat = 0 + + for size in sizes { + if currentPosition.x + size.width > (proposal.width ?? .infinity) { + currentPosition.x = 0 + currentPosition.y = maxY + verticalSpacing + } + result.append(currentPosition) + currentPosition.x += size.width + horizontalSpacing + maxY = max(maxY, currentPosition.y + size.height) + } + + return (result, CGSize(width: proposal.width ?? .infinity, height: maxY)) + } +} diff --git a/Sources/OTPKit/Miscellaneous/Formatters.swift b/Sources/OTPKit/Miscellaneous/Formatters.swift new file mode 100644 index 0000000..714526b --- /dev/null +++ b/Sources/OTPKit/Miscellaneous/Formatters.swift @@ -0,0 +1,49 @@ +// +// Formatters.swift +// OTPKit +// +// Created by Aaron Brethorst on 8/5/24. +// + +import SwiftUI + +/// Reusable, commonly-used formatters for dates, durations, and distance. +class Formatters { + static func formatTimeDuration(_ duration: Int) -> String { + if duration < 60 { + return "\(duration) second\(duration > 1 ? "s" : "")" + } + + let (hours, minutes) = hoursAndMinutesFrom(seconds: duration) + + if hours == 0 { + return String(format: "%d min", minutes) + } + + return String(format: "%d hr %d min", hours, minutes) + } + + static func hoursAndMinutesFrom(seconds: Int) -> (hours: Int, minutes: Int) { + let hours = seconds / 3600 + let remainingSeconds = seconds % 3600 + let minutes = remainingSeconds / 60 + return (hours, minutes) + } + + static func formatDistance(_ distance: Int) -> String { + if distance < 1000 { + return "\(distance) meters" + } else { + let miles = Double(distance) / 1609.34 + return String(format: "%.1f miles", miles) + } + } + + static func formatDateToTime(_ date: Date, locale: Locale = .current) -> String { + let formatter = DateFormatter() + formatter.locale = locale + formatter.dateStyle = .none + formatter.timeStyle = .short + return formatter.string(from: date) + } +} diff --git a/Sources/OTPKit/Miscellaneous/PresentationManager.swift b/Sources/OTPKit/Miscellaneous/PresentationManager.swift new file mode 100644 index 0000000..001436f --- /dev/null +++ b/Sources/OTPKit/Miscellaneous/PresentationManager.swift @@ -0,0 +1,25 @@ +// +// PresentationManager.swift +// OTPKit +// +// Created by Aaron Brethorst on 8/11/24. +// + +import SwiftUI + +/// Manages the presentation state of dependent modal sheets. +/// +/// In other words, instead of having to maintain several independent boolean values for determining which +/// sheet is currently visible, you can use `PresentationManager` to DRY up and orchestrate your +/// modal sheet state. +class PresentationManager: ObservableObject { + @Published var activePresentation: PresentationType? + + func present(_ presentationType: PresentationType) { + activePresentation = presentationType + } + + func dismiss() { + activePresentation = nil + } +} diff --git a/Sources/OTPKit/Miscellaneous/Utilities.swift b/Sources/OTPKit/Miscellaneous/Utilities.swift new file mode 100644 index 0000000..c97ae8c --- /dev/null +++ b/Sources/OTPKit/Miscellaneous/Utilities.swift @@ -0,0 +1,10 @@ +// +// Utilities.swift +// OTPKit +// +// Created by Aaron Brethorst on 7/31/24. +// + +import Foundation + +typealias VoidBlock = () -> Void diff --git a/Sources/OTPKit/Models/Helpers/DebugDescriptionBuilder.swift b/Sources/OTPKit/Models/Helpers/DebugDescriptionBuilder.swift new file mode 100644 index 0000000..23da776 --- /dev/null +++ b/Sources/OTPKit/Models/Helpers/DebugDescriptionBuilder.swift @@ -0,0 +1,45 @@ +/* + * Copyright (C) Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/** + A simple way to construct a `debugDescription` property for an object. + + Here's how you might use it: + + public override var debugDescription: String { + var descriptionBuilder = DebugDescriptionBuilder(baseDescription: super.debugDescription) + descriptionBuilder.add(key: "id", value: id) + return descriptionBuilder.description + } + */ +public struct DebugDescriptionBuilder { + let baseDescription: String + var properties = [String: Any]() + + public init(baseDescription: String) { + self.baseDescription = baseDescription + } + + public mutating func add(key: String, value: Any?) { + properties[key] = value ?? "(nil)" + } + + public var description: String { + "\(baseDescription) \(properties)" + } +} diff --git a/Sources/OTPKit/Models/MapExtension/MarkerItem.swift b/Sources/OTPKit/Models/MapExtension/MarkerItem.swift new file mode 100644 index 0000000..1004e41 --- /dev/null +++ b/Sources/OTPKit/Models/MapExtension/MarkerItem.swift @@ -0,0 +1,15 @@ +// +// MarkerItem.swift +// OTPKit +// +// Created by Hilmy Veradin on 16/07/24. +// + +import Foundation +import MapKit + +/// Make the `MKMapItem` identifiable and hashable +public struct MarkerItem: Identifiable, Hashable { + public let id: UUID = .init() + public let item: MKMapItem +} diff --git a/Sources/OTPKit/Models/OriginDestination/OriginDestinationState.swift b/Sources/OTPKit/Models/OriginDestination/OriginDestinationState.swift new file mode 100644 index 0000000..385e2c4 --- /dev/null +++ b/Sources/OTPKit/Models/OriginDestination/OriginDestinationState.swift @@ -0,0 +1,17 @@ +// +// OriginDestinationState.swift +// OTPKit +// +// Created by Hilmy Veradin on 18/07/24. +// + +import Foundation + +/// Responsible for managing origin or destination state +/// - Enums: +/// - origin: This manage origin state of the trip planner +/// - destination: This manage destination state of the trip planner +public enum OriginDestinationState { + case origin + case destination +} diff --git a/Sources/OTPKit/Models/Polyline/Polyline.swift b/Sources/OTPKit/Models/Polyline/Polyline.swift new file mode 100644 index 0000000..e19352e --- /dev/null +++ b/Sources/OTPKit/Models/Polyline/Polyline.swift @@ -0,0 +1,387 @@ +// Polyline.swift +// +// Copyright (c) 2015 Raphaël Mor +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// swiftlint:disable line_length + +import CoreLocation +import Foundation +#if canImport(MapKit) && !os(watchOS) + import MapKit +#endif + +// MARK: - Public Classes - + +/// This class can be used for : +/// +/// - Encoding an [CLLocation] or a [CLLocationCoordinate2D] to a polyline String +/// - Decoding a polyline String to an [CLLocation] or a [CLLocationCoordinate2D] +/// - Encoding / Decoding associated levels +/// +/// it is aims to produce the same results as google's iOS sdk not as the online +/// tool which is fuzzy when it comes to rounding values +/// +/// it is based on google's algorithm that can be found here : +/// +/// :see: https://developers.google.com/maps/documentation/utilities/polylinealgorithm +public struct Polyline { + /// The array of coordinates (nil if polyline cannot be decoded) + public let coordinates: [CLLocationCoordinate2D]? + /// The encoded polyline + public let encodedPolyline: String + + /// The array of levels (nil if cannot be decoded, or is not provided) + public let levels: [UInt32]? + /// The encoded levels (nil if cannot be encoded, or is not provided) + public let encodedLevels: String? + + /// The array of location (computed from coordinates) + #if canImport(CoreLocation) + public var locations: [CLLocation]? { + coordinates.map(toLocations) + } + #endif + + #if canImport(MapKit) && !os(watchOS) + /// Convert polyline to MKPolyline to use with MapKit (nil if polyline cannot be decoded) + @available(tvOS 9.2, *) + public var mkPolyline: MKPolyline? { + guard let coordinates else { return nil } + let mkPolyline = MKPolyline(coordinates: coordinates, count: coordinates.count) + return mkPolyline + } + #endif + + // MARK: - Public Methods - + + /// This designated initializer encodes a `[CLLocationCoordinate2D]` + /// + /// - parameter coordinates: The `Array` of `CLLocationCoordinate2D`s (that is, `CLLocationCoordinate2D`s) that you want to encode + /// - parameter levels: The optional `Array` of levels that you want to encode (default: `nil`) + /// - parameter precision: The precision used for encoding (default: `1e5`) + public init(coordinates: [CLLocationCoordinate2D], levels: [UInt32]? = nil, precision: Double = 1e5) { + self.coordinates = coordinates + self.levels = levels + + encodedPolyline = encodeCoordinates(coordinates, precision: precision) + + encodedLevels = levels.map(encodeLevels) + } + + /// This designated initializer decodes a polyline `String` + /// + /// - parameter encodedPolyline: The polyline that you want to decode + /// - parameter encodedLevels: The levels that you want to decode (default: `nil`) + /// - parameter precision: The precision used for decoding (default: `1e5`) + public init(encodedPolyline: String, encodedLevels: String? = nil, precision: Double = 1e5) { + self.encodedPolyline = encodedPolyline + self.encodedLevels = encodedLevels + + coordinates = decodePolyline(encodedPolyline, precision: precision) + + levels = self.encodedLevels.flatMap(decodeLevels) + } + + #if canImport(CoreLocation) + /// This init encodes a `[CLLocation]` + /// + /// - parameter locations: The `Array` of `CLLocation` that you want to encode + /// - parameter levels: The optional array of levels that you want to encode (default: `nil`) + /// - parameter precision: The precision used for encoding (default: `1e5`) + public init(locations: [CLLocation], levels: [UInt32]? = nil, precision: Double = 1e5) { + let coordinates = toCoordinates(locations) + self.init(coordinates: coordinates, levels: levels, precision: precision) + } + #endif +} + +// MARK: - Public Functions - + +/// This function encodes an `[CLLocationCoordinate2D]` to a `String` +/// +/// - parameter coordinates: The `Array` of `CLLocationCoordinate2D`s (that is, `CLLocationCoordinate2D`s) that you want to encode +/// - parameter precision: The precision used to encode coordinates (default: `1e5`) +/// +/// - returns: A `String` representing the encoded Polyline +public func encodeCoordinates(_ coordinates: [CLLocationCoordinate2D], precision: Double = 1e5) -> String { + var previousCoordinate = IntegerCoordinates(0, 0) + var encodedPolyline = "" + + for coordinate in coordinates { + let intLatitude = Int(round(coordinate.latitude * precision)) + let intLongitude = Int(round(coordinate.longitude * precision)) + + let coordinatesDifference = (intLatitude - previousCoordinate.latitude, intLongitude - previousCoordinate.longitude) + + encodedPolyline += encodeCoordinate(coordinatesDifference) + + previousCoordinate = (intLatitude, intLongitude) + } + + return encodedPolyline +} + +#if canImport(CoreLocation) + /// This function encodes an `[CLLocation]` to a `String` + /// + /// - parameter coordinates: The `Array` of `CLLocation` that you want to encode + /// - parameter precision: The precision used to encode locations (default: `1e5`) + /// + /// - returns: A `String` representing the encoded Polyline + public func encodeLocations(_ locations: [CLLocation], precision: Double = 1e5) -> String { + encodeCoordinates(toCoordinates(locations), precision: precision) + } +#endif + +/// This function encodes an `[UInt32]` to a `String` +/// +/// - parameter levels: The `Array` of `UInt32` levels that you want to encode +/// +/// - returns: A `String` representing the encoded Levels +public func encodeLevels(_ levels: [UInt32]) -> String { + levels.reduce("") { + $0 + encodeLevel($1) + } +} + +/// This function decodes a `String` to a `[CLLocationCoordinate2D]?` +/// +/// - parameter encodedPolyline: `String` representing the encoded Polyline +/// - parameter precision: The precision used to decode coordinates (default: `1e5`) +/// +/// - returns: A `[CLLocationCoordinate2D]` representing the decoded polyline if valid, `nil` otherwise +public func decodePolyline(_ encodedPolyline: String, precision: Double = 1e5) -> [CLLocationCoordinate2D]? { + let data = encodedPolyline.data(using: .utf8)! + return data.withUnsafeBytes { byteArray -> [CLLocationCoordinate2D]? in + let length = data.count + var position = 0 + + var decodedCoordinates = [CLLocationCoordinate2D]() + + var lat = 0.0 + var lon = 0.0 + + while position < length { + do { + let resultingLat = try decodeSingleCoordinate(byteArray: byteArray, length: length, position: &position, precision: precision) + lat += resultingLat + + let resultingLon = try decodeSingleCoordinate(byteArray: byteArray, length: length, position: &position, precision: precision) + lon += resultingLon + } catch { + return nil + } + + decodedCoordinates.append(CLLocationCoordinate2D(latitude: lat, longitude: lon)) + } + + return decodedCoordinates + } +} + +#if canImport(CoreLocation) + /// This function decodes a String to a [CLLocation]? + /// + /// - parameter encodedPolyline: String representing the encoded Polyline + /// - parameter precision: The precision used to decode locations (default: 1e5) + /// + /// - returns: A [CLLocation] representing the decoded polyline if valid, nil otherwise + public func decodePolyline(_ encodedPolyline: String, precision: Double = 1e5) -> [CLLocation]? { + decodePolyline(encodedPolyline, precision: precision).map(toLocations) + } +#endif + +/// This function decodes a `String` to an `[UInt32]` +/// +/// - parameter encodedLevels: The `String` representing the levels to decode +/// +/// - returns: A `[UInt32]` representing the decoded Levels if the `String` is valid, `nil` otherwise +public func decodeLevels(_ encodedLevels: String) -> [UInt32]? { + var remainingLevels = encodedLevels.unicodeScalars + var decodedLevels = [UInt32]() + + while remainingLevels.count > 0 { + do { + let chunk = try extractNextChunk(&remainingLevels) + let level = decodeLevel(chunk) + decodedLevels.append(level) + } catch { + return nil + } + } + + return decodedLevels +} + +// MARK: - Private - + +// MARK: Encode Coordinate + +private func encodeCoordinate(_ locationCoordinate: IntegerCoordinates) -> String { + let latitudeString = encodeSingleComponent(locationCoordinate.latitude) + let longitudeString = encodeSingleComponent(locationCoordinate.longitude) + + return latitudeString + longitudeString +} + +private func encodeSingleComponent(_ value: Int) -> String { + var intValue = value + + if intValue < 0 { + intValue = intValue << 1 + intValue = ~intValue + } else { + intValue = intValue << 1 + } + + return encodeFiveBitComponents(intValue) +} + +// MARK: Encode Levels + +private func encodeLevel(_ level: UInt32) -> String { + encodeFiveBitComponents(Int(level)) +} + +private func encodeFiveBitComponents(_ value: Int) -> String { + var remainingComponents = value + + var fiveBitComponent = 0 + var returnString = String() + + repeat { + fiveBitComponent = remainingComponents & 0x1F + + if remainingComponents >= 0x20 { + fiveBitComponent |= 0x20 + } + + fiveBitComponent += 63 + + let char = UnicodeScalar(fiveBitComponent)! + returnString.append(String(char)) + remainingComponents = remainingComponents >> 5 + } while remainingComponents != 0 + + return returnString +} + +// MARK: Decode Coordinate + +// We use a byte array (UnsafePointer) here for performance reasons. Check with swift 2 if we can +// go back to using [Int8] +private func decodeSingleCoordinate(byteArray: UnsafeRawBufferPointer, length: Int, position: inout Int, precision: Double = 1e5) throws -> Double { + guard position < length else { throw PolylineError.singleCoordinateDecodingError } + + let bitMask = Int8(0x1F) + + var coordinate: Int32 = 0 + + var currentChar: Int8 + var componentCounter: Int32 = 0 + var component: Int32 = 0 + + repeat { + currentChar = Int8(byteArray[position]) - 63 + component = Int32(currentChar & bitMask) + coordinate |= (component << (5 * componentCounter)) + position += 1 + componentCounter += 1 + } while ((currentChar & 0x20) == 0x20) && (position < length) && (componentCounter < 6) + + if componentCounter == 6, (currentChar & 0x20) == 0x20 { + throw PolylineError.singleCoordinateDecodingError + } + + if (coordinate & 0x01) == 0x01 { + coordinate = ~(coordinate >> 1) + } else { + coordinate = coordinate >> 1 + } + + return Double(coordinate) / precision +} + +// MARK: Decode Levels + +private func extractNextChunk(_ encodedString: inout String.UnicodeScalarView) throws -> String { + var currentIndex = encodedString.startIndex + + while currentIndex != encodedString.endIndex { + let currentCharacterValue = Int32(encodedString[currentIndex].value) + if isSeparator(currentCharacterValue) { + let extractedScalars = encodedString[encodedString.startIndex ... currentIndex] + encodedString = String.UnicodeScalarView(encodedString[encodedString.index(after: currentIndex) ..< encodedString.endIndex]) + + return String(extractedScalars) + } + + currentIndex = encodedString.index(after: currentIndex) + } + + throw PolylineError.chunkExtractingError +} + +private func decodeLevel(_ encodedLevel: String) -> UInt32 { + let scalarArray = [] + encodedLevel.unicodeScalars + + return UInt32(agregateScalarArray(scalarArray)) +} + +private func agregateScalarArray(_ scalars: [UnicodeScalar]) -> Int32 { + let lastValue = Int32(scalars.last!.value) + + let fiveBitComponents: [Int32] = scalars.map { scalar in + let value = Int32(scalar.value) + if value != lastValue { + return (value - 63) ^ 0x20 + } else { + return value - 63 + } + } + + return Array(fiveBitComponents.reversed()).reduce(0) { ($0 << 5) | $1 } +} + +// MARK: Utilities + +enum PolylineError: Error { + case singleCoordinateDecodingError + case chunkExtractingError +} + +private func toCoordinates(_ locations: [CLLocation]) -> [CLLocationCoordinate2D] { + locations.map { location in location.coordinate } +} + +private func toLocations(_ coordinates: [CLLocationCoordinate2D]) -> [CLLocation] { + coordinates.map { coordinate in + CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude) + } +} + +private func isSeparator(_ value: Int32) -> Bool { + (value - 63) & 0x20 != 0x20 +} + +private typealias IntegerCoordinates = (latitude: Int, longitude: Int) + +// swiftlint:enable line_length diff --git a/Sources/OTPKit/Models/TripPlanner/ErrorResponse.swift b/Sources/OTPKit/Models/TripPlanner/ErrorResponse.swift new file mode 100644 index 0000000..7f7e9c4 --- /dev/null +++ b/Sources/OTPKit/Models/TripPlanner/ErrorResponse.swift @@ -0,0 +1,28 @@ +/* + * Copyright (C) Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// `ErrorResponse` represents an error structure used across the application to handle and represent +/// OTP errors uniformly. +public struct ErrorResponse: Codable, Hashable { + /// A unique identifier for the error. + public let id: Int + + /// A descriptive message associated with the error, providing more detailed information about what went wrong. + /// This message can be presented to the user or used in debugging to provide context about the error. + public let message: String +} diff --git a/Sources/OTPKit/Models/TripPlanner/Itinerary.swift b/Sources/OTPKit/Models/TripPlanner/Itinerary.swift new file mode 100644 index 0000000..34fc5ac --- /dev/null +++ b/Sources/OTPKit/Models/TripPlanner/Itinerary.swift @@ -0,0 +1,64 @@ +/* + * Copyright (C) Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// Represents a travel itinerary with detailed segments and timings. +public struct Itinerary: Codable, Hashable { + /// Total duration of the itinerary in seconds. + public let duration: Int + + /// Start time of the itinerary. + public let startTime: Date + + /// End time of the itinerary. + public let endTime: Date + + /// Total walking time in minutes within the itinerary. + public let walkTime: Int + + /// Total transit time in minutes within the itinerary. + public let transitTime: Int + + /// Total waiting time in minutes within the itinerary. + public let waitingTime: Int + + /// Total walking distance in meters within the itinerary. + public let walkDistance: Double + + /// Indicates whether the walking distance limit was exceeded. + public let walkLimitExceeded: Bool + + /// Total elevation lost in meters within the itinerary. + public let elevationLost: Double + + /// Total elevation gained in meters within the itinerary. + public let elevationGained: Double + + /// Number of transfers within the itinerary. + public let transfers: Int + + /// Array of `Leg` objects representing individual segments of the itinerary. + public let legs: [Leg] + + public var summary: String { + // TODO: localize this! + let time = Formatters.formatDateToTime(startTime) + let formattedDuration = Formatters.formatTimeDuration(duration) + // return something like "43 minutes, departs at X:YY PM" + return "Departs at \(time); duration: \(formattedDuration)" + } +} diff --git a/Sources/OTPKit/Models/TripPlanner/Leg.swift b/Sources/OTPKit/Models/TripPlanner/Leg.swift new file mode 100644 index 0000000..0f039b5 --- /dev/null +++ b/Sources/OTPKit/Models/TripPlanner/Leg.swift @@ -0,0 +1,78 @@ +/* + * Copyright (C) Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import CoreLocation +import Foundation + +// swiftlint:disable identifier_name + +/// Represents a single segment or leg of a travel itinerary. +public struct Leg: Codable, Hashable { + /// Start time of the leg. + public let startTime: Date + + /// End time of the leg. + public let endTime: Date + + /// Mode of transportation used in this leg (e.g., "BUS", "TRAIN"). + public let mode: String + + /// Optional route identifier for this leg. + public let route: String? + + /// Optional name of the transportation agency for this leg. + public let agencyName: String? + + /// Starting point of the leg. + public let from: Place + + /// Ending point of the leg. + public let to: Place + + /// A container for the polyline of this leg. + public let legGeometry: LegGeometry + + /// Returns an array of `CLLocationCoordinate2D`s representing the geometry of this `Leg`. + public func decodePolyline() -> [CLLocationCoordinate2D]? { + OTPKit.decodePolyline(legGeometry.points) + } + + /// Distance covered in this leg, in meters. + public let distance: Double + + /// Optional flag indicating whether this leg involves transit. + public let transitLeg: Bool? + + /// Duration of the leg in seconds. + public let duration: Int + + /// Optional flag indicating if the leg details are based on real-time data. + public let realTime: Bool? + + /// Optional list of street names traversed in this leg. + public let streetNames: [String]? + + /// Optional flag indicating whether the leg involves a pathway. + public let pathway: Bool? + + /// Optional detailed steps for navigating this leg. + public let steps: [Step]? + + /// Optional head sign of the transit legs, bus and trams + public let headsign: String? +} + +// swiftlint:enable identifier_name diff --git a/Sources/OTPKit/Models/TripPlanner/LegGeometry.swift b/Sources/OTPKit/Models/TripPlanner/LegGeometry.swift new file mode 100644 index 0000000..8604570 --- /dev/null +++ b/Sources/OTPKit/Models/TripPlanner/LegGeometry.swift @@ -0,0 +1,17 @@ +// +// LegGeometry.swift +// OTPKit +// +// Created by Aaron Brethorst on 8/10/24. +// + +import Foundation + +/// A container for the polyline of a `Leg`. +public struct LegGeometry: Codable, Hashable { + /// The raw polyline; encoded with the Google Polyline Algorithm Format. + public let points: String + + /// The number of coordinates represented by `points`. + public let length: Int +} diff --git a/Sources/OTPKit/Models/TripPlanner/Location.swift b/Sources/OTPKit/Models/TripPlanner/Location.swift new file mode 100644 index 0000000..3299e5a --- /dev/null +++ b/Sources/OTPKit/Models/TripPlanner/Location.swift @@ -0,0 +1,25 @@ +// +// Location.swift +// OTPKitDemo +// +// Created by Hilmy Veradin on 03/07/24. +// + +import Foundation + +/// Location is the main model for defining favorite location, recent location, map points +public struct Location: Identifiable, Codable, Equatable, Hashable { + public var id: UUID + public let title: String + public let subTitle: String + public let latitude: Double + public let longitude: Double + + public init(id: UUID = UUID(), title: String, subTitle: String, latitude: Double, longitude: Double) { + self.id = id + self.title = title + self.subTitle = subTitle + self.latitude = latitude + self.longitude = longitude + } +} diff --git a/Sources/OTPKit/Models/TripPlanner/OTPResponse.swift b/Sources/OTPKit/Models/TripPlanner/OTPResponse.swift new file mode 100644 index 0000000..ad7b0d9 --- /dev/null +++ b/Sources/OTPKit/Models/TripPlanner/OTPResponse.swift @@ -0,0 +1,29 @@ +/* + * Copyright (C) Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// Represents the response from the OpenTripPlanner (OTP) API. +public struct OTPResponse: Codable, Hashable { + /// Parameters used in the request that generated this response. + public let requestParameters: RequestParameters + + /// Optional `Plan` object containing detailed itinerary plans if the request was successful. + public let plan: Plan? + + /// Optional `ErrorResponse` object containing error details if the request failed. + public let error: ErrorResponse? +} diff --git a/Sources/OTPKit/Models/TripPlanner/Place.swift b/Sources/OTPKit/Models/TripPlanner/Place.swift new file mode 100644 index 0000000..08e32c1 --- /dev/null +++ b/Sources/OTPKit/Models/TripPlanner/Place.swift @@ -0,0 +1,32 @@ +/* + * Copyright (C) Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// Represents a geographical location used in travel itineraries. +public struct Place: Codable, Hashable { + /// Name or description of the place. + public let name: String + + /// Longitude of the place. + public let lon: Double + + /// Latitude of the place. + public let lat: Double + + /// Type of vertex representing the place, such as 'NORMAL', 'STOP', or 'STATION'. + public let vertexType: String +} diff --git a/Sources/OTPKit/Models/TripPlanner/Plan.swift b/Sources/OTPKit/Models/TripPlanner/Plan.swift new file mode 100644 index 0000000..f861ae1 --- /dev/null +++ b/Sources/OTPKit/Models/TripPlanner/Plan.swift @@ -0,0 +1,36 @@ +/* + * Copyright (C) Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +// swiftlint:disable identifier_name + +/// Represents a comprehensive travel plan containing multiple itineraries. +public struct Plan: Codable, Hashable { + /// Date and time when the travel plan was generated. + public let date: Date + + /// Starting point of the travel plan. + public let from: Place + + /// Destination point of the travel plan. + public let to: Place + + /// List of `Itinerary` objects providing different routing options within the travel plan. + public let itineraries: [Itinerary] +} + +// swiftlint:enable identifier_name diff --git a/Sources/OTPKit/Models/TripPlanner/RequestParameters.swift b/Sources/OTPKit/Models/TripPlanner/RequestParameters.swift new file mode 100644 index 0000000..91bcbe5 --- /dev/null +++ b/Sources/OTPKit/Models/TripPlanner/RequestParameters.swift @@ -0,0 +1,44 @@ +/* + * Copyright (C) Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// Contains parameters used to define the specifics of a request to the OpenTripPlanner API. +public struct RequestParameters: Codable, Hashable { + /// The starting location for the travel plan, expressed in a string format, typically as coordinates. + public let fromPlace: String + + /// The destination location for the travel plan, expressed in a string format, typically as coordinates. + public let toPlace: String + + /// The preferred time for departure or arrival, depending on `arriveBy`. + public let time: String + + /// The date of travel. + public let date: String + + /// Travel modes included in the trip planning, such as "TRANSIT", "WALK". + public let mode: String + + /// Indicates whether the `time` parameter refers to arrival time ("true") or departure time ("false"). + public let arriveBy: String + + /// Maximum walking distance the user is willing to walk, expressed in meters. + public let maxWalkDistance: String + + /// Indicates whether the route should accommodate wheelchair access ("true" or "false"). + public let wheelchair: String +} diff --git a/Sources/OTPKit/Models/TripPlanner/Step.swift b/Sources/OTPKit/Models/TripPlanner/Step.swift new file mode 100644 index 0000000..d358ef9 --- /dev/null +++ b/Sources/OTPKit/Models/TripPlanner/Step.swift @@ -0,0 +1,38 @@ +/* + * Copyright (C) Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// Represents a detailed step within a leg of an itinerary, providing navigation details. +public struct Step: Codable, Hashable { + /// Distance of this step in meters. + public let distance: Double + + /// Name of the street involved in this step. + public let streetName: String + + /// Optional description of the direction to take at this step (e.g., "left", "right"). + public let relativeDirection: String? + + /// Optional elevation change during this step, in meters. + public let elevationChange: Double? + + /// Longitude of the place. + public let lon: Double + + /// Latitude of the place. + public let lat: Double +} diff --git a/Sources/OTPKit/Network/RestAPI.swift b/Sources/OTPKit/Network/RestAPI.swift new file mode 100644 index 0000000..f9d605c --- /dev/null +++ b/Sources/OTPKit/Network/RestAPI.swift @@ -0,0 +1,107 @@ +/* + * Copyright (C) Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +// swiftlint:disable function_parameter_count + +/// An actor representing a REST API client for making network requests +public actor RestAPI { + + /// Initializes a new instance of RestAPI + /// + /// - Parameters: + /// - baseURL: The base URL for the API + /// - dataLoader: The data loader to use for network requests (defaults to URLSession.shared) + public init( + baseURL: URL, + dataLoader: URLDataLoader = URLSession.shared + ) { + self.baseURL = baseURL + self.dataLoader = dataLoader + } + + /// The base URL for the API + public let baseURL: URL + + /// The data loader used for network requests + public nonisolated let dataLoader: URLDataLoader + + /// Fetches a trip plan from the API + /// + /// - Parameters: + /// - fromPlace: The starting location of the trip + /// - toPlace: The destination of the trip + /// - time: The time of the trip + /// - date: The date of the trip + /// - mode: The transportation mode(s) for the trip + /// - arriveBy: Whether the trip should arrive by the specified time + /// - maxWalkDistance: The maximum walking distance in meters + /// - wheelchair: Whether the trip should be wheelchair accessible + /// + /// - Returns: An OTPResponse object containing the trip plan + /// - Throws: An error if the network request fails or the response is invalid + public func fetchPlan( + fromPlace: String, + toPlace: String, + time: String, + date: String, + mode: String, + arriveBy: Bool, + maxWalkDistance: Int, + wheelchair: Bool + ) async throws -> OTPResponse { + var components = URLComponents(url: buildURL(endpoint: "plan"), resolvingAgainstBaseURL: false)! + + components.queryItems = [ + URLQueryItem(name: "fromPlace", value: fromPlace), + URLQueryItem(name: "toPlace", value: toPlace), + URLQueryItem(name: "time", value: time), + URLQueryItem(name: "date", value: date), + URLQueryItem(name: "mode", value: mode), + URLQueryItem(name: "arriveBy", value: arriveBy ? "true" : "false"), + URLQueryItem(name: "maxWalkDistance", value: String(maxWalkDistance)), + URLQueryItem(name: "wheelchair", value: wheelchair ? "true" : "false") + ] + + let request = URLRequest(url: components.url!) + let (data, response) = try await dataLoader.data(for: request) + + guard + let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 + else { + throw URLError(.badServerResponse) + } + + // Decode the JSON data to the OTPResponse struct + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .millisecondsSince1970 + let decodedResponse = try decoder.decode(OTPResponse.self, from: data) + + return decodedResponse + } + + /// Builds a URL for the given endpoint + /// + /// - Parameter endpoint: The API endpoint + /// - Returns: A URL combining the base URL and the endpoint + private func buildURL(endpoint: String) -> URL { + baseURL.appending(path: endpoint) + } +} + +// swiftlint:enable function_parameter_count diff --git a/Sources/OTPKit/Network/URLDataLoader.swift b/Sources/OTPKit/Network/URLDataLoader.swift new file mode 100644 index 0000000..8a978fa --- /dev/null +++ b/Sources/OTPKit/Network/URLDataLoader.swift @@ -0,0 +1,27 @@ +/* + * Copyright (C) Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +public protocol URLDataLoader: NSObjectProtocol { + func dataTask( + with request: URLRequest, + completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void + ) -> URLSessionDataTask + func data(for request: URLRequest) async throws -> (Data, URLResponse) +} + +extension URLSession: URLDataLoader {} diff --git a/Sources/OTPKit/OTPKit.h b/Sources/OTPKit/OTPKit.h new file mode 100644 index 0000000..f41a4bb --- /dev/null +++ b/Sources/OTPKit/OTPKit.h @@ -0,0 +1,18 @@ +// +// OTPKit.h +// OTPKit +// +// Created by Aaron Brethorst on 5/2/24. +// + +#import + +//! Project version number for OTPKit. +FOUNDATION_EXPORT double OTPKitVersionNumber; + +//! Project version string for OTPKit. +FOUNDATION_EXPORT const unsigned char OTPKitVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/Sources/OTPKit/Previews/PreviewHelpers.swift b/Sources/OTPKit/Previews/PreviewHelpers.swift new file mode 100644 index 0000000..62e7954 --- /dev/null +++ b/Sources/OTPKit/Previews/PreviewHelpers.swift @@ -0,0 +1,41 @@ +// +// PreviewHelpers.swift +// OTPKit +// +// Created by Aaron Brethorst on 8/5/24. +// + +import CoreLocation +import MapKit +import SwiftUI + +class PreviewHelpers { + static func buildTripPlannerService() -> TripPlannerService { + TripPlannerService( + apiClient: RestAPI(baseURL: URL(string: "https://otp.prod.sound.obaweb.org/otp/routers/default/")!), + locationManager: CLLocationManager(), + searchCompleter: MKLocalSearchCompleter() + ) + } + + static func buildLeg() -> Leg { + Leg( + startTime: Date(), + endTime: Date(), + mode: "TRAM", + route: nil, + agencyName: nil, + from: Place(name: "foo", lon: 47, lat: -122, vertexType: ""), + to: Place(name: "foo", lon: 47, lat: -122, vertexType: ""), + legGeometry: LegGeometry(points: "AA@@", length: 4), + distance: 100, + transitLeg: false, + duration: 10, + realTime: true, + streetNames: nil, + pathway: nil, + steps: nil, + headsign: nil + ) + } +} diff --git a/Sources/OTPKit/Services/TripPlannerService.swift b/Sources/OTPKit/Services/TripPlannerService.swift new file mode 100644 index 0000000..756f4a3 --- /dev/null +++ b/Sources/OTPKit/Services/TripPlannerService.swift @@ -0,0 +1,401 @@ +// +// TripPlannerService.swift +// OTPKit +// +// Created by Hilmy Veradin on 18/07/24. +// + +import Foundation +import MapKit +import SwiftUI + +/// Services to manage all functions related to trip planning +public final class TripPlannerService: NSObject, ObservableObject { + // MARK: - Properties + + private let apiClient: RestAPI + + // Trip Planner + @Published public var planResponse: OTPResponse? + @Published public var isFetchingResponse = false + @Published public var tripPlannerErrorMessage: String? + @Published public var selectedItinerary: Itinerary? + @Published public var isStepsViewPresented = false + + // Origin Destination + @Published public var originDestinationState: OriginDestinationState = .origin + @Published public var originCoordinate: CLLocationCoordinate2D? + @Published public var destinationCoordinate: CLLocationCoordinate2D? + + // Location Search + private let searchCompleter: MKLocalSearchCompleter + private let debounceInterval: TimeInterval + private var debounceTimer: Timer? + private var currentRegion: MKCoordinateRegion? + private var searchTask: Task? + + @Published var completions = [Location]() + + // Map Extension + @Published public var selectedMapPoint: [String: MarkerItem?] = [ + "origin": nil, + "destination": nil + ] + + @Published public var isMapMarkingMode = false + @Published public var currentCameraPosition: MapCameraPosition = .userLocation(fallback: .automatic) + + @Published public var originName = "Origin" + @Published public var destinationName = "Destination" + + // User Location + @Published var currentLocation: Location? + private let locationManager: CLLocationManager + + // MARK: - Initialization + + /// Initializes a new instance of TripPlannerService + /// + /// - Parameters: + /// - apiClient: The REST API client for making network requests + /// - locationManager: The location manager for handling user location + /// - searchCompleter: The search completer for location search functionality + public init(apiClient: RestAPI, locationManager: CLLocationManager, searchCompleter: MKLocalSearchCompleter) { + self.apiClient = apiClient + self.locationManager = locationManager + self.searchCompleter = searchCompleter + debounceInterval = 1 + super.init() + + searchCompleter.delegate = self + locationManager.delegate = self + locationManager.desiredAccuracy = kCLLocationAccuracyBest + } + + deinit { + debounceTimer?.invalidate() + searchCompleter.cancel() + } + + // MARK: - Location Search Methods + + /// Initiates a local search for `queryFragment`. + /// This will be debounced, as set by the `debounceInterval` on the initializer. + /// - Parameter queryFragment: The search term + public func updateQuery(queryFragment: String) { + debounceTimer?.invalidate() + debounceTimer = Timer.scheduledTimer(withTimeInterval: debounceInterval, repeats: false) { [weak self] _ in + guard let self else { return } + searchCompleter.resultTypes = .query + searchCompleter.queryFragment = queryFragment + } + } + + private func updateCompleterRegion() { + if let region = currentRegion { + searchCompleter.region = region + } + } + + // MARK: - Map Extension Methods + + /// Selects and refreshes the coordinate based on the current origin/destination state + public func selectAndRefreshCoordinate() { + switch originDestinationState { + case .origin: + guard let coordinate = selectedMapPoint["origin"]??.item.placemark.coordinate else { return } + originCoordinate = coordinate + case .destination: + guard let coordinate = selectedMapPoint["destination"]??.item.placemark.coordinate else { return } + destinationCoordinate = coordinate + } + } + + /// Appends a marker for the given location + /// + /// - Parameter location: The location to add a marker for + public func appendMarker(location: Location) { + let coordinate = CLLocationCoordinate2D(latitude: location.latitude, longitude: location.longitude) + let mapItem = MKMapItem(placemark: .init(coordinate: coordinate)) + mapItem.name = location.title + let markerItem = MarkerItem(item: mapItem) + switch originDestinationState { + case .origin: + selectedMapPoint["origin"] = markerItem + changeMapCamera(mapItem) + case .destination: + selectedMapPoint["destination"] = markerItem + changeMapCamera(mapItem) + } + } + + /// Adds origin or destination data based on the current state + public func addOriginDestinationData() { + switch originDestinationState { + case .origin: + originName = selectedMapPoint["origin"]??.item.name ?? "Location unknown" + originCoordinate = selectedMapPoint["origin"]??.item.placemark.coordinate + case .destination: + destinationName = selectedMapPoint["destination"]??.item.name ?? "Location unknown" + destinationCoordinate = selectedMapPoint["destination"]??.item.placemark.coordinate + } + + checkAndFetchTripPlanner() + } + + /// Removes origin or destination data based on the current state + public func removeOriginDestinationData() { + switch originDestinationState { + case .origin: + originName = "Origin" + originCoordinate = nil + selectedMapPoint["origin"] = nil + case .destination: + destinationName = "Destination" + destinationCoordinate = nil + selectedMapPoint["destination"] = nil + } + } + + /// Toggles the map marking mode + /// + /// - Parameter isMapMarking: Boolean indicating whether map marking is enabled + public func toggleMapMarkingMode(_ isMapMarking: Bool) { + isMapMarkingMode = isMapMarking + } + + /// Changes the map camera to focus on the given map item + /// + /// - Parameter item: The map item to focus on + public func changeMapCamera(_ item: MKMapItem) { + currentCameraPosition = MapCameraPosition.item(item) + } + + /// Generates markers for the map based on selected points + /// + /// - Returns: MapContent containing the markers + public func generateMarkers() -> some MapContent { + ForEach(Array(selectedMapPoint.values.compactMap { $0 }), id: \.id) { markerItem in + Marker(item: markerItem.item) + } + } + + /// Generates a map polyline based on the selected itinerary + /// + /// - Returns: MapPolyline object or nil if no valid itinerary is selected + public func generateMapPolyline() -> MapPolyline? { + guard let itinerary = selectedItinerary else { return nil } + + // Use steps to calculate the Location Coordinate + let coordinates = itinerary.legs.flatMap { leg in + leg.decodePolyline()?.compactMap { coordinate in + coordinate + } ?? [] + } + + let coodinateExists = !coordinates.isEmpty + + guard coodinateExists else { return nil } + + return MapPolyline(coordinates: coordinates) + } + + /// Adjusts the camera to show both origin and destination + public func adjustOriginDestinationCamera() { + guard let originCoordinate, let destinationCoordinate else { return } + // Create a rectangle that encompasses both coordinates + let minLat = min(originCoordinate.latitude, destinationCoordinate.latitude) + let maxLat = max(originCoordinate.latitude, destinationCoordinate.latitude) + let minLon = min(originCoordinate.longitude, destinationCoordinate.longitude) + let maxLon = max(originCoordinate.longitude, destinationCoordinate.longitude) + + let center = CLLocationCoordinate2D(latitude: (minLat + maxLat) / 2, + longitude: (minLon + maxLon) / 2) + let span = MKCoordinateSpan(latitudeDelta: (maxLat - minLat) * 1.5, + longitudeDelta: (maxLon - minLon) * 1.5) + + let region = MKCoordinateRegion(center: center, span: span) + + currentCameraPosition = .region(region) + } + + // MARK: - Trip Planner Methods + + /// Automatically fetch the Trip Planner if there's origin coordinate and destination coordinate + private func checkAndFetchTripPlanner() { + guard originCoordinate != nil, + destinationCoordinate != nil + else { + return + } + + let fromPlace = formatCoordinate(originCoordinate) + let toPlace = formatCoordinate(destinationCoordinate) + + isFetchingResponse = true + + Task { + do { + let response = try await apiClient.fetchPlan( + fromPlace: fromPlace, + toPlace: toPlace, + time: getCurrentTimeFormatted(), + date: getFormattedTodayDate(), + mode: "TRANSIT,WALK", + arriveBy: false, + maxWalkDistance: 1000, + wheelchair: false + ) + DispatchQueue.main.async { + self.planResponse = response + self.isFetchingResponse = false + } + } catch { + DispatchQueue.main.async { + self.tripPlannerErrorMessage = "Failed to fetch data: \(error.localizedDescription)" + self.isFetchingResponse = false + } + } + } + } + + /// Resets all trip planner related data + public func resetTripPlanner() { + planResponse = nil + selectedMapPoint = [ + "origin": nil, + "destination": nil + ] + destinationCoordinate = nil + originCoordinate = nil + originName = "Origin" + destinationName = "Destination" + selectedItinerary = nil + isStepsViewPresented = false + } + + // MARK: - User Location Methods + + /// Checks if location services are enabled and requests authorization if necessary + public func checkIfLocationServicesIsEnabled() { + DispatchQueue.global().async { + if CLLocationManager.locationServicesEnabled() { + self.checkLocationAuthorization() + } + } + } + + private func checkLocationAuthorization() { + switch locationManager.authorizationStatus { + case .notDetermined: + locationManager.requestWhenInUseAuthorization() + case .restricted, .denied: + // Handle restricted or denied + break + case .authorizedAlways, .authorizedWhenInUse: + locationManager.startUpdatingLocation() + @unknown default: + break + } + } +} + +// MARK: - MKLocalSearchCompleterDelegate + +extension TripPlannerService: MKLocalSearchCompleterDelegate { + public func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) { + completions.removeAll() + + for result in completer.results { + let searchRequest = MKLocalSearch.Request(completion: result) + let search = MKLocalSearch(request: searchRequest) + + search.start { [weak self] response, error in + guard let self, let response else { + if let error { + print("Error performing local search: \(error)") + } + return + } + + if let mapItem = response.mapItems.first { + let completion = Location( + title: result.title, + subTitle: result.subtitle, + latitude: mapItem.placemark.coordinate.latitude, + longitude: mapItem.placemark.coordinate.longitude + ) + + DispatchQueue.main.async { + self.completions.append(completion) + } + } + } + } + } +} + +// MARK: - CLLocationManagerDelegate + +extension TripPlannerService: CLLocationManagerDelegate { + public func locationManager(_: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.last else { return } + DispatchQueue.main.async { + self.currentLocation = Location( + title: "My Location", + subTitle: "Your current location", + latitude: location.coordinate.latitude, + longitude: location.coordinate.longitude + ) + + self.currentRegion = MKCoordinateRegion( + center: location.coordinate, + latitudinalMeters: 1000, + longitudinalMeters: 1000 + ) + self.updateCompleterRegion() + } + } + + public func locationManagerDidChangeAuthorization(_: CLLocationManager) { + checkLocationAuthorization() + } +} + +// MARK: - Service Extension + +extension TripPlannerService { + + /// Formats a coordinate into a string representation + /// + /// - Parameter coordinate: The coordinate to format + /// - Returns: A string representation of the coordinate + func formatCoordinate(_ coordinate: CLLocationCoordinate2D?) -> String { + guard let coordinate else { return "" } + return String(format: "%.4f,%.4f", coordinate.latitude, coordinate.longitude) + } + + /// Gets the current date formatted as a string + /// + /// - Returns: The formatted date string + func getFormattedTodayDate() -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "MM-dd-yyyy" + let today = Date() + + return dateFormatter.string(from: today) + } + + /// Gets the current time formatted as a string + /// + /// - Returns: The formatted time string + func getCurrentTimeFormatted() -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "h:mm a" + dateFormatter.amSymbol = "AM" + dateFormatter.pmSymbol = "PM" + let currentDate = Date() + + return dateFormatter.string(from: currentDate) + } +} diff --git a/Sources/OTPKit/Services/UserDefaultsServices.swift b/Sources/OTPKit/Services/UserDefaultsServices.swift new file mode 100644 index 0000000..a707b4f --- /dev/null +++ b/Sources/OTPKit/Services/UserDefaultsServices.swift @@ -0,0 +1,124 @@ +// +// UserDefaultsServices.swift +// OTPKitDemo +// +// Created by Hilmy Veradin on 25/06/24. +// + +import Foundation + +/// Manages data persistance +/// Each CRUD features divided by `MARK` comment +public final class UserDefaultsServices { + public static let shared = UserDefaultsServices() + private let userDefaults = UserDefaults.standard + private let savedLocationsKey = "SavedLocations" + private let recentLocationsKey = "RecentLocations" + + // MARK: - Saved Location Data + + func getFavoriteLocationsData() -> Result<[Location], Error> { + guard let savedLocationsData = userDefaults.data(forKey: savedLocationsKey) else { + let error = NSError(domain: "UserDefaults", + code: 1001, + userInfo: [NSLocalizedDescriptionKey: "Failed to retrieve saved locations data"]) + return .failure(error) + } + + let decoder = JSONDecoder() + do { + let decodedSavedLocations = try decoder.decode([Location].self, from: savedLocationsData) + return .success(decodedSavedLocations) + } catch { + return .failure(error) + } + } + + func saveFavoriteLocationData(data: Location) -> Result { + var locations: [Location] = switch getFavoriteLocationsData() { + case let .success(existingLocations): + existingLocations + case .failure: + [] + } + + locations.append(data) + + let encoder = JSONEncoder() + do { + let encoded = try encoder.encode(locations) + userDefaults.set(encoded, forKey: savedLocationsKey) + return .success(()) + } catch { + return .failure(error) + } + } + + func deleteFavoriteLocationData(with id: UUID) -> Result { + var locations: [Location] + + switch getFavoriteLocationsData() { + case let .success(existingLocations): + locations = existingLocations + case let .failure(error): + return .failure(error) + } + + locations.removeAll { $0.id == id } + + let encoder = JSONEncoder() + do { + let encoded = try encoder.encode(locations) + userDefaults.set(encoded, forKey: savedLocationsKey) + return .success(()) + } catch { + return .failure(error) + } + } + + // MARK: - Recent Location Data + + func getRecentLocations() -> Result<[Location], Error> { + guard let savedLocationsData = userDefaults.data(forKey: recentLocationsKey) else { + let error = NSError(domain: "UserDefaults", + code: 1001, + userInfo: [NSLocalizedDescriptionKey: "Failed to retrieve saved locations data"]) + return .failure(error) + } + + let decoder = JSONDecoder() + do { + let decodedSavedLocations = try decoder.decode([Location].self, from: savedLocationsData) + return .success(decodedSavedLocations) + } catch { + return .failure(error) + } + } + + func saveRecentLocations(data: Location) -> Result { + var locations: [Location] = switch getFavoriteLocationsData() { + case let .success(existingLocations): + existingLocations + case .failure: + [] + } + + locations.insert(data, at: 0) + + let encoder = JSONEncoder() + do { + let encoded = try encoder.encode(locations) + userDefaults.set(encoded, forKey: recentLocationsKey) + return .success(()) + } catch { + return .failure(error) + } + } + + // MARK: - User Defaults Utils + + func deleteAllObjects() { + userDefaults.removeObject(forKey: savedLocationsKey) + userDefaults.removeObject(forKey: recentLocationsKey) + } +} diff --git a/Sources/OTPKit/TripPlannerExtensionView.swift b/Sources/OTPKit/TripPlannerExtensionView.swift new file mode 100644 index 0000000..d9159eb --- /dev/null +++ b/Sources/OTPKit/TripPlannerExtensionView.swift @@ -0,0 +1,114 @@ +// +// TripPlannerExtensionView.swift +// OTPKit +// +// Created by Hilmy Veradin on 12/08/24. +// + +import MapKit +import SwiftUI + + +/// Main Extension View that take Map as it's content +/// This simplify all the process of making the Trip Planner UI +public struct TripPlannerExtensionView: View { + @StateObject private var sheetEnvironment = OriginDestinationSheetEnvironment() + @EnvironmentObject private var tripPlanner: TripPlannerService + + @State private var directionSheetDetent: PresentationDetent = .fraction(0.2) + + private let mapContent: () -> MapContent + + public init(@ViewBuilder mapContent: @escaping () -> MapContent) { + self.mapContent = mapContent + } + + private var isPlanResponsePresented: Binding { + Binding( + get: { tripPlanner.planResponse != nil && tripPlanner.isStepsViewPresented == false }, + set: { _ in } + ) + } + + private var isStepsViewPresented: Binding { + Binding( + get: { tripPlanner.isStepsViewPresented }, + set: { _ in } + ) + } + + public var body: some View { + ZStack { + MapReader { proxy in + mapContent() + .onTapGesture { tappedLocation in + handleMapTap(proxy: proxy, tappedLocation: tappedLocation) + } + } + .sheet(isPresented: $sheetEnvironment.isSheetOpened) { + OriginDestinationSheetView() + .environmentObject(sheetEnvironment) + .environmentObject(tripPlanner) + } + .sheet(isPresented: isPlanResponsePresented) { + TripPlannerSheetView() + .presentationDetents([.medium, .large]) + .interactiveDismissDisabled() + .environmentObject(tripPlanner) + } + .sheet(isPresented: isStepsViewPresented, onDismiss: { + tripPlanner.resetTripPlanner() + }) { + DirectionSheetView(sheetDetent: $directionSheetDetent) + .presentationDetents([.fraction(0.2), .medium, .large], selection: $directionSheetDetent) + .interactiveDismissDisabled() + .presentationBackgroundInteraction(.enabled(upThrough: .fraction(0.2))) + .environmentObject(tripPlanner) + } + + overlayContent + } + .onAppear { + tripPlanner.checkIfLocationServicesIsEnabled() + } + } + + @ViewBuilder + private var overlayContent: some View { + if tripPlanner.isFetchingResponse { + ProgressView() + } else if tripPlanner.isMapMarkingMode { + MapMarkingView() + .environmentObject(tripPlanner) + } else if let selectedItinerary = tripPlanner.selectedItinerary, !tripPlanner.isStepsViewPresented { + VStack { + Spacer() + TripPlannerView(text: selectedItinerary.summary) + .environmentObject(tripPlanner) + } + } else if tripPlanner.planResponse == nil, tripPlanner.isStepsViewPresented == false { + VStack { + Spacer() + OriginDestinationView() + .environmentObject(sheetEnvironment) + .environmentObject(tripPlanner) + } + } + } + + private func handleMapTap(proxy: MapProxy, tappedLocation: CGPoint) { + if tripPlanner.isMapMarkingMode { + guard let coordinate = proxy.convert(tappedLocation, from: .local) else { return } + let mapItem = MKMapItem(placemark: .init(coordinate: coordinate)) + let locationTitle = mapItem.name ?? "Location unknown" + let locationSubtitle = mapItem.placemark.title ?? "Location unknown" + let location = Location( + title: locationTitle, + subTitle: locationSubtitle, + latitude: coordinate.latitude, + longitude: coordinate.longitude + ) + tripPlanner.appendMarker(location: location) + } + } +} diff --git a/Tests/OTPKitTests/OTPKitTests.swift b/Tests/OTPKitTests/OTPKitTests.swift new file mode 100644 index 0000000..f8a346b --- /dev/null +++ b/Tests/OTPKitTests/OTPKitTests.swift @@ -0,0 +1,12 @@ +import XCTest +@testable import OTPKit + +final class OTPKitTests: XCTestCase { + func testExample() throws { + // XCTest Documentation + // https://developer.apple.com/documentation/xctest + + // Defining Test Cases and Test Methods + // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods + } +} diff --git a/project.yml b/project.yml deleted file mode 100644 index 890ada8..0000000 --- a/project.yml +++ /dev/null @@ -1,65 +0,0 @@ -name: "OTPKit" - -############ -# Options -############ - -settings: - base: - MARKETING_VERSION: 0.0.1 - -options: - deploymentTarget: - iOS: "17.0" - -targets: - OTPKitDemo: - info: - path: OTPKitDemo/Info.plist - properties: - CFBundleShortVersionString: "$(MARKETING_VERSION)" - NSLocationWhenInUseUsageDescription: See where you are in relation to transit, and help you navigate more easily. - UILaunchScreen: LaunchScreen - type: application - platform: iOS - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: "org.onebusaway.otpkitdemo" - sources: - - OTPKitDemo - dependencies: - - target: OTPKit - OTPKit: - type: framework - platform: iOS - sources: - - OTPKit - postBuildScripts: - - path: ./scripts/swiftformat.sh - basedOnDependencyAnalysis: false - name: SwiftFormat - - path: ./scripts/swiftlint.sh - basedOnDependencyAnalysis: false - name: Swiftlint - info: - path: OTPKit/Info.plist - properties: - CFBundleShortVersionString: "$(MARKETING_VERSION)" - settings: - base: - APPLICATION_EXTENSION_API_ONLY: true - PRODUCT_BUNDLE_IDENTIFIER: "org.onebusaway.otpkit" - ENABLE_MODULE_VERIFIER: true - MODULE_VERIFIER_SUPPORTED_LANGUAGES: objective-c objective-c++ - MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS: gnu11 gnu++14 - OTPKitTests: - type: bundle.unit-test - platform: iOS - dependencies: - - target: OTPKitDemo - sources: - - OTPKitTests - info: - path: OTPKitTests/Info.plist - properties: - NSPrincipalClass: OTPKitTests.OTPKitTestsSetup From ed721a4745a2512dff0db80af57c74836c6cd988 Mon Sep 17 00:00:00 2001 From: hilmyveradin Date: Tue, 13 Aug 2024 17:36:09 +0700 Subject: [PATCH 2/6] remove OTPKit old folders --- OTPKit/Controls/PageHeaderView.swift | 39 -- OTPKit/Controls/SearchView.swift | 32 -- OTPKit/Controls/SectionHeaderView.swift | 39 -- .../MapExtension/MapMarkingView.swift | 56 --- .../OriginDestination/FavoriteView.swift | 52 --- .../OriginDestinationSheetEnvironment.swift | 47 --- .../OriginDestinationView.swift | 67 --- .../Sheets/AddFavoriteLocationsSheet.swift | 112 ----- .../Sheets/FavoriteLocationDetailSheet.swift | 62 --- .../Sheets/MoreFavoriteLocationsSheet.swift | 48 --- .../Sheets/MoreRecentLocationsSheet.swift | 38 -- .../Sheets/OriginDestinationSheetView.swift | 253 ------------ .../DirectionLegOriginDestinationView.swift | 42 -- .../Direction/DirectionLegUnknownView.swift | 37 -- .../Direction/DirectionLegVehicleView.swift | 51 --- .../Direction/DirectionLegWalkView.swift | 39 -- .../Direction/DirectionSheetView.swift | 112 ----- .../ItineraryLegUnknownView.swift | 29 -- .../ItineraryLegVehicleView.swift | 52 --- .../ItineraryLegWalkView.swift | 30 -- .../TripPlannerSheetView.swift | 103 ----- .../TripPlanner/TripPlannerView.swift | 50 --- OTPKit/Miscellaneous/FlowLayout.swift | 45 -- OTPKit/Miscellaneous/Formatters.swift | 49 --- .../Miscellaneous/PresentationManager.swift | 25 -- OTPKit/Miscellaneous/Utilities.swift | 10 - .../Helpers/DebugDescriptionBuilder.swift | 45 -- OTPKit/Models/MapExtension/MarkerItem.swift | 14 - .../OriginDestinationState.swift | 17 - OTPKit/Models/Polyline/Polyline.swift | 387 ------------------ OTPKit/Models/TripPlanner/ErrorResponse.swift | 28 -- OTPKit/Models/TripPlanner/Itinerary.swift | 64 --- OTPKit/Models/TripPlanner/Leg.swift | 78 ---- OTPKit/Models/TripPlanner/LegGeometry.swift | 17 - OTPKit/Models/TripPlanner/Location.swift | 25 -- OTPKit/Models/TripPlanner/OTPResponse.swift | 29 -- OTPKit/Models/TripPlanner/Place.swift | 32 -- OTPKit/Models/TripPlanner/Plan.swift | 36 -- .../TripPlanner/RequestParameters.swift | 44 -- OTPKit/Models/TripPlanner/Step.swift | 38 -- OTPKit/Network/RestAPI.swift | 79 ---- OTPKit/Network/URLDataLoader.swift | 27 -- OTPKit/OTPKit.h | 18 - OTPKit/Previews/PreviewHelpers.swift | 41 -- OTPKit/Services/TripPlannerService.swift | 362 ---------------- OTPKit/Services/UserDefaultsServices.swift | 124 ------ OTPKit/TripPlannerExtensionView.swift | 114 ------ .../AccentColor.colorset/Contents.json | 11 - .../AppIcon.appiconset/Contents.json | 13 - OTPKitDemo/Assets.xcassets/Contents.json | 6 - OTPKitDemo/LaunchScreen.storyboard | 48 --- OTPKitDemo/MapView.swift | 42 -- OTPKitDemo/OTPKitDemoApp.swift | 36 -- .../Preview Assets.xcassets/Contents.json | 6 - OTPKitTests/Helpers/Fixtures.swift | 55 --- OTPKitTests/Helpers/MockDataLoader.swift | 148 ------- OTPKitTests/Helpers/OTPTestCase.swift | 50 --- OTPKitTests/OTPKitTests.swift | 57 --- OTPKitTests/OTPKitTestsSetup.swift | 16 - OTPKitTests/fixtures/plan_basic_case.json | 1 - 60 files changed, 3627 deletions(-) delete mode 100644 OTPKit/Controls/PageHeaderView.swift delete mode 100644 OTPKit/Controls/SearchView.swift delete mode 100644 OTPKit/Controls/SectionHeaderView.swift delete mode 100644 OTPKit/Features/MapExtension/MapMarkingView.swift delete mode 100644 OTPKit/Features/OriginDestination/FavoriteView.swift delete mode 100644 OTPKit/Features/OriginDestination/OriginDestinationSheetEnvironment.swift delete mode 100644 OTPKit/Features/OriginDestination/OriginDestinationView.swift delete mode 100644 OTPKit/Features/OriginDestination/Sheets/AddFavoriteLocationsSheet.swift delete mode 100644 OTPKit/Features/OriginDestination/Sheets/FavoriteLocationDetailSheet.swift delete mode 100644 OTPKit/Features/OriginDestination/Sheets/MoreFavoriteLocationsSheet.swift delete mode 100644 OTPKit/Features/OriginDestination/Sheets/MoreRecentLocationsSheet.swift delete mode 100644 OTPKit/Features/OriginDestination/Sheets/OriginDestinationSheetView.swift delete mode 100644 OTPKit/Features/TripPlanner/Direction/DirectionLegOriginDestinationView.swift delete mode 100644 OTPKit/Features/TripPlanner/Direction/DirectionLegUnknownView.swift delete mode 100644 OTPKit/Features/TripPlanner/Direction/DirectionLegVehicleView.swift delete mode 100644 OTPKit/Features/TripPlanner/Direction/DirectionLegWalkView.swift delete mode 100644 OTPKit/Features/TripPlanner/Direction/DirectionSheetView.swift delete mode 100644 OTPKit/Features/TripPlanner/SelectItenerary/ItineraryLegUnknownView.swift delete mode 100644 OTPKit/Features/TripPlanner/SelectItenerary/ItineraryLegVehicleView.swift delete mode 100644 OTPKit/Features/TripPlanner/SelectItenerary/ItineraryLegWalkView.swift delete mode 100644 OTPKit/Features/TripPlanner/SelectItenerary/TripPlannerSheetView.swift delete mode 100644 OTPKit/Features/TripPlanner/TripPlannerView.swift delete mode 100644 OTPKit/Miscellaneous/FlowLayout.swift delete mode 100644 OTPKit/Miscellaneous/Formatters.swift delete mode 100644 OTPKit/Miscellaneous/PresentationManager.swift delete mode 100644 OTPKit/Miscellaneous/Utilities.swift delete mode 100644 OTPKit/Models/Helpers/DebugDescriptionBuilder.swift delete mode 100644 OTPKit/Models/MapExtension/MarkerItem.swift delete mode 100644 OTPKit/Models/OriginDestination/OriginDestinationState.swift delete mode 100644 OTPKit/Models/Polyline/Polyline.swift delete mode 100644 OTPKit/Models/TripPlanner/ErrorResponse.swift delete mode 100644 OTPKit/Models/TripPlanner/Itinerary.swift delete mode 100644 OTPKit/Models/TripPlanner/Leg.swift delete mode 100644 OTPKit/Models/TripPlanner/LegGeometry.swift delete mode 100644 OTPKit/Models/TripPlanner/Location.swift delete mode 100644 OTPKit/Models/TripPlanner/OTPResponse.swift delete mode 100644 OTPKit/Models/TripPlanner/Place.swift delete mode 100644 OTPKit/Models/TripPlanner/Plan.swift delete mode 100644 OTPKit/Models/TripPlanner/RequestParameters.swift delete mode 100644 OTPKit/Models/TripPlanner/Step.swift delete mode 100644 OTPKit/Network/RestAPI.swift delete mode 100644 OTPKit/Network/URLDataLoader.swift delete mode 100644 OTPKit/OTPKit.h delete mode 100644 OTPKit/Previews/PreviewHelpers.swift delete mode 100644 OTPKit/Services/TripPlannerService.swift delete mode 100644 OTPKit/Services/UserDefaultsServices.swift delete mode 100644 OTPKit/TripPlannerExtensionView.swift delete mode 100644 OTPKitDemo/Assets.xcassets/AccentColor.colorset/Contents.json delete mode 100644 OTPKitDemo/Assets.xcassets/AppIcon.appiconset/Contents.json delete mode 100644 OTPKitDemo/Assets.xcassets/Contents.json delete mode 100644 OTPKitDemo/LaunchScreen.storyboard delete mode 100644 OTPKitDemo/MapView.swift delete mode 100644 OTPKitDemo/OTPKitDemoApp.swift delete mode 100644 OTPKitDemo/Preview Content/Preview Assets.xcassets/Contents.json delete mode 100644 OTPKitTests/Helpers/Fixtures.swift delete mode 100644 OTPKitTests/Helpers/MockDataLoader.swift delete mode 100644 OTPKitTests/Helpers/OTPTestCase.swift delete mode 100644 OTPKitTests/OTPKitTests.swift delete mode 100644 OTPKitTests/OTPKitTestsSetup.swift delete mode 100644 OTPKitTests/fixtures/plan_basic_case.json diff --git a/OTPKit/Controls/PageHeaderView.swift b/OTPKit/Controls/PageHeaderView.swift deleted file mode 100644 index 1fb5742..0000000 --- a/OTPKit/Controls/PageHeaderView.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// PageHeaderView.swift -// OTPKit -// -// Created by Aaron Brethorst on 7/31/24. -// - -import SwiftUI - -/// Appears at the top of UI pages with a title and close button. -struct PageHeaderView: View { - private let text: String - private let action: VoidBlock? - - init(text: String, action: VoidBlock? = nil) { - self.text = text - self.action = action - } - - var body: some View { - HStack { - Text(text) - .font(.title2) - .fontWeight(.bold) - Spacer() - Button(action: { - action?() - }, label: { - Image(systemName: "xmark.circle.fill") - .font(.title2) - .foregroundColor(.gray) - }) - } - } -} - -#Preview { - PageHeaderView(text: "Favorites") -} diff --git a/OTPKit/Controls/SearchView.swift b/OTPKit/Controls/SearchView.swift deleted file mode 100644 index ec2bda1..0000000 --- a/OTPKit/Controls/SearchView.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// SearchView.swift -// OTPKit -// -// Created by Aaron Brethorst on 8/11/24. -// - -import SwiftUI - -/// A reusable Search control suitable for displaying in the header of a view. -struct SearchView: View { - var placeholder: String - @Binding var searchText: String - @FocusState var isSearchFocused: Bool - - var body: some View { - HStack { - Image(systemName: "magnifyingglass") - TextField(placeholder, text: $searchText) - .autocorrectionDisabled() - .focused($isSearchFocused) - } - .padding(.vertical, 8) - .padding(.horizontal, 12) - .background(Color.gray.opacity(0.2)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } -} - -// #Preview { -// SearchView() -// } diff --git a/OTPKit/Controls/SectionHeaderView.swift b/OTPKit/Controls/SectionHeaderView.swift deleted file mode 100644 index 317f5e0..0000000 --- a/OTPKit/Controls/SectionHeaderView.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// SectionHeaderView.swift -// OTPKit -// -// Created by Aaron Brethorst on 7/31/24. -// - -import SwiftUI - -/// The view that appears above a section on the `OriginDestinationSheetView`. -/// For instance, the header for the Recents and Favorites sections. -struct SectionHeaderView: View { - private let text: String - private let action: VoidBlock? - - init(text: String, action: VoidBlock? = nil) { - self.text = text - self.action = action - } - - var body: some View { - HStack { - Text(text) - .textCase(.none) - Spacer() - Button(action: { - action?() - }, label: { - Text("More") - .textCase(.none) - .font(.subheadline) - }) - } - } -} - -#Preview { - SectionHeaderView(text: "Hello, world!") -} diff --git a/OTPKit/Features/MapExtension/MapMarkingView.swift b/OTPKit/Features/MapExtension/MapMarkingView.swift deleted file mode 100644 index 7321aa3..0000000 --- a/OTPKit/Features/MapExtension/MapMarkingView.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// MapMarkingView.swift -// OTPKit -// -// Created by Hilmy Veradin on 18/07/24. -// - -import SwiftUI - -public struct MapMarkingView: View { - @EnvironmentObject private var tripPlanner: TripPlannerService - - public init() {} - public var body: some View { - VStack { - Spacer() - - Text("Tap on the map to add a pin.") - .padding(16) - .background(.regularMaterial) - .cornerRadius(16) - - HStack(spacing: 16) { - Button { - tripPlanner.toggleMapMarkingMode(false) - tripPlanner.selectAndRefreshCoordinate() - tripPlanner.removeOriginDestinationData() - } label: { - Text("Cancel") - .padding(8) - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - - Button { - tripPlanner.toggleMapMarkingMode(false) - tripPlanner.addOriginDestinationData() - tripPlanner.selectAndRefreshCoordinate() - } label: { - Text("Add Pin") - .padding(8) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - } - .frame(maxWidth: .infinity) - .padding(16) - } - .padding(.bottom, 24) - } -} - -#Preview { - MapMarkingView() - .environmentObject(PreviewHelpers.buildTripPlannerService()) -} diff --git a/OTPKit/Features/OriginDestination/FavoriteView.swift b/OTPKit/Features/OriginDestination/FavoriteView.swift deleted file mode 100644 index ce4d3a4..0000000 --- a/OTPKit/Features/OriginDestination/FavoriteView.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// FavoriteView.swift -// OTPKit -// -// Created by Aaron Brethorst on 7/31/24. -// - -import SwiftUI - -/// A button that wraps a circle with an icon above a line of text. -struct FavoriteView: View { - private let title: String - private let imageName: String - private let tapAction: VoidBlock? - private let longTapAction: VoidBlock? - - init(title: String, imageName: String, tapAction: VoidBlock? = nil, longTapAction: VoidBlock? = nil) { - self.title = title - self.imageName = imageName - self.tapAction = tapAction - self.longTapAction = longTapAction - } - - var body: some View { - Button(action: {}, label: { - VStack(alignment: .center) { - Image(systemName: imageName) - .frame(width: 48, height: 48) - .background(Color.gray.opacity(0.5)) - .clipShape(Circle()) - - Text(title) - .font(.caption) - .frame(width: 64) - .lineLimit(1) - .truncationMode(.tail) - } - .padding(.all, 4) - .foregroundStyle(.foreground) - }) - .simultaneousGesture(LongPressGesture().onEnded { _ in - longTapAction?() - }) - .simultaneousGesture(TapGesture().onEnded { - tapAction?() - }) - } -} - -#Preview { - FavoriteView(title: "Hello, world!", imageName: "mappin") -} diff --git a/OTPKit/Features/OriginDestination/OriginDestinationSheetEnvironment.swift b/OTPKit/Features/OriginDestination/OriginDestinationSheetEnvironment.swift deleted file mode 100644 index 7eede29..0000000 --- a/OTPKit/Features/OriginDestination/OriginDestinationSheetEnvironment.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// OriginDestinationSheetEnvironment.swift -// OTPKitDemo -// -// Created by Hilmy Veradin on 25/06/24. -// - -import Foundation -import SwiftUI - -/// OriginDestinationSheetEnvironment responsible for manage the environment of `OriginDestination` features -/// - sheetState: responsible for managing shown sheet in `OriginDestinationView` -/// - selectedValue: responsible for managing selected value when user taped the list in `OriginDestinationSheetView` -public final class OriginDestinationSheetEnvironment: ObservableObject { - @Published public var isSheetOpened = false - @Published public var selectedValue: String = "" - - // This responsible for showing favorite locations and recent locations in sheets - @Published public var favoriteLocations: [Location] = [] - @Published public var recentLocations: [Location] = [] - - /// Selected detail favorite locations that will be shown in `FavoriteLocationDetailSheet` - @Published public var selectedDetailFavoriteLocation: Location? - - // Public initializer - public init() {} - - /// Refresh favorite locations data from user defaults - func refreshFavoriteLocations() { - switch UserDefaultsServices.shared.getFavoriteLocationsData() { - case let .success(locations): - favoriteLocations = locations - case let .failure(error): - print("Failed to refresh favorite locations: \(error)") - } - } - - /// Refresh recent locations data from user defaults - func refreshRecentLocations() { - switch UserDefaultsServices.shared.getRecentLocations() { - case let .success(locations): - recentLocations = locations - case let .failure(error): - print("Failed to refresh favorite locations: \(error)") - } - } -} diff --git a/OTPKit/Features/OriginDestination/OriginDestinationView.swift b/OTPKit/Features/OriginDestination/OriginDestinationView.swift deleted file mode 100644 index 8e90079..0000000 --- a/OTPKit/Features/OriginDestination/OriginDestinationView.swift +++ /dev/null @@ -1,67 +0,0 @@ -// -// OriginDestinationView.swift -// OTPKit -// -// Created by Hilmy Veradin on 05/07/24. -// - -import MapKit -import SwiftUI - -/// OriginDestinationView is the main view for setting up Origin/Destination in OTPKit. -/// It consists a list of Origin and Destination along with the `MapKit` -public struct OriginDestinationView: View { - @EnvironmentObject private var sheetEnvironment: OriginDestinationSheetEnvironment - @EnvironmentObject private var tripPlanner: TripPlannerService - @State private var isSheetOpened = false - - // Public Initializer - public init() {} - - public var body: some View { - VStack { - List { - Button(action: { - sheetEnvironment.isSheetOpened.toggle() - tripPlanner.originDestinationState = .origin - }, label: { - HStack(spacing: 16) { - Image(systemName: "paperplane.fill") - .background( - Circle() - .fill(Color.green) - .frame(width: 30, height: 30) - ) - Text(tripPlanner.originName) - } - }) - .foregroundStyle(.foreground) - - Button(action: { - sheetEnvironment.isSheetOpened.toggle() - tripPlanner.originDestinationState = .destination - }, label: { - HStack(spacing: 16) { - Image(systemName: "mappin") - .background( - Circle() - .fill(Color.green) - .frame(width: 30, height: 30) - ) - Text(tripPlanner.destinationName) - } - }) - .foregroundStyle(.foreground) - } - .frame(height: 135) - .scrollContentBackground(.hidden) - .scrollDisabled(true) - .padding(.bottom, 24) - } - } -} - -#Preview { - OriginDestinationView() - .environmentObject(PreviewHelpers.buildTripPlannerService()) -} diff --git a/OTPKit/Features/OriginDestination/Sheets/AddFavoriteLocationsSheet.swift b/OTPKit/Features/OriginDestination/Sheets/AddFavoriteLocationsSheet.swift deleted file mode 100644 index 881e612..0000000 --- a/OTPKit/Features/OriginDestination/Sheets/AddFavoriteLocationsSheet.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// AddFavoriteLocationsSheet.swift -// OTPKitDemo -// -// Created by Hilmy Veradin on 03/07/24. -// - -import SwiftUI - -/// This sheet responsible to add a new favorite location. -/// Users can search and add their favorite locations -public struct AddFavoriteLocationsSheet: View { - @Environment(\.dismiss) var dismiss - @EnvironmentObject private var sheetEnvironment: OriginDestinationSheetEnvironment - @EnvironmentObject private var tripPlanner: TripPlannerService - - @State private var search = "" - - @FocusState private var isSearchFocused: Bool - - private var filteredCompletions: [Location] { - let favorites = sheetEnvironment.favoriteLocations - return tripPlanner.completions.filter { completion in - !favorites.contains { favorite in - favorite.title == completion.title && - favorite.subTitle == completion.subTitle - } - } - } - - private func currentUserSection() -> some View { - if search.isEmpty, let userLocation = tripPlanner.currentLocation { - AnyView( - Button(action: { - switch UserDefaultsServices.shared.saveFavoriteLocationData(data: userLocation) { - case .success: - sheetEnvironment.refreshFavoriteLocations() - dismiss() - case let .failure(error): - print(error) - } - }, label: { - HStack { - VStack(alignment: .leading) { - Text(userLocation.title) - .font(.headline) - Text(userLocation.subTitle) - }.foregroundStyle(.foreground) - - Spacer() - - Image(systemName: "plus") - } - - }) - ) - - } else { - AnyView(EmptyView()) - } - } - - private func searchedResultsSection() -> some View { - ForEach(filteredCompletions) { location in - Button(action: { - switch UserDefaultsServices.shared.saveFavoriteLocationData(data: location) { - case .success: - sheetEnvironment.refreshFavoriteLocations() - dismiss() - case let .failure(error): - print(error) - } - }, label: { - HStack { - VStack(alignment: .leading) { - Text(location.title) - .font(.headline) - Text(location.subTitle) - }.foregroundStyle(.foreground) - - Spacer() - - Image(systemName: "plus") - } - - }) - } - } - - public var body: some View { - VStack { - PageHeaderView(text: "Add Favorite") { - dismiss() - } - .padding() - SearchView(placeholder: "Search for a place", searchText: $search, isSearchFocused: _isSearchFocused) - .padding(.horizontal, 16) - List { - currentUserSection() - searchedResultsSection() - } - .onChange(of: search) { _, searchValue in - tripPlanner.updateQuery(queryFragment: searchValue) - } - } - } -} - -#Preview { - AddFavoriteLocationsSheet() - .environmentObject(PreviewHelpers.buildTripPlannerService()) -} diff --git a/OTPKit/Features/OriginDestination/Sheets/FavoriteLocationDetailSheet.swift b/OTPKit/Features/OriginDestination/Sheets/FavoriteLocationDetailSheet.swift deleted file mode 100644 index cdbc0e6..0000000 --- a/OTPKit/Features/OriginDestination/Sheets/FavoriteLocationDetailSheet.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// FavoriteLocationDetailSheet.swift -// OTPKitDemo -// -// Created by Hilmy Veradin on 04/07/24. -// - -import SwiftUI - -/// This responsible for showing the details of favorite locations -/// Users can see the details and delete the location sheet -public struct FavoriteLocationDetailSheet: View { - @Environment(\.dismiss) private var dismiss - @EnvironmentObject private var sheetEnvironment: OriginDestinationSheetEnvironment - - @State private var isShowErrorAlert = false - @State private var errorMessage = "" - - public var body: some View { - VStack { - PageHeaderView(text: "Favorite Location Detail") { - sheetEnvironment.selectedDetailFavoriteLocation = nil - dismiss() - } - .padding(.vertical) - - Text("\(sheetEnvironment.selectedDetailFavoriteLocation?.title ?? "")") - .font(.headline) - Text("\(sheetEnvironment.selectedDetailFavoriteLocation?.subTitle ?? "")") - - Button(action: { - guard let uid = sheetEnvironment.selectedDetailFavoriteLocation?.id else { - return - } - switch UserDefaultsServices.shared.deleteFavoriteLocationData(with: uid) { - case .success: - sheetEnvironment.selectedDetailFavoriteLocation = nil - sheetEnvironment.refreshFavoriteLocations() - dismiss() - case let .failure(failure): - errorMessage = failure.localizedDescription - isShowErrorAlert.toggle() - } - }, label: { - Text("Delete Location") - }) - .padding() - - Spacer() - } - .padding() - .alert(isPresented: $isShowErrorAlert) { - Alert(title: Text("Error Delete Favorite Location"), - message: Text(errorMessage), - dismissButton: .cancel(Text("Ok"))) - } - } -} - -#Preview { - FavoriteLocationDetailSheet() -} diff --git a/OTPKit/Features/OriginDestination/Sheets/MoreFavoriteLocationsSheet.swift b/OTPKit/Features/OriginDestination/Sheets/MoreFavoriteLocationsSheet.swift deleted file mode 100644 index 03a6a32..0000000 --- a/OTPKit/Features/OriginDestination/Sheets/MoreFavoriteLocationsSheet.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// MoreFavoriteLocationsSheet.swift -// OTPKitDemo -// -// Created by Hilmy Veradin on 03/07/24. -// - -import SwiftUI - -/// Show all the lists of favorite locations -public struct MoreFavoriteLocationsSheet: View { - @Environment(\.dismiss) private var dismiss - @EnvironmentObject private var sheetEnvironment: OriginDestinationSheetEnvironment - @EnvironmentObject private var tripPlanner: TripPlannerService - - @State private var isDetailSheetOpened = false - - public var body: some View { - VStack { - PageHeaderView(text: "Favorites") { - dismiss() - } - .padding() - - List { - ForEach(sheetEnvironment.favoriteLocations) { location in - Button(action: {}, label: { - VStack(alignment: .leading) { - Text(location.title) - .font(.headline) - Text(location.subTitle) - } - .foregroundStyle(.foreground) - }) - } - } - .sheet(isPresented: $isDetailSheetOpened, content: { - FavoriteLocationDetailSheet() - .environmentObject(sheetEnvironment) - }) - } - } -} - -#Preview { - MoreFavoriteLocationsSheet() - .environmentObject(PreviewHelpers.buildTripPlannerService()) -} diff --git a/OTPKit/Features/OriginDestination/Sheets/MoreRecentLocationsSheet.swift b/OTPKit/Features/OriginDestination/Sheets/MoreRecentLocationsSheet.swift deleted file mode 100644 index a718f41..0000000 --- a/OTPKit/Features/OriginDestination/Sheets/MoreRecentLocationsSheet.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// MoreRecentLocationsSheet.swift -// OTPKitDemo -// -// Created by Hilmy Veradin on 03/07/24. -// - -import SwiftUI - -/// Show all the lists of all recent locations -public struct MoreRecentLocationsSheet: View { - @Environment(\.dismiss) var dismiss - - @EnvironmentObject private var sheetEnvironment: OriginDestinationSheetEnvironment - - public var body: some View { - VStack { - PageHeaderView(text: "Recents") { - dismiss() - } - .padding() - - List { - ForEach(sheetEnvironment.recentLocations) { location in - VStack(alignment: .leading) { - Text(location.title) - .font(.headline) - Text(location.subTitle) - } - } - } - } - } -} - -#Preview { - MoreRecentLocationsSheet() -} diff --git a/OTPKit/Features/OriginDestination/Sheets/OriginDestinationSheetView.swift b/OTPKit/Features/OriginDestination/Sheets/OriginDestinationSheetView.swift deleted file mode 100644 index 915a453..0000000 --- a/OTPKit/Features/OriginDestination/Sheets/OriginDestinationSheetView.swift +++ /dev/null @@ -1,253 +0,0 @@ -import MapKit -import SwiftUI - -/// OriginDestinationSheetView responsible for showing sheets -/// consists of available origin/destination of OriginDestinationView -/// - Attributes: -/// - sheetEnvironment responsible for manage sheet states across the view. See `OriginDestinationSheetEnvironment` -/// - locationService responsible for manage autocompletion of origin/destination search bar. See `LocationService` -/// -public struct OriginDestinationSheetView: View { - @Environment(\.dismiss) var dismiss - @EnvironmentObject var sheetEnvironment: OriginDestinationSheetEnvironment - @EnvironmentObject private var tripPlanner: TripPlannerService - - @State private var search: String = "" - - // Sheet States - private enum Modals: Identifiable { - case addFavoriteSheet - case moreFavoritesSheet - case favoriteDetailsSheet - case moreRecentsSheet - - var id: Self { self } - } - - @StateObject private var presentationManager = PresentationManager() - - @State private var isShowFavoriteConfirmationDialog = false - - // Alert States - @State private var isShowErrorAlert = false - @State private var errorTitle = "" - @State private var errorMessage = "" - - @FocusState private var isSearchFocused: Bool - - // Public initializer - public init() {} - - private func favoriteSectionConfirmationDialog() -> some View { - Group { - Button(action: { - presentationManager.present(.favoriteDetailsSheet) - }, label: { - Text("Show Details") - }) - - Button(role: .destructive, action: { - guard let uid = sheetEnvironment.selectedDetailFavoriteLocation?.id else { - return - } - switch UserDefaultsServices.shared.deleteFavoriteLocationData(with: uid) { - case .success: - sheetEnvironment.selectedDetailFavoriteLocation = nil - sheetEnvironment.refreshFavoriteLocations() - case let .failure(failure): - errorTitle = "Failed to Delete Favorite Location" - errorMessage = failure.localizedDescription - isShowErrorAlert.toggle() - } - }, label: { - Text("Delete") - }) - } - } - - private func favoritesSection() -> some View { - Section(content: { - ScrollView(.horizontal) { - HStack { - ForEach(sheetEnvironment.favoriteLocations, content: { location in - FavoriteView(title: location.title, imageName: "mappin", tapAction: { - tripPlanner.appendMarker(location: location) - tripPlanner.addOriginDestinationData() - dismiss() - }, longTapAction: { - isShowFavoriteConfirmationDialog = true - sheetEnvironment.selectedDetailFavoriteLocation = location - }) - }) - - FavoriteView(title: "Add", imageName: "plus", tapAction: { - presentationManager.present(.addFavoriteSheet) - }) - } - } - }, header: { - SectionHeaderView(text: "Favorites") { - presentationManager.present(.moreFavoritesSheet) - } - }) - } - - private func recentsSection() -> some View { - guard sheetEnvironment.recentLocations.count > 0 else { - return AnyView(EmptyView()) - } - - return AnyView( - Section(content: { - ForEach(Array(sheetEnvironment.recentLocations.prefix(5)), content: { location in - Button { - tripPlanner.appendMarker(location: location) - tripPlanner.addOriginDestinationData() - dismiss() - } label: { - VStack(alignment: .leading) { - Text(location.title) - .font(.headline) - Text(location.subTitle) - } - .foregroundColor(.primary) - } - }) - }, header: { - SectionHeaderView(text: "Recents") { - presentationManager.present(.moreRecentsSheet) - } - }) - ) - } - - private func searchResultsSection() -> some View { - Group { - ForEach(tripPlanner.completions) { location in - Button(action: { - tripPlanner.appendMarker(location: location) - tripPlanner.addOriginDestinationData() - switch UserDefaultsServices.shared.saveRecentLocations(data: location) { - case .success: - dismiss() - case .failure: - break - } - - }, label: { - VStack(alignment: .leading) { - Text(location.title) - .font(.headline) - Text(location.subTitle) - } - }) - .buttonStyle(PlainButtonStyle()) - } - } - } - - private func currentUserSection() -> some View { - Group { - if let userLocation = tripPlanner.currentLocation { - Button(action: { - tripPlanner.appendMarker(location: userLocation) - tripPlanner.addOriginDestinationData() - switch UserDefaultsServices.shared.saveRecentLocations(data: userLocation) { - case .success: - dismiss() - case .failure: - break - } - - }, label: { - VStack(alignment: .leading) { - Text("My Location") - .font(.headline) - Text("Your current location") - } - }) - .buttonStyle(PlainButtonStyle()) - } else { - EmptyView() - } - } - } - - private func selectLocationBasedOnMap() -> some View { - Button(action: { - tripPlanner.toggleMapMarkingMode(true) - dismiss() - }, label: { - HStack { - Image(systemName: "mappin") - Text("Choose on Map") - } - }) - .buttonStyle(PlainButtonStyle()) - } - - public var body: some View { - VStack { - PageHeaderView(text: "Change Stop") { - dismiss() - } - .padding() - - SearchView(placeholder: "Search for a place", searchText: $search, isSearchFocused: _isSearchFocused) - .padding(.horizontal, 16) - - List { - if search.isEmpty, isSearchFocused { - currentUserSection() - } else if search.isEmpty { - selectLocationBasedOnMap() - favoritesSection() - recentsSection() - } else { - searchResultsSection() - } - } - .onChange(of: search) { _, searchValue in - tripPlanner.updateQuery(queryFragment: searchValue) - } - - Spacer() - } - .onAppear { - sheetEnvironment.refreshFavoriteLocations() - sheetEnvironment.refreshRecentLocations() - } - .sheet(item: $presentationManager.activePresentation) { presentation in - switch presentation { - case .addFavoriteSheet: - AddFavoriteLocationsSheet() - .environmentObject(sheetEnvironment) - .environmentObject(tripPlanner) - case .moreFavoritesSheet: - MoreFavoriteLocationsSheet() - .environmentObject(sheetEnvironment) - .environmentObject(tripPlanner) - case .favoriteDetailsSheet: - FavoriteLocationDetailSheet() - .environmentObject(sheetEnvironment) - case .moreRecentsSheet: - MoreRecentLocationsSheet() - .environmentObject(sheetEnvironment) - } - } - .alert(isPresented: $isShowErrorAlert) { - Alert(title: Text(errorTitle), - message: Text(errorMessage), - dismissButton: .cancel(Text("OK"))) - } - .confirmationDialog("", isPresented: $isShowFavoriteConfirmationDialog, actions: { - favoriteSectionConfirmationDialog() - }) - } -} - -#Preview { - OriginDestinationSheetView() - .environmentObject(OriginDestinationSheetEnvironment()) - .environmentObject(PreviewHelpers.buildTripPlannerService()) -} diff --git a/OTPKit/Features/TripPlanner/Direction/DirectionLegOriginDestinationView.swift b/OTPKit/Features/TripPlanner/Direction/DirectionLegOriginDestinationView.swift deleted file mode 100644 index 973abe2..0000000 --- a/OTPKit/Features/TripPlanner/Direction/DirectionLegOriginDestinationView.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// DirectionLegOriginDestinationView.swift -// OTPKit -// -// Created by Hilmy Veradin on 08/08/24. -// - -import SwiftUI - -struct DirectionLegOriginDestinationView: View { - private let title: String - private let description: String - - init(title: String, description: String) { - self.title = title - self.description = description - } - - var body: some View { - HStack(spacing: 24) { - Image(systemName: "mappin") - .font(.system(size: 24)) - .padding(8) - .background(Color.red.opacity(0.8)) - .clipShape(Circle()) - .frame(width: 40) - .padding(.bottom, 16) - - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.title3) - .fontWeight(.bold) - Text(description) - .foregroundStyle(.gray) - } - } - } -} - -#Preview { - DirectionLegOriginDestinationView(title: "Origin", description: "Unknown Location") -} diff --git a/OTPKit/Features/TripPlanner/Direction/DirectionLegUnknownView.swift b/OTPKit/Features/TripPlanner/Direction/DirectionLegUnknownView.swift deleted file mode 100644 index 1d44c1b..0000000 --- a/OTPKit/Features/TripPlanner/Direction/DirectionLegUnknownView.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// DirectionLegUnknownView.swift -// OTPKit -// -// Created by Hilmy Veradin on 08/08/24. -// - -import SwiftUI - -struct DirectionLegUnknownView: View { - let leg: Leg - - var body: some View { - Image(systemName: "questionmark.circle") - .font(.system(size: 24)) - .padding() - .frame(width: 40) - - VStack(alignment: .leading, spacing: 4) { - Text("To \(leg.to.name)") - .font(.title3) - .fontWeight(.bold) - .fixedSize(horizontal: false, vertical: true) - Text( - Formatters.formatDistance(Int(leg.distance)) + - ", about " + - Formatters.formatTimeDuration(leg.duration) - ) - .foregroundStyle(.gray) - .fixedSize(horizontal: false, vertical: true) - } - } -} - -#Preview { - DirectionLegUnknownView(leg: PreviewHelpers.buildLeg()) -} diff --git a/OTPKit/Features/TripPlanner/Direction/DirectionLegVehicleView.swift b/OTPKit/Features/TripPlanner/Direction/DirectionLegVehicleView.swift deleted file mode 100644 index b3c7a75..0000000 --- a/OTPKit/Features/TripPlanner/Direction/DirectionLegVehicleView.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// DirectionLegVehicleView.swift -// OTPKit -// -// Created by Hilmy Veradin on 08/08/24. -// - -import SwiftUI - -struct DirectionLegVehicleView: View { - let leg: Leg - - var body: some View { - HStack(spacing: 24) { - Text(leg.route ?? "") - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(backgroundColor) - .foregroundStyle(.foreground) - .font(.caption) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .frame(width: 40) - - VStack(alignment: .leading, spacing: 4) { - Text("Board to \(leg.agencyName ?? "")") - .font(.title3) - .fontWeight(.bold) - .fixedSize(horizontal: false, vertical: true) - Text("\(leg.headsign ?? "")") - .foregroundStyle(.gray) - .fixedSize(horizontal: false, vertical: true) - Text("Scheduled at \(Formatters.formatDateToTime(leg.startTime))") - .fixedSize(horizontal: false, vertical: true) - } - } - } - - private var backgroundColor: Color { - if leg.mode == "TRAM" { - Color.blue - } else if leg.mode == "BUS" { - Color.green - } else { - Color.pink - } - } -} - -#Preview { - DirectionLegVehicleView(leg: PreviewHelpers.buildLeg()) -} diff --git a/OTPKit/Features/TripPlanner/Direction/DirectionLegWalkView.swift b/OTPKit/Features/TripPlanner/Direction/DirectionLegWalkView.swift deleted file mode 100644 index f4bf184..0000000 --- a/OTPKit/Features/TripPlanner/Direction/DirectionLegWalkView.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// DirectionLegWalkView.swift -// OTPKit -// -// Created by Hilmy Veradin on 08/08/24. -// - -import SwiftUI - -struct DirectionLegWalkView: View { - let leg: Leg - - var body: some View { - HStack(spacing: 24) { - Image(systemName: "figure.walk") - .font(.system(size: 24)) - .padding() - .frame(width: 40) - - VStack(alignment: .leading, spacing: 4) { - Text("Walk to \(leg.to.name)") - .font(.title3) - .fontWeight(.bold) - .fixedSize(horizontal: false, vertical: true) - Text( - Formatters.formatDistance(Int(leg.distance)) + - ", about " + - Formatters.formatTimeDuration(leg.duration) - ) - .foregroundStyle(.gray) - .fixedSize(horizontal: false, vertical: true) - } - } - } -} - -#Preview { - DirectionLegWalkView(leg: PreviewHelpers.buildLeg()) -} diff --git a/OTPKit/Features/TripPlanner/Direction/DirectionSheetView.swift b/OTPKit/Features/TripPlanner/Direction/DirectionSheetView.swift deleted file mode 100644 index 8ff78ee..0000000 --- a/OTPKit/Features/TripPlanner/Direction/DirectionSheetView.swift +++ /dev/null @@ -1,112 +0,0 @@ -import MapKit -import SwiftUI - -public struct DirectionSheetView: View { - @EnvironmentObject private var tripPlanner: TripPlannerService - @Environment(\.dismiss) private var dismiss - @Binding var sheetDetent: PresentationDetent - @State private var scrollToItem: String? - - public init(sheetDetent: Binding) { - _sheetDetent = sheetDetent - } - - private func generateLegView(leg: Leg) -> some View { - Group { - switch leg.mode { - case "BUS", "TRAM": - DirectionLegVehicleView(leg: leg) - case "WALK": - DirectionLegWalkView(leg: leg) - default: - DirectionLegUnknownView(leg: leg) - } - } - } - - private func handleTap(coordinate: CLLocationCoordinate2D, itemId: String) { - let placemark = MKPlacemark(coordinate: coordinate) - let item = MKMapItem(placemark: placemark) - tripPlanner.changeMapCamera(item) - scrollToItem = itemId - sheetDetent = .fraction(0.2) - } - - public var body: some View { - ScrollViewReader { proxy in - List { - Section { - PageHeaderView(text: "\(tripPlanner.destinationName)") { - tripPlanner.resetTripPlanner() - dismiss() - } - .frame(height: 50) - .listRowInsets(EdgeInsets()) - } - - if let itinerary = tripPlanner.selectedItinerary { - Section { - createOriginView(itinerary: itinerary) - createLegsView(itinerary: itinerary) - createDestinationView(itinerary: itinerary) - } - } - } - .padding(.horizontal, 12) - .padding(.top, 16) - .listStyle(PlainListStyle()) - .onChange(of: scrollToItem) { - if let itemId = scrollToItem { - withAnimation { - proxy.scrollTo(itemId, anchor: .top) - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - scrollToItem = nil - } - } - } - } - } - - private func createOriginView(itinerary _: Itinerary) -> some View { - DirectionLegOriginDestinationView( - title: "Origin", - description: tripPlanner.originName - ) - .id("item-0") - .onTapGesture { - if let originCoordinate = tripPlanner.originCoordinate { - handleTap(coordinate: originCoordinate, itemId: "item-0") - } - } - } - - private func createLegsView(itinerary: Itinerary) -> some View { - ForEach(Array(itinerary.legs.enumerated()), id: \.offset) { index, leg in - generateLegView(leg: leg) - .id("item-\(index + 1)") - .onTapGesture { - let coordinate = CLLocationCoordinate2D(latitude: leg.to.lat, longitude: leg.to.lon) - handleTap(coordinate: coordinate, itemId: "item-\(index + 1)") - } - } - } - - private func createDestinationView(itinerary: Itinerary) -> some View { - DirectionLegOriginDestinationView( - title: "Destination", - description: tripPlanner.destinationName - ) - .id("item-\(itinerary.legs.count + 1)") - .onTapGesture { - if let destinationCoordinate = tripPlanner.destinationCoordinate { - handleTap(coordinate: destinationCoordinate, itemId: "item-\(itinerary.legs.count + 1)") - } - } - } -} - -#Preview { - DirectionSheetView(sheetDetent: .constant(.fraction(0.2))) - .environmentObject(PreviewHelpers.buildTripPlannerService()) -} diff --git a/OTPKit/Features/TripPlanner/SelectItenerary/ItineraryLegUnknownView.swift b/OTPKit/Features/TripPlanner/SelectItenerary/ItineraryLegUnknownView.swift deleted file mode 100644 index a3867e7..0000000 --- a/OTPKit/Features/TripPlanner/SelectItenerary/ItineraryLegUnknownView.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// ItineraryLegUnknownView.swift -// OTPKit -// -// Created by Aaron Brethorst on 8/5/24. -// - -import SwiftUI - -/// Represents an itinerary leg that uses an unknown method of conveyance. -struct ItineraryLegUnknownView: View { - let leg: Leg - - var body: some View { - HStack(spacing: 4) { - Text("\(leg.mode): \(Formatters.formatTimeDuration(leg.duration))") - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color.gray.opacity(0.2)) - .foregroundStyle(.gray) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .frame(height: 40) - } -} - -#Preview { - ItineraryLegUnknownView(leg: PreviewHelpers.buildLeg()) -} diff --git a/OTPKit/Features/TripPlanner/SelectItenerary/ItineraryLegVehicleView.swift b/OTPKit/Features/TripPlanner/SelectItenerary/ItineraryLegVehicleView.swift deleted file mode 100644 index fddc50e..0000000 --- a/OTPKit/Features/TripPlanner/SelectItenerary/ItineraryLegVehicleView.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// ItineraryLegVehicleView.swift -// OTPKit -// -// Created by Aaron Brethorst on 8/5/24. -// - -import SwiftUI - -/// Represents an itinerary leg that uses a vehicular method of conveyance. -struct ItineraryLegVehicleView: View { - let leg: Leg - - var body: some View { - HStack(spacing: 4) { - Text(leg.route ?? "") - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(backgroundColor) - .foregroundStyle(.foreground) - .font(.caption) - .clipShape(RoundedRectangle(cornerRadius: 4)) - - Image(systemName: imageName) - .foregroundStyle(.foreground) - }.frame(height: 40) - } - - private var imageName: String { - if leg.mode == "TRAM" { - "tram" - } else if leg.mode == "BUS" { - "bus" - } else { - "" - } - } - - private var backgroundColor: Color { - if leg.mode == "TRAM" { - Color.blue - } else if leg.mode == "BUS" { - Color.green - } else { - Color.pink - } - } -} - -#Preview { - ItineraryLegVehicleView(leg: PreviewHelpers.buildLeg()) -} diff --git a/OTPKit/Features/TripPlanner/SelectItenerary/ItineraryLegWalkView.swift b/OTPKit/Features/TripPlanner/SelectItenerary/ItineraryLegWalkView.swift deleted file mode 100644 index 3217183..0000000 --- a/OTPKit/Features/TripPlanner/SelectItenerary/ItineraryLegWalkView.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// ItineraryLegWalkView.swift -// OTPKit -// -// Created by Aaron Brethorst on 8/5/24. -// - -import SwiftUI - -/// Represents an itinerary leg that uses a walking method of conveyance. -struct ItineraryLegWalkView: View { - let leg: Leg - - var body: some View { - HStack(spacing: 4) { - Image(systemName: "figure.walk") - Text(Formatters.formatTimeDuration(leg.duration)) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(Color.gray.opacity(0.2)) - .foregroundStyle(.gray) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .frame(height: 40) - } -} - -#Preview { - ItineraryLegWalkView(leg: PreviewHelpers.buildLeg()) -} diff --git a/OTPKit/Features/TripPlanner/SelectItenerary/TripPlannerSheetView.swift b/OTPKit/Features/TripPlanner/SelectItenerary/TripPlannerSheetView.swift deleted file mode 100644 index c7066ea..0000000 --- a/OTPKit/Features/TripPlanner/SelectItenerary/TripPlannerSheetView.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// TripPlannerSheetView.swift -// OTPKit -// -// Created by Hilmy Veradin on 25/07/24. -// - -import SwiftUI - -public struct TripPlannerSheetView: View { - @EnvironmentObject private var tripPlanner: TripPlannerService - @Environment(\.dismiss) var dismiss - - public init() {} - - private func generateLegView(leg: Leg) -> some View { - Group { - switch leg.mode { - case "BUS", "TRAM": - ItineraryLegVehicleView(leg: leg) - case "WALK": - ItineraryLegWalkView(leg: leg) - default: - ItineraryLegUnknownView(leg: leg) - } - } - } - - public var body: some View { - VStack { - if let itineraries = tripPlanner.planResponse?.plan?.itineraries { - List(itineraries, id: \.self) { itinerary in - Button(action: { - tripPlanner.selectedItinerary = itinerary - tripPlanner.planResponse = nil - dismiss() - }, label: { - HStack(spacing: 20) { - VStack(alignment: .leading) { - Text(Formatters.formatTimeDuration(itinerary.duration)) - .font(.title) - .fontWeight(.bold) - .foregroundStyle(.foreground) - Text("Bus scheduled at \(Formatters.formatDateToTime(itinerary.startTime))") - .foregroundStyle(.gray) - - FlowLayout { - ForEach(Array(zip(itinerary.legs.indices, itinerary.legs)), id: \.1) { index, leg in - - generateLegView(leg: leg) - - if index < itinerary.legs.count - 1 { - VStack { - Image(systemName: "chevron.right.circle.fill") - .frame(width: 8, height: 16) - }.frame(height: 40) - } - } - } - } - - Button(action: { - tripPlanner.selectedItinerary = itinerary - tripPlanner.planResponse = nil - tripPlanner.adjustOriginDestinationCamera() - dismiss() - }, label: { - Text("Preview") - .padding(30) - .background(Color.green) - .foregroundStyle(.foreground) - .fontWeight(.bold) - .clipShape(RoundedRectangle(cornerRadius: 12)) - }) - } - - }) - .foregroundStyle(.foreground) - } - } else { - Text("Can't find trip planner. Please try another pin point") - } - - Button(action: { - tripPlanner.resetTripPlanner() - dismiss() - }, label: { - Text("Cancel") - .frame(maxWidth: .infinity) - .padding() - .background(Color.gray) - .foregroundStyle(.foreground) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .padding(.horizontal, 16) - }) - } - } -} - -#Preview { - TripPlannerSheetView() - .environmentObject(PreviewHelpers.buildTripPlannerService()) -} diff --git a/OTPKit/Features/TripPlanner/TripPlannerView.swift b/OTPKit/Features/TripPlanner/TripPlannerView.swift deleted file mode 100644 index 4ac397e..0000000 --- a/OTPKit/Features/TripPlanner/TripPlannerView.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// TripPlannerView.swift -// OTPKit -// -// Created by Hilmy Veradin on 30/07/24. -// - -import SwiftUI - -public struct TripPlannerView: View { - @EnvironmentObject private var tripPlanner: TripPlannerService - - public init(text: String) { - self.text = text - } - - private let text: String - - public var body: some View { - VStack(alignment: .leading, spacing: 0) { - Text(text) - .fontWeight(.semibold) - .padding(16) - HStack { - Button(action: { - tripPlanner.resetTripPlanner() - }, label: { - Text("Cancel") - .frame(maxWidth: .infinity) - }) - .buttonStyle(BorderedButtonStyle()) - - Button(action: { - tripPlanner.isStepsViewPresented = true - }, label: { - Text("Start") - .frame(maxWidth: .infinity) - }) - .buttonStyle(BorderedProminentButtonStyle()) - } - .padding() - } - .background(.thickMaterial) - } -} - -#Preview { - TripPlannerView(text: "43 minutes, departs at 4:15 PM") - .environmentObject(PreviewHelpers.buildTripPlannerService()) -} diff --git a/OTPKit/Miscellaneous/FlowLayout.swift b/OTPKit/Miscellaneous/FlowLayout.swift deleted file mode 100644 index 9944568..0000000 --- a/OTPKit/Miscellaneous/FlowLayout.swift +++ /dev/null @@ -1,45 +0,0 @@ -// -// FlowLayout.swift -// OTPKit -// -// Created by Hilmy Veradin on 04/08/24. -// - -import SwiftUI - -/// Extension to make adaptive layout -struct FlowLayout: Layout { - func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) -> CGSize { - let sizes = subviews.map { $0.sizeThatFits(.unspecified) } - return layout(sizes: sizes, proposal: proposal).size - } - - func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache _: inout ()) { - let sizes = subviews.map { $0.sizeThatFits(.unspecified) } - let offsets = layout(sizes: sizes, proposal: proposal).offsets - - for (offset, subview) in zip(offsets, subviews) { - subview.place(at: CGPoint(x: bounds.minX + offset.x, y: bounds.minY + offset.y), proposal: .unspecified) - } - } - - private func layout(sizes: [CGSize], proposal: ProposedViewSize) -> (offsets: [CGPoint], size: CGSize) { - let verticalSpacing: CGFloat = 4 - let horizontalSpacing: CGFloat = 8 - var result: [CGPoint] = [] - var currentPosition: CGPoint = .zero - var maxY: CGFloat = 0 - - for size in sizes { - if currentPosition.x + size.width > (proposal.width ?? .infinity) { - currentPosition.x = 0 - currentPosition.y = maxY + verticalSpacing - } - result.append(currentPosition) - currentPosition.x += size.width + horizontalSpacing - maxY = max(maxY, currentPosition.y + size.height) - } - - return (result, CGSize(width: proposal.width ?? .infinity, height: maxY)) - } -} diff --git a/OTPKit/Miscellaneous/Formatters.swift b/OTPKit/Miscellaneous/Formatters.swift deleted file mode 100644 index 714526b..0000000 --- a/OTPKit/Miscellaneous/Formatters.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// Formatters.swift -// OTPKit -// -// Created by Aaron Brethorst on 8/5/24. -// - -import SwiftUI - -/// Reusable, commonly-used formatters for dates, durations, and distance. -class Formatters { - static func formatTimeDuration(_ duration: Int) -> String { - if duration < 60 { - return "\(duration) second\(duration > 1 ? "s" : "")" - } - - let (hours, minutes) = hoursAndMinutesFrom(seconds: duration) - - if hours == 0 { - return String(format: "%d min", minutes) - } - - return String(format: "%d hr %d min", hours, minutes) - } - - static func hoursAndMinutesFrom(seconds: Int) -> (hours: Int, minutes: Int) { - let hours = seconds / 3600 - let remainingSeconds = seconds % 3600 - let minutes = remainingSeconds / 60 - return (hours, minutes) - } - - static func formatDistance(_ distance: Int) -> String { - if distance < 1000 { - return "\(distance) meters" - } else { - let miles = Double(distance) / 1609.34 - return String(format: "%.1f miles", miles) - } - } - - static func formatDateToTime(_ date: Date, locale: Locale = .current) -> String { - let formatter = DateFormatter() - formatter.locale = locale - formatter.dateStyle = .none - formatter.timeStyle = .short - return formatter.string(from: date) - } -} diff --git a/OTPKit/Miscellaneous/PresentationManager.swift b/OTPKit/Miscellaneous/PresentationManager.swift deleted file mode 100644 index 001436f..0000000 --- a/OTPKit/Miscellaneous/PresentationManager.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// PresentationManager.swift -// OTPKit -// -// Created by Aaron Brethorst on 8/11/24. -// - -import SwiftUI - -/// Manages the presentation state of dependent modal sheets. -/// -/// In other words, instead of having to maintain several independent boolean values for determining which -/// sheet is currently visible, you can use `PresentationManager` to DRY up and orchestrate your -/// modal sheet state. -class PresentationManager: ObservableObject { - @Published var activePresentation: PresentationType? - - func present(_ presentationType: PresentationType) { - activePresentation = presentationType - } - - func dismiss() { - activePresentation = nil - } -} diff --git a/OTPKit/Miscellaneous/Utilities.swift b/OTPKit/Miscellaneous/Utilities.swift deleted file mode 100644 index c97ae8c..0000000 --- a/OTPKit/Miscellaneous/Utilities.swift +++ /dev/null @@ -1,10 +0,0 @@ -// -// Utilities.swift -// OTPKit -// -// Created by Aaron Brethorst on 7/31/24. -// - -import Foundation - -typealias VoidBlock = () -> Void diff --git a/OTPKit/Models/Helpers/DebugDescriptionBuilder.swift b/OTPKit/Models/Helpers/DebugDescriptionBuilder.swift deleted file mode 100644 index 23da776..0000000 --- a/OTPKit/Models/Helpers/DebugDescriptionBuilder.swift +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright (C) Open Transit Software Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Foundation - -/** - A simple way to construct a `debugDescription` property for an object. - - Here's how you might use it: - - public override var debugDescription: String { - var descriptionBuilder = DebugDescriptionBuilder(baseDescription: super.debugDescription) - descriptionBuilder.add(key: "id", value: id) - return descriptionBuilder.description - } - */ -public struct DebugDescriptionBuilder { - let baseDescription: String - var properties = [String: Any]() - - public init(baseDescription: String) { - self.baseDescription = baseDescription - } - - public mutating func add(key: String, value: Any?) { - properties[key] = value ?? "(nil)" - } - - public var description: String { - "\(baseDescription) \(properties)" - } -} diff --git a/OTPKit/Models/MapExtension/MarkerItem.swift b/OTPKit/Models/MapExtension/MarkerItem.swift deleted file mode 100644 index dc0954f..0000000 --- a/OTPKit/Models/MapExtension/MarkerItem.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// MarkerItem.swift -// OTPKit -// -// Created by Hilmy Veradin on 16/07/24. -// - -import Foundation -import MapKit - -public struct MarkerItem: Identifiable, Hashable { - public let id: UUID = .init() - public let item: MKMapItem -} diff --git a/OTPKit/Models/OriginDestination/OriginDestinationState.swift b/OTPKit/Models/OriginDestination/OriginDestinationState.swift deleted file mode 100644 index 385e2c4..0000000 --- a/OTPKit/Models/OriginDestination/OriginDestinationState.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// OriginDestinationState.swift -// OTPKit -// -// Created by Hilmy Veradin on 18/07/24. -// - -import Foundation - -/// Responsible for managing origin or destination state -/// - Enums: -/// - origin: This manage origin state of the trip planner -/// - destination: This manage destination state of the trip planner -public enum OriginDestinationState { - case origin - case destination -} diff --git a/OTPKit/Models/Polyline/Polyline.swift b/OTPKit/Models/Polyline/Polyline.swift deleted file mode 100644 index e19352e..0000000 --- a/OTPKit/Models/Polyline/Polyline.swift +++ /dev/null @@ -1,387 +0,0 @@ -// Polyline.swift -// -// Copyright (c) 2015 Raphaël Mor -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -// swiftlint:disable line_length - -import CoreLocation -import Foundation -#if canImport(MapKit) && !os(watchOS) - import MapKit -#endif - -// MARK: - Public Classes - - -/// This class can be used for : -/// -/// - Encoding an [CLLocation] or a [CLLocationCoordinate2D] to a polyline String -/// - Decoding a polyline String to an [CLLocation] or a [CLLocationCoordinate2D] -/// - Encoding / Decoding associated levels -/// -/// it is aims to produce the same results as google's iOS sdk not as the online -/// tool which is fuzzy when it comes to rounding values -/// -/// it is based on google's algorithm that can be found here : -/// -/// :see: https://developers.google.com/maps/documentation/utilities/polylinealgorithm -public struct Polyline { - /// The array of coordinates (nil if polyline cannot be decoded) - public let coordinates: [CLLocationCoordinate2D]? - /// The encoded polyline - public let encodedPolyline: String - - /// The array of levels (nil if cannot be decoded, or is not provided) - public let levels: [UInt32]? - /// The encoded levels (nil if cannot be encoded, or is not provided) - public let encodedLevels: String? - - /// The array of location (computed from coordinates) - #if canImport(CoreLocation) - public var locations: [CLLocation]? { - coordinates.map(toLocations) - } - #endif - - #if canImport(MapKit) && !os(watchOS) - /// Convert polyline to MKPolyline to use with MapKit (nil if polyline cannot be decoded) - @available(tvOS 9.2, *) - public var mkPolyline: MKPolyline? { - guard let coordinates else { return nil } - let mkPolyline = MKPolyline(coordinates: coordinates, count: coordinates.count) - return mkPolyline - } - #endif - - // MARK: - Public Methods - - - /// This designated initializer encodes a `[CLLocationCoordinate2D]` - /// - /// - parameter coordinates: The `Array` of `CLLocationCoordinate2D`s (that is, `CLLocationCoordinate2D`s) that you want to encode - /// - parameter levels: The optional `Array` of levels that you want to encode (default: `nil`) - /// - parameter precision: The precision used for encoding (default: `1e5`) - public init(coordinates: [CLLocationCoordinate2D], levels: [UInt32]? = nil, precision: Double = 1e5) { - self.coordinates = coordinates - self.levels = levels - - encodedPolyline = encodeCoordinates(coordinates, precision: precision) - - encodedLevels = levels.map(encodeLevels) - } - - /// This designated initializer decodes a polyline `String` - /// - /// - parameter encodedPolyline: The polyline that you want to decode - /// - parameter encodedLevels: The levels that you want to decode (default: `nil`) - /// - parameter precision: The precision used for decoding (default: `1e5`) - public init(encodedPolyline: String, encodedLevels: String? = nil, precision: Double = 1e5) { - self.encodedPolyline = encodedPolyline - self.encodedLevels = encodedLevels - - coordinates = decodePolyline(encodedPolyline, precision: precision) - - levels = self.encodedLevels.flatMap(decodeLevels) - } - - #if canImport(CoreLocation) - /// This init encodes a `[CLLocation]` - /// - /// - parameter locations: The `Array` of `CLLocation` that you want to encode - /// - parameter levels: The optional array of levels that you want to encode (default: `nil`) - /// - parameter precision: The precision used for encoding (default: `1e5`) - public init(locations: [CLLocation], levels: [UInt32]? = nil, precision: Double = 1e5) { - let coordinates = toCoordinates(locations) - self.init(coordinates: coordinates, levels: levels, precision: precision) - } - #endif -} - -// MARK: - Public Functions - - -/// This function encodes an `[CLLocationCoordinate2D]` to a `String` -/// -/// - parameter coordinates: The `Array` of `CLLocationCoordinate2D`s (that is, `CLLocationCoordinate2D`s) that you want to encode -/// - parameter precision: The precision used to encode coordinates (default: `1e5`) -/// -/// - returns: A `String` representing the encoded Polyline -public func encodeCoordinates(_ coordinates: [CLLocationCoordinate2D], precision: Double = 1e5) -> String { - var previousCoordinate = IntegerCoordinates(0, 0) - var encodedPolyline = "" - - for coordinate in coordinates { - let intLatitude = Int(round(coordinate.latitude * precision)) - let intLongitude = Int(round(coordinate.longitude * precision)) - - let coordinatesDifference = (intLatitude - previousCoordinate.latitude, intLongitude - previousCoordinate.longitude) - - encodedPolyline += encodeCoordinate(coordinatesDifference) - - previousCoordinate = (intLatitude, intLongitude) - } - - return encodedPolyline -} - -#if canImport(CoreLocation) - /// This function encodes an `[CLLocation]` to a `String` - /// - /// - parameter coordinates: The `Array` of `CLLocation` that you want to encode - /// - parameter precision: The precision used to encode locations (default: `1e5`) - /// - /// - returns: A `String` representing the encoded Polyline - public func encodeLocations(_ locations: [CLLocation], precision: Double = 1e5) -> String { - encodeCoordinates(toCoordinates(locations), precision: precision) - } -#endif - -/// This function encodes an `[UInt32]` to a `String` -/// -/// - parameter levels: The `Array` of `UInt32` levels that you want to encode -/// -/// - returns: A `String` representing the encoded Levels -public func encodeLevels(_ levels: [UInt32]) -> String { - levels.reduce("") { - $0 + encodeLevel($1) - } -} - -/// This function decodes a `String` to a `[CLLocationCoordinate2D]?` -/// -/// - parameter encodedPolyline: `String` representing the encoded Polyline -/// - parameter precision: The precision used to decode coordinates (default: `1e5`) -/// -/// - returns: A `[CLLocationCoordinate2D]` representing the decoded polyline if valid, `nil` otherwise -public func decodePolyline(_ encodedPolyline: String, precision: Double = 1e5) -> [CLLocationCoordinate2D]? { - let data = encodedPolyline.data(using: .utf8)! - return data.withUnsafeBytes { byteArray -> [CLLocationCoordinate2D]? in - let length = data.count - var position = 0 - - var decodedCoordinates = [CLLocationCoordinate2D]() - - var lat = 0.0 - var lon = 0.0 - - while position < length { - do { - let resultingLat = try decodeSingleCoordinate(byteArray: byteArray, length: length, position: &position, precision: precision) - lat += resultingLat - - let resultingLon = try decodeSingleCoordinate(byteArray: byteArray, length: length, position: &position, precision: precision) - lon += resultingLon - } catch { - return nil - } - - decodedCoordinates.append(CLLocationCoordinate2D(latitude: lat, longitude: lon)) - } - - return decodedCoordinates - } -} - -#if canImport(CoreLocation) - /// This function decodes a String to a [CLLocation]? - /// - /// - parameter encodedPolyline: String representing the encoded Polyline - /// - parameter precision: The precision used to decode locations (default: 1e5) - /// - /// - returns: A [CLLocation] representing the decoded polyline if valid, nil otherwise - public func decodePolyline(_ encodedPolyline: String, precision: Double = 1e5) -> [CLLocation]? { - decodePolyline(encodedPolyline, precision: precision).map(toLocations) - } -#endif - -/// This function decodes a `String` to an `[UInt32]` -/// -/// - parameter encodedLevels: The `String` representing the levels to decode -/// -/// - returns: A `[UInt32]` representing the decoded Levels if the `String` is valid, `nil` otherwise -public func decodeLevels(_ encodedLevels: String) -> [UInt32]? { - var remainingLevels = encodedLevels.unicodeScalars - var decodedLevels = [UInt32]() - - while remainingLevels.count > 0 { - do { - let chunk = try extractNextChunk(&remainingLevels) - let level = decodeLevel(chunk) - decodedLevels.append(level) - } catch { - return nil - } - } - - return decodedLevels -} - -// MARK: - Private - - -// MARK: Encode Coordinate - -private func encodeCoordinate(_ locationCoordinate: IntegerCoordinates) -> String { - let latitudeString = encodeSingleComponent(locationCoordinate.latitude) - let longitudeString = encodeSingleComponent(locationCoordinate.longitude) - - return latitudeString + longitudeString -} - -private func encodeSingleComponent(_ value: Int) -> String { - var intValue = value - - if intValue < 0 { - intValue = intValue << 1 - intValue = ~intValue - } else { - intValue = intValue << 1 - } - - return encodeFiveBitComponents(intValue) -} - -// MARK: Encode Levels - -private func encodeLevel(_ level: UInt32) -> String { - encodeFiveBitComponents(Int(level)) -} - -private func encodeFiveBitComponents(_ value: Int) -> String { - var remainingComponents = value - - var fiveBitComponent = 0 - var returnString = String() - - repeat { - fiveBitComponent = remainingComponents & 0x1F - - if remainingComponents >= 0x20 { - fiveBitComponent |= 0x20 - } - - fiveBitComponent += 63 - - let char = UnicodeScalar(fiveBitComponent)! - returnString.append(String(char)) - remainingComponents = remainingComponents >> 5 - } while remainingComponents != 0 - - return returnString -} - -// MARK: Decode Coordinate - -// We use a byte array (UnsafePointer) here for performance reasons. Check with swift 2 if we can -// go back to using [Int8] -private func decodeSingleCoordinate(byteArray: UnsafeRawBufferPointer, length: Int, position: inout Int, precision: Double = 1e5) throws -> Double { - guard position < length else { throw PolylineError.singleCoordinateDecodingError } - - let bitMask = Int8(0x1F) - - var coordinate: Int32 = 0 - - var currentChar: Int8 - var componentCounter: Int32 = 0 - var component: Int32 = 0 - - repeat { - currentChar = Int8(byteArray[position]) - 63 - component = Int32(currentChar & bitMask) - coordinate |= (component << (5 * componentCounter)) - position += 1 - componentCounter += 1 - } while ((currentChar & 0x20) == 0x20) && (position < length) && (componentCounter < 6) - - if componentCounter == 6, (currentChar & 0x20) == 0x20 { - throw PolylineError.singleCoordinateDecodingError - } - - if (coordinate & 0x01) == 0x01 { - coordinate = ~(coordinate >> 1) - } else { - coordinate = coordinate >> 1 - } - - return Double(coordinate) / precision -} - -// MARK: Decode Levels - -private func extractNextChunk(_ encodedString: inout String.UnicodeScalarView) throws -> String { - var currentIndex = encodedString.startIndex - - while currentIndex != encodedString.endIndex { - let currentCharacterValue = Int32(encodedString[currentIndex].value) - if isSeparator(currentCharacterValue) { - let extractedScalars = encodedString[encodedString.startIndex ... currentIndex] - encodedString = String.UnicodeScalarView(encodedString[encodedString.index(after: currentIndex) ..< encodedString.endIndex]) - - return String(extractedScalars) - } - - currentIndex = encodedString.index(after: currentIndex) - } - - throw PolylineError.chunkExtractingError -} - -private func decodeLevel(_ encodedLevel: String) -> UInt32 { - let scalarArray = [] + encodedLevel.unicodeScalars - - return UInt32(agregateScalarArray(scalarArray)) -} - -private func agregateScalarArray(_ scalars: [UnicodeScalar]) -> Int32 { - let lastValue = Int32(scalars.last!.value) - - let fiveBitComponents: [Int32] = scalars.map { scalar in - let value = Int32(scalar.value) - if value != lastValue { - return (value - 63) ^ 0x20 - } else { - return value - 63 - } - } - - return Array(fiveBitComponents.reversed()).reduce(0) { ($0 << 5) | $1 } -} - -// MARK: Utilities - -enum PolylineError: Error { - case singleCoordinateDecodingError - case chunkExtractingError -} - -private func toCoordinates(_ locations: [CLLocation]) -> [CLLocationCoordinate2D] { - locations.map { location in location.coordinate } -} - -private func toLocations(_ coordinates: [CLLocationCoordinate2D]) -> [CLLocation] { - coordinates.map { coordinate in - CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude) - } -} - -private func isSeparator(_ value: Int32) -> Bool { - (value - 63) & 0x20 != 0x20 -} - -private typealias IntegerCoordinates = (latitude: Int, longitude: Int) - -// swiftlint:enable line_length diff --git a/OTPKit/Models/TripPlanner/ErrorResponse.swift b/OTPKit/Models/TripPlanner/ErrorResponse.swift deleted file mode 100644 index 7f7e9c4..0000000 --- a/OTPKit/Models/TripPlanner/ErrorResponse.swift +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (C) Open Transit Software Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Foundation - -/// `ErrorResponse` represents an error structure used across the application to handle and represent -/// OTP errors uniformly. -public struct ErrorResponse: Codable, Hashable { - /// A unique identifier for the error. - public let id: Int - - /// A descriptive message associated with the error, providing more detailed information about what went wrong. - /// This message can be presented to the user or used in debugging to provide context about the error. - public let message: String -} diff --git a/OTPKit/Models/TripPlanner/Itinerary.swift b/OTPKit/Models/TripPlanner/Itinerary.swift deleted file mode 100644 index 34fc5ac..0000000 --- a/OTPKit/Models/TripPlanner/Itinerary.swift +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright (C) Open Transit Software Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Foundation - -/// Represents a travel itinerary with detailed segments and timings. -public struct Itinerary: Codable, Hashable { - /// Total duration of the itinerary in seconds. - public let duration: Int - - /// Start time of the itinerary. - public let startTime: Date - - /// End time of the itinerary. - public let endTime: Date - - /// Total walking time in minutes within the itinerary. - public let walkTime: Int - - /// Total transit time in minutes within the itinerary. - public let transitTime: Int - - /// Total waiting time in minutes within the itinerary. - public let waitingTime: Int - - /// Total walking distance in meters within the itinerary. - public let walkDistance: Double - - /// Indicates whether the walking distance limit was exceeded. - public let walkLimitExceeded: Bool - - /// Total elevation lost in meters within the itinerary. - public let elevationLost: Double - - /// Total elevation gained in meters within the itinerary. - public let elevationGained: Double - - /// Number of transfers within the itinerary. - public let transfers: Int - - /// Array of `Leg` objects representing individual segments of the itinerary. - public let legs: [Leg] - - public var summary: String { - // TODO: localize this! - let time = Formatters.formatDateToTime(startTime) - let formattedDuration = Formatters.formatTimeDuration(duration) - // return something like "43 minutes, departs at X:YY PM" - return "Departs at \(time); duration: \(formattedDuration)" - } -} diff --git a/OTPKit/Models/TripPlanner/Leg.swift b/OTPKit/Models/TripPlanner/Leg.swift deleted file mode 100644 index 0f039b5..0000000 --- a/OTPKit/Models/TripPlanner/Leg.swift +++ /dev/null @@ -1,78 +0,0 @@ -/* - * Copyright (C) Open Transit Software Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import CoreLocation -import Foundation - -// swiftlint:disable identifier_name - -/// Represents a single segment or leg of a travel itinerary. -public struct Leg: Codable, Hashable { - /// Start time of the leg. - public let startTime: Date - - /// End time of the leg. - public let endTime: Date - - /// Mode of transportation used in this leg (e.g., "BUS", "TRAIN"). - public let mode: String - - /// Optional route identifier for this leg. - public let route: String? - - /// Optional name of the transportation agency for this leg. - public let agencyName: String? - - /// Starting point of the leg. - public let from: Place - - /// Ending point of the leg. - public let to: Place - - /// A container for the polyline of this leg. - public let legGeometry: LegGeometry - - /// Returns an array of `CLLocationCoordinate2D`s representing the geometry of this `Leg`. - public func decodePolyline() -> [CLLocationCoordinate2D]? { - OTPKit.decodePolyline(legGeometry.points) - } - - /// Distance covered in this leg, in meters. - public let distance: Double - - /// Optional flag indicating whether this leg involves transit. - public let transitLeg: Bool? - - /// Duration of the leg in seconds. - public let duration: Int - - /// Optional flag indicating if the leg details are based on real-time data. - public let realTime: Bool? - - /// Optional list of street names traversed in this leg. - public let streetNames: [String]? - - /// Optional flag indicating whether the leg involves a pathway. - public let pathway: Bool? - - /// Optional detailed steps for navigating this leg. - public let steps: [Step]? - - /// Optional head sign of the transit legs, bus and trams - public let headsign: String? -} - -// swiftlint:enable identifier_name diff --git a/OTPKit/Models/TripPlanner/LegGeometry.swift b/OTPKit/Models/TripPlanner/LegGeometry.swift deleted file mode 100644 index 8604570..0000000 --- a/OTPKit/Models/TripPlanner/LegGeometry.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// LegGeometry.swift -// OTPKit -// -// Created by Aaron Brethorst on 8/10/24. -// - -import Foundation - -/// A container for the polyline of a `Leg`. -public struct LegGeometry: Codable, Hashable { - /// The raw polyline; encoded with the Google Polyline Algorithm Format. - public let points: String - - /// The number of coordinates represented by `points`. - public let length: Int -} diff --git a/OTPKit/Models/TripPlanner/Location.swift b/OTPKit/Models/TripPlanner/Location.swift deleted file mode 100644 index 3299e5a..0000000 --- a/OTPKit/Models/TripPlanner/Location.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// Location.swift -// OTPKitDemo -// -// Created by Hilmy Veradin on 03/07/24. -// - -import Foundation - -/// Location is the main model for defining favorite location, recent location, map points -public struct Location: Identifiable, Codable, Equatable, Hashable { - public var id: UUID - public let title: String - public let subTitle: String - public let latitude: Double - public let longitude: Double - - public init(id: UUID = UUID(), title: String, subTitle: String, latitude: Double, longitude: Double) { - self.id = id - self.title = title - self.subTitle = subTitle - self.latitude = latitude - self.longitude = longitude - } -} diff --git a/OTPKit/Models/TripPlanner/OTPResponse.swift b/OTPKit/Models/TripPlanner/OTPResponse.swift deleted file mode 100644 index ad7b0d9..0000000 --- a/OTPKit/Models/TripPlanner/OTPResponse.swift +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright (C) Open Transit Software Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Foundation - -/// Represents the response from the OpenTripPlanner (OTP) API. -public struct OTPResponse: Codable, Hashable { - /// Parameters used in the request that generated this response. - public let requestParameters: RequestParameters - - /// Optional `Plan` object containing detailed itinerary plans if the request was successful. - public let plan: Plan? - - /// Optional `ErrorResponse` object containing error details if the request failed. - public let error: ErrorResponse? -} diff --git a/OTPKit/Models/TripPlanner/Place.swift b/OTPKit/Models/TripPlanner/Place.swift deleted file mode 100644 index 08e32c1..0000000 --- a/OTPKit/Models/TripPlanner/Place.swift +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (C) Open Transit Software Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Foundation - -/// Represents a geographical location used in travel itineraries. -public struct Place: Codable, Hashable { - /// Name or description of the place. - public let name: String - - /// Longitude of the place. - public let lon: Double - - /// Latitude of the place. - public let lat: Double - - /// Type of vertex representing the place, such as 'NORMAL', 'STOP', or 'STATION'. - public let vertexType: String -} diff --git a/OTPKit/Models/TripPlanner/Plan.swift b/OTPKit/Models/TripPlanner/Plan.swift deleted file mode 100644 index f861ae1..0000000 --- a/OTPKit/Models/TripPlanner/Plan.swift +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright (C) Open Transit Software Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Foundation - -// swiftlint:disable identifier_name - -/// Represents a comprehensive travel plan containing multiple itineraries. -public struct Plan: Codable, Hashable { - /// Date and time when the travel plan was generated. - public let date: Date - - /// Starting point of the travel plan. - public let from: Place - - /// Destination point of the travel plan. - public let to: Place - - /// List of `Itinerary` objects providing different routing options within the travel plan. - public let itineraries: [Itinerary] -} - -// swiftlint:enable identifier_name diff --git a/OTPKit/Models/TripPlanner/RequestParameters.swift b/OTPKit/Models/TripPlanner/RequestParameters.swift deleted file mode 100644 index 91bcbe5..0000000 --- a/OTPKit/Models/TripPlanner/RequestParameters.swift +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (C) Open Transit Software Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Foundation - -/// Contains parameters used to define the specifics of a request to the OpenTripPlanner API. -public struct RequestParameters: Codable, Hashable { - /// The starting location for the travel plan, expressed in a string format, typically as coordinates. - public let fromPlace: String - - /// The destination location for the travel plan, expressed in a string format, typically as coordinates. - public let toPlace: String - - /// The preferred time for departure or arrival, depending on `arriveBy`. - public let time: String - - /// The date of travel. - public let date: String - - /// Travel modes included in the trip planning, such as "TRANSIT", "WALK". - public let mode: String - - /// Indicates whether the `time` parameter refers to arrival time ("true") or departure time ("false"). - public let arriveBy: String - - /// Maximum walking distance the user is willing to walk, expressed in meters. - public let maxWalkDistance: String - - /// Indicates whether the route should accommodate wheelchair access ("true" or "false"). - public let wheelchair: String -} diff --git a/OTPKit/Models/TripPlanner/Step.swift b/OTPKit/Models/TripPlanner/Step.swift deleted file mode 100644 index d358ef9..0000000 --- a/OTPKit/Models/TripPlanner/Step.swift +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright (C) Open Transit Software Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Foundation - -/// Represents a detailed step within a leg of an itinerary, providing navigation details. -public struct Step: Codable, Hashable { - /// Distance of this step in meters. - public let distance: Double - - /// Name of the street involved in this step. - public let streetName: String - - /// Optional description of the direction to take at this step (e.g., "left", "right"). - public let relativeDirection: String? - - /// Optional elevation change during this step, in meters. - public let elevationChange: Double? - - /// Longitude of the place. - public let lon: Double - - /// Latitude of the place. - public let lat: Double -} diff --git a/OTPKit/Network/RestAPI.swift b/OTPKit/Network/RestAPI.swift deleted file mode 100644 index 8f1c4fb..0000000 --- a/OTPKit/Network/RestAPI.swift +++ /dev/null @@ -1,79 +0,0 @@ -/* - * Copyright (C) Open Transit Software Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Foundation - -// swiftlint:disable function_parameter_count - -public actor RestAPI { - public init( - baseURL: URL, - dataLoader: URLDataLoader = URLSession.shared - ) { - self.baseURL = baseURL - self.dataLoader = dataLoader - } - - public let baseURL: URL - public nonisolated let dataLoader: URLDataLoader - - public func fetchPlan( - fromPlace: String, - toPlace: String, - time: String, - date: String, - mode: String, - arriveBy: Bool, - maxWalkDistance: Int, - wheelchair: Bool - ) async throws -> OTPResponse { - var components = URLComponents(url: buildURL(endpoint: "plan"), resolvingAgainstBaseURL: false)! - - components.queryItems = [ - URLQueryItem(name: "fromPlace", value: fromPlace), - URLQueryItem(name: "toPlace", value: toPlace), - URLQueryItem(name: "time", value: time), - URLQueryItem(name: "date", value: date), - URLQueryItem(name: "mode", value: mode), - URLQueryItem(name: "arriveBy", value: arriveBy ? "true" : "false"), - URLQueryItem(name: "maxWalkDistance", value: String(maxWalkDistance)), - URLQueryItem(name: "wheelchair", value: wheelchair ? "true" : "false") - ] - - let request = URLRequest(url: components.url!) - let (data, response) = try await dataLoader.data(for: request) - - guard - let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 - else { - throw URLError(.badServerResponse) - } - - // Decode the JSON data to the OTPResponse struct - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .millisecondsSince1970 - let decodedResponse = try decoder.decode(OTPResponse.self, from: data) - - return decodedResponse - } - - private func buildURL(endpoint: String) -> URL { - baseURL.appending(path: endpoint) - } -} - -// swiftlint:enable function_parameter_count diff --git a/OTPKit/Network/URLDataLoader.swift b/OTPKit/Network/URLDataLoader.swift deleted file mode 100644 index 8a978fa..0000000 --- a/OTPKit/Network/URLDataLoader.swift +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright (C) Open Transit Software Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Foundation - -public protocol URLDataLoader: NSObjectProtocol { - func dataTask( - with request: URLRequest, - completionHandler: @escaping @Sendable (Data?, URLResponse?, Error?) -> Void - ) -> URLSessionDataTask - func data(for request: URLRequest) async throws -> (Data, URLResponse) -} - -extension URLSession: URLDataLoader {} diff --git a/OTPKit/OTPKit.h b/OTPKit/OTPKit.h deleted file mode 100644 index f41a4bb..0000000 --- a/OTPKit/OTPKit.h +++ /dev/null @@ -1,18 +0,0 @@ -// -// OTPKit.h -// OTPKit -// -// Created by Aaron Brethorst on 5/2/24. -// - -#import - -//! Project version number for OTPKit. -FOUNDATION_EXPORT double OTPKitVersionNumber; - -//! Project version string for OTPKit. -FOUNDATION_EXPORT const unsigned char OTPKitVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import - - diff --git a/OTPKit/Previews/PreviewHelpers.swift b/OTPKit/Previews/PreviewHelpers.swift deleted file mode 100644 index 62e7954..0000000 --- a/OTPKit/Previews/PreviewHelpers.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// PreviewHelpers.swift -// OTPKit -// -// Created by Aaron Brethorst on 8/5/24. -// - -import CoreLocation -import MapKit -import SwiftUI - -class PreviewHelpers { - static func buildTripPlannerService() -> TripPlannerService { - TripPlannerService( - apiClient: RestAPI(baseURL: URL(string: "https://otp.prod.sound.obaweb.org/otp/routers/default/")!), - locationManager: CLLocationManager(), - searchCompleter: MKLocalSearchCompleter() - ) - } - - static func buildLeg() -> Leg { - Leg( - startTime: Date(), - endTime: Date(), - mode: "TRAM", - route: nil, - agencyName: nil, - from: Place(name: "foo", lon: 47, lat: -122, vertexType: ""), - to: Place(name: "foo", lon: 47, lat: -122, vertexType: ""), - legGeometry: LegGeometry(points: "AA@@", length: 4), - distance: 100, - transitLeg: false, - duration: 10, - realTime: true, - streetNames: nil, - pathway: nil, - steps: nil, - headsign: nil - ) - } -} diff --git a/OTPKit/Services/TripPlannerService.swift b/OTPKit/Services/TripPlannerService.swift deleted file mode 100644 index 63c93f6..0000000 --- a/OTPKit/Services/TripPlannerService.swift +++ /dev/null @@ -1,362 +0,0 @@ -// -// TripPlannerService.swift -// OTPKit -// -// Created by Hilmy Veradin on 18/07/24. -// - -import Foundation -import MapKit -import SwiftUI - -/// Services to manage all functions related to trip planning -public final class TripPlannerService: NSObject, ObservableObject { - // MARK: - Properties - - private let apiClient: RestAPI - - // Trip Planner - @Published public var planResponse: OTPResponse? - @Published public var isFetchingResponse = false - @Published public var tripPlannerErrorMessage: String? - @Published public var selectedItinerary: Itinerary? - @Published public var isStepsViewPresented = false - - // Origin Destination - @Published public var originDestinationState: OriginDestinationState = .origin - @Published public var originCoordinate: CLLocationCoordinate2D? - @Published public var destinationCoordinate: CLLocationCoordinate2D? - - // Location Search - private let searchCompleter: MKLocalSearchCompleter - private let debounceInterval: TimeInterval - private var debounceTimer: Timer? - private var currentRegion: MKCoordinateRegion? - private var searchTask: Task? - - @Published var completions = [Location]() - - // Map Extension - @Published public var selectedMapPoint: [String: MarkerItem?] = [ - "origin": nil, - "destination": nil - ] - - @Published public var isMapMarkingMode = false - @Published public var currentCameraPosition: MapCameraPosition = .userLocation(fallback: .automatic) - - @Published public var originName = "Origin" - @Published public var destinationName = "Destination" - - // User Location - @Published var currentLocation: Location? - private let locationManager: CLLocationManager - - // MARK: - Initialization - - public init(apiClient: RestAPI, locationManager: CLLocationManager, searchCompleter: MKLocalSearchCompleter) { - self.apiClient = apiClient - self.locationManager = locationManager - self.searchCompleter = searchCompleter - debounceInterval = 1 - super.init() - - searchCompleter.delegate = self - locationManager.delegate = self - locationManager.desiredAccuracy = kCLLocationAccuracyBest - } - - deinit { - debounceTimer?.invalidate() - searchCompleter.cancel() - } - - // MARK: - Location Search Methods - - /// Initiates a local search for `queryFragment`. - /// This will be debounced, as set by the `debounceInterval` on the initializer. - /// - Parameter queryFragment: The search term - public func updateQuery(queryFragment: String) { - debounceTimer?.invalidate() - debounceTimer = Timer.scheduledTimer(withTimeInterval: debounceInterval, repeats: false) { [weak self] _ in - guard let self else { return } - searchCompleter.resultTypes = .query - searchCompleter.queryFragment = queryFragment - } - } - - private func updateCompleterRegion() { - if let region = currentRegion { - searchCompleter.region = region - } - } - - // MARK: - Map Extension Methods - - public func selectAndRefreshCoordinate() { - switch originDestinationState { - case .origin: - guard let coordinate = selectedMapPoint["origin"]??.item.placemark.coordinate else { return } - originCoordinate = coordinate - case .destination: - guard let coordinate = selectedMapPoint["destination"]??.item.placemark.coordinate else { return } - destinationCoordinate = coordinate - } - } - - public func appendMarker(location: Location) { - let coordinate = CLLocationCoordinate2D(latitude: location.latitude, longitude: location.longitude) - let mapItem = MKMapItem(placemark: .init(coordinate: coordinate)) - mapItem.name = location.title - let markerItem = MarkerItem(item: mapItem) - switch originDestinationState { - case .origin: - selectedMapPoint["origin"] = markerItem - changeMapCamera(mapItem) - case .destination: - selectedMapPoint["destination"] = markerItem - changeMapCamera(mapItem) - } - } - - public func addOriginDestinationData() { - switch originDestinationState { - case .origin: - originName = selectedMapPoint["origin"]??.item.name ?? "Location unknown" - originCoordinate = selectedMapPoint["origin"]??.item.placemark.coordinate - case .destination: - destinationName = selectedMapPoint["destination"]??.item.name ?? "Location unknown" - destinationCoordinate = selectedMapPoint["destination"]??.item.placemark.coordinate - } - - checkAndFetchTripPlanner() - } - - public func removeOriginDestinationData() { - switch originDestinationState { - case .origin: - originName = "Origin" - originCoordinate = nil - selectedMapPoint["origin"] = nil - case .destination: - destinationName = "Destination" - destinationCoordinate = nil - selectedMapPoint["destination"] = nil - } - } - - public func toggleMapMarkingMode(_ isMapMarking: Bool) { - isMapMarkingMode = isMapMarking - } - - public func changeMapCamera(_ item: MKMapItem) { - currentCameraPosition = MapCameraPosition.item(item) - } - - public func generateMarkers() -> some MapContent { - ForEach(Array(selectedMapPoint.values.compactMap { $0 }), id: \.id) { markerItem in - Marker(item: markerItem.item) - } - } - - public func generateMapPolyline() -> MapPolyline? { - guard let itinerary = selectedItinerary else { return nil } - - // Use steps to calculate the Location Coordinate - let coordinates = itinerary.legs.flatMap { leg in - leg.decodePolyline()?.compactMap { coordinate in - coordinate - } ?? [] - } - - let coodinateExists = !coordinates.isEmpty - - guard coodinateExists else { return nil } - - return MapPolyline(coordinates: coordinates) - } - - public func adjustOriginDestinationCamera() { - guard let originCoordinate, let destinationCoordinate else { return } - // Create a rectangle that encompasses both coordinates - let minLat = min(originCoordinate.latitude, destinationCoordinate.latitude) - let maxLat = max(originCoordinate.latitude, destinationCoordinate.latitude) - let minLon = min(originCoordinate.longitude, destinationCoordinate.longitude) - let maxLon = max(originCoordinate.longitude, destinationCoordinate.longitude) - - let center = CLLocationCoordinate2D(latitude: (minLat + maxLat) / 2, - longitude: (minLon + maxLon) / 2) - let span = MKCoordinateSpan(latitudeDelta: (maxLat - minLat) * 1.5, - longitudeDelta: (maxLon - minLon) * 1.5) - - let region = MKCoordinateRegion(center: center, span: span) - - currentCameraPosition = .region(region) - } - - // MARK: - Trip Planner Methods - - private func checkAndFetchTripPlanner() { - guard originCoordinate != nil, - destinationCoordinate != nil - else { - return - } - - let fromPlace = formatCoordinate(originCoordinate) - let toPlace = formatCoordinate(destinationCoordinate) - - isFetchingResponse = true - - Task { - do { - let response = try await apiClient.fetchPlan( - fromPlace: fromPlace, - toPlace: toPlace, - time: getCurrentTimeFormatted(), - date: getFormattedTodayDate(), - mode: "TRANSIT,WALK", - arriveBy: false, - maxWalkDistance: 1000, - wheelchair: false - ) - DispatchQueue.main.async { - self.planResponse = response - self.isFetchingResponse = false - } - } catch { - DispatchQueue.main.async { - self.tripPlannerErrorMessage = "Failed to fetch data: \(error.localizedDescription)" - self.isFetchingResponse = false - } - } - } - } - - public func resetTripPlanner() { - planResponse = nil - selectedMapPoint = [ - "origin": nil, - "destination": nil - ] - destinationCoordinate = nil - originCoordinate = nil - originName = "Origin" - destinationName = "Destination" - selectedItinerary = nil - isStepsViewPresented = false - } - - // MARK: - User Location Methods - - public func checkIfLocationServicesIsEnabled() { - DispatchQueue.global().async { - if CLLocationManager.locationServicesEnabled() { - self.checkLocationAuthorization() - } - } - } - - private func checkLocationAuthorization() { - switch locationManager.authorizationStatus { - case .notDetermined: - locationManager.requestWhenInUseAuthorization() - case .restricted, .denied: - // Handle restricted or denied - break - case .authorizedAlways, .authorizedWhenInUse: - locationManager.startUpdatingLocation() - @unknown default: - break - } - } -} - -// MARK: - MKLocalSearchCompleterDelegate - -extension TripPlannerService: MKLocalSearchCompleterDelegate { - public func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) { - completions.removeAll() - - for result in completer.results { - let searchRequest = MKLocalSearch.Request(completion: result) - let search = MKLocalSearch(request: searchRequest) - - search.start { [weak self] response, error in - guard let self, let response else { - if let error { - print("Error performing local search: \(error)") - } - return - } - - if let mapItem = response.mapItems.first { - let completion = Location( - title: result.title, - subTitle: result.subtitle, - latitude: mapItem.placemark.coordinate.latitude, - longitude: mapItem.placemark.coordinate.longitude - ) - - DispatchQueue.main.async { - self.completions.append(completion) - } - } - } - } - } -} - -// MARK: - CLLocationManagerDelegate - -extension TripPlannerService: CLLocationManagerDelegate { - public func locationManager(_: CLLocationManager, didUpdateLocations locations: [CLLocation]) { - guard let location = locations.last else { return } - DispatchQueue.main.async { - self.currentLocation = Location( - title: "My Location", - subTitle: "Your current location", - latitude: location.coordinate.latitude, - longitude: location.coordinate.longitude - ) - - self.currentRegion = MKCoordinateRegion( - center: location.coordinate, - latitudinalMeters: 1000, - longitudinalMeters: 1000 - ) - self.updateCompleterRegion() - } - } - - public func locationManagerDidChangeAuthorization(_: CLLocationManager) { - checkLocationAuthorization() - } -} - -// MARK: - Service Extension - -extension TripPlannerService { - func formatCoordinate(_ coordinate: CLLocationCoordinate2D?) -> String { - guard let coordinate else { return "" } - return String(format: "%.4f,%.4f", coordinate.latitude, coordinate.longitude) - } - - func getFormattedTodayDate() -> String { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "MM-dd-yyyy" - let today = Date() - - return dateFormatter.string(from: today) - } - - func getCurrentTimeFormatted() -> String { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "h:mm a" - dateFormatter.amSymbol = "AM" - dateFormatter.pmSymbol = "PM" - let currentDate = Date() - - return dateFormatter.string(from: currentDate) - } -} diff --git a/OTPKit/Services/UserDefaultsServices.swift b/OTPKit/Services/UserDefaultsServices.swift deleted file mode 100644 index a707b4f..0000000 --- a/OTPKit/Services/UserDefaultsServices.swift +++ /dev/null @@ -1,124 +0,0 @@ -// -// UserDefaultsServices.swift -// OTPKitDemo -// -// Created by Hilmy Veradin on 25/06/24. -// - -import Foundation - -/// Manages data persistance -/// Each CRUD features divided by `MARK` comment -public final class UserDefaultsServices { - public static let shared = UserDefaultsServices() - private let userDefaults = UserDefaults.standard - private let savedLocationsKey = "SavedLocations" - private let recentLocationsKey = "RecentLocations" - - // MARK: - Saved Location Data - - func getFavoriteLocationsData() -> Result<[Location], Error> { - guard let savedLocationsData = userDefaults.data(forKey: savedLocationsKey) else { - let error = NSError(domain: "UserDefaults", - code: 1001, - userInfo: [NSLocalizedDescriptionKey: "Failed to retrieve saved locations data"]) - return .failure(error) - } - - let decoder = JSONDecoder() - do { - let decodedSavedLocations = try decoder.decode([Location].self, from: savedLocationsData) - return .success(decodedSavedLocations) - } catch { - return .failure(error) - } - } - - func saveFavoriteLocationData(data: Location) -> Result { - var locations: [Location] = switch getFavoriteLocationsData() { - case let .success(existingLocations): - existingLocations - case .failure: - [] - } - - locations.append(data) - - let encoder = JSONEncoder() - do { - let encoded = try encoder.encode(locations) - userDefaults.set(encoded, forKey: savedLocationsKey) - return .success(()) - } catch { - return .failure(error) - } - } - - func deleteFavoriteLocationData(with id: UUID) -> Result { - var locations: [Location] - - switch getFavoriteLocationsData() { - case let .success(existingLocations): - locations = existingLocations - case let .failure(error): - return .failure(error) - } - - locations.removeAll { $0.id == id } - - let encoder = JSONEncoder() - do { - let encoded = try encoder.encode(locations) - userDefaults.set(encoded, forKey: savedLocationsKey) - return .success(()) - } catch { - return .failure(error) - } - } - - // MARK: - Recent Location Data - - func getRecentLocations() -> Result<[Location], Error> { - guard let savedLocationsData = userDefaults.data(forKey: recentLocationsKey) else { - let error = NSError(domain: "UserDefaults", - code: 1001, - userInfo: [NSLocalizedDescriptionKey: "Failed to retrieve saved locations data"]) - return .failure(error) - } - - let decoder = JSONDecoder() - do { - let decodedSavedLocations = try decoder.decode([Location].self, from: savedLocationsData) - return .success(decodedSavedLocations) - } catch { - return .failure(error) - } - } - - func saveRecentLocations(data: Location) -> Result { - var locations: [Location] = switch getFavoriteLocationsData() { - case let .success(existingLocations): - existingLocations - case .failure: - [] - } - - locations.insert(data, at: 0) - - let encoder = JSONEncoder() - do { - let encoded = try encoder.encode(locations) - userDefaults.set(encoded, forKey: recentLocationsKey) - return .success(()) - } catch { - return .failure(error) - } - } - - // MARK: - User Defaults Utils - - func deleteAllObjects() { - userDefaults.removeObject(forKey: savedLocationsKey) - userDefaults.removeObject(forKey: recentLocationsKey) - } -} diff --git a/OTPKit/TripPlannerExtensionView.swift b/OTPKit/TripPlannerExtensionView.swift deleted file mode 100644 index d9159eb..0000000 --- a/OTPKit/TripPlannerExtensionView.swift +++ /dev/null @@ -1,114 +0,0 @@ -// -// TripPlannerExtensionView.swift -// OTPKit -// -// Created by Hilmy Veradin on 12/08/24. -// - -import MapKit -import SwiftUI - - -/// Main Extension View that take Map as it's content -/// This simplify all the process of making the Trip Planner UI -public struct TripPlannerExtensionView: View { - @StateObject private var sheetEnvironment = OriginDestinationSheetEnvironment() - @EnvironmentObject private var tripPlanner: TripPlannerService - - @State private var directionSheetDetent: PresentationDetent = .fraction(0.2) - - private let mapContent: () -> MapContent - - public init(@ViewBuilder mapContent: @escaping () -> MapContent) { - self.mapContent = mapContent - } - - private var isPlanResponsePresented: Binding { - Binding( - get: { tripPlanner.planResponse != nil && tripPlanner.isStepsViewPresented == false }, - set: { _ in } - ) - } - - private var isStepsViewPresented: Binding { - Binding( - get: { tripPlanner.isStepsViewPresented }, - set: { _ in } - ) - } - - public var body: some View { - ZStack { - MapReader { proxy in - mapContent() - .onTapGesture { tappedLocation in - handleMapTap(proxy: proxy, tappedLocation: tappedLocation) - } - } - .sheet(isPresented: $sheetEnvironment.isSheetOpened) { - OriginDestinationSheetView() - .environmentObject(sheetEnvironment) - .environmentObject(tripPlanner) - } - .sheet(isPresented: isPlanResponsePresented) { - TripPlannerSheetView() - .presentationDetents([.medium, .large]) - .interactiveDismissDisabled() - .environmentObject(tripPlanner) - } - .sheet(isPresented: isStepsViewPresented, onDismiss: { - tripPlanner.resetTripPlanner() - }) { - DirectionSheetView(sheetDetent: $directionSheetDetent) - .presentationDetents([.fraction(0.2), .medium, .large], selection: $directionSheetDetent) - .interactiveDismissDisabled() - .presentationBackgroundInteraction(.enabled(upThrough: .fraction(0.2))) - .environmentObject(tripPlanner) - } - - overlayContent - } - .onAppear { - tripPlanner.checkIfLocationServicesIsEnabled() - } - } - - @ViewBuilder - private var overlayContent: some View { - if tripPlanner.isFetchingResponse { - ProgressView() - } else if tripPlanner.isMapMarkingMode { - MapMarkingView() - .environmentObject(tripPlanner) - } else if let selectedItinerary = tripPlanner.selectedItinerary, !tripPlanner.isStepsViewPresented { - VStack { - Spacer() - TripPlannerView(text: selectedItinerary.summary) - .environmentObject(tripPlanner) - } - } else if tripPlanner.planResponse == nil, tripPlanner.isStepsViewPresented == false { - VStack { - Spacer() - OriginDestinationView() - .environmentObject(sheetEnvironment) - .environmentObject(tripPlanner) - } - } - } - - private func handleMapTap(proxy: MapProxy, tappedLocation: CGPoint) { - if tripPlanner.isMapMarkingMode { - guard let coordinate = proxy.convert(tappedLocation, from: .local) else { return } - let mapItem = MKMapItem(placemark: .init(coordinate: coordinate)) - let locationTitle = mapItem.name ?? "Location unknown" - let locationSubtitle = mapItem.placemark.title ?? "Location unknown" - let location = Location( - title: locationTitle, - subTitle: locationSubtitle, - latitude: coordinate.latitude, - longitude: coordinate.longitude - ) - tripPlanner.appendMarker(location: location) - } - } -} diff --git a/OTPKitDemo/Assets.xcassets/AccentColor.colorset/Contents.json b/OTPKitDemo/Assets.xcassets/AccentColor.colorset/Contents.json deleted file mode 100644 index eb87897..0000000 --- a/OTPKitDemo/Assets.xcassets/AccentColor.colorset/Contents.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "colors" : [ - { - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/OTPKitDemo/Assets.xcassets/AppIcon.appiconset/Contents.json b/OTPKitDemo/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 13613e3..0000000 --- a/OTPKitDemo/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/OTPKitDemo/Assets.xcassets/Contents.json b/OTPKitDemo/Assets.xcassets/Contents.json deleted file mode 100644 index 73c0059..0000000 --- a/OTPKitDemo/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/OTPKitDemo/LaunchScreen.storyboard b/OTPKitDemo/LaunchScreen.storyboard deleted file mode 100644 index 7ad1a5e..0000000 --- a/OTPKitDemo/LaunchScreen.storyboard +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/OTPKitDemo/MapView.swift b/OTPKitDemo/MapView.swift deleted file mode 100644 index 372057a..0000000 --- a/OTPKitDemo/MapView.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// MapView.swift -// OTPKitDemo -// -// Created by Hilmy Veradin on 25/06/24. -// - -import MapKit -import OTPKit -import SwiftUI - -struct MapView: View { - @EnvironmentObject private var tripPlanner: TripPlannerService - - var body: some View { - TripPlannerExtensionView { - Map(position: $tripPlanner.currentCameraPosition, interactionModes: .all) { - tripPlanner.generateMarkers() - tripPlanner.generateMapPolyline() - .stroke(.blue, lineWidth: 5) - } - .mapControls { - if !tripPlanner.isMapMarkingMode { - MapUserLocationButton() - MapPitchToggle() - } - } - } - .environmentObject(tripPlanner) - } -} - -#Preview { - let planner = TripPlannerService( - apiClient: RestAPI(baseURL: URL(string: "https://otp.prod.sound.obaweb.org/otp/routers/default/")!), - locationManager: CLLocationManager(), - searchCompleter: MKLocalSearchCompleter() - ) - - return MapView() - .environmentObject(planner) -} diff --git a/OTPKitDemo/OTPKitDemoApp.swift b/OTPKitDemo/OTPKitDemoApp.swift deleted file mode 100644 index 13cd143..0000000 --- a/OTPKitDemo/OTPKitDemoApp.swift +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright (C) Open Transit Software Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import CoreLocation -import MapKit -import OTPKit -import SwiftUI - -@main -struct OTPKitDemoApp: App { - let tripPlannerService = TripPlannerService( - apiClient: RestAPI(baseURL: URL(string: "https://otp.prod.sound.obaweb.org/otp/routers/default/")!), - locationManager: CLLocationManager(), - searchCompleter: MKLocalSearchCompleter() - ) - - var body: some Scene { - WindowGroup { - MapView() - .environmentObject(tripPlannerService) - } - } -} diff --git a/OTPKitDemo/Preview Content/Preview Assets.xcassets/Contents.json b/OTPKitDemo/Preview Content/Preview Assets.xcassets/Contents.json deleted file mode 100644 index 73c0059..0000000 --- a/OTPKitDemo/Preview Content/Preview Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/OTPKitTests/Helpers/Fixtures.swift b/OTPKitTests/Helpers/Fixtures.swift deleted file mode 100644 index e72786e..0000000 --- a/OTPKitTests/Helpers/Fixtures.swift +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (C) Open Transit Software Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Foundation -@testable import OTPKit - -class Fixtures { - private class var testBundle: Bundle { - Bundle(for: self) - } - - /// Converts the specified dictionary to a model object of type `T`. - /// - Parameters: - /// - type: The model type to which the dictionary will be converted. - /// - dictionary: The data - /// - Returns: A model object - class func dictionaryToModel(type: T.Type, dictionary: [String: Any]) throws -> T where T: Decodable { - let jsonData = try JSONSerialization.data(withJSONObject: dictionary, options: []) - return try JSONDecoder().decode(type, from: jsonData) - } - - /// Returns the path to the specified file in the test bundle. - /// - Parameter fileName: The file name, e.g. "regions.json" - class func path(to fileName: String) -> String { - testBundle.path(forResource: fileName, ofType: nil)! - } - - /// Encodes and decodes the provided `Codable` object. Useful for testing roundtripping. - /// - Parameter type: The object type. - /// - Parameter model: The object or objects. - class func roundtripCodable(type: T.Type, model: T) throws -> T where T: Codable { - let encoded = try PropertyListEncoder().encode(model) - let decoded = try PropertyListDecoder().decode(type, from: encoded) - return decoded - } - - /// Loads data from the specified file name, searching within the test bundle. - /// - Parameter file: The file name to load data from. Example: `stop_data.pb`. - class func loadData(file: String) -> Data { - NSData(contentsOfFile: path(to: file))! as Data - } -} diff --git a/OTPKitTests/Helpers/MockDataLoader.swift b/OTPKitTests/Helpers/MockDataLoader.swift deleted file mode 100644 index d6fb52d..0000000 --- a/OTPKitTests/Helpers/MockDataLoader.swift +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright (C) Open Transit Software Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Foundation -import OTPKit - -typealias MockDataLoaderMatcher = (URLRequest) -> Bool - -struct MockDataResponse { - let data: Data? - let urlResponse: URLResponse? - let error: Error? - let matcher: MockDataLoaderMatcher -} - -class MockTask: URLSessionDataTask { - override var progress: Progress { - Progress() - } - - private var closure: (Data?, URLResponse?, Error?) -> Void - private let mockResponse: MockDataResponse - - init(mockResponse: MockDataResponse, closure: @escaping (Data?, URLResponse?, Error?) -> Void) { - self.mockResponse = mockResponse - self.closure = closure - } - - // We override the 'resume' method and simply call our closure - // instead of actually resuming any task. - override func resume() { - closure(mockResponse.data, mockResponse.urlResponse, mockResponse.error) - } - - override func cancel() { - // nop - } -} - -class MockDataLoader: NSObject, URLDataLoader { - var mockResponses = [MockDataResponse]() - - let testName: String - - init(testName: String) { - self.testName = testName - } - - func dataTask( - with request: URLRequest, - completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void - ) -> URLSessionDataTask { - guard let response = matchResponse(to: request) else { - fatalError("\(testName): Missing response to URL: \(request.url!)") - } - - return MockTask(mockResponse: response, closure: completionHandler) - } - - func data(for request: URLRequest) async throws -> (Data, URLResponse) { - guard let response = matchResponse(to: request) else { - fatalError("\(testName): Missing response to URL: \(request.url!)") - } - - if let error = response.error { - throw error - } - - guard let data = response.data else { - fatalError("\(testName): Missing data to URL: \(request.url!))") - } - - guard let urlResponse = response.urlResponse else { - fatalError("\(testName): Missing urlResponse to URL: \(request.url!))") - } - - return (data, urlResponse) - } - - // MARK: - Response Mapping - - func matchResponse(to request: URLRequest) -> MockDataResponse? { - for r in mockResponses where r.matcher(request) { - return r - } - - return nil - } - - func mock(data: Data, matcher: @escaping MockDataLoaderMatcher) { - let urlResponse = buildURLResponse(URL: URL(string: "https://mockdataloader.example.com")!, statusCode: 200) - let mockResponse = MockDataResponse(data: data, urlResponse: urlResponse, error: nil, matcher: matcher) - mock(response: mockResponse) - } - - func mock(URLString: String, with data: Data) { - mock(url: URL(string: URLString)!, with: data) - } - - func mock(url: URL, with data: Data) { - let urlResponse = buildURLResponse(URL: url, statusCode: 200) - let mockResponse = MockDataResponse(data: data, urlResponse: urlResponse, error: nil) { - let requestURL = $0.url! - return requestURL.host == url.host && requestURL.path == url.path - } - mock(response: mockResponse) - } - - func mock(response: MockDataResponse) { - mockResponses.append(response) - } - - func removeMappedResponses() { - mockResponses.removeAll() - } - - // MARK: - URL Response - - func buildURLResponse(URL: URL, statusCode: Int) -> HTTPURLResponse { - HTTPURLResponse( - url: URL, - statusCode: statusCode, - httpVersion: "2", - headerFields: ["Content-Type": "application/json"] - )! - } - - // MARK: - Description - - override var debugDescription: String { - var descriptionBuilder = DebugDescriptionBuilder(baseDescription: super.debugDescription) - descriptionBuilder.add(key: "mockResponses", value: mockResponses) - return descriptionBuilder.description - } -} diff --git a/OTPKitTests/Helpers/OTPTestCase.swift b/OTPKitTests/Helpers/OTPTestCase.swift deleted file mode 100644 index def9706..0000000 --- a/OTPKitTests/Helpers/OTPTestCase.swift +++ /dev/null @@ -1,50 +0,0 @@ -// -// OTPTestCase.swift -// OTPKitTests -// -// Created by Aaron Brethorst on 5/2/24. -// - -import Foundation -@testable import OTPKit -import XCTest - -public class OTPTestCase: XCTestCase { - var userDefaults: UserDefaults! - - override open func setUp() { - super.setUp() - NSTimeZone.default = NSTimeZone(forSecondsFromGMT: 0) as TimeZone - userDefaults = buildUserDefaults() - userDefaults.removePersistentDomain(forName: userDefaultsSuiteName) - } - - override open func tearDown() { - super.tearDown() - NSTimeZone.resetSystemTimeZone() - userDefaults.removePersistentDomain(forName: userDefaultsSuiteName) - } - - // MARK: - User Defaults - - func buildUserDefaults(suiteName: String? = nil) -> UserDefaults { - UserDefaults(suiteName: suiteName ?? userDefaultsSuiteName)! - } - - var userDefaultsSuiteName: String { - String(describing: self) - } - - // MARK: - Network and Data - - func buildMockDataLoader() -> MockDataLoader { - MockDataLoader(testName: name) - } - - func buildRestAPIClient( - baseURLString: String = "https://otp.prod.sound.obaweb.org/otp/routers/default/" - ) -> RestAPI { - let baseURL = URL(string: baseURLString)! - return RestAPI(baseURL: baseURL, dataLoader: buildMockDataLoader()) - } -} diff --git a/OTPKitTests/OTPKitTests.swift b/OTPKitTests/OTPKitTests.swift deleted file mode 100644 index be5bee1..0000000 --- a/OTPKitTests/OTPKitTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (C) Open Transit Software Foundation - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// swiftlint:disable force_cast line_length - -@testable import OTPKit -import XCTest - -class OTPKitTests: OTPTestCase { - let soundTransitBaseURL = URL(string: "https://otp.prod.sound.obaweb.org/otp/routers/default/")! - - func testPlanBasics() async throws { - let restApi = buildRestAPIClient() - - let dataLoader = restApi.dataLoader as! MockDataLoader - - dataLoader.mock(URLString: "https://otp.prod.sound.obaweb.org/otp/routers/default/plan?fromPlace=47.6097,-122.3331&toPlace=47.6154,-122.3208&time=8:00%20AM&date=05-10-2024&mode=TRANSIT,WALK&arriveBy=false&maxWalkDistance=800&wheelchair=false", with: Fixtures.loadData(file: "plan_basic_case.json")) - - let result = try await restApi.fetchPlan( - fromPlace: "47.6097,-122.3331", - toPlace: "47.6154,-122.3208", - time: "8:00 AM", - date: "05-10-2024", - mode: "TRANSIT,WALK", - arriveBy: false, - maxWalkDistance: 800, - wheelchair: false - ) - - XCTAssertNotNil(result) - - let plan = result.plan! - - XCTAssertNotNil(plan) - - XCTAssertEqual(plan.itineraries.count, 3) - - let itinerary = plan.itineraries.first - - XCTAssertEqual(itinerary?.duration, 595) - } -} - -// swiftlint:enable force_cast line_length diff --git a/OTPKitTests/OTPKitTestsSetup.swift b/OTPKitTests/OTPKitTestsSetup.swift deleted file mode 100644 index 7dd033e..0000000 --- a/OTPKitTests/OTPKitTestsSetup.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// OTPKitTestsSetup.swift -// OTPKit -// -// Copyright © Open Transit Software Foundation -// This source code is licensed under the Apache 2.0 license found in the -// LICENSE file in the root directory of this source tree. -// - -import Foundation - -class OTPKitTestsSetup: NSObject { - override init() { - super.init() - } -} diff --git a/OTPKitTests/fixtures/plan_basic_case.json b/OTPKitTests/fixtures/plan_basic_case.json deleted file mode 100644 index 8d5d38a..0000000 --- a/OTPKitTests/fixtures/plan_basic_case.json +++ /dev/null @@ -1 +0,0 @@ -{"requestParameters":{"date":"05-10-2024","mode":"TRANSIT,WALK","arriveBy":"false","wheelchair":"false","fromPlace":"47.6097,-122.3331","toPlace":"47.6154,-122.3208","time":"8:00 AM","maxWalkDistance":"800"},"plan":{"date":1715353200000,"from":{"name":"Origin","lon":-122.3331,"lat":47.6097,"orig":"","vertexType":"NORMAL"},"to":{"name":"Destination","lon":-122.3208,"lat":47.6154,"orig":"","vertexType":"NORMAL"},"itineraries":[{"duration":595,"startTime":1715354006000,"endTime":1715354601000,"walkTime":235,"transitTime":358,"waitingTime":2,"walkDistance":275.6775338293097,"walkLimitExceeded":false,"elevationLost":0.0,"elevationGained":0.0,"transfers":0,"fare":{"fare":{"youth":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":0},"senior":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":0},"regular":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":250}},"details":{"youth":[{"fareId":"1:305","price":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":0},"routes":["1:100447"]}],"senior":[{"fareId":"1:305","price":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":0},"routes":["1:100447"]}],"regular":[{"fareId":"1:101","price":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":250},"routes":["1:100447"]}]}},"legs":[{"startTime":1715354006000,"endTime":1715354161000,"departureDelay":0,"arrivalDelay":0,"realTime":false,"distance":190.90900000000002,"pathway":false,"mode":"WALK","route":"","agencyTimeZoneOffset":-25200000,"interlineWithPreviousLeg":false,"from":{"name":"Origin","lon":-122.3331,"lat":47.6097,"departure":1715354006000,"orig":"","vertexType":"NORMAL"},"to":{"name":"Pike St & 6th Ave","stopId":"1:1190","stopCode":"1190","lon":-122.334526,"lat":47.611034,"arrival":1715354161000,"departure":1715354162000,"zoneId":"21","stopIndex":1,"stopSequence":6,"vertexType":"TRANSIT","boardAlightType":"DEFAULT"},"legGeometry":{"points":"owqaHbetiVEB}@x@GFOLSP}@x@_BvADL@DCBAD","length":12},"rentedBike":false,"flexDrtAdvanceBookMin":0.0,"duration":155.0,"transitLeg":false,"steps":[{"distance":177.07100000000003,"relativeDirection":"DEPART","streetName":"6th Avenue","absoluteDirection":"NORTHWEST","stayOn":false,"area":false,"bogusName":false,"lon":-122.33313046442926,"lat":47.609687150686874,"elevation":[]},{"distance":13.838000000000001,"relativeDirection":"LEFT","streetName":"path","absoluteDirection":"SOUTHWEST","stayOn":false,"area":false,"bogusName":true,"lon":-122.334378,"lat":47.6110394,"elevation":[]}]},{"startTime":1715354162000,"endTime":1715354520000,"departureDelay":0,"arrivalDelay":0,"realTime":false,"distance":1190.860971998657,"pathway":false,"mode":"BUS","route":"49","agencyName":"Metro Transit","agencyUrl":"https://kingcounty.gov/en/dept/metro","agencyTimeZoneOffset":-25200000,"routeType":3,"routeId":"1:100447","interlineWithPreviousLeg":false,"tripBlockId":"7160136","headsign":"U-District Station Capitol Hill","agencyId":"1","tripId":"1:664034336","serviceDate":"20240510","from":{"name":"Pike St & 6th Ave","stopId":"1:1190","stopCode":"1190","lon":-122.334526,"lat":47.611034,"arrival":1715354161000,"departure":1715354162000,"zoneId":"21","stopIndex":1,"stopSequence":6,"vertexType":"TRANSIT","boardAlightType":"DEFAULT"},"to":{"name":"E Pine St & Harvard Ave","stopId":"1:11150","stopCode":"11150","lon":-122.321617,"lat":47.615162,"arrival":1715354520000,"departure":1715354521000,"zoneId":"1","stopIndex":5,"stopSequence":32,"vertexType":"TRANSIT","boardAlightType":"DEFAULT"},"legGeometry":{"points":"i`raHbntiVISm@oBm@oBs@yBg@cB}A}Eo@mBQi@Oi@Ws@oAeEc@qAI[o@oBUu@ESC]@}AaF??{B?eCAqD?_@AqE@aGAgB","length":27},"routeShortName":"49","rentedBike":false,"flexDrtAdvanceBookMin":0.0,"duration":358.0,"transitLeg":true,"steps":[]},{"startTime":1715354521000,"endTime":1715354601000,"departureDelay":0,"arrivalDelay":0,"realTime":false,"distance":84.605,"pathway":false,"mode":"WALK","route":"","agencyTimeZoneOffset":-25200000,"interlineWithPreviousLeg":false,"from":{"name":"E Pine St & Harvard Ave","stopId":"1:11150","stopCode":"11150","lon":-122.321617,"lat":47.615162,"arrival":1715354520000,"departure":1715354521000,"zoneId":"1","stopIndex":5,"stopSequence":32,"vertexType":"TRANSIT","boardAlightType":"DEFAULT"},"to":{"name":"Destination","lon":-122.3208,"lat":47.6154,"arrival":1715354601000,"orig":"","vertexType":"NORMAL"},"legGeometry":{"points":"wyraHb}qiV?]Q??_@?cA?U?EU@E?","length":9},"rentedBike":false,"flexDrtAdvanceBookMin":0.0,"duration":80.0,"transitLeg":false,"steps":[{"distance":11.238,"relativeDirection":"DEPART","streetName":"sidewalk","absoluteDirection":"EAST","stayOn":false,"area":false,"bogusName":true,"lon":-122.32161701929206,"lat":47.615163059437414,"elevation":[]},{"distance":9.83,"relativeDirection":"LEFT","streetName":"alley","absoluteDirection":"NORTH","stayOn":true,"area":false,"bogusName":true,"lon":-122.3214671,"lat":47.6151643,"elevation":[]},{"distance":47.894999999999996,"relativeDirection":"RIGHT","streetName":"East Pine Street","absoluteDirection":"EAST","stayOn":false,"area":false,"bogusName":false,"lon":-122.32146890000001,"lat":47.615252700000006,"elevation":[]},{"distance":15.642,"relativeDirection":"LEFT","streetName":"Broadway","absoluteDirection":"NORTH","stayOn":false,"area":false,"bogusName":false,"lon":-122.32083,"lat":47.615259,"elevation":[]}]}],"tooSloped":false},{"duration":647,"startTime":1715354134000,"endTime":1715354781000,"walkTime":235,"transitTime":410,"waitingTime":2,"walkDistance":275.6775338293097,"walkLimitExceeded":false,"elevationLost":0.0,"elevationGained":0.0,"transfers":0,"fare":{"fare":{"youth":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":0},"senior":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":0},"regular":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":250}},"details":{"youth":[{"fareId":"1:305","price":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":0},"routes":["1:100009"]}],"senior":[{"fareId":"1:305","price":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":0},"routes":["1:100009"]}],"regular":[{"fareId":"1:101","price":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":250},"routes":["1:100009"]}]}},"legs":[{"startTime":1715354134000,"endTime":1715354289000,"departureDelay":0,"arrivalDelay":0,"realTime":false,"distance":190.90900000000002,"pathway":false,"mode":"WALK","route":"","agencyTimeZoneOffset":-25200000,"interlineWithPreviousLeg":false,"from":{"name":"Origin","lon":-122.3331,"lat":47.6097,"departure":1715354134000,"orig":"","vertexType":"NORMAL"},"to":{"name":"Pike St & 6th Ave","stopId":"1:1190","stopCode":"1190","lon":-122.334526,"lat":47.611034,"arrival":1715354289000,"departure":1715354290000,"zoneId":"21","stopIndex":4,"stopSequence":30,"vertexType":"TRANSIT","boardAlightType":"DEFAULT"},"legGeometry":{"points":"owqaHbetiVEB}@x@GFOLSP}@x@_BvADL@DCBAD","length":12},"rentedBike":false,"flexDrtAdvanceBookMin":0.0,"duration":155.0,"transitLeg":false,"steps":[{"distance":177.07100000000003,"relativeDirection":"DEPART","streetName":"6th Avenue","absoluteDirection":"NORTHWEST","stayOn":false,"area":false,"bogusName":false,"lon":-122.33313046442926,"lat":47.609687150686874,"elevation":[]},{"distance":13.838000000000001,"relativeDirection":"LEFT","streetName":"path","absoluteDirection":"SOUTHWEST","stayOn":false,"area":false,"bogusName":true,"lon":-122.334378,"lat":47.6110394,"elevation":[]}]},{"startTime":1715354290000,"endTime":1715354700000,"departureDelay":0,"arrivalDelay":0,"realTime":false,"distance":1190.860971998657,"pathway":false,"mode":"BUS","route":"11","agencyName":"Metro Transit","agencyUrl":"https://kingcounty.gov/en/dept/metro","agencyTimeZoneOffset":-25200000,"routeType":3,"routeId":"1:100009","interlineWithPreviousLeg":false,"tripBlockId":"7159194","headsign":"Madison Park Via E Madison St","agencyId":"1","tripId":"1:628187066","serviceDate":"20240510","from":{"name":"Pike St & 6th Ave","stopId":"1:1190","stopCode":"1190","lon":-122.334526,"lat":47.611034,"arrival":1715354289000,"departure":1715354290000,"zoneId":"21","stopIndex":4,"stopSequence":30,"vertexType":"TRANSIT","boardAlightType":"DEFAULT"},"to":{"name":"E Pine St & Harvard Ave","stopId":"1:11150","stopCode":"11150","lon":-122.321617,"lat":47.615162,"arrival":1715354700000,"departure":1715354701000,"zoneId":"1","stopIndex":8,"stopSequence":56,"vertexType":"TRANSIT","boardAlightType":"DEFAULT"},"legGeometry":{"points":"i`raHbntiVISm@oBm@oBs@yBg@cB}A}Eo@mBQi@Oi@Ws@oAeEc@qAI[o@oBUu@ESC]@}AaF??{B?eCAqD?_@AqE@aGAgB","length":27},"routeShortName":"11","rentedBike":false,"flexDrtAdvanceBookMin":0.0,"duration":410.0,"transitLeg":true,"steps":[]},{"startTime":1715354701000,"endTime":1715354781000,"departureDelay":0,"arrivalDelay":0,"realTime":false,"distance":84.605,"pathway":false,"mode":"WALK","route":"","agencyTimeZoneOffset":-25200000,"interlineWithPreviousLeg":false,"from":{"name":"E Pine St & Harvard Ave","stopId":"1:11150","stopCode":"11150","lon":-122.321617,"lat":47.615162,"arrival":1715354700000,"departure":1715354701000,"zoneId":"1","stopIndex":8,"stopSequence":56,"vertexType":"TRANSIT","boardAlightType":"DEFAULT"},"to":{"name":"Destination","lon":-122.3208,"lat":47.6154,"arrival":1715354781000,"orig":"","vertexType":"NORMAL"},"legGeometry":{"points":"wyraHb}qiV?]Q??_@?cA?U?EU@E?","length":9},"rentedBike":false,"flexDrtAdvanceBookMin":0.0,"duration":80.0,"transitLeg":false,"steps":[{"distance":11.238,"relativeDirection":"DEPART","streetName":"sidewalk","absoluteDirection":"EAST","stayOn":false,"area":false,"bogusName":true,"lon":-122.32161701929206,"lat":47.615163059437414,"elevation":[]},{"distance":9.83,"relativeDirection":"LEFT","streetName":"alley","absoluteDirection":"NORTH","stayOn":true,"area":false,"bogusName":true,"lon":-122.3214671,"lat":47.6151643,"elevation":[]},{"distance":47.894999999999996,"relativeDirection":"RIGHT","streetName":"East Pine Street","absoluteDirection":"EAST","stayOn":false,"area":false,"bogusName":false,"lon":-122.32146890000001,"lat":47.615252700000006,"elevation":[]},{"distance":15.642,"relativeDirection":"LEFT","streetName":"Broadway","absoluteDirection":"NORTH","stayOn":false,"area":false,"bogusName":false,"lon":-122.32083,"lat":47.615259,"elevation":[]}]}],"tooSloped":false},{"duration":595,"startTime":1715354906000,"endTime":1715355501000,"walkTime":235,"transitTime":358,"waitingTime":2,"walkDistance":275.6775338293097,"walkLimitExceeded":false,"elevationLost":0.0,"elevationGained":0.0,"transfers":0,"fare":{"fare":{"youth":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":0},"senior":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":0},"regular":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":250}},"details":{"youth":[{"fareId":"1:305","price":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":0},"routes":["1:100447"]}],"senior":[{"fareId":"1:305","price":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":0},"routes":["1:100447"]}],"regular":[{"fareId":"1:101","price":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":250},"routes":["1:100447"]}]}},"legs":[{"startTime":1715354906000,"endTime":1715355061000,"departureDelay":0,"arrivalDelay":0,"realTime":false,"distance":190.90900000000002,"pathway":false,"mode":"WALK","route":"","agencyTimeZoneOffset":-25200000,"interlineWithPreviousLeg":false,"from":{"name":"Origin","lon":-122.3331,"lat":47.6097,"departure":1715354906000,"orig":"","vertexType":"NORMAL"},"to":{"name":"Pike St & 6th Ave","stopId":"1:1190","stopCode":"1190","lon":-122.334526,"lat":47.611034,"arrival":1715355061000,"departure":1715355062000,"zoneId":"21","stopIndex":1,"stopSequence":6,"vertexType":"TRANSIT","boardAlightType":"DEFAULT"},"legGeometry":{"points":"owqaHbetiVEB}@x@GFOLSP}@x@_BvADL@DCBAD","length":12},"rentedBike":false,"flexDrtAdvanceBookMin":0.0,"duration":155.0,"transitLeg":false,"steps":[{"distance":177.07100000000003,"relativeDirection":"DEPART","streetName":"6th Avenue","absoluteDirection":"NORTHWEST","stayOn":false,"area":false,"bogusName":false,"lon":-122.33313046442926,"lat":47.609687150686874,"elevation":[]},{"distance":13.838000000000001,"relativeDirection":"LEFT","streetName":"path","absoluteDirection":"SOUTHWEST","stayOn":false,"area":false,"bogusName":true,"lon":-122.334378,"lat":47.6110394,"elevation":[]}]},{"startTime":1715355062000,"endTime":1715355420000,"departureDelay":0,"arrivalDelay":0,"realTime":false,"distance":1190.860971998657,"pathway":false,"mode":"BUS","route":"49","agencyName":"Metro Transit","agencyUrl":"https://kingcounty.gov/en/dept/metro","agencyTimeZoneOffset":-25200000,"routeType":3,"routeId":"1:100447","interlineWithPreviousLeg":false,"tripBlockId":"7160100","headsign":"U-District Station Capitol Hill","agencyId":"1","tripId":"1:608581516","serviceDate":"20240510","from":{"name":"Pike St & 6th Ave","stopId":"1:1190","stopCode":"1190","lon":-122.334526,"lat":47.611034,"arrival":1715355061000,"departure":1715355062000,"zoneId":"21","stopIndex":1,"stopSequence":6,"vertexType":"TRANSIT","boardAlightType":"DEFAULT"},"to":{"name":"E Pine St & Harvard Ave","stopId":"1:11150","stopCode":"11150","lon":-122.321617,"lat":47.615162,"arrival":1715355420000,"departure":1715355421000,"zoneId":"1","stopIndex":5,"stopSequence":32,"vertexType":"TRANSIT","boardAlightType":"DEFAULT"},"legGeometry":{"points":"i`raHbntiVISm@oBm@oBs@yBg@cB}A}Eo@mBQi@Oi@Ws@oAeEc@qAI[o@oBUu@ESC]@}AaF??{B?eCAqD?_@AqE@aGAgB","length":27},"routeShortName":"49","rentedBike":false,"flexDrtAdvanceBookMin":0.0,"duration":358.0,"transitLeg":true,"steps":[]},{"startTime":1715355421000,"endTime":1715355501000,"departureDelay":0,"arrivalDelay":0,"realTime":false,"distance":84.605,"pathway":false,"mode":"WALK","route":"","agencyTimeZoneOffset":-25200000,"interlineWithPreviousLeg":false,"from":{"name":"E Pine St & Harvard Ave","stopId":"1:11150","stopCode":"11150","lon":-122.321617,"lat":47.615162,"arrival":1715355420000,"departure":1715355421000,"zoneId":"1","stopIndex":5,"stopSequence":32,"vertexType":"TRANSIT","boardAlightType":"DEFAULT"},"to":{"name":"Destination","lon":-122.3208,"lat":47.6154,"arrival":1715355501000,"orig":"","vertexType":"NORMAL"},"legGeometry":{"points":"wyraHb}qiV?]Q??_@?cA?U?EU@E?","length":9},"rentedBike":false,"flexDrtAdvanceBookMin":0.0,"duration":80.0,"transitLeg":false,"steps":[{"distance":11.238,"relativeDirection":"DEPART","streetName":"sidewalk","absoluteDirection":"EAST","stayOn":false,"area":false,"bogusName":true,"lon":-122.32161701929206,"lat":47.615163059437414,"elevation":[]},{"distance":9.83,"relativeDirection":"LEFT","streetName":"alley","absoluteDirection":"NORTH","stayOn":true,"area":false,"bogusName":true,"lon":-122.3214671,"lat":47.6151643,"elevation":[]},{"distance":47.894999999999996,"relativeDirection":"RIGHT","streetName":"East Pine Street","absoluteDirection":"EAST","stayOn":false,"area":false,"bogusName":false,"lon":-122.32146890000001,"lat":47.615252700000006,"elevation":[]},{"distance":15.642,"relativeDirection":"LEFT","streetName":"Broadway","absoluteDirection":"NORTH","stayOn":false,"area":false,"bogusName":false,"lon":-122.32083,"lat":47.615259,"elevation":[]}]}],"tooSloped":false}]},"debugOutput":{"precalculationTime":181,"pathCalculationTime":97,"pathTimes":[36,32,29],"renderingTime":0,"totalTime":278,"timedOut":false},"elevationMetadata":{"ellipsoidToGeoidDifference":-16.677402251842814,"geoidElevation":false}} \ No newline at end of file From 9eef0089fd41bec91ef77934ca1bbcdc9c4250b8 Mon Sep 17 00:00:00 2001 From: hilmyveradin Date: Tue, 13 Aug 2024 17:40:03 +0700 Subject: [PATCH 3/6] make local package --- .../OTPKitDemo.xcodeproj/project.pbxproj | 27 ++++++++----------- .../xcshareddata/swiftpm/Package.resolved | 14 ---------- 2 files changed, 11 insertions(+), 30 deletions(-) delete mode 100644 Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.pbxproj b/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.pbxproj index a0cbe64..f81a5e4 100644 --- a/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.pbxproj +++ b/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.pbxproj @@ -3,15 +3,15 @@ archiveVersion = 1; classes = { }; - objectVersion = 56; + objectVersion = 60; objects = { /* Begin PBXBuildFile section */ + 014316DF2C6B6F2C00B33240 /* OTPKit in Frameworks */ = {isa = PBXBuildFile; productRef = 014316DE2C6B6F2C00B33240 /* OTPKit */; }; 01AA80542C6B6A7500D4038A /* OTPKitDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01AA80532C6B6A7500D4038A /* OTPKitDemoApp.swift */; }; 01AA80562C6B6A7500D4038A /* MapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01AA80552C6B6A7500D4038A /* MapView.swift */; }; 01AA80582C6B6A7600D4038A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 01AA80572C6B6A7600D4038A /* Assets.xcassets */; }; 01AA805B2C6B6A7600D4038A /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 01AA805A2C6B6A7600D4038A /* Preview Assets.xcassets */; }; - 01CF11752C6B6AB0005B6B47 /* OTPKit in Frameworks */ = {isa = PBXBuildFile; productRef = 01CF11742C6B6AB0005B6B47 /* OTPKit */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -27,7 +27,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 01CF11752C6B6AB0005B6B47 /* OTPKit in Frameworks */, + 014316DF2C6B6F2C00B33240 /* OTPKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -86,7 +86,7 @@ ); name = OTPKitDemo; packageProductDependencies = ( - 01CF11742C6B6AB0005B6B47 /* OTPKit */, + 014316DE2C6B6F2C00B33240 /* OTPKit */, ); productName = OTPKitDemo; productReference = 01AA80502C6B6A7500D4038A /* OTPKitDemo.app */; @@ -117,7 +117,7 @@ ); mainGroup = 01AA80472C6B6A7500D4038A; packageReferences = ( - 01CF11732C6B6AB0005B6B47 /* XCRemoteSwiftPackageReference "otpkit-test" */, + 014316DD2C6B6F2C00B33240 /* XCLocalSwiftPackageReference "../.." */, ); productRefGroup = 01AA80512C6B6A7500D4038A /* Products */; projectDirPath = ""; @@ -351,21 +351,16 @@ }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - 01CF11732C6B6AB0005B6B47 /* XCRemoteSwiftPackageReference "otpkit-test" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/hilmyveradin/otpkit-test"; - requirement = { - branch = master; - kind = branch; - }; +/* Begin XCLocalSwiftPackageReference section */ + 014316DD2C6B6F2C00B33240 /* XCLocalSwiftPackageReference "../.." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ../..; }; -/* End XCRemoteSwiftPackageReference section */ +/* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 01CF11742C6B6AB0005B6B47 /* OTPKit */ = { + 014316DE2C6B6F2C00B33240 /* OTPKit */ = { isa = XCSwiftPackageProductDependency; - package = 01CF11732C6B6AB0005B6B47 /* XCRemoteSwiftPackageReference "otpkit-test" */; productName = OTPKit; }; /* End XCSwiftPackageProductDependency section */ diff --git a/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index bc0b173..0000000 --- a/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,14 +0,0 @@ -{ - "pins" : [ - { - "identity" : "otpkit-test", - "kind" : "remoteSourceControl", - "location" : "https://github.com/hilmyveradin/otpkit-test", - "state" : { - "branch" : "master", - "revision" : "e1010be7e016a65eeec614a7e832051abc3b7cea" - } - } - ], - "version" : 2 -} From 8d28a269fc135667fd13cc3528711efafe0767c5 Mon Sep 17 00:00:00 2001 From: hilmyveradin Date: Tue, 13 Aug 2024 17:49:43 +0700 Subject: [PATCH 4/6] add location privacy permission --- Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.pbxproj | 6 ++++++ Examples/OTPKitDemo/OTPKitDemo/Info.plist | 5 +++++ 2 files changed, 11 insertions(+) create mode 100644 Examples/OTPKitDemo/OTPKitDemo/Info.plist diff --git a/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.pbxproj b/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.pbxproj index f81a5e4..7078af5 100644 --- a/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.pbxproj +++ b/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 014316E02C6B713D00B33240 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 01AA80502C6B6A7500D4038A /* OTPKitDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OTPKitDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 01AA80532C6B6A7500D4038A /* OTPKitDemoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OTPKitDemoApp.swift; sourceTree = ""; }; 01AA80552C6B6A7500D4038A /* MapView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapView.swift; sourceTree = ""; }; @@ -53,6 +54,7 @@ 01AA80522C6B6A7500D4038A /* OTPKitDemo */ = { isa = PBXGroup; children = ( + 014316E02C6B713D00B33240 /* Info.plist */, 01AA80532C6B6A7500D4038A /* OTPKitDemoApp.swift */, 01AA80552C6B6A7500D4038A /* MapView.swift */, 01AA80572C6B6A7600D4038A /* Assets.xcassets */, @@ -282,6 +284,8 @@ DEVELOPMENT_ASSET_PATHS = "\"OTPKitDemo/Preview Content\""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = OTPKitDemo/Info.plist; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "See where you are in relation to transit, and help you navigate more easily."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; @@ -310,6 +314,8 @@ DEVELOPMENT_ASSET_PATHS = "\"OTPKitDemo/Preview Content\""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = OTPKitDemo/Info.plist; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "See where you are in relation to transit, and help you navigate more easily."; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; diff --git a/Examples/OTPKitDemo/OTPKitDemo/Info.plist b/Examples/OTPKitDemo/OTPKitDemo/Info.plist new file mode 100644 index 0000000..0c67376 --- /dev/null +++ b/Examples/OTPKitDemo/OTPKitDemo/Info.plist @@ -0,0 +1,5 @@ + + + + + From 7beab71f6c8eb501f57e76143f9d99f29e6da87c Mon Sep 17 00:00:00 2001 From: hilmyveradin Date: Tue, 13 Aug 2024 17:57:04 +0700 Subject: [PATCH 5/6] fix swiftlint fix --- Package.swift | 6 +++--- Sources/OTPKit/Features/MapExtension/MapMarkingView.swift | 1 - Sources/OTPKit/Network/RestAPI.swift | 4 ++-- Sources/OTPKit/Services/TripPlannerService.swift | 2 +- Sources/OTPKit/TripPlannerExtensionView.swift | 1 - 5 files changed, 6 insertions(+), 8 deletions(-) diff --git a/Package.swift b/Package.swift index 6932d29..6b7ee03 100644 --- a/Package.swift +++ b/Package.swift @@ -8,12 +8,12 @@ let package = Package( platforms: [ .iOS(.v17) ], - + products: [ // Products define the executables and libraries a package produces, making them visible to other packages. .library( name: "OTPKit", - targets: ["OTPKit"]), + targets: ["OTPKit"]) ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. @@ -22,6 +22,6 @@ let package = Package( name: "OTPKit"), .testTarget( name: "OTPKitTests", - dependencies: ["OTPKit"]), + dependencies: ["OTPKit"]) ] ) diff --git a/Sources/OTPKit/Features/MapExtension/MapMarkingView.swift b/Sources/OTPKit/Features/MapExtension/MapMarkingView.swift index e2de0a1..d761063 100644 --- a/Sources/OTPKit/Features/MapExtension/MapMarkingView.swift +++ b/Sources/OTPKit/Features/MapExtension/MapMarkingView.swift @@ -7,7 +7,6 @@ import SwiftUI - /// View for Map Marking Mode /// User able to add Marking directly from the map public struct MapMarkingView: View { diff --git a/Sources/OTPKit/Network/RestAPI.swift b/Sources/OTPKit/Network/RestAPI.swift index f9d605c..c6c5c89 100644 --- a/Sources/OTPKit/Network/RestAPI.swift +++ b/Sources/OTPKit/Network/RestAPI.swift @@ -20,7 +20,7 @@ import Foundation /// An actor representing a REST API client for making network requests public actor RestAPI { - + /// Initializes a new instance of RestAPI /// /// - Parameters: @@ -36,7 +36,7 @@ public actor RestAPI { /// The base URL for the API public let baseURL: URL - + /// The data loader used for network requests public nonisolated let dataLoader: URLDataLoader diff --git a/Sources/OTPKit/Services/TripPlannerService.swift b/Sources/OTPKit/Services/TripPlannerService.swift index 756f4a3..d9afd03 100644 --- a/Sources/OTPKit/Services/TripPlannerService.swift +++ b/Sources/OTPKit/Services/TripPlannerService.swift @@ -365,7 +365,7 @@ extension TripPlannerService: CLLocationManagerDelegate { // MARK: - Service Extension extension TripPlannerService { - + /// Formats a coordinate into a string representation /// /// - Parameter coordinate: The coordinate to format diff --git a/Sources/OTPKit/TripPlannerExtensionView.swift b/Sources/OTPKit/TripPlannerExtensionView.swift index d9159eb..d025c55 100644 --- a/Sources/OTPKit/TripPlannerExtensionView.swift +++ b/Sources/OTPKit/TripPlannerExtensionView.swift @@ -8,7 +8,6 @@ import MapKit import SwiftUI - /// Main Extension View that take Map as it's content /// This simplify all the process of making the Trip Planner UI public struct TripPlannerExtensionView: View { From a867b1232eae237c8b186d552d227bee93ad759d Mon Sep 17 00:00:00 2001 From: hilmyveradin Date: Wed, 14 Aug 2024 23:39:39 +0700 Subject: [PATCH 6/6] feat: re-add test, modify CI --- .github/workflows/ci.yml | 61 +++----- .../OTPKitDemo.xcodeproj/project.pbxproj | 7 + Package.swift | 9 +- Tests/Helpers/Fixtures.swift | 55 +++++++ Tests/Helpers/MockDataLoader.swift | 148 ++++++++++++++++++ Tests/Helpers/OTPTestCase.swift | 50 ++++++ Tests/OTPKitTests.swift | 57 +++++++ Tests/OTPKitTests/OTPKitTests.swift | 12 -- Tests/OTPKitTestsSetup.swift | 16 ++ Tests/Resources/plan_basic_case.json | 1 + 10 files changed, 365 insertions(+), 51 deletions(-) create mode 100644 Tests/Helpers/Fixtures.swift create mode 100644 Tests/Helpers/MockDataLoader.swift create mode 100644 Tests/Helpers/OTPTestCase.swift create mode 100644 Tests/OTPKitTests.swift delete mode 100644 Tests/OTPKitTests/OTPKitTests.swift create mode 100644 Tests/OTPKitTestsSetup.swift create mode 100644 Tests/Resources/plan_basic_case.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2cfa88..5ffadfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,48 +2,33 @@ name: OTPKitTests on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] jobs: build: runs-on: macos-latest steps: - - uses: actions/checkout@v2 - - - name: Switch Xcode 15 - run: sudo xcode-select -switch /Applications/Xcode_15.0.1.app - - - name: Install xcodegen - run: brew install xcodegen - - - name: Generate xcodeproj for OTPKit - run: xcodegen - - # Build - - name: Build OneBusAway - run: xcodebuild clean build-for-testing - -scheme 'OTPKitDemo' - -destination 'platform=iOS Simulator,name=iPhone 15' - -quiet - - # Unit Test - - name: OBAKit Unit Test - run: xcodebuild test-without-building - -only-testing:OTPKitTests - -project 'OTPKit.xcodeproj' - -scheme 'OTPKitDemo' - -destination 'platform=iOS Simulator,name=iPhone 15' - -resultBundlePath OTPKitTests.xcresult - -quiet - - # Upload results - - uses: kishikawakatsumi/xcresulttool@v1.7.0 - continue-on-error: true - with: - show-passed-tests: false # Avoid truncation of annotations by GitHub by omitting succeeding tests. - path: | - OTPKitTests.xcresult - if: success() || failure() + - uses: actions/checkout@v2 + + - name: Switch Xcode 15 + run: sudo xcode-select -switch /Applications/Xcode_15.0.1.app + + # Build + - name: Build OTPKit + run: | + xcodebuild build-for-testing \ + -scheme OTPKit \ + -destination "platform=iOS Simulator,name=iPhone 15,OS=latest" \ + -enableCodeCoverage YES + + # Upload results + - uses: kishikawakatsumi/xcresulttool@v1.7.0 + continue-on-error: true + with: + show-passed-tests: false # Avoid truncation of annotations by GitHub by omitting succeeding tests. + path: | + OTPKitTests.xcresult + if: success() || failure() diff --git a/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.pbxproj b/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.pbxproj index 7078af5..8f9f690 100644 --- a/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.pbxproj +++ b/Examples/OTPKitDemo/OTPKitDemo.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 0111FD712C6CFBE400B4472E /* OTPKit in Frameworks */ = {isa = PBXBuildFile; productRef = 0111FD702C6CFBE400B4472E /* OTPKit */; }; 014316DF2C6B6F2C00B33240 /* OTPKit in Frameworks */ = {isa = PBXBuildFile; productRef = 014316DE2C6B6F2C00B33240 /* OTPKit */; }; 01AA80542C6B6A7500D4038A /* OTPKitDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01AA80532C6B6A7500D4038A /* OTPKitDemoApp.swift */; }; 01AA80562C6B6A7500D4038A /* MapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01AA80552C6B6A7500D4038A /* MapView.swift */; }; @@ -29,6 +30,7 @@ buildActionMask = 2147483647; files = ( 014316DF2C6B6F2C00B33240 /* OTPKit in Frameworks */, + 0111FD712C6CFBE400B4472E /* OTPKit in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -89,6 +91,7 @@ name = OTPKitDemo; packageProductDependencies = ( 014316DE2C6B6F2C00B33240 /* OTPKit */, + 0111FD702C6CFBE400B4472E /* OTPKit */, ); productName = OTPKitDemo; productReference = 01AA80502C6B6A7500D4038A /* OTPKitDemo.app */; @@ -365,6 +368,10 @@ /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 0111FD702C6CFBE400B4472E /* OTPKit */ = { + isa = XCSwiftPackageProductDependency; + productName = OTPKit; + }; 014316DE2C6B6F2C00B33240 /* OTPKit */ = { isa = XCSwiftPackageProductDependency; productName = OTPKit; diff --git a/Package.swift b/Package.swift index 6b7ee03..c163d00 100644 --- a/Package.swift +++ b/Package.swift @@ -22,6 +22,13 @@ let package = Package( name: "OTPKit"), .testTarget( name: "OTPKitTests", - dependencies: ["OTPKit"]) + dependencies: ["OTPKit"], + path: "Tests", + resources: [ + // Copy Tests/ExampleTests/Resources directories as-is. + // Use to retain directory structure. + // Will be at top level in bundle. + .process("Resources"), + ]), ] ) diff --git a/Tests/Helpers/Fixtures.swift b/Tests/Helpers/Fixtures.swift new file mode 100644 index 0000000..43589d1 --- /dev/null +++ b/Tests/Helpers/Fixtures.swift @@ -0,0 +1,55 @@ +/* + * Copyright (C) Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +@testable import OTPKit + +class Fixtures { + private class var testBundle: Bundle { + Bundle.module + } + + /// Converts the specified dictionary to a model object of type `T`. + /// - Parameters: + /// - type: The model type to which the dictionary will be converted. + /// - dictionary: The data + /// - Returns: A model object + class func dictionaryToModel(type: T.Type, dictionary: [String: Any]) throws -> T where T: Decodable { + let jsonData = try JSONSerialization.data(withJSONObject: dictionary, options: []) + return try JSONDecoder().decode(type, from: jsonData) + } + + /// Returns the path to the specified file in the test bundle. + /// - Parameter fileName: The file name, e.g. "regions.json" + class func path(to fileName: String) -> String { + testBundle.path(forResource: fileName, ofType: nil)! + } + + /// Encodes and decodes the provided `Codable` object. Useful for testing roundtripping. + /// - Parameter type: The object type. + /// - Parameter model: The object or objects. + class func roundtripCodable(type: T.Type, model: T) throws -> T where T: Codable { + let encoded = try PropertyListEncoder().encode(model) + let decoded = try PropertyListDecoder().decode(type, from: encoded) + return decoded + } + + /// Loads data from the specified file name, searching within the test bundle. + /// - Parameter file: The file name to load data from. Example: `stop_data.pb`. + class func loadData(file: String) -> Data { + NSData(contentsOfFile: path(to: file))! as Data + } +} diff --git a/Tests/Helpers/MockDataLoader.swift b/Tests/Helpers/MockDataLoader.swift new file mode 100644 index 0000000..d6fb52d --- /dev/null +++ b/Tests/Helpers/MockDataLoader.swift @@ -0,0 +1,148 @@ +/* + * Copyright (C) Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +import OTPKit + +typealias MockDataLoaderMatcher = (URLRequest) -> Bool + +struct MockDataResponse { + let data: Data? + let urlResponse: URLResponse? + let error: Error? + let matcher: MockDataLoaderMatcher +} + +class MockTask: URLSessionDataTask { + override var progress: Progress { + Progress() + } + + private var closure: (Data?, URLResponse?, Error?) -> Void + private let mockResponse: MockDataResponse + + init(mockResponse: MockDataResponse, closure: @escaping (Data?, URLResponse?, Error?) -> Void) { + self.mockResponse = mockResponse + self.closure = closure + } + + // We override the 'resume' method and simply call our closure + // instead of actually resuming any task. + override func resume() { + closure(mockResponse.data, mockResponse.urlResponse, mockResponse.error) + } + + override func cancel() { + // nop + } +} + +class MockDataLoader: NSObject, URLDataLoader { + var mockResponses = [MockDataResponse]() + + let testName: String + + init(testName: String) { + self.testName = testName + } + + func dataTask( + with request: URLRequest, + completionHandler: @escaping (Data?, URLResponse?, Error?) -> Void + ) -> URLSessionDataTask { + guard let response = matchResponse(to: request) else { + fatalError("\(testName): Missing response to URL: \(request.url!)") + } + + return MockTask(mockResponse: response, closure: completionHandler) + } + + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + guard let response = matchResponse(to: request) else { + fatalError("\(testName): Missing response to URL: \(request.url!)") + } + + if let error = response.error { + throw error + } + + guard let data = response.data else { + fatalError("\(testName): Missing data to URL: \(request.url!))") + } + + guard let urlResponse = response.urlResponse else { + fatalError("\(testName): Missing urlResponse to URL: \(request.url!))") + } + + return (data, urlResponse) + } + + // MARK: - Response Mapping + + func matchResponse(to request: URLRequest) -> MockDataResponse? { + for r in mockResponses where r.matcher(request) { + return r + } + + return nil + } + + func mock(data: Data, matcher: @escaping MockDataLoaderMatcher) { + let urlResponse = buildURLResponse(URL: URL(string: "https://mockdataloader.example.com")!, statusCode: 200) + let mockResponse = MockDataResponse(data: data, urlResponse: urlResponse, error: nil, matcher: matcher) + mock(response: mockResponse) + } + + func mock(URLString: String, with data: Data) { + mock(url: URL(string: URLString)!, with: data) + } + + func mock(url: URL, with data: Data) { + let urlResponse = buildURLResponse(URL: url, statusCode: 200) + let mockResponse = MockDataResponse(data: data, urlResponse: urlResponse, error: nil) { + let requestURL = $0.url! + return requestURL.host == url.host && requestURL.path == url.path + } + mock(response: mockResponse) + } + + func mock(response: MockDataResponse) { + mockResponses.append(response) + } + + func removeMappedResponses() { + mockResponses.removeAll() + } + + // MARK: - URL Response + + func buildURLResponse(URL: URL, statusCode: Int) -> HTTPURLResponse { + HTTPURLResponse( + url: URL, + statusCode: statusCode, + httpVersion: "2", + headerFields: ["Content-Type": "application/json"] + )! + } + + // MARK: - Description + + override var debugDescription: String { + var descriptionBuilder = DebugDescriptionBuilder(baseDescription: super.debugDescription) + descriptionBuilder.add(key: "mockResponses", value: mockResponses) + return descriptionBuilder.description + } +} diff --git a/Tests/Helpers/OTPTestCase.swift b/Tests/Helpers/OTPTestCase.swift new file mode 100644 index 0000000..def9706 --- /dev/null +++ b/Tests/Helpers/OTPTestCase.swift @@ -0,0 +1,50 @@ +// +// OTPTestCase.swift +// OTPKitTests +// +// Created by Aaron Brethorst on 5/2/24. +// + +import Foundation +@testable import OTPKit +import XCTest + +public class OTPTestCase: XCTestCase { + var userDefaults: UserDefaults! + + override open func setUp() { + super.setUp() + NSTimeZone.default = NSTimeZone(forSecondsFromGMT: 0) as TimeZone + userDefaults = buildUserDefaults() + userDefaults.removePersistentDomain(forName: userDefaultsSuiteName) + } + + override open func tearDown() { + super.tearDown() + NSTimeZone.resetSystemTimeZone() + userDefaults.removePersistentDomain(forName: userDefaultsSuiteName) + } + + // MARK: - User Defaults + + func buildUserDefaults(suiteName: String? = nil) -> UserDefaults { + UserDefaults(suiteName: suiteName ?? userDefaultsSuiteName)! + } + + var userDefaultsSuiteName: String { + String(describing: self) + } + + // MARK: - Network and Data + + func buildMockDataLoader() -> MockDataLoader { + MockDataLoader(testName: name) + } + + func buildRestAPIClient( + baseURLString: String = "https://otp.prod.sound.obaweb.org/otp/routers/default/" + ) -> RestAPI { + let baseURL = URL(string: baseURLString)! + return RestAPI(baseURL: baseURL, dataLoader: buildMockDataLoader()) + } +} diff --git a/Tests/OTPKitTests.swift b/Tests/OTPKitTests.swift new file mode 100644 index 0000000..be5bee1 --- /dev/null +++ b/Tests/OTPKitTests.swift @@ -0,0 +1,57 @@ +/* + * Copyright (C) Open Transit Software Foundation + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// swiftlint:disable force_cast line_length + +@testable import OTPKit +import XCTest + +class OTPKitTests: OTPTestCase { + let soundTransitBaseURL = URL(string: "https://otp.prod.sound.obaweb.org/otp/routers/default/")! + + func testPlanBasics() async throws { + let restApi = buildRestAPIClient() + + let dataLoader = restApi.dataLoader as! MockDataLoader + + dataLoader.mock(URLString: "https://otp.prod.sound.obaweb.org/otp/routers/default/plan?fromPlace=47.6097,-122.3331&toPlace=47.6154,-122.3208&time=8:00%20AM&date=05-10-2024&mode=TRANSIT,WALK&arriveBy=false&maxWalkDistance=800&wheelchair=false", with: Fixtures.loadData(file: "plan_basic_case.json")) + + let result = try await restApi.fetchPlan( + fromPlace: "47.6097,-122.3331", + toPlace: "47.6154,-122.3208", + time: "8:00 AM", + date: "05-10-2024", + mode: "TRANSIT,WALK", + arriveBy: false, + maxWalkDistance: 800, + wheelchair: false + ) + + XCTAssertNotNil(result) + + let plan = result.plan! + + XCTAssertNotNil(plan) + + XCTAssertEqual(plan.itineraries.count, 3) + + let itinerary = plan.itineraries.first + + XCTAssertEqual(itinerary?.duration, 595) + } +} + +// swiftlint:enable force_cast line_length diff --git a/Tests/OTPKitTests/OTPKitTests.swift b/Tests/OTPKitTests/OTPKitTests.swift deleted file mode 100644 index f8a346b..0000000 --- a/Tests/OTPKitTests/OTPKitTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import XCTest -@testable import OTPKit - -final class OTPKitTests: XCTestCase { - func testExample() throws { - // XCTest Documentation - // https://developer.apple.com/documentation/xctest - - // Defining Test Cases and Test Methods - // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods - } -} diff --git a/Tests/OTPKitTestsSetup.swift b/Tests/OTPKitTestsSetup.swift new file mode 100644 index 0000000..7dd033e --- /dev/null +++ b/Tests/OTPKitTestsSetup.swift @@ -0,0 +1,16 @@ +// +// OTPKitTestsSetup.swift +// OTPKit +// +// Copyright © Open Transit Software Foundation +// This source code is licensed under the Apache 2.0 license found in the +// LICENSE file in the root directory of this source tree. +// + +import Foundation + +class OTPKitTestsSetup: NSObject { + override init() { + super.init() + } +} diff --git a/Tests/Resources/plan_basic_case.json b/Tests/Resources/plan_basic_case.json new file mode 100644 index 0000000..8d5d38a --- /dev/null +++ b/Tests/Resources/plan_basic_case.json @@ -0,0 +1 @@ +{"requestParameters":{"date":"05-10-2024","mode":"TRANSIT,WALK","arriveBy":"false","wheelchair":"false","fromPlace":"47.6097,-122.3331","toPlace":"47.6154,-122.3208","time":"8:00 AM","maxWalkDistance":"800"},"plan":{"date":1715353200000,"from":{"name":"Origin","lon":-122.3331,"lat":47.6097,"orig":"","vertexType":"NORMAL"},"to":{"name":"Destination","lon":-122.3208,"lat":47.6154,"orig":"","vertexType":"NORMAL"},"itineraries":[{"duration":595,"startTime":1715354006000,"endTime":1715354601000,"walkTime":235,"transitTime":358,"waitingTime":2,"walkDistance":275.6775338293097,"walkLimitExceeded":false,"elevationLost":0.0,"elevationGained":0.0,"transfers":0,"fare":{"fare":{"youth":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":0},"senior":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":0},"regular":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":250}},"details":{"youth":[{"fareId":"1:305","price":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":0},"routes":["1:100447"]}],"senior":[{"fareId":"1:305","price":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":0},"routes":["1:100447"]}],"regular":[{"fareId":"1:101","price":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":250},"routes":["1:100447"]}]}},"legs":[{"startTime":1715354006000,"endTime":1715354161000,"departureDelay":0,"arrivalDelay":0,"realTime":false,"distance":190.90900000000002,"pathway":false,"mode":"WALK","route":"","agencyTimeZoneOffset":-25200000,"interlineWithPreviousLeg":false,"from":{"name":"Origin","lon":-122.3331,"lat":47.6097,"departure":1715354006000,"orig":"","vertexType":"NORMAL"},"to":{"name":"Pike St & 6th Ave","stopId":"1:1190","stopCode":"1190","lon":-122.334526,"lat":47.611034,"arrival":1715354161000,"departure":1715354162000,"zoneId":"21","stopIndex":1,"stopSequence":6,"vertexType":"TRANSIT","boardAlightType":"DEFAULT"},"legGeometry":{"points":"owqaHbetiVEB}@x@GFOLSP}@x@_BvADL@DCBAD","length":12},"rentedBike":false,"flexDrtAdvanceBookMin":0.0,"duration":155.0,"transitLeg":false,"steps":[{"distance":177.07100000000003,"relativeDirection":"DEPART","streetName":"6th Avenue","absoluteDirection":"NORTHWEST","stayOn":false,"area":false,"bogusName":false,"lon":-122.33313046442926,"lat":47.609687150686874,"elevation":[]},{"distance":13.838000000000001,"relativeDirection":"LEFT","streetName":"path","absoluteDirection":"SOUTHWEST","stayOn":false,"area":false,"bogusName":true,"lon":-122.334378,"lat":47.6110394,"elevation":[]}]},{"startTime":1715354162000,"endTime":1715354520000,"departureDelay":0,"arrivalDelay":0,"realTime":false,"distance":1190.860971998657,"pathway":false,"mode":"BUS","route":"49","agencyName":"Metro Transit","agencyUrl":"https://kingcounty.gov/en/dept/metro","agencyTimeZoneOffset":-25200000,"routeType":3,"routeId":"1:100447","interlineWithPreviousLeg":false,"tripBlockId":"7160136","headsign":"U-District Station Capitol Hill","agencyId":"1","tripId":"1:664034336","serviceDate":"20240510","from":{"name":"Pike St & 6th Ave","stopId":"1:1190","stopCode":"1190","lon":-122.334526,"lat":47.611034,"arrival":1715354161000,"departure":1715354162000,"zoneId":"21","stopIndex":1,"stopSequence":6,"vertexType":"TRANSIT","boardAlightType":"DEFAULT"},"to":{"name":"E Pine St & Harvard Ave","stopId":"1:11150","stopCode":"11150","lon":-122.321617,"lat":47.615162,"arrival":1715354520000,"departure":1715354521000,"zoneId":"1","stopIndex":5,"stopSequence":32,"vertexType":"TRANSIT","boardAlightType":"DEFAULT"},"legGeometry":{"points":"i`raHbntiVISm@oBm@oBs@yBg@cB}A}Eo@mBQi@Oi@Ws@oAeEc@qAI[o@oBUu@ESC]@}AaF??{B?eCAqD?_@AqE@aGAgB","length":27},"routeShortName":"49","rentedBike":false,"flexDrtAdvanceBookMin":0.0,"duration":358.0,"transitLeg":true,"steps":[]},{"startTime":1715354521000,"endTime":1715354601000,"departureDelay":0,"arrivalDelay":0,"realTime":false,"distance":84.605,"pathway":false,"mode":"WALK","route":"","agencyTimeZoneOffset":-25200000,"interlineWithPreviousLeg":false,"from":{"name":"E Pine St & Harvard Ave","stopId":"1:11150","stopCode":"11150","lon":-122.321617,"lat":47.615162,"arrival":1715354520000,"departure":1715354521000,"zoneId":"1","stopIndex":5,"stopSequence":32,"vertexType":"TRANSIT","boardAlightType":"DEFAULT"},"to":{"name":"Destination","lon":-122.3208,"lat":47.6154,"arrival":1715354601000,"orig":"","vertexType":"NORMAL"},"legGeometry":{"points":"wyraHb}qiV?]Q??_@?cA?U?EU@E?","length":9},"rentedBike":false,"flexDrtAdvanceBookMin":0.0,"duration":80.0,"transitLeg":false,"steps":[{"distance":11.238,"relativeDirection":"DEPART","streetName":"sidewalk","absoluteDirection":"EAST","stayOn":false,"area":false,"bogusName":true,"lon":-122.32161701929206,"lat":47.615163059437414,"elevation":[]},{"distance":9.83,"relativeDirection":"LEFT","streetName":"alley","absoluteDirection":"NORTH","stayOn":true,"area":false,"bogusName":true,"lon":-122.3214671,"lat":47.6151643,"elevation":[]},{"distance":47.894999999999996,"relativeDirection":"RIGHT","streetName":"East Pine Street","absoluteDirection":"EAST","stayOn":false,"area":false,"bogusName":false,"lon":-122.32146890000001,"lat":47.615252700000006,"elevation":[]},{"distance":15.642,"relativeDirection":"LEFT","streetName":"Broadway","absoluteDirection":"NORTH","stayOn":false,"area":false,"bogusName":false,"lon":-122.32083,"lat":47.615259,"elevation":[]}]}],"tooSloped":false},{"duration":647,"startTime":1715354134000,"endTime":1715354781000,"walkTime":235,"transitTime":410,"waitingTime":2,"walkDistance":275.6775338293097,"walkLimitExceeded":false,"elevationLost":0.0,"elevationGained":0.0,"transfers":0,"fare":{"fare":{"youth":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":0},"senior":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":0},"regular":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":250}},"details":{"youth":[{"fareId":"1:305","price":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":0},"routes":["1:100009"]}],"senior":[{"fareId":"1:305","price":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":0},"routes":["1:100009"]}],"regular":[{"fareId":"1:101","price":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":250},"routes":["1:100009"]}]}},"legs":[{"startTime":1715354134000,"endTime":1715354289000,"departureDelay":0,"arrivalDelay":0,"realTime":false,"distance":190.90900000000002,"pathway":false,"mode":"WALK","route":"","agencyTimeZoneOffset":-25200000,"interlineWithPreviousLeg":false,"from":{"name":"Origin","lon":-122.3331,"lat":47.6097,"departure":1715354134000,"orig":"","vertexType":"NORMAL"},"to":{"name":"Pike St & 6th Ave","stopId":"1:1190","stopCode":"1190","lon":-122.334526,"lat":47.611034,"arrival":1715354289000,"departure":1715354290000,"zoneId":"21","stopIndex":4,"stopSequence":30,"vertexType":"TRANSIT","boardAlightType":"DEFAULT"},"legGeometry":{"points":"owqaHbetiVEB}@x@GFOLSP}@x@_BvADL@DCBAD","length":12},"rentedBike":false,"flexDrtAdvanceBookMin":0.0,"duration":155.0,"transitLeg":false,"steps":[{"distance":177.07100000000003,"relativeDirection":"DEPART","streetName":"6th Avenue","absoluteDirection":"NORTHWEST","stayOn":false,"area":false,"bogusName":false,"lon":-122.33313046442926,"lat":47.609687150686874,"elevation":[]},{"distance":13.838000000000001,"relativeDirection":"LEFT","streetName":"path","absoluteDirection":"SOUTHWEST","stayOn":false,"area":false,"bogusName":true,"lon":-122.334378,"lat":47.6110394,"elevation":[]}]},{"startTime":1715354290000,"endTime":1715354700000,"departureDelay":0,"arrivalDelay":0,"realTime":false,"distance":1190.860971998657,"pathway":false,"mode":"BUS","route":"11","agencyName":"Metro Transit","agencyUrl":"https://kingcounty.gov/en/dept/metro","agencyTimeZoneOffset":-25200000,"routeType":3,"routeId":"1:100009","interlineWithPreviousLeg":false,"tripBlockId":"7159194","headsign":"Madison Park Via E Madison St","agencyId":"1","tripId":"1:628187066","serviceDate":"20240510","from":{"name":"Pike St & 6th Ave","stopId":"1:1190","stopCode":"1190","lon":-122.334526,"lat":47.611034,"arrival":1715354289000,"departure":1715354290000,"zoneId":"21","stopIndex":4,"stopSequence":30,"vertexType":"TRANSIT","boardAlightType":"DEFAULT"},"to":{"name":"E Pine St & Harvard Ave","stopId":"1:11150","stopCode":"11150","lon":-122.321617,"lat":47.615162,"arrival":1715354700000,"departure":1715354701000,"zoneId":"1","stopIndex":8,"stopSequence":56,"vertexType":"TRANSIT","boardAlightType":"DEFAULT"},"legGeometry":{"points":"i`raHbntiVISm@oBm@oBs@yBg@cB}A}Eo@mBQi@Oi@Ws@oAeEc@qAI[o@oBUu@ESC]@}AaF??{B?eCAqD?_@AqE@aGAgB","length":27},"routeShortName":"11","rentedBike":false,"flexDrtAdvanceBookMin":0.0,"duration":410.0,"transitLeg":true,"steps":[]},{"startTime":1715354701000,"endTime":1715354781000,"departureDelay":0,"arrivalDelay":0,"realTime":false,"distance":84.605,"pathway":false,"mode":"WALK","route":"","agencyTimeZoneOffset":-25200000,"interlineWithPreviousLeg":false,"from":{"name":"E Pine St & Harvard Ave","stopId":"1:11150","stopCode":"11150","lon":-122.321617,"lat":47.615162,"arrival":1715354700000,"departure":1715354701000,"zoneId":"1","stopIndex":8,"stopSequence":56,"vertexType":"TRANSIT","boardAlightType":"DEFAULT"},"to":{"name":"Destination","lon":-122.3208,"lat":47.6154,"arrival":1715354781000,"orig":"","vertexType":"NORMAL"},"legGeometry":{"points":"wyraHb}qiV?]Q??_@?cA?U?EU@E?","length":9},"rentedBike":false,"flexDrtAdvanceBookMin":0.0,"duration":80.0,"transitLeg":false,"steps":[{"distance":11.238,"relativeDirection":"DEPART","streetName":"sidewalk","absoluteDirection":"EAST","stayOn":false,"area":false,"bogusName":true,"lon":-122.32161701929206,"lat":47.615163059437414,"elevation":[]},{"distance":9.83,"relativeDirection":"LEFT","streetName":"alley","absoluteDirection":"NORTH","stayOn":true,"area":false,"bogusName":true,"lon":-122.3214671,"lat":47.6151643,"elevation":[]},{"distance":47.894999999999996,"relativeDirection":"RIGHT","streetName":"East Pine Street","absoluteDirection":"EAST","stayOn":false,"area":false,"bogusName":false,"lon":-122.32146890000001,"lat":47.615252700000006,"elevation":[]},{"distance":15.642,"relativeDirection":"LEFT","streetName":"Broadway","absoluteDirection":"NORTH","stayOn":false,"area":false,"bogusName":false,"lon":-122.32083,"lat":47.615259,"elevation":[]}]}],"tooSloped":false},{"duration":595,"startTime":1715354906000,"endTime":1715355501000,"walkTime":235,"transitTime":358,"waitingTime":2,"walkDistance":275.6775338293097,"walkLimitExceeded":false,"elevationLost":0.0,"elevationGained":0.0,"transfers":0,"fare":{"fare":{"youth":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":0},"senior":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":0},"regular":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":250}},"details":{"youth":[{"fareId":"1:305","price":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":0},"routes":["1:100447"]}],"senior":[{"fareId":"1:305","price":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":0},"routes":["1:100447"]}],"regular":[{"fareId":"1:101","price":{"currency":{"symbol":"$","currency":"USD","defaultFractionDigits":2,"currencyCode":"USD"},"cents":250},"routes":["1:100447"]}]}},"legs":[{"startTime":1715354906000,"endTime":1715355061000,"departureDelay":0,"arrivalDelay":0,"realTime":false,"distance":190.90900000000002,"pathway":false,"mode":"WALK","route":"","agencyTimeZoneOffset":-25200000,"interlineWithPreviousLeg":false,"from":{"name":"Origin","lon":-122.3331,"lat":47.6097,"departure":1715354906000,"orig":"","vertexType":"NORMAL"},"to":{"name":"Pike St & 6th Ave","stopId":"1:1190","stopCode":"1190","lon":-122.334526,"lat":47.611034,"arrival":1715355061000,"departure":1715355062000,"zoneId":"21","stopIndex":1,"stopSequence":6,"vertexType":"TRANSIT","boardAlightType":"DEFAULT"},"legGeometry":{"points":"owqaHbetiVEB}@x@GFOLSP}@x@_BvADL@DCBAD","length":12},"rentedBike":false,"flexDrtAdvanceBookMin":0.0,"duration":155.0,"transitLeg":false,"steps":[{"distance":177.07100000000003,"relativeDirection":"DEPART","streetName":"6th Avenue","absoluteDirection":"NORTHWEST","stayOn":false,"area":false,"bogusName":false,"lon":-122.33313046442926,"lat":47.609687150686874,"elevation":[]},{"distance":13.838000000000001,"relativeDirection":"LEFT","streetName":"path","absoluteDirection":"SOUTHWEST","stayOn":false,"area":false,"bogusName":true,"lon":-122.334378,"lat":47.6110394,"elevation":[]}]},{"startTime":1715355062000,"endTime":1715355420000,"departureDelay":0,"arrivalDelay":0,"realTime":false,"distance":1190.860971998657,"pathway":false,"mode":"BUS","route":"49","agencyName":"Metro Transit","agencyUrl":"https://kingcounty.gov/en/dept/metro","agencyTimeZoneOffset":-25200000,"routeType":3,"routeId":"1:100447","interlineWithPreviousLeg":false,"tripBlockId":"7160100","headsign":"U-District Station Capitol Hill","agencyId":"1","tripId":"1:608581516","serviceDate":"20240510","from":{"name":"Pike St & 6th Ave","stopId":"1:1190","stopCode":"1190","lon":-122.334526,"lat":47.611034,"arrival":1715355061000,"departure":1715355062000,"zoneId":"21","stopIndex":1,"stopSequence":6,"vertexType":"TRANSIT","boardAlightType":"DEFAULT"},"to":{"name":"E Pine St & Harvard Ave","stopId":"1:11150","stopCode":"11150","lon":-122.321617,"lat":47.615162,"arrival":1715355420000,"departure":1715355421000,"zoneId":"1","stopIndex":5,"stopSequence":32,"vertexType":"TRANSIT","boardAlightType":"DEFAULT"},"legGeometry":{"points":"i`raHbntiVISm@oBm@oBs@yBg@cB}A}Eo@mBQi@Oi@Ws@oAeEc@qAI[o@oBUu@ESC]@}AaF??{B?eCAqD?_@AqE@aGAgB","length":27},"routeShortName":"49","rentedBike":false,"flexDrtAdvanceBookMin":0.0,"duration":358.0,"transitLeg":true,"steps":[]},{"startTime":1715355421000,"endTime":1715355501000,"departureDelay":0,"arrivalDelay":0,"realTime":false,"distance":84.605,"pathway":false,"mode":"WALK","route":"","agencyTimeZoneOffset":-25200000,"interlineWithPreviousLeg":false,"from":{"name":"E Pine St & Harvard Ave","stopId":"1:11150","stopCode":"11150","lon":-122.321617,"lat":47.615162,"arrival":1715355420000,"departure":1715355421000,"zoneId":"1","stopIndex":5,"stopSequence":32,"vertexType":"TRANSIT","boardAlightType":"DEFAULT"},"to":{"name":"Destination","lon":-122.3208,"lat":47.6154,"arrival":1715355501000,"orig":"","vertexType":"NORMAL"},"legGeometry":{"points":"wyraHb}qiV?]Q??_@?cA?U?EU@E?","length":9},"rentedBike":false,"flexDrtAdvanceBookMin":0.0,"duration":80.0,"transitLeg":false,"steps":[{"distance":11.238,"relativeDirection":"DEPART","streetName":"sidewalk","absoluteDirection":"EAST","stayOn":false,"area":false,"bogusName":true,"lon":-122.32161701929206,"lat":47.615163059437414,"elevation":[]},{"distance":9.83,"relativeDirection":"LEFT","streetName":"alley","absoluteDirection":"NORTH","stayOn":true,"area":false,"bogusName":true,"lon":-122.3214671,"lat":47.6151643,"elevation":[]},{"distance":47.894999999999996,"relativeDirection":"RIGHT","streetName":"East Pine Street","absoluteDirection":"EAST","stayOn":false,"area":false,"bogusName":false,"lon":-122.32146890000001,"lat":47.615252700000006,"elevation":[]},{"distance":15.642,"relativeDirection":"LEFT","streetName":"Broadway","absoluteDirection":"NORTH","stayOn":false,"area":false,"bogusName":false,"lon":-122.32083,"lat":47.615259,"elevation":[]}]}],"tooSloped":false}]},"debugOutput":{"precalculationTime":181,"pathCalculationTime":97,"pathTimes":[36,32,29],"renderingTime":0,"totalTime":278,"timedOut":false},"elevationMetadata":{"ellipsoidToGeoidDifference":-16.677402251842814,"geoidElevation":false}} \ No newline at end of file