diff --git a/IDNowTest.xcodeproj/project.pbxproj b/IDNowTest.xcodeproj/project.pbxproj new file mode 100644 index 0000000..aeaf76b --- /dev/null +++ b/IDNowTest.xcodeproj/project.pbxproj @@ -0,0 +1,537 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 70; + objects = { + +/* Begin PBXBuildFile section */ + 4F73A58E2CA980C60061114F /* CameraService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F73A58D2CA980C60061114F /* CameraService.swift */; }; + 4FD76C932CA035D300988490 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD76C922CA035D300988490 /* AppDelegate.swift */; }; + 4FD76C952CA035D300988490 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD76C942CA035D300988490 /* SceneDelegate.swift */; }; + 4FD76C972CA035D300988490 /* CameraViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD76C962CA035D300988490 /* CameraViewController.swift */; }; + 4FD76C9A2CA035D300988490 /* Base in Resources */ = {isa = PBXBuildFile; fileRef = 4FD76C992CA035D300988490 /* Base */; }; + 4FD76C9C2CA035D500988490 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 4FD76C9B2CA035D500988490 /* Assets.xcassets */; }; + 4FD76C9F2CA035D500988490 /* Base in Resources */ = {isa = PBXBuildFile; fileRef = 4FD76C9E2CA035D500988490 /* Base */; }; + 4FD76CA72CA03A2300988490 /* Product.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD76CA62CA03A2300988490 /* Product.swift */; }; + 4FD76CB12CA05F0500988490 /* ImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD76CAF2CA05F0500988490 /* ImageViewController.swift */; }; + 4FD76CB22CA05F0500988490 /* ImageViewController.xib in Resources */ = {isa = PBXBuildFile; fileRef = 4FD76CB02CA05F0500988490 /* ImageViewController.xib */; }; + 4FD76CB42CA0B4A300988490 /* ImageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FD76CB32CA0B4A300988490 /* ImageViewModel.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 4F73A59D2CAA70760061114F /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 4FD76C872CA035D300988490 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 4FD76C8E2CA035D300988490; + remoteInfo = IDNowTest; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 4F73A58D2CA980C60061114F /* CameraService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraService.swift; sourceTree = ""; }; + 4F73A5992CAA70760061114F /* IDNowTestTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = IDNowTestTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 4FD76C8F2CA035D300988490 /* IDNowTest.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = IDNowTest.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 4FD76C922CA035D300988490 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 4FD76C942CA035D300988490 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 4FD76C962CA035D300988490 /* CameraViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraViewController.swift; sourceTree = ""; }; + 4FD76C992CA035D300988490 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 4FD76C9B2CA035D500988490 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 4FD76C9E2CA035D500988490 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 4FD76CA02CA035D500988490 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 4FD76CA62CA03A2300988490 /* Product.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Product.swift; sourceTree = ""; }; + 4FD76CAF2CA05F0500988490 /* ImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewController.swift; sourceTree = ""; }; + 4FD76CB02CA05F0500988490 /* ImageViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ImageViewController.xib; sourceTree = ""; }; + 4FD76CB32CA0B4A300988490 /* ImageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewModel.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 4F73A59A2CAA70760061114F /* IDNowTestTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = IDNowTestTests; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 4F73A5962CAA70760061114F /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 4FD76C8C2CA035D300988490 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 4F73A58C2CA980AB0061114F /* Services */ = { + isa = PBXGroup; + children = ( + 4F73A58D2CA980C60061114F /* CameraService.swift */, + ); + path = Services; + sourceTree = ""; + }; + 4F73A58F2CA98A5A0061114F /* Models */ = { + isa = PBXGroup; + children = ( + 4FD76CA62CA03A2300988490 /* Product.swift */, + ); + path = Models; + sourceTree = ""; + }; + 4F73A5922CA9C6450061114F /* CameraViewController */ = { + isa = PBXGroup; + children = ( + 4FD76C962CA035D300988490 /* CameraViewController.swift */, + ); + path = CameraViewController; + sourceTree = ""; + }; + 4FD76C862CA035D300988490 = { + isa = PBXGroup; + children = ( + 4FD76C912CA035D300988490 /* IDNowTest */, + 4F73A59A2CAA70760061114F /* IDNowTestTests */, + 4FD76C902CA035D300988490 /* Products */, + ); + sourceTree = ""; + }; + 4FD76C902CA035D300988490 /* Products */ = { + isa = PBXGroup; + children = ( + 4FD76C8F2CA035D300988490 /* IDNowTest.app */, + 4F73A5992CAA70760061114F /* IDNowTestTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 4FD76C912CA035D300988490 /* IDNowTest */ = { + isa = PBXGroup; + children = ( + 4F73A5922CA9C6450061114F /* CameraViewController */, + 4F73A58F2CA98A5A0061114F /* Models */, + 4F73A58C2CA980AB0061114F /* Services */, + 4FD76CAA2CA05ED200988490 /* ImageViewController */, + 4FD76C922CA035D300988490 /* AppDelegate.swift */, + 4FD76C942CA035D300988490 /* SceneDelegate.swift */, + 4FD76C982CA035D300988490 /* Main.storyboard */, + 4FD76C9B2CA035D500988490 /* Assets.xcassets */, + 4FD76C9D2CA035D500988490 /* LaunchScreen.storyboard */, + 4FD76CA02CA035D500988490 /* Info.plist */, + ); + path = IDNowTest; + sourceTree = ""; + }; + 4FD76CAA2CA05ED200988490 /* ImageViewController */ = { + isa = PBXGroup; + children = ( + 4FD76CAF2CA05F0500988490 /* ImageViewController.swift */, + 4FD76CB02CA05F0500988490 /* ImageViewController.xib */, + 4FD76CB32CA0B4A300988490 /* ImageViewModel.swift */, + ); + path = ImageViewController; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 4F73A5982CAA70760061114F /* IDNowTestTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4F73A59F2CAA70760061114F /* Build configuration list for PBXNativeTarget "IDNowTestTests" */; + buildPhases = ( + 4F73A5952CAA70760061114F /* Sources */, + 4F73A5962CAA70760061114F /* Frameworks */, + 4F73A5972CAA70760061114F /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 4F73A59E2CAA70760061114F /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 4F73A59A2CAA70760061114F /* IDNowTestTests */, + ); + name = IDNowTestTests; + packageProductDependencies = ( + ); + productName = IDNowTestTests; + productReference = 4F73A5992CAA70760061114F /* IDNowTestTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 4FD76C8E2CA035D300988490 /* IDNowTest */ = { + isa = PBXNativeTarget; + buildConfigurationList = 4FD76CA32CA035D500988490 /* Build configuration list for PBXNativeTarget "IDNowTest" */; + buildPhases = ( + 4FD76C8B2CA035D300988490 /* Sources */, + 4FD76C8C2CA035D300988490 /* Frameworks */, + 4FD76C8D2CA035D300988490 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = IDNowTest; + productName = IDNowTest; + productReference = 4FD76C8F2CA035D300988490 /* IDNowTest.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 4FD76C872CA035D300988490 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1540; + TargetAttributes = { + 4F73A5982CAA70760061114F = { + CreatedOnToolsVersion = 16.0; + TestTargetID = 4FD76C8E2CA035D300988490; + }; + 4FD76C8E2CA035D300988490 = { + CreatedOnToolsVersion = 15.4; + }; + }; + }; + buildConfigurationList = 4FD76C8A2CA035D300988490 /* Build configuration list for PBXProject "IDNowTest" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 4FD76C862CA035D300988490; + productRefGroup = 4FD76C902CA035D300988490 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 4FD76C8E2CA035D300988490 /* IDNowTest */, + 4F73A5982CAA70760061114F /* IDNowTestTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 4F73A5972CAA70760061114F /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 4FD76C8D2CA035D300988490 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4FD76C9C2CA035D500988490 /* Assets.xcassets in Resources */, + 4FD76C9F2CA035D500988490 /* Base in Resources */, + 4FD76CB22CA05F0500988490 /* ImageViewController.xib in Resources */, + 4FD76C9A2CA035D300988490 /* Base in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 4F73A5952CAA70760061114F /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 4FD76C8B2CA035D300988490 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 4FD76CB42CA0B4A300988490 /* ImageViewModel.swift in Sources */, + 4FD76C972CA035D300988490 /* CameraViewController.swift in Sources */, + 4FD76C932CA035D300988490 /* AppDelegate.swift in Sources */, + 4FD76C952CA035D300988490 /* SceneDelegate.swift in Sources */, + 4FD76CB12CA05F0500988490 /* ImageViewController.swift in Sources */, + 4FD76CA72CA03A2300988490 /* Product.swift in Sources */, + 4F73A58E2CA980C60061114F /* CameraService.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 4F73A59E2CAA70760061114F /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 4FD76C8E2CA035D300988490 /* IDNowTest */; + targetProxy = 4F73A59D2CAA70760061114F /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 4FD76C982CA035D300988490 /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 4FD76C992CA035D300988490 /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 4FD76C9D2CA035D500988490 /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 4FD76C9E2CA035D500988490 /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 4F73A5A02CAA70760061114F /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = swiftui.IDNowTestTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/IDNowTest.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/IDNowTest"; + }; + name = Debug; + }; + 4F73A5A12CAA70760061114F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = swiftui.IDNowTestTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/IDNowTest.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/IDNowTest"; + }; + name = Release; + }; + 4FD76CA12CA035D500988490 /* 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.5; + 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; + }; + 4FD76CA22CA035D500988490 /* 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.5; + 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; + }; + 4FD76CA42CA035D500988490 /* 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_TEAM = WGY9Q4U92K; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = IDNowTest/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + 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 = swiftui.IDNowTest; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 4FD76CA52CA035D500988490 /* 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_TEAM = WGY9Q4U92K; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = IDNowTest/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + 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 = swiftui.IDNowTest; + 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 */ + 4F73A59F2CAA70760061114F /* Build configuration list for PBXNativeTarget "IDNowTestTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4F73A5A02CAA70760061114F /* Debug */, + 4F73A5A12CAA70760061114F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 4FD76C8A2CA035D300988490 /* Build configuration list for PBXProject "IDNowTest" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4FD76CA12CA035D500988490 /* Debug */, + 4FD76CA22CA035D500988490 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 4FD76CA32CA035D500988490 /* Build configuration list for PBXNativeTarget "IDNowTest" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4FD76CA42CA035D500988490 /* Debug */, + 4FD76CA52CA035D500988490 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 4FD76C872CA035D300988490 /* Project object */; +} diff --git a/IDNowTest.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/IDNowTest.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/IDNowTest.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/IDNowTest.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/IDNowTest.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/IDNowTest.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/IDNowTest.xcodeproj/project.xcworkspace/xcuserdata/kristianrusyn.xcuserdatad/UserInterfaceState.xcuserstate b/IDNowTest.xcodeproj/project.xcworkspace/xcuserdata/kristianrusyn.xcuserdatad/UserInterfaceState.xcuserstate new file mode 100644 index 0000000..d215a58 Binary files /dev/null and b/IDNowTest.xcodeproj/project.xcworkspace/xcuserdata/kristianrusyn.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/IDNowTest.xcodeproj/xcuserdata/kristianrusyn.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/IDNowTest.xcodeproj/xcuserdata/kristianrusyn.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..8d29619 --- /dev/null +++ b/IDNowTest.xcodeproj/xcuserdata/kristianrusyn.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,6 @@ + + + diff --git a/IDNowTest.xcodeproj/xcuserdata/kristianrusyn.xcuserdatad/xcschemes/xcschememanagement.plist b/IDNowTest.xcodeproj/xcuserdata/kristianrusyn.xcuserdatad/xcschemes/xcschememanagement.plist new file mode 100644 index 0000000..3cc2b5a --- /dev/null +++ b/IDNowTest.xcodeproj/xcuserdata/kristianrusyn.xcuserdatad/xcschemes/xcschememanagement.plist @@ -0,0 +1,14 @@ + + + + + SchemeUserState + + IDNowTest.xcscheme_^#shared#^_ + + orderHint + 0 + + + + diff --git a/IDNowTest/AppDelegate.swift b/IDNowTest/AppDelegate.swift new file mode 100644 index 0000000..1066728 --- /dev/null +++ b/IDNowTest/AppDelegate.swift @@ -0,0 +1,36 @@ +// +// AppDelegate.swift +// IDNowTest +// +// Created by Kristian Rusyn on 22/09/2024. +// + +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 + } + + // MARK: UISceneSession Lifecycle + + 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/IDNowTest/Assets.xcassets/AccentColor.colorset/Contents.json b/IDNowTest/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/IDNowTest/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IDNowTest/Assets.xcassets/AppIcon.appiconset/Contents.json b/IDNowTest/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..13613e3 --- /dev/null +++ b/IDNowTest/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/IDNowTest/Assets.xcassets/Contents.json b/IDNowTest/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/IDNowTest/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/IDNowTest/Base.lproj/LaunchScreen.storyboard b/IDNowTest/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/IDNowTest/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/IDNowTest/Base.lproj/Main.storyboard b/IDNowTest/Base.lproj/Main.storyboard new file mode 100644 index 0000000..a7603c0 --- /dev/null +++ b/IDNowTest/Base.lproj/Main.storyboard @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/IDNowTest/CameraViewController/CameraViewController.swift b/IDNowTest/CameraViewController/CameraViewController.swift new file mode 100644 index 0000000..51ca35b --- /dev/null +++ b/IDNowTest/CameraViewController/CameraViewController.swift @@ -0,0 +1,41 @@ +// +// ViewController.swift +// IDNowTest +// +// Created by Kristian Rusyn on 22/09/2024. +// + +import UIKit +import AVFoundation + +class CameraViewController: UIViewController { + + private let cameraService = CameraServiceImpl() + + @IBOutlet weak var captureButton: UIButton! + override func viewDidLoad() { + super.viewDidLoad() + try! cameraService.setupCameraStream(view: view) + } + + @objc func capturePhoto() { + captureButton.isEnabled = false + cameraService.capturePhoto(imageClosure: { [weak self] image, error in + self?.captureButton.isEnabled = true + if let error { + let alert = UIAlertController(title: "Error", + message: error.localizedDescription, + preferredStyle: .alert) + self?.present(alert, animated: true) + } else if let image { + self?.openImageViewController(image: image) + } + + }) + } + + private func openImageViewController(image: UIImage) { + let imageViewController = ImageViewController(viewModel: ImageViewModel(image: image)) + present(UINavigationController(rootViewController: imageViewController), animated: true) + } +} diff --git a/IDNowTest/ImageViewController/ImageViewController.swift b/IDNowTest/ImageViewController/ImageViewController.swift new file mode 100644 index 0000000..6314d0a --- /dev/null +++ b/IDNowTest/ImageViewController/ImageViewController.swift @@ -0,0 +1,153 @@ +// +// ImageViewController.swift +// IDNowTest +// +// Created by Kristian Rusyn on 22/09/2024. +// + +import Combine +import UIKit +import Photos + +class ImageViewController: UIViewController { + @IBOutlet private weak var titleLabel: UILabel! + @IBOutlet private weak var descriptionLabel: UILabel! + @IBOutlet private weak var priceLabel: UILabel! + + @IBOutlet private weak var imageView: UIImageView! + @IBOutlet private weak var activityIndicator: UIActivityIndicatorView! + + private var viewModel: ImageViewModel + + private var cancellables = Set() + + init(viewModel: ImageViewModel) { + self.viewModel = viewModel + super.init(nibName: String(describing: ImageViewController.self), bundle: nil) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + setupNavigationBar() + setupBindings() + imageView.image = viewModel.image + viewModel.fetchProduct() + + // Hide the activity indicator initially + activityIndicator.hidesWhenStopped = true + activityIndicator.stopAnimating() + } + + private func setupNavigationBar() { + let downloadButton = UIBarButtonItem(title: "Download", + style: .plain, + target: self, + action: #selector(downloadImage)) + navigationItem.rightBarButtonItem = downloadButton + } + + private func setupBindings() { + viewModel.$isLoading + .receive(on: DispatchQueue.main) + .sink { [weak self] isLoading in + if isLoading { + self?.activityIndicator.startAnimating() + } else { + self?.activityIndicator.stopAnimating() + } + } + .store(in: &cancellables) + + viewModel.$title + .receive(on: DispatchQueue.main) + .sink { [weak self] title in + self?.titleLabel.text = title + self?.navigationItem.title = title + } + .store(in: &cancellables) + + viewModel.$description + .receive(on: DispatchQueue.main) + .sink { [weak self] description in + self?.descriptionLabel.text = description + } + .store(in: &cancellables) + + viewModel.$price + .receive(on: DispatchQueue.main) + .sink { [weak self] price in + self?.priceLabel.text = price + } + .store(in: &cancellables) + + viewModel.$image + .receive(on: DispatchQueue.main) + .sink { [weak self] image in + self?.imageView.image = image + } + .store(in: &cancellables) + + viewModel.$errorMessage + .receive(on: DispatchQueue.main) + .sink { [weak self] errorMessage in + if let message = errorMessage { + let alert = UIAlertController(title: "Error", message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + self?.present(alert, animated: true, completion: nil) + } + } + .store(in: &cancellables) + } + + @objc private func downloadImage() { + PHPhotoLibrary.requestAuthorization { [weak self] status in + DispatchQueue.main.async { + switch status { + case .authorized, .limited: + self?.saveImageToPhotoLibrary() + case .denied, .restricted: + let alert = UIAlertController(title: "Access Denied", + message: "Please allow photo library access in Settings to save images.", + preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + self?.present(alert, animated: true, completion: nil) + case .notDetermined: + break + @unknown default: + let alert = UIAlertController(title: "Unknown Error", + message: "An unknown error occurred while accessing the photo library.", + preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + self?.present(alert, animated: true, completion: nil) + } + } + } + } + + private func saveImageToPhotoLibrary() { + guard let image = viewModel.image else { return } + UIImageWriteToSavedPhotosAlbum(image, self, #selector(image(_:didFinishSavingWithError:contextInfo:)), nil) + } + + @objc private func image(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) { + var alert: UIAlertController + if let error = error { + alert = UIAlertController(title: "Save Error", + message: error.localizedDescription, + preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + } else { + alert = UIAlertController(title: "Saved", + message: "Image has been saved to your photos.", + preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + } + + present(alert, animated: true, completion: nil) + } +} diff --git a/IDNowTest/ImageViewController/ImageViewController.xib b/IDNowTest/ImageViewController/ImageViewController.xib new file mode 100644 index 0000000..799c365 --- /dev/null +++ b/IDNowTest/ImageViewController/ImageViewController.xib @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/IDNowTest/ImageViewController/ImageViewModel.swift b/IDNowTest/ImageViewController/ImageViewModel.swift new file mode 100644 index 0000000..a994dfc --- /dev/null +++ b/IDNowTest/ImageViewController/ImageViewModel.swift @@ -0,0 +1,60 @@ +// +// ImageViewModel.swift +// IDNowTest +// +// Created by Kristian Rusyn on 22/09/2024. +// + +import Combine +import Foundation +import UIKit + +class ImageViewModel: ObservableObject { + + enum Constants { + static let productsUrl = "https://dummyjson.com/products/1" + } + + @Published var isLoading: Bool = false + @Published var errorMessage: String? + @Published var image: UIImage? + + @Published var title: String = "" + @Published var price: String = "" + @Published var description: String = "" + + private var session: URLSession + private var cancellables = Set() + + init(image: UIImage, session: URLSession = .shared) { + self.image = image + self.session = session + } + + func fetchProduct() { + guard let url = URL(string: Constants.productsUrl) else { + errorMessage = "Invalid URL" + return + } + + isLoading = true + + session.dataTaskPublisher(for: url) + .map { $0.data } + .decode(type: Product.self, decoder: JSONDecoder()) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { [weak self] completion in + self?.isLoading = false + if case let .failure(error) = completion { + print("Completion error: \(error)") // Debugging Log + self?.errorMessage = error.localizedDescription + } + }, receiveValue: { [weak self] product in + print("Received product: \(product.title)") // Debugging log + self?.title = product.title + self?.price = "$\(product.price)" + self?.description = product.description + }) + .store(in: &cancellables) + } +} diff --git a/IDNowTest/Info.plist b/IDNowTest/Info.plist new file mode 100644 index 0000000..fe0dbd8 --- /dev/null +++ b/IDNowTest/Info.plist @@ -0,0 +1,31 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + NSPhotoLibraryAddUsageDescription + To let user download the image + NSCameraUsageDescription + To let user make a picture + NSPhotoLibraryUsageDescription + To let user download the picture + + diff --git a/IDNowTest/Models/Product.swift b/IDNowTest/Models/Product.swift new file mode 100644 index 0000000..664571c --- /dev/null +++ b/IDNowTest/Models/Product.swift @@ -0,0 +1,15 @@ +// +// Model.swift +// IDNowTest +// +// Created by Kristian Rusyn on 22/09/2024. +// + +import Foundation + +struct Product: Codable { + let id: Int + let title: String + let description: String + let price: Double +} diff --git a/IDNowTest/SceneDelegate.swift b/IDNowTest/SceneDelegate.swift new file mode 100644 index 0000000..7cf6cab --- /dev/null +++ b/IDNowTest/SceneDelegate.swift @@ -0,0 +1,51 @@ +// +// SceneDelegate.swift +// IDNowTest +// +// Created by Kristian Rusyn on 22/09/2024. +// + +import UIKit + +class SceneDelegate: UIResponder, UIWindowSceneDelegate { + + var window: UIWindow? + + func scene(_ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions) { + // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`. + // If using a storyboard, the `window` property will automatically be initialized and attached to the scene. + // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead). + guard let _ = (scene as? UIWindowScene) else { return } + } + + 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/IDNowTest/Services/CameraService.swift b/IDNowTest/Services/CameraService.swift new file mode 100644 index 0000000..4ed5c11 --- /dev/null +++ b/IDNowTest/Services/CameraService.swift @@ -0,0 +1,89 @@ +// +// CameraService.swift +// IDNowTest +// +// Created by Kristian Rusyn on 29/09/2024. +// + +import Foundation +import AVFoundation +import UIKit + +protocol CameraService { + func setupCameraStream(view: UIView) throws + func capturePhoto(imageClosure: @escaping (UIImage?, Error?) -> Void) +} + +class CameraServiceImpl: NSObject, CameraService { + private var captureSession: AVCaptureSession! + private var videoPreviewLayer: AVCaptureVideoPreviewLayer! + private var capturePhotoOutput: AVCapturePhotoOutput! + + private var imageClosure: ((UIImage?, Error?) -> Void)? + + func setupCameraStream(view: UIView) throws { + captureSession = AVCaptureSession() + captureSession.sessionPreset = .photo + + guard let backCamera = AVCaptureDevice.default(for: .video) else { + print("Unable to access back camera!") + return + } + + let input = try AVCaptureDeviceInput(device: backCamera) + captureSession.addInput(input) + + capturePhotoOutput = AVCapturePhotoOutput() + captureSession.addOutput(capturePhotoOutput) + + videoPreviewLayer = AVCaptureVideoPreviewLayer(session: captureSession) + videoPreviewLayer.videoGravity = .resizeAspectFill + videoPreviewLayer.frame = view.layer.bounds + view.layer.insertSublayer(videoPreviewLayer, at: 0) + + captureSession.startRunning() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if #available(iOS 16.0, *) { + let activeFormat = backCamera.activeFormat + let maxDimensions = activeFormat.formatDescription.dimensions + self.capturePhotoOutput.maxPhotoDimensions = CMVideoDimensions(width: maxDimensions.width, height: maxDimensions.height) + } + } + + print("Capture session started successfully") + } + + func capturePhoto(imageClosure: @escaping (UIImage?, Error?) -> Void) { + self.imageClosure = imageClosure + let settings = AVCapturePhotoSettings() + + if #available(iOS 16.0, *) { + if let deviceInput = capturePhotoOutput.connections.first?.inputPorts.first?.input as? AVCaptureDeviceInput { + let activeFormat = deviceInput.device.activeFormat + let maxDimensions = activeFormat.formatDescription.dimensions + settings.maxPhotoDimensions = CMVideoDimensions(width: maxDimensions.width, height: maxDimensions.height) + } + } else { + settings.isHighResolutionPhotoEnabled = true + } + + capturePhotoOutput.capturePhoto(with: settings, delegate: self) + } +} + + +extension CameraServiceImpl: AVCapturePhotoCaptureDelegate { + @objc(captureOutput:didFinishProcessingPhoto:error:) func photoOutput(_ output: AVCapturePhotoOutput, + didFinishProcessingPhoto photo: AVCapturePhoto, + error: Error?) { + guard let imageData = photo.fileDataRepresentation() else { + imageClosure?(nil, error) + return + } + + if let image = UIImage(data: imageData) { + imageClosure?(image, nil) + } + } +} diff --git a/IDNowTestTests/ImageViewModelTests.swift b/IDNowTestTests/ImageViewModelTests.swift new file mode 100644 index 0000000..750c183 --- /dev/null +++ b/IDNowTestTests/ImageViewModelTests.swift @@ -0,0 +1,178 @@ +// +// ImageViewModelTests.swift +// IDNowTestTests +// +// Created by Kristian Rusyn on 29/09/2024. +// + +import Foundation +import XCTest +import Combine +@testable import IDNowTest + +class ImageViewModelTests: XCTestCase { + + private var viewModel: ImageViewModel! + private var cancellables: Set! + + override func setUp() { + super.setUp() + cancellables = [] + + // Injecting a mock URLSession with no cache + let config = URLSessionConfiguration.default + config.protocolClasses = [MockURLProtocol.self] + config.requestCachePolicy = .reloadIgnoringLocalCacheData + let mockSession = URLSession(configuration: config) + + viewModel = ImageViewModel(image: UIImage(), session: mockSession) + } + + override func tearDown() { + // Clear ViewModel and Cancellables + viewModel = nil + cancellables = nil + + // Clear MockURLProtocol's stubbed data and error + MockURLProtocol.stubResponseData = nil + MockURLProtocol.stubResponseError = nil + + // Add a slight delay to ensure all asynchronous tasks are completed + let waitExpectation = XCTestExpectation(description: "Waiting for async clean-up") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + waitExpectation.fulfill() + } + wait(for: [waitExpectation], timeout: 0.5) + + super.tearDown() + } + + func testFetchProductSetsTitlePriceAndDescription() { + // Given + let product = Product(id: 1, title: "Test Product", description: "A sample product", price: 19.99) + let data = try! JSONEncoder().encode(product) + MockURLProtocol.stubResponseData = data + + // Individual expectations + let titleExpectation = XCTestExpectation(description: "Title updated") + let priceExpectation = XCTestExpectation(description: "Price updated") + let descriptionExpectation = XCTestExpectation(description: "Description updated") + + // Set up subscriptions before triggering the fetch + viewModel.$title + .dropFirst() + .sink { title in + print("Received title update in test: \(title)") + XCTAssertEqual(title, "Test Product") + titleExpectation.fulfill() + } + .store(in: &cancellables) + + viewModel.$price + .dropFirst() + .sink { price in + XCTAssertEqual(price, "$19.99") + priceExpectation.fulfill() + } + .store(in: &cancellables) + + viewModel.$description + .dropFirst() + .sink { description in + XCTAssertEqual(description, "A sample product") + descriptionExpectation.fulfill() + } + .store(in: &cancellables) + + // When + viewModel.fetchProduct() + + // Wait for all expectations + wait(for: [titleExpectation, priceExpectation, descriptionExpectation], timeout: 15.0) + } + + func testFetchProductSetsErrorMessageOnInvalidURL() { + // Given + MockURLProtocol.stubResponseError = URLError(.badURL) + + let expectation = XCTestExpectation(description: "Error message should be set") + + // Set up subscription before triggering the fetch + viewModel.$errorMessage + .dropFirst() + .sink { errorMessage in + XCTAssertNotNil(errorMessage) + XCTAssertEqual(errorMessage, URLError(.badURL).localizedDescription) + expectation.fulfill() + } + .store(in: &cancellables) + + // When + viewModel.fetchProduct() + + // Then + wait(for: [expectation], timeout: 5.0) + } + + func testIsLoadingStateDuringFetch() { + // Given + let product = Product(id: 1, title: "Test Product", description: "A sample product", price: 19.99) + let data = try! JSONEncoder().encode(product) + MockURLProtocol.stubResponseData = data + + let expectation1 = XCTestExpectation(description: "isLoading should be true when fetching starts") + let expectation2 = XCTestExpectation(description: "isLoading should be false when fetching ends") + + // Set up subscription before triggering the fetch + viewModel.$isLoading + .dropFirst() + .sink { isLoading in + if isLoading { + expectation1.fulfill() + } else { + expectation2.fulfill() + } + } + .store(in: &cancellables) + + // When + viewModel.fetchProduct() + + // Then + wait(for: [expectation1, expectation2], timeout: 5.0) + } +} + + +class MockURLProtocol: URLProtocol { + static var stubResponseData: Data? + static var stubResponseError: Error? + + override class func canInit(with request: URLRequest) -> Bool { + // This allows the mock to intercept all requests + return true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override func startLoading() { + if let error = MockURLProtocol.stubResponseError { + self.client?.urlProtocol(self, didFailWithError: error) + } else if let data = MockURLProtocol.stubResponseData { + let response = HTTPURLResponse(url: self.request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])! + self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + self.client?.urlProtocol(self, didLoad: data) + } + + self.client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() { + // Required to override but no specific implementation needed + } +} diff --git a/README.md b/README.md index 4ad7880..482bd75 100644 --- a/README.md +++ b/README.md @@ -1 +1,48 @@ -# IDNowTest \ No newline at end of file +# Technical Test IDNow - iOS Camera & Image Preview Application + +## Overview +This project is an iOS application that allows users to capture an image using their device's camera, retrieve data from a JSON API, and display a preview of the captured image along with descriptive information from the API. The application follows the given requirements and aims to provide a simple yet effective demonstration of good software development practices, adhering to clean coding principles and a modular architecture. + +## Architecture & Technical Decisions +- **Architecture**: The application is mix of using the **MVVM (Model-View-ViewModel)** pattern and **MVC (Model-View-Controller)** pattern. **MVVM** was chosen for its clear separation of concerns, making the UI logic and business logic independent and easier to maintain. In addition **MVVM** provides the great way to test the logic of the particular screen. +- **Networking**: The networking layer is implemented using `URLSession` to make requests to the external API. +- **Combine Framework**: To manage asynchronous events, data bindings, and API responses, I used **Combine**, providing a reactive approach to state and data handling. Having reactive bindings is very practical when using MVVM pattern. +- **Camera Integration**: The `CameraService` component handles capturing images using `AVFoundation`. This keeps the view controller focused only on managing the UI. +- **Data Binding**: The view model is responsible for fetching data and updating the view. This design promotes testability and helps maintain a clear separation between business logic and UI. +- **UI Design**: The main screen is constructed in the Main.storyboard just for simplicity as it's a default XCode implementation when you create the project. The Image scren is constructed using with an `.xib` file. This is a better approach to avoid conflicts in .storyboard files, but also having the dedicated `.xib` file for each screen is more practical, takes less time to load and makes the UI construction easier to find. + +## Technical Choices & Best Practices +- **SOLID Principles**: Applied throughout the code to ensure better maintainability and extensibility. For example: + - **Single Responsibility**: The `CameraService` is solely responsible for handling camera interactions. + - **Dependency Inversion**: ImageViewModel depend on abstracted services, making it easy to mock or substitute during testing. The CameraViewController depends on `CameraService`, which is abstract type and can be injected from outside. +- **DRY (Don't Repeat Yourself)**: Code reuse is emphasized across view models and services, avoiding duplication. +- **KISS (Keep It Simple, Stupid)**: The application is kept as simple as possible to achieve the required functionality without unnecessary complexity. For instance, there is no need to have ViewModel for the camera screen, it is too simple. It's logic is purely of UI kind, streaming of the camera image and capturing the image. +There is no implementation of any sort of Router or Coordinator, as there just 2 screen, this would be and overkill. + +## Testing +- **Unit Tests**: `ImageViewModel` is covered with unit tests to verify their behavior. +- **Mocking**: The `MockURLProtocol` is used for testing network requests, ensuring consistent and controlled responses. + +## If Given More Time +- **Better UI/UX**: Currently, the UI/UX is as simple as possible, with more time I would think about better way to display the information in the Image screen. I would improve UI of the both Camera and Image screens. +- **More Robust Error Handling**: Currently, the error handling is minimal, mainly focused on user notifications for API errors and camera access issues. With more time, I would add finer-grained error handling and user-friendly messages for edge cases. +- **Code Coverage Expansion**: I would cover the CameraService with tests. + +## Task Breakdown +In a real work environment, I would break the task into smaller, reviewable sub-tasks as follows: +1. **Setup Project Structure and Dependencies**: Create a project, set up folders, and add necessary dependencies. +2. **Implement Camera Capture**: Develop the camera capture feature, ensuring the user can take pictures. +3. **API Integration**: Set up the network layer and integrate with the Dummy JSON API. +4. **Camera screen implementation**: Create the `.xib` file for the UI, ensuring all required elements are present. +5. **Image Preview with Data**: Combine camera functionality and API data to display the preview. +6. **Download Image Feature**: Implement functionality to save the captured image to the device. +7. **Testing**: Write unit tests for the view models and camera service. + +## Time Spent +The entire challenge was completed in approximately **6 hours**, which included: +- **Development**: 4.5 hours +- **Testing**: 1 hour +- **Documentation and Final Review**: 0.5 hours + +## Conclusion +This project demonstrates a simple yet structured approach to solving the given problem using modern iOS development techniques. The architectural decisions aim for maintainability, testability and simplicity.