diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97d23f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +busbud-coding-challenge.xcodeproj/project.xcworkspace/xcuserdata/ + diff --git a/busbud-coding-challenge.xcodeproj/project.pbxproj b/busbud-coding-challenge.xcodeproj/project.pbxproj new file mode 100644 index 0000000..20053ff --- /dev/null +++ b/busbud-coding-challenge.xcodeproj/project.pbxproj @@ -0,0 +1,473 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXContainerItemProxy section */ + 14BBD4632D11E6250047FE7A /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 14BBD4442D11E6240047FE7A /* Project object */; + proxyType = 1; + remoteGlobalIDString = 14BBD44B2D11E6240047FE7A; + remoteInfo = "busbud-coding-challenge"; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 14BBD44C2D11E6240047FE7A /* busbud-coding-challenge.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "busbud-coding-challenge.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 14BBD4622D11E6250047FE7A /* busbud-coding-challengeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "busbud-coding-challengeTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; + 14BBD4F72D12796F0047FE7A /* busbud-coding-challenge.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "busbud-coding-challenge.xctestplan"; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 14BBD4742D11E6250047FE7A /* Exceptions for "busbud-coding-challenge" folder in "busbud-coding-challenge" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 14BBD44B2D11E6240047FE7A /* busbud-coding-challenge */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 14BBD44E2D11E6240047FE7A /* busbud-coding-challenge */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 14BBD4742D11E6250047FE7A /* Exceptions for "busbud-coding-challenge" folder in "busbud-coding-challenge" target */, + ); + path = "busbud-coding-challenge"; + sourceTree = ""; + }; + 14BBD4652D11E6250047FE7A /* busbud-coding-challengeTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "busbud-coding-challengeTests"; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 14BBD4492D11E6240047FE7A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 14BBD45F2D11E6250047FE7A /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 14BBD4432D11E6240047FE7A = { + isa = PBXGroup; + children = ( + 14BBD4F72D12796F0047FE7A /* busbud-coding-challenge.xctestplan */, + 14BBD44E2D11E6240047FE7A /* busbud-coding-challenge */, + 14BBD4652D11E6250047FE7A /* busbud-coding-challengeTests */, + 14BBD44D2D11E6240047FE7A /* Products */, + ); + sourceTree = ""; + }; + 14BBD44D2D11E6240047FE7A /* Products */ = { + isa = PBXGroup; + children = ( + 14BBD44C2D11E6240047FE7A /* busbud-coding-challenge.app */, + 14BBD4622D11E6250047FE7A /* busbud-coding-challengeTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 14BBD44B2D11E6240047FE7A /* busbud-coding-challenge */ = { + isa = PBXNativeTarget; + buildConfigurationList = 14BBD4752D11E6250047FE7A /* Build configuration list for PBXNativeTarget "busbud-coding-challenge" */; + buildPhases = ( + 14BBD4482D11E6240047FE7A /* Sources */, + 14BBD4492D11E6240047FE7A /* Frameworks */, + 14BBD44A2D11E6240047FE7A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 14BBD44E2D11E6240047FE7A /* busbud-coding-challenge */, + ); + name = "busbud-coding-challenge"; + packageProductDependencies = ( + ); + productName = "busbud-coding-challenge"; + productReference = 14BBD44C2D11E6240047FE7A /* busbud-coding-challenge.app */; + productType = "com.apple.product-type.application"; + }; + 14BBD4612D11E6250047FE7A /* busbud-coding-challengeTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 14BBD47A2D11E6250047FE7A /* Build configuration list for PBXNativeTarget "busbud-coding-challengeTests" */; + buildPhases = ( + 14BBD45E2D11E6250047FE7A /* Sources */, + 14BBD45F2D11E6250047FE7A /* Frameworks */, + 14BBD4602D11E6250047FE7A /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 14BBD4642D11E6250047FE7A /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 14BBD4652D11E6250047FE7A /* busbud-coding-challengeTests */, + ); + name = "busbud-coding-challengeTests"; + packageProductDependencies = ( + ); + productName = "busbud-coding-challengeTests"; + productReference = 14BBD4622D11E6250047FE7A /* busbud-coding-challengeTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 14BBD4442D11E6240047FE7A /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1620; + LastUpgradeCheck = 1620; + TargetAttributes = { + 14BBD44B2D11E6240047FE7A = { + CreatedOnToolsVersion = 16.2; + }; + 14BBD4612D11E6250047FE7A = { + CreatedOnToolsVersion = 16.2; + TestTargetID = 14BBD44B2D11E6240047FE7A; + }; + }; + }; + buildConfigurationList = 14BBD4472D11E6240047FE7A /* Build configuration list for PBXProject "busbud-coding-challenge" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + "pt-BR", + ); + mainGroup = 14BBD4432D11E6240047FE7A; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 14BBD44D2D11E6240047FE7A /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 14BBD44B2D11E6240047FE7A /* busbud-coding-challenge */, + 14BBD4612D11E6250047FE7A /* busbud-coding-challengeTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 14BBD44A2D11E6240047FE7A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 14BBD4602D11E6250047FE7A /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 14BBD4482D11E6240047FE7A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 14BBD45E2D11E6250047FE7A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 14BBD4642D11E6250047FE7A /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 14BBD44B2D11E6240047FE7A /* busbud-coding-challenge */; + targetProxy = 14BBD4632D11E6250047FE7A /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 14BBD4762D11E6250047FE7A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "busbud-coding-challenge/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = Busbud; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "We need your location even when the app is in the background."; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "spencer.busbud-coding-challenge"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + 14BBD4772D11E6250047FE7A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = "busbud-coding-challenge/Info.plist"; + INFOPLIST_KEY_CFBundleDisplayName = Busbud; + INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "We need your location even when the app is in the background."; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "spencer.busbud-coding-challenge"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + 14BBD4782D11E6250047FE7A /* 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 = 18.2; + 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_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 14BBD4792D11E6250047FE7A /* 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 = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 14BBD47B2D11E6250047FE7A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "spencer.busbud-coding-challengeTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/busbud-coding-challenge.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/busbud-coding-challenge"; + }; + name = Debug; + }; + 14BBD47C2D11E6250047FE7A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "spencer.busbud-coding-challengeTests"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/busbud-coding-challenge.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/busbud-coding-challenge"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 14BBD4472D11E6240047FE7A /* Build configuration list for PBXProject "busbud-coding-challenge" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 14BBD4782D11E6250047FE7A /* Debug */, + 14BBD4792D11E6250047FE7A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 14BBD4752D11E6250047FE7A /* Build configuration list for PBXNativeTarget "busbud-coding-challenge" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 14BBD4762D11E6250047FE7A /* Debug */, + 14BBD4772D11E6250047FE7A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 14BBD47A2D11E6250047FE7A /* Build configuration list for PBXNativeTarget "busbud-coding-challengeTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 14BBD47B2D11E6250047FE7A /* Debug */, + 14BBD47C2D11E6250047FE7A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 14BBD4442D11E6240047FE7A /* Project object */; +} diff --git a/busbud-coding-challenge.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/busbud-coding-challenge.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/busbud-coding-challenge.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/busbud-coding-challenge.xcodeproj/xcshareddata/xcschemes/busbud-coding-challenge.xcscheme b/busbud-coding-challenge.xcodeproj/xcshareddata/xcschemes/busbud-coding-challenge.xcscheme new file mode 100644 index 0000000..6f4cc20 --- /dev/null +++ b/busbud-coding-challenge.xcodeproj/xcshareddata/xcschemes/busbud-coding-challenge.xcscheme @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/busbud-coding-challenge.xcodeproj/xcuserdata/spencerdiniz.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/busbud-coding-challenge.xcodeproj/xcuserdata/spencerdiniz.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..1342c69 --- /dev/null +++ b/busbud-coding-challenge.xcodeproj/xcuserdata/spencerdiniz.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,6 @@ + + + diff --git a/busbud-coding-challenge.xcodeproj/xcuserdata/spencerdiniz.xcuserdatad/xcschemes/xcschememanagement.plist b/busbud-coding-challenge.xcodeproj/xcuserdata/spencerdiniz.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..916c58d --- /dev/null +++ b/busbud-coding-challenge.xcodeproj/xcuserdata/spencerdiniz.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,32 @@ + + + + + SchemeUserState + + busbud-coding-challenge.xcscheme_^#shared#^_ + + orderHint + 0 + + + SuppressBuildableAutocreation + + 14BBD44B2D11E6240047FE7A + + primary + + + 14BBD4612D11E6250047FE7A + + primary + + + 14BBD46B2D11E6250047FE7A + + primary + + + + + diff --git a/busbud-coding-challenge.xctestplan b/busbud-coding-challenge.xctestplan new file mode 100644 index 0000000..6fd853c --- /dev/null +++ b/busbud-coding-challenge.xctestplan @@ -0,0 +1,29 @@ +{ + "configurations" : [ + { + "id" : "C4A3CDE5-520C-4586-AF91-960B8B4D74F5", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "targetForVariableExpansion" : { + "containerPath" : "container:busbud-coding-challenge.xcodeproj", + "identifier" : "14BBD44B2D11E6240047FE7A", + "name" : "busbud-coding-challenge" + } + }, + "testTargets" : [ + { + "parallelizable" : true, + "target" : { + "containerPath" : "container:busbud-coding-challenge.xcodeproj", + "identifier" : "14BBD4612D11E6250047FE7A", + "name" : "busbud-coding-challengeTests" + } + } + ], + "version" : 1 +} diff --git a/busbud-coding-challenge/AppDelegate.swift b/busbud-coding-challenge/AppDelegate.swift new file mode 100644 index 0000000..0446958 --- /dev/null +++ b/busbud-coding-challenge/AppDelegate.swift @@ -0,0 +1,29 @@ +// +// AppDelegate.swift +// busbud-coding-challenge +// +// Created by Spencer Diniz on 17/12/24. +// + +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + // Override point for customization after application launch. + return true + } + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + // Called when a new scene session is being created. + // Use this method to select a configuration to create the new scene with. + return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + } + + func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { + // Called when the user discards a scene session. + // If any sessions were discarded while the application was not running, this will be called shortly after application:didFinishLaunchingWithOptions. + // Use this method to release any resources that were specific to the discarded scenes, as they will not return. + } +} + diff --git a/busbud-coding-challenge/Assets.xcassets/AccentColor.colorset/Contents.json b/busbud-coding-challenge/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/busbud-coding-challenge/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/busbud-coding-challenge/Assets.xcassets/AppIcon.appiconset/Contents.json b/busbud-coding-challenge/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..2305880 --- /dev/null +++ b/busbud-coding-challenge/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/busbud-coding-challenge/Assets.xcassets/Contents.json b/busbud-coding-challenge/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/busbud-coding-challenge/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/busbud-coding-challenge/Base.lproj/LaunchScreen.storyboard b/busbud-coding-challenge/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/busbud-coding-challenge/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/busbud-coding-challenge/Info.plist b/busbud-coding-challenge/Info.plist new file mode 100644 index 0000000..0eb786d --- /dev/null +++ b/busbud-coding-challenge/Info.plist @@ -0,0 +1,23 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + + + + + + diff --git a/busbud-coding-challenge/InfoPlist.xcstrings b/busbud-coding-challenge/InfoPlist.xcstrings new file mode 100644 index 0000000..1f3c354 --- /dev/null +++ b/busbud-coding-challenge/InfoPlist.xcstrings @@ -0,0 +1,59 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "CFBundleDisplayName" : { + "comment" : "Bundle display name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Busbud" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Busbud BR" + } + } + } + }, + "CFBundleName" : { + "comment" : "Bundle name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "busbud-coding-challenge" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "busbud-coding-challenge" + } + } + } + }, + "NSLocationWhenInUseUsageDescription" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "We use your location to provide better services." + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Usamos sua localização para fornecer melhores serviços." + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/busbud-coding-challenge/Localizable.xcstrings b/busbud-coding-challenge/Localizable.xcstrings new file mode 100644 index 0000000..57bbac8 --- /dev/null +++ b/busbud-coding-challenge/Localizable.xcstrings @@ -0,0 +1,143 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "Choose the experience:" : { + "extractionState" : "manual", + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escolha a experiência:" + } + } + } + }, + "City" : { + "extractionState" : "manual", + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cidade" + } + } + } + }, + "Country" : { + "extractionState" : "manual", + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "País" + } + } + } + }, + "Details" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Detalhes" + } + } + } + }, + "Dismiss" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sair" + } + } + } + }, + "Distance" : { + "extractionState" : "manual", + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Distância" + } + } + } + }, + "Error: %@" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erro: %@" + } + } + } + }, + "Go to Busbud Website" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vá para Website Busbud" + } + } + } + }, + "Loading suggestions..." : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Carregando sugestões..." + } + } + } + }, + "State / Province / Region" : { + "extractionState" : "manual", + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estado / Província / Região" + } + } + } + }, + "Suggestions (SwiftUI)" : { + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sugestões (SwiftUI)" + } + } + } + }, + "SwiftUI" : { + "extractionState" : "manual", + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "SwiftUI" + } + } + } + }, + "UIKit" : { + "extractionState" : "manual", + "localizations" : { + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "UIKit" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/busbud-coding-challenge/SceneDelegate.swift b/busbud-coding-challenge/SceneDelegate.swift new file mode 100644 index 0000000..93d29ec --- /dev/null +++ b/busbud-coding-challenge/SceneDelegate.swift @@ -0,0 +1,55 @@ +// +// SceneDelegate.swift +// busbud-coding-challenge +// +// Created by Spencer Diniz on 17/12/24. +// + +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + var window: UIWindow? + + func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { + guard let windowScene = (scene as? UIWindowScene) else { return } + + let window = UIWindow(windowScene: windowScene) + window.backgroundColor = .white + self.window = window + + let homeViewController = HomeViewController() + let navigationController = UINavigationController(rootViewController: homeViewController) + + window.rootViewController = navigationController + window.makeKeyAndVisible() + } + + func sceneDidDisconnect(_ scene: UIScene) { + // Called as the scene is being released by the system. + // This occurs shortly after the scene enters the background, or when its session is discarded. + // Release any resources associated with this scene that can be re-created the next time the scene connects. + // The scene may re-connect later, as its session was not necessarily discarded (see `application:didDiscardSceneSessions` instead). + } + + func sceneDidBecomeActive(_ scene: UIScene) { + // Called when the scene has moved from an inactive state to an active state. + // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + } + + func sceneWillResignActive(_ scene: UIScene) { + // Called when the scene will move from an active state to an inactive state. + // This may occur due to temporary interruptions (ex. an incoming phone call). + } + + func sceneWillEnterForeground(_ scene: UIScene) { + // Called as the scene transitions from the background to the foreground. + // Use this method to undo the changes made on entering the background. + } + + func sceneDidEnterBackground(_ scene: UIScene) { + // Called as the scene transitions from the foreground to the background. + // Use this method to save data, release shared resources, and store enough scene-specific state information + // to restore the scene back to its current state. + } +} + diff --git a/busbud-coding-challenge/Service/BusbudService.swift b/busbud-coding-challenge/Service/BusbudService.swift new file mode 100644 index 0000000..c76d1a6 --- /dev/null +++ b/busbud-coding-challenge/Service/BusbudService.swift @@ -0,0 +1,59 @@ +// +// BusbudService.swift +// busbud-coding-challenge +// +// Created by Spencer Diniz on 17/12/24. +// + +import Foundation +import CoreLocation + +protocol BusbudServiceProtocol { + func fetchSuggestions(for coordinate: CLLocationCoordinate2D) async throws -> [Suggestion] +} + +public class BusbudService: BusbudServiceProtocol { + public static let shared = BusbudService() + + private let supportedLanguages: [String: String] = [ + "en": "en", + "pt": "pt" + ] + + func fetchSuggestions(for coordinate: CLLocationCoordinate2D) async throws -> [Suggestion] { + let deviceLanguage = Locale.current.language.languageCode?.identifier ?? "en" + let currantLanguage = supportedLanguages[deviceLanguage] ?? "en" + + let url = URL(string: "https://napi.busbud.com/flex/suggestions/points-of-interest?lang=\(currantLanguage)&limit=100&lat=\(coordinate.latitude)&lon=\(coordinate.longitude)")! + var request = URLRequest(url: url) + request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData + request.httpMethod = "GET" + request.addValue("application/vnd.busbud+json; version=3; profile=https://schema.busbud.com/v3/anything.json", forHTTPHeaderField: "Accept") + + do { + let (data, response) = try await URLSession.shared.data(for: request) + + //TODO: Check response codes and handle accordingly. + if let httpResponse = response as? HTTPURLResponse, !(200...299).contains(httpResponse.statusCode) { + throw URLError(.badServerResponse) + } + + let decoder = JSONDecoder() + let suggestionResponse = try decoder.decode(SuggestionsResponse.self, from: data) + var suggestionsWithDistance: [Suggestion] = [] + + for var suggestion in suggestionResponse.suggestions { + let suggestionLocation = CLLocation(latitude: suggestion.lat, longitude: suggestion.lon) + let distance = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude).distance(from: suggestionLocation) + suggestion.distance = distance + suggestionsWithDistance.append(suggestion) + } + + let sortedSuggestions = suggestionsWithDistance.sorted { $0.distance ?? 0 < $1.distance ?? 0 } + return sortedSuggestions + } catch { + print("Failed to fetch suggestions: \(error)") + throw error + } + } +} diff --git a/busbud-coding-challenge/Service/LocationService.swift b/busbud-coding-challenge/Service/LocationService.swift new file mode 100644 index 0000000..4b3520a --- /dev/null +++ b/busbud-coding-challenge/Service/LocationService.swift @@ -0,0 +1,78 @@ +// +// LocationService.swift +// busbud-coding-challenge +// +// Created by Spencer Diniz on 17/12/24. +// + +import Foundation +import CoreLocation + +class LocationService: NSObject, CLLocationManagerDelegate { + private let locationManager = CLLocationManager() + private var locationContinuation: CheckedContinuation? + private var permissionContinuation: CheckedContinuation? + + func requestLocation() async throws -> CLLocationCoordinate2D { + try await requestPermission() // Ensure permission is granted + + return try await withCheckedThrowingContinuation { continuation in + self.locationContinuation = continuation + locationManager.delegate = self + locationManager.desiredAccuracy = kCLLocationAccuracyBest + locationManager.startUpdatingLocation() + } + } + + private func requestPermission() async throws { + return try await withCheckedThrowingContinuation { continuation in + self.permissionContinuation = continuation + + locationManager.delegate = self + let status = locationManager.authorizationStatus + + switch status { + case .notDetermined: + locationManager.requestWhenInUseAuthorization() + default: + break + } + } + } + + func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { + switch status { + case .authorizedWhenInUse, .authorizedAlways: + permissionContinuation?.resume() + permissionContinuation = nil + case .restricted, .denied: + permissionContinuation?.resume(throwing: LocationError.authorizationDenied) + permissionContinuation = nil + default: + break + } + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let location = locations.last else { + return + } + + let coordinate = location.coordinate + + locationContinuation?.resume(returning: coordinate) + locationContinuation = nil + locationManager.stopUpdatingLocation() + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + locationContinuation?.resume(throwing: error) + locationContinuation = nil + locationManager.stopUpdatingLocation() + } + + enum LocationError: Error { + case authorizationDenied + case unknown + } +} diff --git a/busbud-coding-challenge/Service/ServiceModels.swift b/busbud-coding-challenge/Service/ServiceModels.swift new file mode 100644 index 0000000..b61c4ce --- /dev/null +++ b/busbud-coding-challenge/Service/ServiceModels.swift @@ -0,0 +1,101 @@ +// +// ServiceModels.swift +// busbud-coding-challenge +// +// Created by Spencer Diniz on 17/12/24. +// + +import Foundation +import CoreLocation + +struct SuggestionsResponse: Codable { + let suggestions: [Suggestion] + let resultsType: String + + enum CodingKeys: String, CodingKey { + case suggestions + case resultsType = "results_type" + } +} + +struct Suggestion: Codable { + let provider: String + let id: String + let placeID: String + let geoEntityID: String + let parentID: String? + let label: String + let score: Double + let placeType: String + let stopType: String + let url: String + let geohash: String + let lat: Double + let lon: Double + let hierarchyInfo: HierarchyInfo + let cityName: String + let regionName: String + let countryName: String + let fullName: String + let shortName: String + let requestID: String + var distance: Double? + + enum CodingKeys: String, CodingKey { + case provider + case id + case placeID = "place_id" + case geoEntityID = "geo_entity_id" + case parentID = "parent_id" + case label + case score + case placeType = "place_type" + case stopType = "stop_type" + case url + case geohash + case lat + case lon + case hierarchyInfo = "hierarchy_info" + case cityName = "city_name" + case regionName = "region_name" + case countryName = "country_name" + case fullName = "full_name" + case shortName = "short_name" + case requestID = "request_id" + } + + var formattedDistanceInKm: String { + guard let distance = distance else { + return "" + } + + return String(format: "%.2f km", distance / 1000) + } + + var formattedDistanceInMiles: String { + guard let distance = distance else { + return "" + } + + return String(format: "%.2f mi", distance / 1609.34) + } + + var formattedDistance: String { + return "\(formattedDistanceInKm) / \(formattedDistanceInMiles)" + } +} + +struct HierarchyInfo: Codable { + let country: Country + let region: Region +} + +struct Country: Codable { + let code: String? + let name: String +} + +struct Region: Codable { + let code: String? + let name: String +} diff --git a/busbud-coding-challenge/ViewControllers/HomeViewController.swift b/busbud-coding-challenge/ViewControllers/HomeViewController.swift new file mode 100644 index 0000000..8b206f3 --- /dev/null +++ b/busbud-coding-challenge/ViewControllers/HomeViewController.swift @@ -0,0 +1,117 @@ +// +// HomeViewController.swift +// busbud-coding-challenge +// +// Created by Spencer Diniz on 17/12/24. +// + +import UIKit +import SwiftUI + +class HomeViewController: UIViewController { + private var stackViewMain: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = 20 + stackView.distribution = .fillEqually + + return stackView + }() + + private var stackViewButtons: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .horizontal + stackView.spacing = 20 + stackView.distribution = .fillEqually + + return stackView + }() + + private var labelTitle: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.text = String(localized: "Choose the experience:") + label.textAlignment = .center + label.font = UIFont.systemFont(ofSize: 20) + + return label + }() + + private var buttonUIKit: UIButton = { + let button = UIButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(String(localized: "UIKit"), for: .normal) + button.setTitleColor(.darkText, for: .normal) + button.backgroundColor = .systemOrange + button.clipsToBounds = true + button.layer.cornerRadius = 4.0 + button.layer.masksToBounds = true + + return button + }() + + private var buttonSwiftUI: UIButton = { + let button = UIButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(String(localized: "SwiftUI"), for: .normal) + button.setTitleColor(.darkText, for: .normal) + button.backgroundColor = .systemOrange + button.clipsToBounds = true + button.layer.cornerRadius = 4.0 + button.layer.masksToBounds = true + + return button + }() + + override func viewDidLoad() { + super.viewDidLoad() + + title = "Home" + + setupUI() + } + + private func setupUI() { + view.backgroundColor = .systemBackground + + view.addSubview(stackViewMain) + + stackViewMain.addArrangedSubview(labelTitle) + stackViewMain.addArrangedSubview(stackViewButtons) + stackViewButtons.addArrangedSubview(buttonUIKit) + stackViewButtons.addArrangedSubview(buttonSwiftUI) + + NSLayoutConstraint.activate([ + stackViewMain.centerXAnchor.constraint(equalTo: view.centerXAnchor), + stackViewMain.centerYAnchor.constraint(equalTo: view.centerYAnchor), + buttonUIKit.heightAnchor.constraint(equalToConstant: 44), + buttonUIKit.widthAnchor.constraint(equalToConstant: 120), + buttonSwiftUI.heightAnchor.constraint(equalToConstant: 44), + buttonSwiftUI.widthAnchor.constraint(equalToConstant: 120), + ]) + + buttonUIKit.addTarget(self, action: #selector(startUIKitDemo), for: .touchUpInside) + buttonSwiftUI.addTarget(self, action: #selector(startSwiftUIDemo), for: .touchUpInside) + } + + @objc private func startUIKitDemo() { + let suggestionsViewModel = SuggestionsListViewModel() + let suggestionsViewController = SuggestionsListViewController(viewModel: suggestionsViewModel) + let navigationViewController = UINavigationController(rootViewController: suggestionsViewController) + navigationViewController.modalPresentationStyle = .fullScreen + + present(navigationViewController, animated: true) + } + + @objc private func startSwiftUIDemo() { + let suggestionsViewModel = SuggestionsListViewModel() + let suggestionsView = SuggestionsListView(viewModel: suggestionsViewModel) + + let hostingController = UIHostingController(rootView: suggestionsView) + hostingController.modalPresentationStyle = .fullScreen + + present(hostingController, animated: true) + } +} diff --git a/busbud-coding-challenge/ViewControllers/SuggestionDetailViewController.swift b/busbud-coding-challenge/ViewControllers/SuggestionDetailViewController.swift new file mode 100644 index 0000000..bb46ba3 --- /dev/null +++ b/busbud-coding-challenge/ViewControllers/SuggestionDetailViewController.swift @@ -0,0 +1,225 @@ +// +// SuggestionDetailViewController.swift +// busbud-coding-challenge +// +// Created by Spencer Diniz on 17/12/24. +// + +import UIKit +import MapKit + +class SuggestionDetailViewController: UIViewController { + let scrollViewMain: UIScrollView = { + let scrollView = UIScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.alwaysBounceVertical = true + + return scrollView + }() + + let stackViewMain: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.spacing = 0.0 + stackView.distribution = .fill + + return stackView + }() + + let labelCity: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 16) + label.text = String(localized: "City") + label.textColor = .secondaryLabel + + return label + }() + + let labelCityValue: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .boldSystemFont(ofSize: 16) + label.textColor = .label + + return label + }() + + let labelRegion: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 16) + label.text = String(localized: "State / Province / Region") + label.textColor = .secondaryLabel + + return label + }() + + let labelRegionValue: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .boldSystemFont(ofSize: 16) + label.textColor = .label + + return label + }() + + let labelCountry: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 16) + label.text = String(localized: "Country") + label.textColor = .secondaryLabel + + return label + }() + + let labelCountryValue: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .boldSystemFont(ofSize: 16) + label.textColor = .label + + return label + }() + + let labelDistance: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 16) + label.text = String(localized: "Distance") + label.textColor = .secondaryLabel + + return label + }() + + let labelDistanceValue: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .boldSystemFont(ofSize: 16) + label.textColor = .label + + return label + }() + + let mapView: MKMapView = { + let mapView = MKMapView() + mapView.translatesAutoresizingMaskIntoConstraints = false + mapView.isScrollEnabled = false + mapView.isZoomEnabled = false + mapView.clipsToBounds = true + mapView.layer.cornerRadius = 4 + mapView.layer.masksToBounds = true + mapView.layer.borderWidth = 1 + mapView.layer.borderColor = UIColor.opaqueSeparator.cgColor + + return mapView + }() + + let buttonGoToWebsite: UIButton = { + let button = UIButton() + button.translatesAutoresizingMaskIntoConstraints = false + button.setTitle(String(localized: "Go to Busbud Website"), for: .normal) + button.setTitleColor(.darkText, for: .normal) + button.backgroundColor = .systemOrange + button.clipsToBounds = true + button.layer.cornerRadius = 4.0 + button.layer.masksToBounds = true + + return button + }() + + let viewModel: SuggestionDetailViewModel + + init(viewModel: SuggestionDetailViewModel) { + self.viewModel = viewModel + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + title = String(localized: "Details") + + setupUI() + loadInfo() + } + + private func setupUI() { + view.backgroundColor = .systemBackground + + view.addSubview(scrollViewMain) + scrollViewMain.addSubview(stackViewMain) + + stackViewMain.addArrangedSubview(labelCity) + stackViewMain.addArrangedSubview(labelCityValue) + stackViewMain.addArrangedSubview(labelRegion) + stackViewMain.addArrangedSubview(labelRegionValue) + stackViewMain.addArrangedSubview(labelCountry) + stackViewMain.addArrangedSubview(labelCountryValue) + stackViewMain.addArrangedSubview(labelDistance) + stackViewMain.addArrangedSubview(labelDistanceValue) + stackViewMain.addArrangedSubview(mapView) + stackViewMain.addArrangedSubview(buttonGoToWebsite) + + stackViewMain.setCustomSpacing(14.0, after: labelCityValue) + stackViewMain.setCustomSpacing(14.0, after: labelRegionValue) + stackViewMain.setCustomSpacing(14.0, after: labelCountryValue) + stackViewMain.setCustomSpacing(14.0, after: labelDistanceValue) + stackViewMain.setCustomSpacing(14.0, after: mapView) + + NSLayoutConstraint.activate([ + scrollViewMain.topAnchor.constraint(equalTo: view.topAnchor), + scrollViewMain.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollViewMain.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollViewMain.bottomAnchor.constraint(equalTo: view.bottomAnchor), + stackViewMain.topAnchor.constraint(equalTo: scrollViewMain.topAnchor), + stackViewMain.bottomAnchor.constraint(equalTo: scrollViewMain.bottomAnchor), + stackViewMain.leadingAnchor.constraint(equalTo: scrollViewMain.leadingAnchor, constant: 20.0), + stackViewMain.trailingAnchor.constraint(equalTo: scrollViewMain.trailingAnchor, constant: -20.0), + stackViewMain.widthAnchor.constraint(equalTo: view.widthAnchor, constant: -40.0), + mapView.heightAnchor.constraint(equalTo: mapView.widthAnchor), + buttonGoToWebsite.heightAnchor.constraint(equalToConstant: 44.0), + ]) + + buttonGoToWebsite.addTarget(self, action: #selector(goToWebsite), for: .touchUpInside) + } + + private func loadInfo() { + labelCityValue.text = viewModel.city + labelRegionValue.text = viewModel.region + labelCountryValue.text = viewModel.country + labelDistanceValue.text = viewModel.distance + + centerMapOnLocation() + } + + private func centerMapOnLocation() { + let region = MKCoordinateRegion( + center: viewModel.coordinate, + latitudinalMeters: 10000, + longitudinalMeters: 10000 + ) + + let annotation = MKPointAnnotation() + annotation.coordinate = viewModel.coordinate + annotation.title = viewModel.city + + mapView.addAnnotation(annotation) + mapView.setRegion(region, animated: true) + } + + @objc private func goToWebsite() { + guard let url = URL(string: "https://www.busbud.com/en/c/\(viewModel.geohash)") else { + return + } + + UIApplication.shared.open(url) + } +} diff --git a/busbud-coding-challenge/ViewControllers/SuggestionsListViewController.swift b/busbud-coding-challenge/ViewControllers/SuggestionsListViewController.swift new file mode 100644 index 0000000..f8c4bd7 --- /dev/null +++ b/busbud-coding-challenge/ViewControllers/SuggestionsListViewController.swift @@ -0,0 +1,81 @@ +// +// SuggestionsViewController.swift +// busbud-coding-challenge +// +// Created by Spencer Diniz on 17/12/24. +// + +import UIKit +import CoreLocation + +class SuggestionsListViewController: UITableViewController { + private let viewModel: SuggestionsListViewModel + + init(viewModel: SuggestionsListViewModel) { + self.viewModel = viewModel + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + title = "Suggestions (UIKit)" + navigationItem.backButtonTitle = "" + navigationItem.rightBarButtonItem = UIBarButtonItem(title: "Dismiss", style: .plain, target: self, action: #selector(dismissViewController)) + + setupUI() + + Task { + do { + let locationService = LocationService() + let coordinate = try await locationService.requestLocation() + await viewModel.loadData(for: coordinate) + + DispatchQueue.main.async { [weak self] in + self?.tableView.reloadData() + } + } catch { + print("Failed to get location: \(error.localizedDescription)") + } + } + } + + private func setupUI() { + view.backgroundColor = .systemBackground + tableView.separatorInset = .zero + } + + @objc private func dismissViewController() { + dismiss(animated: true) + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return viewModel.suggestions.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = UITableViewCell(style: .subtitle, reuseIdentifier: nil) + let suggestion = viewModel.suggestions[indexPath.row] + + cell.textLabel?.text = suggestion.cityName + cell.detailTextLabel?.text = suggestion.formattedDistance + cell.accessoryType = .disclosureIndicator + cell.separatorInset = .zero + cell.selectionStyle = .none + + return cell + } + + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + let suggestion = viewModel.suggestions[indexPath.row] + let suggestionDetailViewModel = SuggestionDetailViewModel(suggestion: suggestion) + let suggestionDetailViewController = SuggestionDetailViewController(viewModel: suggestionDetailViewModel) + + navigationController?.pushViewController(suggestionDetailViewController, animated: true) + } +} diff --git a/busbud-coding-challenge/ViewModels/SuggestionDetailViewModel.swift b/busbud-coding-challenge/ViewModels/SuggestionDetailViewModel.swift new file mode 100644 index 0000000..9bb953b --- /dev/null +++ b/busbud-coding-challenge/ViewModels/SuggestionDetailViewModel.swift @@ -0,0 +1,41 @@ +// +// SuggestionDetailViewModel.swift +// busbud-coding-challenge +// +// Created by Spencer Diniz on 17/12/24. +// + +import Foundation +import CoreLocation + +class SuggestionDetailViewModel: ObservableObject { + private let suggestion: Suggestion + + init(suggestion: Suggestion) { + self.suggestion = suggestion + } + + var city: String { + return suggestion.cityName + } + + var region: String { + return suggestion.regionName + } + + var country: String { + return suggestion.countryName + } + + var distance: String { + suggestion.formattedDistance + } + + var geohash: String { + return suggestion.geohash + } + + var coordinate: CLLocationCoordinate2D { + CLLocationCoordinate2D(latitude: suggestion.lat, longitude: suggestion.lon) + } +} diff --git a/busbud-coding-challenge/ViewModels/SuggestionsListViewModel.swift b/busbud-coding-challenge/ViewModels/SuggestionsListViewModel.swift new file mode 100644 index 0000000..8b27ace --- /dev/null +++ b/busbud-coding-challenge/ViewModels/SuggestionsListViewModel.swift @@ -0,0 +1,24 @@ +// +// SuggestionsListViewModel.swift +// busbud-coding-challenge +// +// Created by Spencer Diniz on 17/12/24. +// + +import Foundation +import CoreLocation + +@MainActor +class SuggestionsListViewModel: ObservableObject { + @Published var suggestions: [Suggestion] = [] + private let service: BusbudServiceProtocol + + init(service: BusbudServiceProtocol = BusbudService.shared) { + self.service = service + } + + func loadData(for coordinate: CLLocationCoordinate2D) async { + let fetchedSuggestions = try? await service.fetchSuggestions(for: coordinate) + suggestions = fetchedSuggestions ?? [] + } +} diff --git a/busbud-coding-challenge/Views/SuggestionDetailView.swift b/busbud-coding-challenge/Views/SuggestionDetailView.swift new file mode 100644 index 0000000..b6e0221 --- /dev/null +++ b/busbud-coding-challenge/Views/SuggestionDetailView.swift @@ -0,0 +1,110 @@ +// +// SuggestionDetailView.swift +// busbud-coding-challenge +// +// Created by Spencer Diniz on 17/12/24. +// + +import SwiftUI +import MapKit + +struct SuggestionDetailView: View { + @ObservedObject var viewModel: SuggestionDetailViewModel + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + LabelView(title: "City", value: viewModel.city) + LabelView(title: "State / Province / Region", value: viewModel.region) + LabelView(title: "Country", value: viewModel.country) + LabelView(title: "Distance", value: viewModel.distance) + + MapView(coordinate: viewModel.coordinate, city: viewModel.city) + .frame(height: 300) + .cornerRadius(8) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.gray.opacity(0.5), lineWidth: 1)) + .padding(.vertical, 8) + + Button(action: goToWebsite) { + Text("Go to Busbud Website") + .foregroundColor(.primary) + .frame(maxWidth: .infinity) + .padding() + .background(.orange) + .cornerRadius(8) + } + } + .padding(.horizontal, 20) + .navigationTitle("Details") + .navigationBarTitleDisplayMode(.inline) + } + .background(Color(UIColor.systemBackground)) + } + + private func goToWebsite() { + if let url = URL(string: "https://www.busbud.com/en/c/\(viewModel.geohash)") { + UIApplication.shared.open(url) + } + } +} + +struct LabelView: View { + let title: LocalizedStringKey + let value: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: 16)) + .foregroundColor(Color.secondary) + Text(value) + .font(.system(size: 16, weight: .bold)) + .foregroundColor(Color.primary) + } + } +} + +struct MapView: View { + let coordinate: CLLocationCoordinate2D + let city: String + + @State private var region: MKCoordinateRegion + + init(coordinate: CLLocationCoordinate2D, city: String) { + self.coordinate = coordinate + self.city = city + _region = State(initialValue: MKCoordinateRegion( + center: coordinate, + latitudinalMeters: 10000, + longitudinalMeters: 10000 + )) + } + + var body: some View { + Map(position: .constant(.region(region)), interactionModes: []) { + Annotation(city, coordinate: coordinate) { + Image(systemName: "mappin.circle.fill") + .foregroundColor(.red) + .font(.title) + } + } + .scrollDisabled(true) + .onAppear { + updateRegion() + } + } + + private func updateRegion() { + region = MKCoordinateRegion( + center: coordinate, + latitudinalMeters: 10000, + longitudinalMeters: 10000 + ) + } +} + +struct MapAnnotationItem: Identifiable { + let id = UUID() + let coordinate: CLLocationCoordinate2D + let title: String +} diff --git a/busbud-coding-challenge/Views/SuggestionsListView.swift b/busbud-coding-challenge/Views/SuggestionsListView.swift new file mode 100644 index 0000000..de9a378 --- /dev/null +++ b/busbud-coding-challenge/Views/SuggestionsListView.swift @@ -0,0 +1,81 @@ +// +// SuggestionsListView.swift +// busbud-coding-challenge +// +// Created by Spencer Diniz on 17/12/24. +// + +import SwiftUI +import CoreLocation + +struct SuggestionsListView: View { + @Environment(\.dismiss) private var dismiss + @ObservedObject private var viewModel: SuggestionsListViewModel + @State private var isLoading = false + @State private var locationError: String? + + init(viewModel: SuggestionsListViewModel) { + self.viewModel = viewModel + } + + var body: some View { + NavigationView { + Group { + if isLoading { + ProgressView("Loading suggestions...") + } else if let error = locationError { + Text("Error: \(error)").foregroundColor(.red) + } else { + List(viewModel.suggestions, id: \.id) { suggestion in + NavigationLink(destination: SuggestionDetailView(viewModel: SuggestionDetailViewModel(suggestion: suggestion))) { + SuggestionRowView(suggestion: suggestion) + } + } + } + } + .navigationTitle("Suggestions (SwiftUI)") + .navigationBarTitleDisplayMode(.inline) + .onAppear { + fetchLocationAndLoadData() + } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: { + dismiss() + }) { + Text("Dismiss") + } + } + } + } + } + + private func fetchLocationAndLoadData() { + Task { + isLoading = true + do { + let locationService = LocationService() + let coordinate = try await locationService.requestLocation() + await viewModel.loadData(for: coordinate) + } catch { + locationError = error.localizedDescription + } + isLoading = false + } + } +} + +// Row View +struct SuggestionRowView: View { + let suggestion: Suggestion + + var body: some View { + VStack(alignment: .leading) { + Text(suggestion.cityName) + .font(.headline) + Text(suggestion.formattedDistance) + .font(.subheadline) + .foregroundColor(.secondary) + } + } +} diff --git a/busbud-coding-challengeTests/ServiceModelsUnitTests.swift b/busbud-coding-challengeTests/ServiceModelsUnitTests.swift new file mode 100644 index 0000000..f0a9370 --- /dev/null +++ b/busbud-coding-challengeTests/ServiceModelsUnitTests.swift @@ -0,0 +1,164 @@ +// +// ServiceModelsUnitTests.swift +// busbud-coding-challengeTests +// +// Created by Spencer Diniz on 17/12/24. +// + +import XCTest +@testable import busbud_coding_challenge + +final class ServiceModelsTests: XCTestCase { + func testDecodingSuggestionsResponse() throws { + let json = """ + { + "suggestions": [ + { + "provider": "testProvider", + "id": "123", + "place_id": "place123", + "geo_entity_id": "geo123", + "parent_id": "parent123", + "label": "Test Label", + "score": 0.9, + "place_type": "city", + "stop_type": "bus_stop", + "url": "https://example.com", + "geohash": "u4pruydqqvj", + "lat": 40.7128, + "lon": -74.0060, + "hierarchy_info": { + "country": { + "code": "US", + "name": "United States" + }, + "region": { + "code": "NY", + "name": "New York" + } + }, + "city_name": "New York", + "region_name": "New York", + "country_name": "United States", + "full_name": "New York, United States", + "short_name": "NY", + "request_id": "req123" + } + ], + "results_type": "test" + } + """ + + let jsonData = Data(json.utf8) + let decoder = JSONDecoder() + + let response = try decoder.decode(SuggestionsResponse.self, from: jsonData) + + XCTAssertEqual(response.resultsType, "test") + XCTAssertEqual(response.suggestions.count, 1) + + let suggestion = response.suggestions.first! + XCTAssertEqual(suggestion.provider, "testProvider") + XCTAssertEqual(suggestion.id, "123") + XCTAssertEqual(suggestion.label, "Test Label") + XCTAssertEqual(suggestion.hierarchyInfo.country.name, "United States") + XCTAssertEqual(suggestion.hierarchyInfo.region.name, "New York") + } + + func testFormattedDistanceInKm() { + var suggestion = Suggestion( + provider: "testProvider", + id: "123", + placeID: "place123", + geoEntityID: "geo123", + parentID: nil, + label: "Test Label", + score: 0.9, + placeType: "city", + stopType: "bus_stop", + url: "https://example.com", + geohash: "u4pruydqqvj", + lat: 40.7128, + lon: -74.0060, + hierarchyInfo: HierarchyInfo( + country: Country(code: "US", name: "United States"), + region: Region(code: "NY", name: "New York") + ), + cityName: "New York", + regionName: "New York", + countryName: "United States", + fullName: "New York, United States", + shortName: "NY", + requestID: "req123", + distance: nil + ) + + XCTAssertEqual(suggestion.formattedDistanceInKm, "") + suggestion.distance = 1500 + XCTAssertEqual(suggestion.formattedDistanceInKm, "1.50 km") + } + + func testFormattedDistanceInMiles() { + var suggestion = Suggestion( + provider: "testProvider", + id: "123", + placeID: "place123", + geoEntityID: "geo123", + parentID: nil, + label: "Test Label", + score: 0.9, + placeType: "city", + stopType: "bus_stop", + url: "https://example.com", + geohash: "u4pruydqqvj", + lat: 40.7128, + lon: -74.0060, + hierarchyInfo: HierarchyInfo( + country: Country(code: "US", name: "United States"), + region: Region(code: "NY", name: "New York") + ), + cityName: "New York", + regionName: "New York", + countryName: "United States", + fullName: "New York, United States", + shortName: "NY", + requestID: "req123", + distance: nil + ) + + XCTAssertEqual(suggestion.formattedDistanceInMiles, "") + suggestion.distance = 1609.34 + XCTAssertEqual(suggestion.formattedDistanceInMiles, "1.00 mi") + } + + func testFormattedDistance() { + let suggestion = Suggestion( + provider: "testProvider", + id: "123", + placeID: "place123", + geoEntityID: "geo123", + parentID: nil, + label: "Test Label", + score: 0.9, + placeType: "city", + stopType: "bus_stop", + url: "https://example.com", + geohash: "u4pruydqqvj", + lat: 40.7128, + lon: -74.0060, + hierarchyInfo: HierarchyInfo( + country: Country(code: "US", name: "United States"), + region: Region(code: "NY", name: "New York") + ), + cityName: "New York", + regionName: "New York", + countryName: "United States", + fullName: "New York, United States", + shortName: "NY", + requestID: "req123", + distance: 3000 + ) + + XCTAssertEqual(suggestion.formattedDistance, "3.00 km / 1.86 mi") + } +} diff --git a/busbud-coding-challengeTests/SuggestionDetailViewControllerTests.swift b/busbud-coding-challengeTests/SuggestionDetailViewControllerTests.swift new file mode 100644 index 0000000..e95f447 --- /dev/null +++ b/busbud-coding-challengeTests/SuggestionDetailViewControllerTests.swift @@ -0,0 +1,78 @@ +// +// SuggestionDetailViewControllerTests.swift +// busbud-coding-challengeTests +// +// Created by Spencer Diniz on 18/12/24. +// + +import XCTest +import MapKit +@testable import busbud_coding_challenge + +final class SuggestionDetailViewControllerTests: XCTestCase { + private func createMockViewModel() -> SuggestionDetailViewModel { + let mockSuggestion = Suggestion( + provider: "mockProvider", + id: "1", + placeID: "place1", + geoEntityID: "geo1", + parentID: nil, + label: "Test Label", + score: 0.9, + placeType: "city", + stopType: "stop", + url: "https://example.com", + geohash: "abc123", + lat: 40.7128, + lon: -74.0060, + hierarchyInfo: HierarchyInfo( + country: Country(code: "US", name: "United States"), + region: Region(code: "NY", name: "New York") + ), + cityName: "New York", + regionName: "New York", + countryName: "United States", + fullName: "New York, United States", + shortName: "NY", + requestID: "req1", + distance: 1500 + ) + return SuggestionDetailViewModel(suggestion: mockSuggestion) + } + + func testViewControllerLoadsInfoCorrectly() { + let viewModel = createMockViewModel() + let sut = SuggestionDetailViewController(viewModel: viewModel) // System Under Test (SUT) + + sut.loadViewIfNeeded() + + XCTAssertEqual(sut.labelCityValue.text, "New York") + XCTAssertEqual(sut.labelRegionValue.text, "New York") + XCTAssertEqual(sut.labelCountryValue.text, "United States") + XCTAssertEqual(sut.labelDistanceValue.text, "1.50 km / 0.93 mi") + } + + func testMapViewDisplaysCorrectRegionAndAnnotation() { + let viewModel = createMockViewModel() + let sut = SuggestionDetailViewController(viewModel: viewModel) + + sut.loadViewIfNeeded() + sut.view.layoutIfNeeded() + + let mapViewRegion = sut.mapView.region + let expectedRegion = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 40.7128, longitude: -74.0060), + latitudinalMeters: 10000, + longitudinalMeters: 10000 + ) + + XCTAssertEqual(mapViewRegion.center.latitude, expectedRegion.center.latitude, accuracy: 0.01) + XCTAssertEqual(mapViewRegion.center.longitude, expectedRegion.center.longitude, accuracy: 0.01) + XCTAssertEqual(sut.mapView.annotations.count, 1) + + let annotation = sut.mapView.annotations.first as? MKPointAnnotation + XCTAssertEqual(annotation!.coordinate.latitude, 40.7128, accuracy: 0.01) + XCTAssertEqual(annotation!.coordinate.longitude, -74.0060, accuracy: 0.01) + XCTAssertEqual(annotation?.title, "New York") + } +} diff --git a/busbud-coding-challengeTests/SuggestionDetailViewModelTests.swift b/busbud-coding-challengeTests/SuggestionDetailViewModelTests.swift new file mode 100644 index 0000000..394326b --- /dev/null +++ b/busbud-coding-challengeTests/SuggestionDetailViewModelTests.swift @@ -0,0 +1,87 @@ +// +// SuggestionDetailViewModelTests.swift +// busbud-coding-challengeTests +// +// Created by Spencer Diniz on 18/12/24. +// + +import XCTest +import CoreLocation +@testable import busbud_coding_challenge + +final class SuggestionDetailViewModelTests: XCTestCase { + func testCityProperty() { + let suggestion = createMockSuggestion() + let viewModel = SuggestionDetailViewModel(suggestion: suggestion) + + XCTAssertEqual(viewModel.city, "New York") + } + + func testRegionProperty() { + let suggestion = createMockSuggestion() + let viewModel = SuggestionDetailViewModel(suggestion: suggestion) + + XCTAssertEqual(viewModel.region, "New York") + } + + func testCountryProperty() { + let suggestion = createMockSuggestion() + let viewModel = SuggestionDetailViewModel(suggestion: suggestion) + + XCTAssertEqual(viewModel.country, "United States") + } + + func testDistanceProperty() { + var suggestion = createMockSuggestion() + suggestion.distance = 1500 // Distance in meters + let viewModel = SuggestionDetailViewModel(suggestion: suggestion) + + XCTAssertEqual(viewModel.distance, "1.50 km / 0.93 mi") + } + + func testGeohashProperty() { + let suggestion = createMockSuggestion() + let viewModel = SuggestionDetailViewModel(suggestion: suggestion) + + XCTAssertEqual(viewModel.geohash, "u4pruydqqvj") + } + + func testCoordinateProperty() { + let suggestion = createMockSuggestion() + let viewModel = SuggestionDetailViewModel(suggestion: suggestion) + let coordinate = viewModel.coordinate + + XCTAssertEqual(coordinate.latitude, 40.7128) + XCTAssertEqual(coordinate.longitude, -74.0060) + } + + // Helper function to create a mock Suggestion object + private func createMockSuggestion() -> Suggestion { + return Suggestion( + provider: "testProvider", + id: "123", + placeID: "place123", + geoEntityID: "geo123", + parentID: "parent123", + label: "Test Label", + score: 0.9, + placeType: "city", + stopType: "bus_stop", + url: "https://example.com", + geohash: "u4pruydqqvj", + lat: 40.7128, + lon: -74.0060, + hierarchyInfo: HierarchyInfo( + country: Country(code: "US", name: "United States"), + region: Region(code: "NY", name: "New York") + ), + cityName: "New York", + regionName: "New York", + countryName: "United States", + fullName: "New York, United States", + shortName: "NY", + requestID: "req123", + distance: nil + ) + } +} diff --git a/busbud-coding-challengeTests/SuggestionsListViewModelTests.swift b/busbud-coding-challengeTests/SuggestionsListViewModelTests.swift new file mode 100644 index 0000000..70ad789 --- /dev/null +++ b/busbud-coding-challengeTests/SuggestionsListViewModelTests.swift @@ -0,0 +1,103 @@ +// +// SuggestionsListViewModelTests.swift +// busbud-coding-challengeTests +// +// Created by Spencer Diniz on 18/12/24. +// + +import XCTest +import CoreLocation +@testable import busbud_coding_challenge + +@MainActor +final class SuggestionsListViewModelTests: XCTestCase { + func testLoadData_withMockedService_returnsSuggestions() async throws { + let mockService = MockBusbudService() + let viewModel = SuggestionsListViewModel(service: mockService) + let mockCoordinate = CLLocationCoordinate2D(latitude: 40.7128, longitude: -74.0060) + + await viewModel.loadData(for: mockCoordinate) + + XCTAssertEqual(viewModel.suggestions.count, 2) + XCTAssertEqual(viewModel.suggestions.first?.cityName, "New York") + XCTAssertEqual(viewModel.suggestions.last?.cityName, "San Francisco") + } + + func testLoadData_withMockedService_returnsEmptyWhenNoData() async throws { + let mockService = MockBusbudService(empty: true) + let viewModel = SuggestionsListViewModel(service: mockService) + let mockCoordinate = CLLocationCoordinate2D(latitude: 40.7128, longitude: -74.0060) + + await viewModel.loadData(for: mockCoordinate) + XCTAssertTrue(viewModel.suggestions.isEmpty) + } +} + +struct MockBusbudService: BusbudServiceProtocol { + let empty: Bool + + init(empty: Bool = false) { + self.empty = empty + } + + func fetchSuggestions(for coordinate: CLLocationCoordinate2D) async throws -> [Suggestion] { + if empty { + return [] + } else { + return [ + Suggestion( + provider: "mockProvider", + id: "1", + placeID: "place1", + geoEntityID: "geo1", + parentID: nil, + label: "Label 1", + score: 0.9, + placeType: "city", + stopType: "stop", + url: "https://example.com/1", + geohash: "abc123", + lat: 40.7128, + lon: -74.0060, + hierarchyInfo: HierarchyInfo( + country: Country(code: "US", name: "United States"), + region: Region(code: "NY", name: "New York") + ), + cityName: "New York", + regionName: "New York", + countryName: "United States", + fullName: "New York, United States", + shortName: "NY", + requestID: "req1", + distance: nil + ), + Suggestion( + provider: "mockProvider", + id: "2", + placeID: "place2", + geoEntityID: "geo2", + parentID: nil, + label: "Label 2", + score: 0.8, + placeType: "city", + stopType: "stop", + url: "https://example.com/2", + geohash: "xyz789", + lat: 37.7749, + lon: -122.4194, + hierarchyInfo: HierarchyInfo( + country: Country(code: "US", name: "United States"), + region: Region(code: "CA", name: "California") + ), + cityName: "San Francisco", + regionName: "California", + countryName: "United States", + fullName: "San Francisco, United States", + shortName: "SF", + requestID: "req2", + distance: nil + ) + ] + } + } +}