diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index a66f1d2..7d0add2 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -15,9 +15,23 @@ jobs: - name: Select Xcode run: sudo xcode-select -switch /Applications/Xcode_16.2.app - + - name: Xcode version run: /usr/bin/xcodebuild -version - - - name: Build and Test - run: xcodebuild clean build test -project EssentialFeed/EssentialFeed.xcodeproj -scheme "CI" CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO + + - name: Build and Test - CI (macOS) + run: | + xcodebuild clean build test \ + -project EssentialFeed/EssentialFeed.xcodeproj \ + -scheme "CI_macOS" \ + -destination 'platform=macOS' \ + CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO + + - name: Build and Test - CI (iOS) + run: | + xcodebuild clean build test \ + -project EssentialFeed/EssentialFeed.xcodeproj \ + -scheme "CI_iOS" \ + -parallel-testing-enabled NO \ + -destination 'platform=iOS Simulator,name=iPhone 16' \ + CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO diff --git a/EssentialFeed/CI_iOS.xctestplan b/EssentialFeed/CI_iOS.xctestplan new file mode 100644 index 0000000..dba1bdf --- /dev/null +++ b/EssentialFeed/CI_iOS.xctestplan @@ -0,0 +1,61 @@ +{ + "configurations" : [ + { + "id" : "FB6DC599-8749-4C8A-B598-7DA402070AD5", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "codeCoverage" : { + "targets" : [ + { + "containerPath" : "container:EssentialFeed.xcodeproj", + "identifier" : "080EDEF021B6DA7E00813479", + "name" : "EssentialFeed" + }, + { + "containerPath" : "container:EssentialFeed.xcodeproj", + "identifier" : "40D5EE232DA7C5F100D344B3", + "name" : "EssentialFeediOS" + } + ] + }, + "testExecutionOrdering" : "random" + }, + "testTargets" : [ + { + "parallelizable" : true, + "target" : { + "containerPath" : "container:EssentialFeed.xcodeproj", + "identifier" : "40B002442CF9E9DB0058D3E0", + "name" : "EssentialFeedAPIEndToEndTests" + } + }, + { + "parallelizable" : false, + "target" : { + "containerPath" : "container:EssentialFeed.xcodeproj", + "identifier" : "40412A492DA67465004677C4", + "name" : "EssentialFeedCacheIntegrationTests" + } + }, + { + "target" : { + "containerPath" : "container:EssentialFeed.xcodeproj", + "identifier" : "080EDEF921B6DA7E00813479", + "name" : "EssentialFeedTests" + } + }, + { + "target" : { + "containerPath" : "container:EssentialFeed.xcodeproj", + "identifier" : "40D5EE2A2DA7C5F100D344B3", + "name" : "EssentialFeediOSTests" + } + } + ], + "version" : 1 +} diff --git a/EssentialFeed/CI.xctestplan b/EssentialFeed/CI_macOS.xctestplan similarity index 100% rename from EssentialFeed/CI.xctestplan rename to EssentialFeed/CI_macOS.xctestplan diff --git a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj index 0be8578..00d41fc 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj +++ b/EssentialFeed/EssentialFeed.xcodeproj/project.pbxproj @@ -30,6 +30,9 @@ 40B975492D9EA7A2009652B5 /* LocalFeedImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B975482D9EA7A2009652B5 /* LocalFeedImage.swift */; }; 40B9754B2D9EC061009652B5 /* LoadFeedFromCacheUseCaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B9754A2D9EC05C009652B5 /* LoadFeedFromCacheUseCaseTests.swift */; }; 40B9754E2D9EC15A009652B5 /* FeedStoreSpy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 40B9754D2D9EC15A009652B5 /* FeedStoreSpy.swift */; }; + 40C766012DA7E2CF00A3F596 /* EssentialFeed.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 080EDEF121B6DA7E00813479 /* EssentialFeed.framework */; platformFilter = ios; }; + 40C766022DA7E2CF00A3F596 /* EssentialFeed.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 080EDEF121B6DA7E00813479 /* EssentialFeed.framework */; platformFilter = ios; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 40D5EE2C2DA7C5F100D344B3 /* EssentialFeediOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 40D5EE242DA7C5F100D344B3 /* EssentialFeediOS.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -54,8 +57,36 @@ remoteGlobalIDString = 080EDEF021B6DA7E00813479; remoteInfo = EssentialFeed; }; + 40C766032DA7E2CF00A3F596 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 080EDEE821B6DA7E00813479 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 080EDEF021B6DA7E00813479; + remoteInfo = EssentialFeed; + }; + 40D5EE2D2DA7C5F100D344B3 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 080EDEE821B6DA7E00813479 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 40D5EE232DA7C5F100D344B3; + remoteInfo = EssentialFeediOS; + }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + 40C766052DA7E2CF00A3F596 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 40C766022DA7E2CF00A3F596 /* EssentialFeed.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 080EDEF121B6DA7E00813479 /* EssentialFeed.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = EssentialFeed.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 080EDEF521B6DA7E00813479 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -68,7 +99,7 @@ 40412A1E2DA67223004677C4 /* ManagedCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedCache.swift; sourceTree = ""; }; 40412A202DA6723E004677C4 /* ManagedFeedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedFeedImage.swift; sourceTree = ""; }; 40412A4A2DA67465004677C4 /* EssentialFeedCacheIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeedCacheIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 40412A542DA6750C004677C4 /* EssentialFeedCacheIntegrationTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = EssentialFeedCacheIntegrationTests.xctestplan; sourceTree = ""; }; + 406533992DB26D66001DB1A5 /* CI_macOS.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = CI_macOS.xctestplan; sourceTree = ""; }; 407F4FA42D9FD7EF0070F56E /* ValidateFeedCacheUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ValidateFeedCacheUseCaseTests.swift; sourceTree = ""; }; 407F4FA62D9FDB780070F56E /* FeedCacheTestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCacheTestHelpers.swift; sourceTree = ""; }; 407F4FAC2D9FEBC50070F56E /* FeedCachePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedCachePolicy.swift; sourceTree = ""; }; @@ -83,6 +114,9 @@ 40B975482D9EA7A2009652B5 /* LocalFeedImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFeedImage.swift; sourceTree = ""; }; 40B9754A2D9EC05C009652B5 /* LoadFeedFromCacheUseCaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadFeedFromCacheUseCaseTests.swift; sourceTree = ""; }; 40B9754D2D9EC15A009652B5 /* FeedStoreSpy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedStoreSpy.swift; sourceTree = ""; }; + 40C765C52DA7CCCE00A3F596 /* CI_iOS.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = CI_iOS.xctestplan; sourceTree = ""; }; + 40D5EE242DA7C5F100D344B3 /* EssentialFeediOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = EssentialFeediOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 40D5EE2B2DA7C5F100D344B3 /* EssentialFeediOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = EssentialFeediOSTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -113,14 +147,30 @@ ); target = 080EDEF021B6DA7E00813479 /* EssentialFeed */; }; + 40C765C42DA7C9E200A3F596 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + EssentialFeedCacheIntegrationTests.xctestplan, + ); + target = 40412A492DA67465004677C4 /* EssentialFeedCacheIntegrationTests */; + }; + 40C766072DA7E3B000A3F596 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + "XCTestCase+MemoryLeakTrackingHelper.swift", + ); + target = 40D5EE2A2DA7C5F100D344B3 /* EssentialFeediOSTests */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ 40124C322CF8BDD5008BBDB6 /* Feed Api */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = "Feed Api"; sourceTree = ""; }; - 40412A4B2DA67465004677C4 /* EssentialFeedCacheIntegrationTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = EssentialFeedCacheIntegrationTests; sourceTree = ""; }; + 40412A4B2DA67465004677C4 /* EssentialFeedCacheIntegrationTests */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (40C765C42DA7C9E200A3F596 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = EssentialFeedCacheIntegrationTests; sourceTree = ""; }; 40B002462CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = EssentialFeedAPIEndToEndTests; sourceTree = ""; }; - 40B9753D2D9E7CB2009652B5 /* Helpers */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (40B975402D9E7CB2009652B5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 40412A562DA678BE004677C4 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Helpers; sourceTree = ""; }; + 40B9753D2D9E7CB2009652B5 /* Helpers */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (40B975402D9E7CB2009652B5 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 40412A562DA678BE004677C4 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, 40C766072DA7E3B000A3F596 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Helpers; sourceTree = ""; }; 40C57D7A2CF7C16E00518522 /* FeedApi */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (40C57D7D2CF7C19100518522 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = FeedApi; sourceTree = ""; }; + 40D5EE252DA7C5F100D344B3 /* EssentialFeediOS */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = EssentialFeediOS; sourceTree = ""; }; + 40D5EE2F2DA7C5F100D344B3 /* EssentialFeediOSTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = EssentialFeediOSTests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -155,17 +205,37 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 40D5EE212DA7C5F100D344B3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 40C766012DA7E2CF00A3F596 /* EssentialFeed.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 40D5EE282DA7C5F100D344B3 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 40D5EE2C2DA7C5F100D344B3 /* EssentialFeediOS.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 080EDEE721B6DA7E00813479 = { isa = PBXGroup; children = ( - 40412A542DA6750C004677C4 /* EssentialFeedCacheIntegrationTests.xctestplan */, + 406533992DB26D66001DB1A5 /* CI_macOS.xctestplan */, + 40C765C52DA7CCCE00A3F596 /* CI_iOS.xctestplan */, 080EDEF321B6DA7E00813479 /* EssentialFeed */, 080EDEFE21B6DA7E00813479 /* EssentialFeedTests */, 40B002462CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests */, 40412A4B2DA67465004677C4 /* EssentialFeedCacheIntegrationTests */, + 40D5EE252DA7C5F100D344B3 /* EssentialFeediOS */, + 40D5EE2F2DA7C5F100D344B3 /* EssentialFeediOSTests */, + 40C766002DA7E2CF00A3F596 /* Frameworks */, 080EDEF221B6DA7E00813479 /* Products */, ); sourceTree = ""; @@ -177,6 +247,8 @@ 080EDEFA21B6DA7E00813479 /* EssentialFeedTests.xctest */, 40B002452CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests.xctest */, 40412A4A2DA67465004677C4 /* EssentialFeedCacheIntegrationTests.xctest */, + 40D5EE242DA7C5F100D344B3 /* EssentialFeediOS.framework */, + 40D5EE2B2DA7C5F100D344B3 /* EssentialFeediOSTests.xctest */, ); name = Products; sourceTree = ""; @@ -267,6 +339,13 @@ path = Helpers; sourceTree = ""; }; + 40C766002DA7E2CF00A3F596 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -277,6 +356,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 40D5EE1F2DA7C5F100D344B3 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXHeadersBuildPhase section */ /* Begin PBXNativeTarget section */ @@ -366,6 +452,54 @@ productReference = 40B002452CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + 40D5EE232DA7C5F100D344B3 /* EssentialFeediOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = 40D5EE342DA7C5F100D344B3 /* Build configuration list for PBXNativeTarget "EssentialFeediOS" */; + buildPhases = ( + 40D5EE1F2DA7C5F100D344B3 /* Headers */, + 40D5EE202DA7C5F100D344B3 /* Sources */, + 40D5EE212DA7C5F100D344B3 /* Frameworks */, + 40D5EE222DA7C5F100D344B3 /* Resources */, + 40C766052DA7E2CF00A3F596 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 40C766042DA7E2CF00A3F596 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 40D5EE252DA7C5F100D344B3 /* EssentialFeediOS */, + ); + name = EssentialFeediOS; + packageProductDependencies = ( + ); + productName = EssentialFeediOS; + productReference = 40D5EE242DA7C5F100D344B3 /* EssentialFeediOS.framework */; + productType = "com.apple.product-type.framework"; + }; + 40D5EE2A2DA7C5F100D344B3 /* EssentialFeediOSTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 40D5EE372DA7C5F100D344B3 /* Build configuration list for PBXNativeTarget "EssentialFeediOSTests" */; + buildPhases = ( + 40D5EE272DA7C5F100D344B3 /* Sources */, + 40D5EE282DA7C5F100D344B3 /* Frameworks */, + 40D5EE292DA7C5F100D344B3 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 40D5EE2E2DA7C5F100D344B3 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 40D5EE2F2DA7C5F100D344B3 /* EssentialFeediOSTests */, + ); + name = EssentialFeediOSTests; + packageProductDependencies = ( + ); + productName = EssentialFeediOSTests; + productReference = 40D5EE2B2DA7C5F100D344B3 /* EssentialFeediOSTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -374,7 +508,7 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 1620; - LastUpgradeCheck = 1600; + LastUpgradeCheck = 1620; ORGANIZATIONNAME = ""; TargetAttributes = { 080EDEF021B6DA7E00813479 = { @@ -391,6 +525,14 @@ 40B002442CF9E9DB0058D3E0 = { CreatedOnToolsVersion = 16.0; }; + 40D5EE232DA7C5F100D344B3 = { + CreatedOnToolsVersion = 16.2; + LastSwiftMigration = 1620; + }; + 40D5EE2A2DA7C5F100D344B3 = { + CreatedOnToolsVersion = 16.2; + LastSwiftMigration = 1620; + }; }; }; buildConfigurationList = 080EDEEB21B6DA7E00813479 /* Build configuration list for PBXProject "EssentialFeed" */; @@ -400,6 +542,8 @@ knownRegions = ( en, Base, + "pt-BR", + el, ); mainGroup = 080EDEE721B6DA7E00813479; productRefGroup = 080EDEF221B6DA7E00813479 /* Products */; @@ -410,6 +554,8 @@ 080EDEF921B6DA7E00813479 /* EssentialFeedTests */, 40B002442CF9E9DB0058D3E0 /* EssentialFeedAPIEndToEndTests */, 40412A492DA67465004677C4 /* EssentialFeedCacheIntegrationTests */, + 40D5EE232DA7C5F100D344B3 /* EssentialFeediOS */, + 40D5EE2A2DA7C5F100D344B3 /* EssentialFeediOSTests */, ); }; /* End PBXProject section */ @@ -443,6 +589,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 40D5EE222DA7C5F100D344B3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 40D5EE292DA7C5F100D344B3 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -494,6 +654,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 40D5EE202DA7C5F100D344B3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 40D5EE272DA7C5F100D344B3 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -512,6 +686,17 @@ target = 080EDEF021B6DA7E00813479 /* EssentialFeed */; targetProxy = 40B0024A2CF9E9DB0058D3E0 /* PBXContainerItemProxy */; }; + 40C766042DA7E2CF00A3F596 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + platformFilter = ios; + target = 080EDEF021B6DA7E00813479 /* EssentialFeed */; + targetProxy = 40C766032DA7E2CF00A3F596 /* PBXContainerItemProxy */; + }; + 40D5EE2E2DA7C5F100D344B3 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 40D5EE232DA7C5F100D344B3 /* EssentialFeediOS */; + targetProxy = 40D5EE2D2DA7C5F100D344B3 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -577,6 +762,7 @@ ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_VERSION = 5.0; @@ -640,6 +826,7 @@ MTL_FAST_MATH = YES; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_STRICT_CONCURRENCY = minimal; SWIFT_VERSION = 5.0; @@ -674,7 +861,10 @@ PRODUCT_BUNDLE_IDENTIFIER = com.essentialdeveloper.EssentialFeed; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; @@ -704,6 +894,9 @@ PRODUCT_BUNDLE_IDENTIFIER = com.essentialdeveloper.EssentialFeed; PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; @@ -724,8 +917,11 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.essentialdeveloper.EssentialFeedTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; @@ -746,7 +942,10 @@ ); PRODUCT_BUNDLE_IDENTIFIER = com.essentialdeveloper.EssentialFeedTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; @@ -764,9 +963,12 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = me.crisfe.EssentialFeedCacheIntegrationTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; @@ -784,8 +986,11 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = me.crisfe.EssentialFeedCacheIntegrationTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; }; name = Release; }; @@ -803,9 +1008,12 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = lat.cristian.EssentialFeedAPIEndToEndTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; }; name = Debug; }; @@ -823,8 +1031,141 @@ MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = lat.cristian.EssentialFeedAPIEndToEndTests; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + 40D5EE352DA7C5F100D344B3 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUILD_LIBRARY_FOR_DISTRIBUTION = NO; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = NO; + DEVELOPMENT_TEAM = V73WZ9Y4HH; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = me.crisfe.EssentialFeediOS; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 40D5EE362DA7C5F100D344B3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUILD_LIBRARY_FOR_DISTRIBUTION = NO; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = ""; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = NO; + DEVELOPMENT_TEAM = V73WZ9Y4HH; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = me.crisfe.EssentialFeediOS; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 40D5EE382DA7C5F100D344B3 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = V73WZ9Y4HH; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = me.crisfe.EssentialFeediOSTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 40D5EE392DA7C5F100D344B3 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = V73WZ9Y4HH; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = me.crisfe.EssentialFeediOSTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; }; name = Release; }; @@ -876,6 +1217,24 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 40D5EE342DA7C5F100D344B3 /* Build configuration list for PBXNativeTarget "EssentialFeediOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 40D5EE352DA7C5F100D344B3 /* Debug */, + 40D5EE362DA7C5F100D344B3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 40D5EE372DA7C5F100D344B3 /* Build configuration list for PBXNativeTarget "EssentialFeediOSTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 40D5EE382DA7C5F100D344B3 /* Debug */, + 40D5EE392DA7C5F100D344B3 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCVersionGroup section */ diff --git a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI.xcscheme b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI_iOS.xcscheme similarity index 97% rename from EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI.xcscheme rename to EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI_iOS.xcscheme index 5e69754..1d44ce2 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI.xcscheme +++ b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI_iOS.xcscheme @@ -1,6 +1,6 @@ diff --git a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI_macOS.xcscheme b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI_macOS.xcscheme new file mode 100644 index 0000000..99caaaa --- /dev/null +++ b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/CI_macOS.xcscheme @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeed.xcscheme b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeed.xcscheme index e8c534a..da71d56 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeed.xcscheme +++ b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeed.xcscheme @@ -1,6 +1,6 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme index e950f52..0ee3894 100644 --- a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme +++ b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeedCacheIntegrationTests.xcscheme @@ -14,7 +14,7 @@ shouldUseLaunchSchemeArgsEnv = "YES"> diff --git a/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeediOS.xcscheme b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeediOS.xcscheme new file mode 100644 index 0000000..9fde49b --- /dev/null +++ b/EssentialFeed/EssentialFeed.xcodeproj/xcshareddata/xcschemes/EssentialFeediOS.xcscheme @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift index 611007f..33491ba 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataFeedStore.swift @@ -9,13 +9,17 @@ import CoreData public final class CoreDataFeedStore: FeedStore { - let container: NSPersistentContainer + private let container: NSPersistentContainer private let context: NSManagedObjectContext - public init(storeURL: URL, bundle: Bundle = .main) throws { + + static private let modelName = "FeedStore" + static private let model = NSManagedObjectModel.with(name: modelName, in: Bundle(for: CoreDataFeedStore.self)) + + public init(storeURL: URL) throws { container = try NSPersistentContainer.load( - modelName: "FeedStore", - url: storeURL, - in: bundle + modelName: Self.modelName, + model: Self.model, + url: storeURL ) context = container.newBackgroundContext() } @@ -68,4 +72,17 @@ public final class CoreDataFeedStore: FeedStore { action(context) } } + + deinit { + cleanUpReferencesToPersistentStores() + } + + private func cleanUpReferencesToPersistentStores() { + context.performAndWait { + let coordinator = container.persistentStoreCoordinator + for store in coordinator.persistentStores { + try? coordinator.remove(store) + } + } + } } diff --git a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataHelpers.swift b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataHelpers.swift index 9f6053f..474a6ed 100644 --- a/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataHelpers.swift +++ b/EssentialFeed/EssentialFeed/Feed Cache/Infrastructure/CoreData/CoreDataHelpers.swift @@ -15,10 +15,10 @@ extension NSPersistentContainer { static func load( modelName name: String, - url: URL, - in bundle: Bundle + model: NSManagedObjectModel?, + url: URL ) throws -> NSPersistentContainer { - guard let model = NSManagedObjectModel.with(name: name, in: bundle) else { throw LoadingError.modelNotFound } + guard let model else { throw LoadingError.modelNotFound } let container = NSPersistentContainer(name: name, managedObjectModel: model) container.persistentStoreDescriptions = [ @@ -38,7 +38,7 @@ extension NSPersistentContainer { } } -private extension NSManagedObjectModel { +extension NSManagedObjectModel { static func with(name: String, in bundle: Bundle) -> NSManagedObjectModel? { return bundle.url(forResource: name, withExtension: "momd") .flatMap { diff --git a/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift index 7d0402a..806426b 100644 --- a/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift +++ b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.swift @@ -33,7 +33,7 @@ final class EssentialFeedAPIEndToEndTests: XCTestCase { file: StaticString = #file, line: UInt = #line ) -> FeedLoader.Result? { - let testServerURL = URL(string: "https://essentialdeveloper.com/feed-case-study/test-api/feed")! + let testServerURL = URL(string: "https://static1.squarespace.com/static/5891c5b8d1758ec68ef5dbc2/t/5c52cdd0b8a045df091d2fff/1548930512083/feed-case-study-test-api-feed.json")! let client = URLSessionHTTPClient() let loader = RemoteFeedLoader( url: testServerURL, diff --git a/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.xctestplan b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.xctestplan new file mode 100644 index 0000000..e7b7c0d --- /dev/null +++ b/EssentialFeed/EssentialFeedAPIEndToEndTests/EssentialFeedAPIEndToEndTests.xctestplan @@ -0,0 +1,33 @@ +{ + "configurations" : [ + { + "id" : "7AC47FC3-4C77-4455-9E81-477A1EE17D62", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "codeCoverage" : { + "targets" : [ + { + "containerPath" : "container:EssentialFeed.xcodeproj", + "identifier" : "080EDEF021B6DA7E00813479", + "name" : "EssentialFeed" + } + ] + }, + "testExecutionOrdering" : "random" + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:EssentialFeed.xcodeproj", + "identifier" : "40B002442CF9E9DB0058D3E0", + "name" : "EssentialFeedAPIEndToEndTests" + } + } + ], + "version" : 1 +} diff --git a/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift index 29541f3..4ac8da1 100644 --- a/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift +++ b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.swift @@ -51,12 +51,8 @@ final class EssentialFeedCacheIntegrationTests: XCTestCase { // MARK: - Helpers private func makeSUT(file: StaticString = #file, line: UInt = #line) -> LocalFeedLoader { - let storeBundle = Bundle(for: CoreDataFeedStore.self) let storeURL = testSpecificStoreURL() - let store = try! CoreDataFeedStore( - storeURL: storeURL, - bundle: storeBundle - ) + let store = try! CoreDataFeedStore(storeURL: storeURL) let sut = LocalFeedLoader( store: store, currentDate: Date.init diff --git a/EssentialFeed/EssentialFeedCacheIntegrationTests.xctestplan b/EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.xctestplan similarity index 100% rename from EssentialFeed/EssentialFeedCacheIntegrationTests.xctestplan rename to EssentialFeed/EssentialFeedCacheIntegrationTests/EssentialFeedCacheIntegrationTests.xctestplan diff --git a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift index 7f7ac9e..428ee4f 100644 --- a/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift +++ b/EssentialFeed/EssentialFeedTests/Feed Cache/CoreDataFeedStoreSpecs.swift @@ -71,12 +71,8 @@ class CoreDataFeedStoreSpecs: XCTestCase, FeedStoreSpecs { // MARK: - Helpers private func makeSUT(file: StaticString = #file, line: UInt = #line) -> CoreDataFeedStore { - let storeBundle = Bundle(for: CoreDataFeedStore.self) let storeURL = URL(fileURLWithPath: "/dev/null") - let sut = try! CoreDataFeedStore( - storeURL: storeURL, - bundle: storeBundle - ) + let sut = try! CoreDataFeedStore(storeURL: storeURL) trackForMemoryLeaks(sut, file: file, line: line) return sut } diff --git a/EssentialFeed/EssentialFeediOS/Composers/FeedImagePresentationAdapter.swift b/EssentialFeed/EssentialFeediOS/Composers/FeedImagePresentationAdapter.swift new file mode 100644 index 0000000..81f5a06 --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Composers/FeedImagePresentationAdapter.swift @@ -0,0 +1,39 @@ +// +// FeedImagePresentationAdapter.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 15/4/25. +// + + +import EssentialFeed +import UIKit + +final class FeedImagePresentationAdapter: FeedImageCellControllerDelegate { + + private var task: FeedImageDataLoaderTask? + private let model: FeedImage + private let imageLoader: FeedImageDataLoader + var presenter: FeedImagePresenter, UIImage>? + + init(model: FeedImage, imageLoader: FeedImageDataLoader) { + self.model = model + self.imageLoader = imageLoader + } + + func didRequestImage() { + presenter?.didStartShowingImage(for: model) + task = imageLoader.loadImageData(from: model.url) { [weak self, model] result in + switch result { + case .success(let data): + self?.presenter?.didFinishLoadingImageData(with: data, for: model) + case .failure(let error): + self?.presenter?.didFinishLoadingImageData(with: error, for: model) + } + } + } + + func didCancelImageRequest() { + task?.cancel() + } +} \ No newline at end of file diff --git a/EssentialFeed/EssentialFeediOS/Composers/FeedLoaderPresentationAdapter.swift b/EssentialFeed/EssentialFeediOS/Composers/FeedLoaderPresentationAdapter.swift new file mode 100644 index 0000000..0f9e042 --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Composers/FeedLoaderPresentationAdapter.swift @@ -0,0 +1,35 @@ +// +// FeedLoaderPresentationAdapter.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 15/4/25. +// + + +import EssentialFeed +import UIKit + +final class FeedLoaderPresentationAdapter: FeedViewControllerDelegate { + private let feedLoader: FeedLoader + var presenter: FeedPresenter? + + init(feedLoader: FeedLoader) { + self.feedLoader = feedLoader + } + + func loadFeed() { + presenter?.didStartLoadingFeed() + feedLoader.load { [weak self] result in + switch result { + case let .success(feed): + self?.presenter?.didFinishLoadingFeed(with: feed) + case let .failure(error): + self?.presenter?.didFinishLoadingFeed(with: error) + } + } + } + + func didRequestFeedRefresh() { + loadFeed() + } +} \ No newline at end of file diff --git a/EssentialFeed/EssentialFeediOS/Composers/FeedUIComposer.swift b/EssentialFeed/EssentialFeediOS/Composers/FeedUIComposer.swift new file mode 100644 index 0000000..46e7871 --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Composers/FeedUIComposer.swift @@ -0,0 +1,40 @@ +// +// FeedUIComposer.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 14/4/25. +// + + +import EssentialFeed +import UIKit + +public enum FeedUIComposer { + + public static func feedComposedWith( + feedLoader: FeedLoader, + imageLoader: FeedImageDataLoader + ) -> FeedViewController { + + let presentationAdapter = FeedLoaderPresentationAdapter(feedLoader: MainQueueDispatchDecorator(feedLoader)) + + let feedController = FeedViewController.makeWith( + delegate: presentationAdapter, + title: FeedPresenter.title + ) + + presentationAdapter.presenter = FeedPresenter( + feedView: FeedViewAdapter( + controller: feedController, + imageLoader: MainQueueDispatchDecorator(imageLoader) + ), + loadingView: WeakRefVirtualProxy(feedController) + ) + return feedController + } +} + + + + + diff --git a/EssentialFeed/EssentialFeediOS/Composers/FeedViewAdapter.swift b/EssentialFeed/EssentialFeediOS/Composers/FeedViewAdapter.swift new file mode 100644 index 0000000..9ed0d2c --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Composers/FeedViewAdapter.swift @@ -0,0 +1,40 @@ +// +// FeedViewAdapter.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 15/4/25. +// + + +import EssentialFeed +import UIKit + +final class FeedViewAdapter: FeedView { + private weak var controller: FeedViewController? + private let imageLoader: FeedImageDataLoader + + init(controller: FeedViewController, imageLoader: FeedImageDataLoader) { + self.controller = controller + self.imageLoader = imageLoader + } + + func display(_ viewModel: FeedViewModel) { + controller?.tableModel = viewModel.feed.map { model in + let adapter = FeedImagePresentationAdapter( + model: model, + imageLoader: imageLoader + ) + + let controller = FeedImageCellController(delegate: adapter) + + let presenter = FeedImagePresenter( + view: WeakRefVirtualProxy(controller), + model: model, + imageTransformer: UIImage.init + ) + + adapter.presenter = presenter + return controller + } + } +} \ No newline at end of file diff --git a/EssentialFeed/EssentialFeediOS/Composers/FeedViewController+factory.swift b/EssentialFeed/EssentialFeediOS/Composers/FeedViewController+factory.swift new file mode 100644 index 0000000..37f8a35 --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Composers/FeedViewController+factory.swift @@ -0,0 +1,21 @@ +// +// FeedViewController+factory.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 15/4/25. +// + + +import EssentialFeed +import UIKit + +extension FeedViewController { + static func makeWith(delegate: FeedViewControllerDelegate, title: String) -> FeedViewController { + let bundle = Bundle(for: FeedViewController.self) + let storyboard = UIStoryboard(name: "Feed", bundle: bundle) + let feedController = storyboard.instantiateInitialViewController() as! FeedViewController + feedController.delegate = delegate + feedController.title = title + return feedController + } +} \ No newline at end of file diff --git a/EssentialFeed/EssentialFeediOS/Composers/MainQueueDispatchDecorator.swift b/EssentialFeed/EssentialFeediOS/Composers/MainQueueDispatchDecorator.swift new file mode 100644 index 0000000..64805a6 --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Composers/MainQueueDispatchDecorator.swift @@ -0,0 +1,41 @@ +// +// MainQueueDispatchDecorator.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 15/4/25. +// + + +import EssentialFeed +import UIKit + +final class MainQueueDispatchDecorator { + private let decoratee: T + init(_ decorate: T) { + self.decoratee = decorate + } + + static func dispatch(work: @escaping () -> Void) { + Thread.isMainThread ? work() : DispatchQueue.main.async(execute: work) + } +} + +extension MainQueueDispatchDecorator: FeedLoader where T == FeedLoader { + func load(completion: @escaping (FeedLoader.Result) -> Void) { + decoratee.load { result in + Self.dispatch { + completion(result) + } + } + } +} + +extension MainQueueDispatchDecorator: FeedImageDataLoader where T == FeedImageDataLoader { + func loadImageData(from url: URL, completion: @escaping (FeedImageDataLoader.Result) -> Void) -> any FeedImageDataLoaderTask { + decoratee.loadImageData(from: url) { result in + Self.dispatch { + completion(result) + } + } + } +} diff --git a/EssentialFeed/EssentialFeediOS/Composers/WeakRefVirtualProxy.swift b/EssentialFeed/EssentialFeediOS/Composers/WeakRefVirtualProxy.swift new file mode 100644 index 0000000..70fc900 --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Composers/WeakRefVirtualProxy.swift @@ -0,0 +1,28 @@ +// +// WeakRefVirtualProxy.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 15/4/25. +// + + +final class WeakRefVirtualProxy { + private weak var object: T? + init(_ object: T) { + self.object = object + } +} + +extension WeakRefVirtualProxy: FeedLoadingView where T: FeedLoadingView { + func display(_ viewModel: FeedLoadingViewModel) { + object?.display(viewModel) + } +} + +import UIKit + +extension WeakRefVirtualProxy: FeedImageView where T: FeedImageView, T.Image == UIImage { + func display(_ viewModel: FeedImageViewModel) { + object?.display(viewModel) + } +} diff --git a/EssentialFeed/EssentialFeediOS/Feed Image Loder/FeedImageDataLoader.swift b/EssentialFeed/EssentialFeediOS/Feed Image Loder/FeedImageDataLoader.swift new file mode 100644 index 0000000..af57482 --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Feed Image Loder/FeedImageDataLoader.swift @@ -0,0 +1,16 @@ +// +// FeedImageDataLoader.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 14/4/25. +// +import Foundation + +public protocol FeedImageDataLoaderTask { + func cancel() +} + +public protocol FeedImageDataLoader { + typealias Result = Swift.Result + func loadImageData(from url: URL, completion: @escaping (Result) -> Void) -> FeedImageDataLoaderTask +} diff --git a/EssentialFeed/EssentialFeediOS/Feed Presentation/Feed.xcstrings b/EssentialFeed/EssentialFeediOS/Feed Presentation/Feed.xcstrings new file mode 100644 index 0000000..6c2eb50 --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Feed Presentation/Feed.xcstrings @@ -0,0 +1,17 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "FEED_VIEW_TITLE" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "My Feed" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/EssentialFeed/EssentialFeediOS/Feed Presentation/FeedImagePresenter.swift b/EssentialFeed/EssentialFeediOS/Feed Presentation/FeedImagePresenter.swift new file mode 100644 index 0000000..726cd5b --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Feed Presentation/FeedImagePresenter.swift @@ -0,0 +1,86 @@ +// +// FeedImagePresenter.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 15/4/25. +// + +import Foundation +import EssentialFeed + +struct FeedImageViewModel { + let description: String? + let location: String? + let image: T? + + let isLoading: Bool + let shouldRetry: Bool + + var hasLocation: Bool { location != nil } +} + +protocol FeedImageView { + associatedtype Image + func display(_ viewModel: FeedImageViewModel) +} + +final class FeedImagePresenter where View.Image == Image { + + private let view: View + private let model: FeedImage + private let imageTransformer: (Data) -> Image? + + init(view: View, model: FeedImage, imageTransformer: @escaping (Data) -> Image?) { + self.view = view + self.model = model + self.imageTransformer = imageTransformer + } + + func didStartShowingImage(for model: FeedImage) { + view.display( + FeedImageViewModel( + description: model.description, + location: model.location, + image: nil, + isLoading: true, + shouldRetry: false + ) + ) + } + + func didFinishLoadingImageData(with data: Data, for model: FeedImage) { + guard let image = imageTransformer(data) else { + view.display( + FeedImageViewModel( + description: model.description, + location: model.location, + image: nil, + isLoading: false, + shouldRetry: true + ) + ) + return + } + view.display( + FeedImageViewModel( + description: model.description, + location: model.location, + image: image, + isLoading: false, + shouldRetry: false + ) + ) + } + + func didFinishLoadingImageData(with error: Error, for model: FeedImage) { + view.display( + FeedImageViewModel( + description: model.description, + location: model.location, + image: nil, + isLoading: false, + shouldRetry: true + ) + ) + } +} diff --git a/EssentialFeed/EssentialFeediOS/Feed Presentation/FeedPresenter.swift b/EssentialFeed/EssentialFeediOS/Feed Presentation/FeedPresenter.swift new file mode 100644 index 0000000..044e323 --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Feed Presentation/FeedPresenter.swift @@ -0,0 +1,58 @@ +// +// FeedViewModel.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 14/4/25. +// + + +import EssentialFeed +import UIKit + +struct FeedLoadingViewModel { + let isLoading: Bool +} + +protocol FeedLoadingView { + func display(_ viewModel: FeedLoadingViewModel) +} + +struct FeedViewModel { + let feed: [FeedImage] +} + +protocol FeedView { + func display(_ viewModel: FeedViewModel) +} + +final class FeedPresenter { + private let feedView: FeedView + private let loadingView: FeedLoadingView + + init(feedView: FeedView, loadingView: FeedLoadingView) { + self.feedView = feedView + self.loadingView = loadingView + } + + static var title: String { + NSLocalizedString( + "FEED_VIEW_TITLE", + tableName: "Feed", + bundle: Bundle(for: FeedPresenter.self), + comment: "Title for the feed view" + ) + } + + func didStartLoadingFeed() { + loadingView.display(FeedLoadingViewModel(isLoading: true)) + } + + func didFinishLoadingFeed(with feed: [FeedImage]) { + feedView.display(FeedViewModel(feed: feed)) + loadingView.display(FeedLoadingViewModel(isLoading: false)) + } + + func didFinishLoadingFeed(with error: Error) { + loadingView.display(FeedLoadingViewModel(isLoading: false)) + } +} diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift new file mode 100644 index 0000000..66c2b8e --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedImageCellController.swift @@ -0,0 +1,57 @@ +// +// FeedImageCellController.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 14/4/25. +// + +import EssentialFeed +import UIKit + +protocol FeedImageCellControllerDelegate { + func didRequestImage() + func didCancelImageRequest() +} + +final class FeedImageCellController { + + let delegate: FeedImageCellControllerDelegate + private var cell: FeedImageCell? + + init(delegate: FeedImageCellControllerDelegate) { + self.delegate = delegate + } + + func view(in tableView: UITableView) -> UITableViewCell { + cell = tableView.dequeueReusableCell() + delegate.didRequestImage() + return cell! + } + + func preload() { + delegate.didRequestImage() + } + + func cancelLoad() { + releaseCellForReuse() + delegate.didCancelImageRequest() + } + + + private func releaseCellForReuse() { + cell = nil + } +} + +extension FeedImageCellController: FeedImageView { + func display(_ viewModel: FeedImageViewModel) { + cell?.feedImageView.setImageAnimately(viewModel.image) + cell?.locationContainer.isHidden = !viewModel.hasLocation + cell?.locationLabel.text = viewModel.location + cell?.descriptionLabel.text = viewModel.description + cell?.feedImageRetryButton.isHidden = !viewModel.shouldRetry + cell?.onRetry = delegate.didRequestImage + cell?.feedImageContainer.isShimmering = viewModel.isLoading + } +} + diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedViewController.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedViewController.swift new file mode 100644 index 0000000..944c8ad --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/FeedViewController.swift @@ -0,0 +1,94 @@ +// +// FeedViewController.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 11/4/25. +// + +import UIKit + +protocol FeedViewControllerDelegate { + func didRequestFeedRefresh() +} + +public final class FeedViewController: UITableViewControllerExtendedLifecycle, UITableViewDataSourcePrefetching { + var delegate: FeedViewControllerDelegate? + + var tableModel = [FeedImageCellController]() { + didSet { + tableView.reloadData() + } + } + + @IBAction private func refresh() { + delegate?.didRequestFeedRefresh() + } + + public override func viewDidLoad() { + super.viewDidLoad() + } + + public override func viewFirstAppearance() { + super.viewFirstAppearance() + refresh() + } + + public override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return tableModel.count + } + + public override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + return cellController(forRowAt: indexPath).view(in: tableView) + } + + public override func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { + cancelCellControllerLoad(forRowAt: indexPath) + } + + public func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + indexPaths.forEach { indexPath in + cellController(forRowAt: indexPath).preload() + } + } + + public func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { + indexPaths.forEach(cancelCellControllerLoad) + } + + private func cancelCellControllerLoad(forRowAt indexPath: IndexPath) { + cellController(forRowAt: indexPath).cancelLoad() + } + + private func cellController(forRowAt indexPath: IndexPath) -> FeedImageCellController { + return tableModel[indexPath.row] + } +} + +extension FeedViewController: FeedLoadingView { + func display(_ viewModel: FeedLoadingViewModel) { + refreshControl?.refreshIf(viewModel.isLoading) + } +} + +private extension UIRefreshControl { + func refreshIf(_ shouldRefresh: Bool) { + if shouldRefresh { + beginRefreshing() + } else { + endRefreshing() + } + } +} + +public class UITableViewControllerExtendedLifecycle: UITableViewController { + + var firstAppeared = true + public override func viewIsAppearing(_ animated: Bool) { + super.viewIsAppearing(animated) + firstAppeared ? viewFirstAppearance() : () + } + + func viewFirstAppearance() { + firstAppeared = false + } +} diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/UIImage+setImageAnimately.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/UIImage+setImageAnimately.swift new file mode 100644 index 0000000..818edab --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/UIImage+setImageAnimately.swift @@ -0,0 +1,19 @@ +// +// UIImage+setImageAnimately.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 15/4/25. +// + +import UIKit + +extension UIImageView { + func setImageAnimately(_ newImage: UIImage?) { + image = newImage + guard newImage != nil else { return } + alpha = 0 + UIView.animate(withDuration: 0.25) { [weak self] in + self?.alpha = 1 + } + } +} diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/UITableView+dequeueReusable.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/UITableView+dequeueReusable.swift new file mode 100644 index 0000000..9162522 --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Controllers/UITableView+dequeueReusable.swift @@ -0,0 +1,15 @@ +// +// UITableView+dequeueReusable.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 15/4/25. +// + +import UIKit + +extension UITableView { + func dequeueReusableCell() -> T { + let identifier = String(describing: T.self) + return dequeueReusableCell(withIdentifier: identifier) as! T + } +} diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.storyboard b/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.storyboard new file mode 100644 index 0000000..dde809b --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.storyboard @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/Contents.json b/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/pin.imageset/Contents.json b/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/pin.imageset/Contents.json new file mode 100644 index 0000000..ab7c630 --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/pin.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "pin.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "pin@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "pin@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/pin.imageset/pin.png b/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/pin.imageset/pin.png new file mode 100644 index 0000000..6acdd61 Binary files /dev/null and b/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/pin.imageset/pin.png differ diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/pin.imageset/pin@2x.png b/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/pin.imageset/pin@2x.png new file mode 100644 index 0000000..770daef Binary files /dev/null and b/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/pin.imageset/pin@2x.png differ diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/pin.imageset/pin@3x.png b/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/pin.imageset/pin@3x.png new file mode 100644 index 0000000..b1b2b54 Binary files /dev/null and b/EssentialFeed/EssentialFeediOS/Feed UI/Views/Feed.xcassets/pin.imageset/pin@3x.png differ diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Views/FeedImageCell.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Views/FeedImageCell.swift new file mode 100644 index 0000000..decb51a --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Views/FeedImageCell.swift @@ -0,0 +1,23 @@ +// +// FeedImageCell.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 11/4/25. +// + +import UIKit + +public final class FeedImageCell: UITableViewCell { + @IBOutlet private(set) public var locationContainer: UIView! + @IBOutlet private(set) public var locationLabel: UILabel! + @IBOutlet private(set) public var descriptionLabel: UILabel! + @IBOutlet private(set) public var feedImageContainer: UIView! + @IBOutlet private(set) public var feedImageRetryButton: UIButton! + @IBOutlet private(set) public var feedImageView: UIImageView! + + var onRetry: (() -> Void)? + + @IBAction func retryButtonTapped() { + onRetry?() + } +} diff --git a/EssentialFeed/EssentialFeediOS/Feed UI/Views/UIView+Shimmering.swift b/EssentialFeed/EssentialFeediOS/Feed UI/Views/UIView+Shimmering.swift new file mode 100644 index 0000000..638c9cb --- /dev/null +++ b/EssentialFeed/EssentialFeediOS/Feed UI/Views/UIView+Shimmering.swift @@ -0,0 +1,47 @@ +// +// UIView+Shimmering.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 11/4/25. +// +import UIKit + +extension UIView { + private var _isShimmering: Bool { + return layer.mask?.animation(forKey: shimmerAnimationKey) != nil + } + + public var isShimmering: Bool { + get { _isShimmering } + set { newValue ? startShimmering() : stopShimmering() } + } + private var shimmerAnimationKey: String { + "shimmer" + } + + func startShimmering() { + let white = UIColor.white.cgColor + let alpha = UIColor.white.withAlphaComponent(0.7).cgColor + let width = bounds.width + let height = bounds.height + + let gradient = CAGradientLayer() + gradient.colors = [alpha, white, alpha] + gradient.startPoint = CGPoint(x: 0.0, y: 0.4) + gradient.endPoint = CGPoint(x: 1.0, y: 0.6) + gradient.locations = [0.4, 0.5, 0.6] + gradient.frame = CGRect(x: -width, y: 0, width: width*3, height: height) + layer.mask = gradient + + let animation = CABasicAnimation(keyPath: #keyPath(CAGradientLayer.locations)) + animation.fromValue = [0.0, 0.1, 0.2] + animation.toValue = [0.8, 0.9, 1.0] + animation.duration = 1 + animation.repeatCount = .infinity + gradient.add(animation, forKey: shimmerAnimationKey) + } + + func stopShimmering() { + layer.mask = nil + } +} diff --git a/EssentialFeed/EssentialFeediOSTests/EssentialFeediOS.xctestplan b/EssentialFeed/EssentialFeediOSTests/EssentialFeediOS.xctestplan new file mode 100644 index 0000000..804ab74 --- /dev/null +++ b/EssentialFeed/EssentialFeediOSTests/EssentialFeediOS.xctestplan @@ -0,0 +1,40 @@ +{ + "configurations" : [ + { + "id" : "FC8ED705-16ED-492D-86A1-F2CB8BE70449", + "name" : "Test Scheme Action", + "options" : { + + } + } + ], + "defaultOptions" : { + "codeCoverage" : { + "targets" : [ + { + "containerPath" : "container:EssentialFeed.xcodeproj", + "identifier" : "40D5EE232DA7C5F100D344B3", + "name" : "EssentialFeediOS" + } + ] + }, + "environmentVariableEntries" : [ + { + "key" : "MTC_CRASH_ON_REPORT", + "value" : "1" + } + ], + "testExecutionOrdering" : "random" + }, + "testTargets" : [ + { + "parallelizable" : true, + "target" : { + "containerPath" : "container:EssentialFeed.xcodeproj", + "identifier" : "40D5EE2A2DA7C5F100D344B3", + "name" : "EssentialFeediOSTests" + } + } + ], + "version" : 1 +} diff --git a/EssentialFeed/EssentialFeediOSTests/FeedUI/FeedLocalizationTests.swift b/EssentialFeed/EssentialFeediOSTests/FeedUI/FeedLocalizationTests.swift new file mode 100644 index 0000000..8dbbc50 --- /dev/null +++ b/EssentialFeed/EssentialFeediOSTests/FeedUI/FeedLocalizationTests.swift @@ -0,0 +1,57 @@ +import XCTest +@testable import EssentialFeediOS + +final class FeedLocalizationTests: XCTestCase { + + func test_localizedStrings_haveKeysAndValuesForAllSupportedLocalizations() { + let table = "Feed" + let presentationBundle = Bundle(for: FeedPresenter.self) + let localizationBundles = allLocalizationBundles(in: presentationBundle) + let localizedStringKeys = allLocalizedStringKeys(in: localizationBundles, table: table) + + localizationBundles.forEach { (bundle, localization) in + localizedStringKeys.forEach { key in + let localizedString = bundle.localizedString(forKey: key, value: nil, table: table) + + if localizedString == key { + let language = Locale.current.localizedString(forLanguageCode: localization) ?? "" + + XCTFail("Missing \(language) (\(localization)) localized string for key: '\(key)' in table: '\(table)'") + } + } + } + } + + // MARK: - Helpers + + private typealias LocalizedBundle = (bundle: Bundle, localization: String) + + private func allLocalizationBundles(in bundle: Bundle, file: StaticString = #file, line: UInt = #line) -> [LocalizedBundle] { + return bundle.localizations.compactMap { localization in + guard + let path = bundle.path(forResource: localization, ofType: "lproj"), + let localizedBundle = Bundle(path: path) + else { + XCTFail("Couldn't find bundle for localization: \(localization)", file: file, line: line) + return nil + } + + return (localizedBundle, localization) + } + } + + private func allLocalizedStringKeys(in bundles: [LocalizedBundle], table: String, file: StaticString = #file, line: UInt = #line) -> Set { + return bundles.reduce([]) { (acc, current) in + guard + let path = current.bundle.path(forResource: table, ofType: "strings"), + let strings = NSDictionary(contentsOfFile: path), + let keys = strings.allKeys as? [String] + else { + XCTFail("Couldn't load localized strings for localization: \(current.localization)", file: file, line: line) + return acc + } + + return acc.union(Set(keys)) + } + } +} diff --git a/EssentialFeed/EssentialFeediOSTests/FeedUI/FeedUIIntegrationTests.swift b/EssentialFeed/EssentialFeediOSTests/FeedUI/FeedUIIntegrationTests.swift new file mode 100644 index 0000000..651dbb1 --- /dev/null +++ b/EssentialFeed/EssentialFeediOSTests/FeedUI/FeedUIIntegrationTests.swift @@ -0,0 +1,611 @@ +// +// FeedViewControllerTests.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 10/4/25. +// + +import XCTest +import UIKit +import EssentialFeed +import EssentialFeediOS + +final class FeedUIIntegrationTests: XCTestCase { + + func test_feedView_hasTitle() { + let (sut, _) = makeSUT() + + sut.simulateAppearance() + + XCTAssertEqual(sut.title, localized("FEED_VIEW_TITLE")) + } + + func test_viewDidLoad_doesNotShowRefreshControl() { + let (sut, _) = makeSUT() + sut.loadViewIfNeeded() + XCTAssertFalse(sut.isShowingLoadingIndicator) + } + + func test_viewIsAppearing_showsLoadingIndicatorOnce() { + let (sut, _) = makeSUT() + + sut.simulateAppearance() + sut.refreshControl?.endRefreshing() + + sut.simulateAppearance() + XCTAssertFalse(sut.isShowingLoadingIndicator) + } + + func test_loadFeedActions_requestFeedFromLoader() { + let (sut, loader) = makeSUT() + XCTAssertEqual(loader.loadFeedCallCount, 0, "Expect no loading requests before view is load") + + sut.simulateAppearance() + XCTAssertEqual(loader.loadFeedCallCount, 1, "Expect a loading request once view is loaded") + + sut.simulateUserInitiatedFeedReload() + XCTAssertEqual(loader.loadFeedCallCount, 2, "Expect another loading request after user initiates reload") + + sut.simulateUserInitiatedFeedReload() + XCTAssertEqual(loader.loadFeedCallCount, 3, "Expect a third loading request once user initiates another reload") + } + + func test_loadingFeedIndicator_isVisibleWhileLoadingFeed() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + XCTAssertTrue(sut.isShowingLoadingIndicator, "Expect loading indicator on viewAppearance") + + loader.completeFeedLoading(at: 0) + XCTAssertFalse(sut.isShowingLoadingIndicator, "Expect no loading indicator once loading completes succesfully") + + sut.simulateUserInitiatedFeedReload() + XCTAssertTrue(sut.isShowingLoadingIndicator, "Expect loading indicator once user initiates a reload") + + loader.completeFeedLoadingWithError(at: 1) + XCTAssertFalse(sut.isShowingLoadingIndicator, "Expect no loading indicator once loading completes with error") + + } + + func test_loadFeedCompletion_rendersSuccesfullyLoadedFeed() { + let image0 = makeImage(description: "a description", location: "a location") + let image1 = makeImage(description: nil, location: "another location") + let image2 = makeImage(description: "a description", location: nil) + let image3 = makeImage(description: nil, location: nil) + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + assertThat(sut, isRendering: []) + + loader.completeFeedLoading(with: [image0], at: 0) + assertThat(sut, isRendering: [image0]) + + sut.simulateUserInitiatedFeedReload() + loader.completeFeedLoading( + with: [image0, image1, image2, image3], + at: 1 + ) + assertThat(sut, isRendering: [image0, image1, image2, image3]) + } + + func test_loadFeedCompletion_doesNotAlterCurrentRenderingStateOnError() { + let image0 = makeImage() + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [image0], at: 0) + assertThat(sut, isRendering: [image0]) + + sut.simulateUserInitiatedFeedReload() + loader.completeFeedLoadingWithError(at: 1) + assertThat(sut, isRendering: [image0]) + } + + func test_feedImageView_loadsImageURLWhenVisible() { + let image0 = makeImage(url: URL(string: "http://url-0.com")!) + let image1 = makeImage(url: URL(string: "http://url-1.com")!) + + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [image0, image1], at: 0) + XCTAssertEqual(loader.loadedImageURLs, [], "Expected no image URL requests until views become visible") + + sut.simulateFeedImageViewVisible(at: 0) + XCTAssertEqual(loader.loadedImageURLs, [image0.url], "Expected first image URL request once first view becomes visible") + + sut.simulateFeedImageViewVisible(at: 1) + XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url], "Expected second image URL request once second view also becomes visible") + } + + func test_feedImageView_cancelsImageLoadingWhenNotVisibleAnymore() { + let image0 = makeImage(url: URL(string: "http://url-0.com")!) + let image1 = makeImage(url: URL(string: "http://url-1.com")!) + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [image0, image1], at: 0) + XCTAssertEqual(loader.cancelledImageURls, [], "Expected no cancelled image URL requests until image is not visible") + + sut.simulateFeedImageNotViewVisible(at: 0) + XCTAssertEqual(loader.cancelledImageURls, [image0.url], "Expected one cancelled image URL request once first image is not visible anymore") + + sut.simulateFeedImageNotViewVisible(at: 1) + XCTAssertEqual(loader.cancelledImageURls, [image0.url, image1.url], "Expected two cancelled image URL requests once second image is also not visible anymore") + } + + func test_feedImageViewLoadingIndicator_isVisibleWhileLoadingImage() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [makeImage(), makeImage()], at: 0) + + let view0 = sut.simulateFeedImageViewVisible(at: 0) + let view1 = sut.simulateFeedImageViewVisible(at: 1) + + + XCTAssertEqual(view0?.isShowingImageLoadingIndicator, true, "Expected loading indicator for first view while loading first image") + XCTAssertEqual(view1?.isShowingImageLoadingIndicator, true, "Expected loading indicator for second view while loading second image") + + loader.completeImageLoading(at: 0) + XCTAssertEqual(view0?.isShowingImageLoadingIndicator, false, "Expected no loading indicator for first view once first image loading completes successfully") + XCTAssertEqual(view1?.isShowingImageLoadingIndicator, true, "Expected loading indicator for second view once second image loading completes successfully") + + + loader.completeImageLoadingWithError(at: 1) + XCTAssertEqual(view0?.isShowingImageLoadingIndicator, false, "Expected no loading indicator for first view once second image loading completes with error") + XCTAssertEqual(view1?.isShowingImageLoadingIndicator, false, "Expected no loading indicator for second view once second image completes with error") + } + + func test_feedImageView_rendersImageLoadedFromURL() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [makeImage(), makeImage()], at: 0) + + let view0 = sut.simulateFeedImageViewVisible(at: 0) + let view1 = sut.simulateFeedImageViewVisible(at: 1) + + + XCTAssertEqual(view0?.renderedImage, .none, "Expected no image for 1st view hile loading 1st image") + XCTAssertEqual(view1?.renderedImage, .none, "Expected no image for 2nd view hile loading 2nd image") + + let imageData0 = UIImage.make(withColor: .red).pngData()! + loader.completeImageLoading(with: imageData0, at: 0) + XCTAssertEqual(view0?.renderedImage, imageData0, "Expected image for 1st view once 1st image loading completes succesfully") + XCTAssertEqual(view1?.renderedImage, .none, "Expected no image for 2nd view once 1st image loading completes succesfully") + + let imageData1 = UIImage.make(withColor: .blue).pngData()! + loader.completeImageLoading(with: imageData1, at: 1) + XCTAssertEqual(view0?.renderedImage, imageData0, "Expected no image state change for 1st view once 2nd image loading completes succesfully") + XCTAssertEqual(view1?.renderedImage, imageData1, "Expected image for 2nd view once 2nd image loading completes succesfully") + } + + func test_feedImageViewRetryButton_isVisibleOnImageURLLoadError() { + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [makeImage(), makeImage()], at: 0) + + let view0 = sut.simulateFeedImageViewVisible(at: 0) + let view1 = sut.simulateFeedImageViewVisible(at: 1) + + XCTAssertEqual(view0?.isShowingRetryAction, false, "Expected no retry action for first view while loading first image") + XCTAssertEqual(view1?.isShowingRetryAction, false, "Expected no retry action for second view while loading second image") + + let imageData0 = UIImage.make(withColor: .red).pngData()! + loader.completeImageLoading(with: imageData0, at: 0) + XCTAssertEqual(view0?.isShowingRetryAction, false, "Expected no retry action for first view once first image loading completes successfully") + XCTAssertEqual(view1?.isShowingRetryAction, false, "Expected no retry action for second view once first image loading completes successfully") + + let imageData1 = UIImage.make(withColor: .blue).pngData()! + loader.completeImageLoading(with: imageData1, at: 1) + XCTAssertEqual(view0?.isShowingRetryAction, false, "Expected no retry action for first view once second image loading completes successfully") + XCTAssertEqual(view1?.isShowingRetryAction, false, "Expected no retry action for second view once second image loading completes successfully") + + loader.completeImageLoadingWithError(at: 1) + XCTAssertEqual(view0?.isShowingRetryAction, false, "Expected no retry action state change for first view once second image loading completes with error") + XCTAssertEqual(view1?.isShowingRetryAction, true, "Expected retry action for second view once second image loading completes with error") + } + + func test_feedImageViewRetryButton_isVisibleOnInvalidImageData() { + + let (sut, loader) = makeSUT() + sut.simulateAppearance() + loader.completeFeedLoading(with: [makeImage()], at: 0) + + let view = sut.simulateFeedImageViewVisible(at: 0) + XCTAssertEqual(view?.isShowingRetryAction, false, "Expeced no retry action while loading image") + + let invalidImageData = Data("invalid image data".utf8) + loader.completeImageLoading(with: invalidImageData, at: 0) + XCTAssertEqual(view?.isShowingRetryAction, true, "Expected retry action once image loading completes with invalid image data") + } + + func test_feedImageViewRetryActiion_retrievesImageLoad() { + let image0 = makeImage(url: URL(string: "http://url-0.com")!) + let image1 = makeImage(url: URL(string: "http://url-1.com")!) + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [image0, image1], at: 0) + + let view0 = sut.simulateFeedImageViewVisible(at: 0) + let view1 = sut.simulateFeedImageViewVisible(at: 1) + XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url], "Expected two image URL request for two visible views") + + loader.completeImageLoadingWithError(at: 0) + loader.completeImageLoadingWithError(at: 1) + XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url], "Expected only two image URL requests before retry action") + + view0?.simulateRetryAction() + XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url, image0.url], "Expected third imageURL request after first view retry action") + + view1?.simulateRetryAction() + XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url, image0.url, image1.url], "Expected fourth imageURL request after second view retry action") + } + + func test_feedImageView_preloadsImageURLWhenNearVisible() { + let image0 = makeImage(url: URL(string: "http://url-0.com")!) + let image1 = makeImage(url: URL(string: "http://url-1.com")!) + + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [image0, image1], at: 0) + XCTAssertEqual(loader.loadedImageURLs, [], "Expected no image URL requests until image is near visible") + + sut.simulateFeedImageViewNearVisible(at: 0) + XCTAssertEqual(loader.loadedImageURLs, [image0.url], "Expected first image URL request once first image is near visible") + + sut.simulateFeedImageViewNearVisible(at: 1) + XCTAssertEqual(loader.loadedImageURLs, [image0.url, image1.url], "Expected second image URL request once second image is near visible") + } + + func test_feedImageView_cancelsImageURLPreloadingWhenNotNearVisibleAnymore() { + let image0 = makeImage(url: URL(string: "http://url-0.com")!) + let image1 = makeImage(url: URL(string: "http://url-1.com")!) + + let (sut, loader) = makeSUT() + + sut.simulateAppearance() + loader.completeFeedLoading(with: [image0, image1], at: 0) + XCTAssertEqual(loader.loadedImageURLs, [], "Expected no image URL requests until image is near visible") + + sut.simulateFeedImageViewNotNearVisible(at: 0) + XCTAssertEqual(loader.cancelledImageURls, [image0.url], "Expected first image URL request cancle once first image is not near visible") + + sut.simulateFeedImageViewNotNearVisible(at: 1) + XCTAssertEqual(loader.cancelledImageURls, [image0.url, image1.url], "Expected second image URL request cancel once second image is not near visible") + } + + func test_feedImageView_doesNotRenderLoadedImageWhenNotVisibleAnymore() { + let (sut, loader) = makeSUT() + sut.simulateAppearance() + loader.completeFeedLoading(with: [makeImage()], at: 0) + + let view = sut.simulateFeedImageNotViewVisible(at: 0) + loader.completeImageLoading(with: anyImageData()) + + XCTAssertNil(view?.renderedImage, "Expected no rendered image when an image load finishes after the view is not visible anymore") + } + + func test_loadFeedCompletion_dispatchesFromBackgroundToMainThread() { + let (sut, loader) = makeSUT() + sut.simulateAppearance() + + let exp = expectation(description: "Wait for background queue") + DispatchQueue.global().async { + loader.completeFeedLoading(at: 0) + exp.fulfill() + } + + wait(for: [exp], timeout: 1.0) + } + + func test_loadImageDataCompletion_dispatchesFromBackgroundToMainThread() { + let (sut, loader) = makeSUT() + sut.simulateAppearance() + + loader.completeFeedLoading(with: [makeImage()], at: 0) + sut.simulateFeedImageViewVisible(at: 0) + + let exp = expectation(description: "Wait for background queue") + DispatchQueue.global().async { + loader.completeImageLoading(with: self.anyImageData(), at: 0) + exp.fulfill() + } + + wait(for: [exp], timeout: 1.0) + } + + // MARK: - Helper + + private func makeSUT( + file: StaticString = #file, + line: UInt = #line + ) -> ( + sut: FeedViewController, + loader: LoaderSpy + ) { + let loader = LoaderSpy() + + let sut = FeedUIComposer.feedComposedWith( + feedLoader: loader, + imageLoader: loader + ) + trackForMemoryLeaks(loader, file: file, line: line) + trackForMemoryLeaks(sut, file: file, line: line) + return (sut, loader) + } + + private func assertThat( + _ sut: FeedViewController, + isRendering feed: [FeedImage], + file: StaticString = #file, + line: UInt = #line + ) { + guard sut.numberOfRenderedFeedImageViews() == feed.count else { + return XCTFail( + "Expected \(feed.count) images, got \(sut.numberOfRenderedFeedImageViews()) instead", + file: file, + line: line + ) + } + + feed.enumerated().forEach { + assertThat( + sut, + hasViewConfiguredFor: $1, + at: $0, + file: file, + line: line + ) + } + } + + private func assertThat( + _ sut: FeedViewController, + hasViewConfiguredFor image: FeedImage, + at index: Int, + file: StaticString = #file, + line: UInt = #line + ) { + let view = sut.feedImageView(at: index) as? FeedImageCell + XCTAssertNotNil(view, file: file, line: line) + XCTAssertEqual(view?.isShowingLocation, image.location != nil, file: file, line: line) + XCTAssertEqual(view?.locationText, image.location, file: file, line: line) + XCTAssertEqual(view?.descriptionText, image.description, file: file, line: line) + } + + private func makeImage( + description: String? = nil, + location: String? = nil, + url: URL = URL(string: "http://any-url.com")! + ) -> FeedImage { + FeedImage(id: UUID(), description: description, location: location, url: url) + } + + private func anyImageData() -> Data { + UIImage.make(withColor: .red).pngData()! + } + + + class LoaderSpy: FeedLoader, FeedImageDataLoader { + + // MARK: - FeedLoader + + private(set) var feedRequests = [(FeedLoader.Result) -> Void]() + var loadFeedCallCount: Int { feedRequests.count } + + + func load(completion: @escaping (FeedLoader.Result) -> Void) { + feedRequests.append(completion) + } + + + func completeFeedLoading(with feed: [FeedImage] = [], at index: Int) { + feedRequests[index](.success(feed)) + } + + func completeFeedLoadingWithError(at index: Int) { + feedRequests[index](.failure(NSError(domain: "any error", code: 0, userInfo: nil))) + } + + // MARK: - FeedImageDataLoader + + private struct TaskSpy: FeedImageDataLoaderTask { + let cancelCallback: () -> Void + func cancel() { + cancelCallback() + } + } + + private var imageRequests = [( + url: URL, + completion: (FeedImageDataLoader.Result) -> Void + )]() + + var loadedImageURLs: [URL] { + imageRequests.map { $0.url } + } + + private(set) var cancelledImageURls: [URL] = [] + + + func loadImageData( + from url: URL, + completion: @escaping (FeedImageDataLoader.Result) -> Void + ) -> FeedImageDataLoaderTask { + + imageRequests.append((url: url, completion: completion)) + + return TaskSpy { [weak self] in + self?.cancelledImageURls.append(url) + } + } + + func completeImageLoading(with imageData: Data = Data(), at index: Int = 0) { + imageRequests[index].completion(.success(imageData)) + } + + func completeImageLoadingWithError(at index: Int = 0) { + let error = NSError(domain: "an error", code: 0) + imageRequests[index].completion(.failure(error)) + } + } +} + +private extension FeedViewController { + + func simulateUserInitiatedFeedReload() { + refreshControl?.simulatePullToRefresh() + } + + @discardableResult + func simulateFeedImageViewVisible(at index: Int) -> FeedImageCell? { + return feedImageView(at: index) as? FeedImageCell + } + + @discardableResult + func simulateFeedImageNotViewVisible(at row: Int) -> FeedImageCell? { + let view = simulateFeedImageViewVisible(at: row) + + let delegate = tableView.delegate + let index = IndexPath(row: row, section: feedImageSection) + delegate?.tableView?(tableView, didEndDisplaying: view!, forRowAt: index) + return view + } + + func simulateFeedImageViewNearVisible(at row: Int) { + let ds = tableView.prefetchDataSource + let index = IndexPath(row: row, section: feedImageSection) + ds?.tableView(tableView, prefetchRowsAt: [index]) + } + + func simulateFeedImageViewNotNearVisible(at row: Int) { + simulateFeedImageViewVisible(at: row) + let ds = tableView.prefetchDataSource + let index = IndexPath(row: row, section: feedImageSection) + ds?.tableView?(tableView, cancelPrefetchingForRowsAt: [index]) + } + + var isShowingLoadingIndicator: Bool { + refreshControl?.isRefreshing == true + } + + func numberOfRenderedFeedImageViews() -> Int { + return tableView.numberOfRows(inSection: feedImageSection) + } + + private var feedImageSection: Int { + return 0 + } + + func feedImageView(at row: Int) -> UITableViewCell? { + let ds = tableView.dataSource + let index = IndexPath(row: row, section: feedImageSection) + return ds?.tableView(tableView, cellForRowAt: index) + } + + func simulateAppearance() { + if !isViewLoaded { + loadViewIfNeeded() + replaceRefreshControlWithFake() + } + beginAppearanceTransition(true, animated: false) + endAppearanceTransition() + } + + func replaceRefreshControlWithFake() { + let fake = FakeRefreshControl() + refreshControl?.allTargets.forEach{ target in + refreshControl?.actions(forTarget: target, forControlEvent: .valueChanged)?.forEach { action in + fake.addTarget(target, action: Selector(action), for: .valueChanged) + } + } + + refreshControl = fake + } + + class FakeRefreshControl: UIRefreshControl { + private var _isRefreshing = false + override var isRefreshing: Bool { + _isRefreshing + } + + override func beginRefreshing() { + _isRefreshing = true + } + + override func endRefreshing() { + _isRefreshing = false + } + } +} + +private extension FeedImageCell { + var isShowingLocation: Bool { + return !locationContainer.isHidden + } + + var locationText: String? { + return locationLabel.text + } + + var descriptionText: String? { + return descriptionLabel.text + } + + var isShowingImageLoadingIndicator: Bool { + return feedImageContainer.isShimmering + } + + var renderedImage: Data? { + return feedImageView.image?.pngData() + } + + var isShowingRetryAction: Bool { + feedImageRetryButton.isHidden == false + } + + func simulateRetryAction() { + feedImageRetryButton.simulateTap() + } +} + +private extension UIButton { + func simulateTap() { + allTargets.forEach { target in + actions(forTarget: target, forControlEvent: .touchUpInside)?.forEach { + (target as NSObject).perform(Selector($0)) + } + } + } +} + +private extension UIRefreshControl { + func simulatePullToRefresh() { + allTargets.forEach { target in + actions(forTarget: target, forControlEvent: .valueChanged)?.forEach { + (target as NSObject).perform(Selector($0)) + } + } + } +} + +extension UIImage { + static func make(withColor color: UIColor) -> UIImage { + let rect = CGRect(x: 0, y: 0, width: 1, height: 1) + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + + return UIGraphicsImageRenderer( + size: rect.size, + format: format + ).image { rendererContext in + color.setFill() + rendererContext.fill(rect) + } + } +} diff --git a/EssentialFeed/EssentialFeediOSTests/Helpers/FeedUIIntegrationTests+Localized.swift b/EssentialFeed/EssentialFeediOSTests/Helpers/FeedUIIntegrationTests+Localized.swift new file mode 100644 index 0000000..bf9c87d --- /dev/null +++ b/EssentialFeed/EssentialFeediOSTests/Helpers/FeedUIIntegrationTests+Localized.swift @@ -0,0 +1,21 @@ +// +// FeedViewControllerTests+Localized.swift +// EssentialFeed +// +// Created by Cristian Felipe Patiño Rojas on 15/4/25. +// +import Foundation +import XCTest +import EssentialFeediOS + +extension FeedUIIntegrationTests { + func localized(_ key: String, file: StaticString = #file, line: UInt = #line) -> String { + let table = "Feed" + let bundle = Bundle(for: FeedViewController.self) + let value = bundle.localizedString(forKey: key, value: nil, table: table) + if value == key { + XCTFail("Missing localized string for key: \(key) in table: \(table)", file: file, line: line) + } + return value + } +} diff --git a/Prototype/Prototype.xcodeproj/project.pbxproj b/Prototype/Prototype.xcodeproj/project.pbxproj new file mode 100644 index 0000000..596608d --- /dev/null +++ b/Prototype/Prototype.xcodeproj/project.pbxproj @@ -0,0 +1,338 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileReference section */ + 405418852DA7ADA9000C2721 /* Prototype.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Prototype.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 405418972DA7ADAA000C2721 /* Exceptions for "Prototype" folder in "Prototype" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 405418842DA7ADA9000C2721 /* Prototype */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 405418872DA7ADA9000C2721 /* Prototype */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 405418972DA7ADAA000C2721 /* Exceptions for "Prototype" folder in "Prototype" target */, + ); + path = Prototype; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 405418822DA7ADA9000C2721 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 4054187C2DA7ADA9000C2721 = { + isa = PBXGroup; + children = ( + 405418872DA7ADA9000C2721 /* Prototype */, + 405418862DA7ADA9000C2721 /* Products */, + ); + sourceTree = ""; + }; + 405418862DA7ADA9000C2721 /* Products */ = { + isa = PBXGroup; + children = ( + 405418852DA7ADA9000C2721 /* Prototype.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 405418842DA7ADA9000C2721 /* Prototype */ = { + isa = PBXNativeTarget; + buildConfigurationList = 405418982DA7ADAA000C2721 /* Build configuration list for PBXNativeTarget "Prototype" */; + buildPhases = ( + 405418812DA7ADA9000C2721 /* Sources */, + 405418822DA7ADA9000C2721 /* Frameworks */, + 405418832DA7ADA9000C2721 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 405418872DA7ADA9000C2721 /* Prototype */, + ); + name = Prototype; + packageProductDependencies = ( + ); + productName = Prototype; + productReference = 405418852DA7ADA9000C2721 /* Prototype.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 4054187D2DA7ADA9000C2721 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1620; + LastUpgradeCheck = 1620; + TargetAttributes = { + 405418842DA7ADA9000C2721 = { + CreatedOnToolsVersion = 16.2; + }; + }; + }; + buildConfigurationList = 405418802DA7ADA9000C2721 /* Build configuration list for PBXProject "Prototype" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 4054187C2DA7ADA9000C2721; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 405418862DA7ADA9000C2721 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 405418842DA7ADA9000C2721 /* Prototype */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 405418832DA7ADA9000C2721 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 405418812DA7ADA9000C2721 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 405418992DA7ADAA000C2721 /* 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 = V73WZ9Y4HH; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Prototype/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = me.crisfe.EssentialFeed.Prototype; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 4054189A2DA7ADAA000C2721 /* 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 = V73WZ9Y4HH; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Prototype/Info.plist; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; + INFOPLIST_KEY_UIMainStoryboardFile = Main; + INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = me.crisfe.EssentialFeed.Prototype; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 4054189B2DA7ADAA000C2721 /* 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_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 4054189C2DA7ADAA000C2721 /* 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; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 405418802DA7ADA9000C2721 /* Build configuration list for PBXProject "Prototype" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 4054189B2DA7ADAA000C2721 /* Debug */, + 4054189C2DA7ADAA000C2721 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 405418982DA7ADAA000C2721 /* Build configuration list for PBXNativeTarget "Prototype" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 405418992DA7ADAA000C2721 /* Debug */, + 4054189A2DA7ADAA000C2721 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 4054187D2DA7ADA9000C2721 /* Project object */; +} diff --git a/Prototype/Prototype.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Prototype/Prototype.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Prototype/Prototype.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Prototype/Prototype/AppDelegate.swift b/Prototype/Prototype/AppDelegate.swift new file mode 100644 index 0000000..721294d --- /dev/null +++ b/Prototype/Prototype/AppDelegate.swift @@ -0,0 +1,36 @@ +// +// AppDelegate.swift +// Prototype +// +// Created by Cristian Felipe Patiño Rojas on 10/4/25. +// + +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/Prototype/Prototype/Assets.xcassets/AccentColor.colorset/Contents.json b/Prototype/Prototype/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/Prototype/Prototype/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Contents.json b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..bb127f9 --- /dev/null +++ b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,116 @@ +{ + "images" : [ + { + "filename" : "Icon-40.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "Icon-60.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "20x20" + }, + { + "filename" : "Icon-58.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "Icon-87.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "29x29" + }, + { + "filename" : "Icon-80.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "Icon-120.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "40x40" + }, + { + "filename" : "Icon-121.png", + "idiom" : "iphone", + "scale" : "2x", + "size" : "60x60" + }, + { + "filename" : "Icon-180.png", + "idiom" : "iphone", + "scale" : "3x", + "size" : "60x60" + }, + { + "filename" : "Icon-20.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "20x20" + }, + { + "filename" : "Icon-41.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "20x20" + }, + { + "filename" : "Icon-29.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "29x29" + }, + { + "filename" : "Icon-59.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "29x29" + }, + { + "filename" : "Icon-42.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "40x40" + }, + { + "filename" : "Icon-81.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "40x40" + }, + { + "filename" : "Icon-76.png", + "idiom" : "ipad", + "scale" : "1x", + "size" : "76x76" + }, + { + "filename" : "Icon-152.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "76x76" + }, + { + "filename" : "Icon-167.png", + "idiom" : "ipad", + "scale" : "2x", + "size" : "83.5x83.5" + }, + { + "filename" : "Icon-1024.png", + "idiom" : "ios-marketing", + "scale" : "1x", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-1024.png b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-1024.png new file mode 100644 index 0000000..946eaa9 Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-1024.png differ diff --git a/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-120.png b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-120.png new file mode 100644 index 0000000..6c379e7 Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-120.png differ diff --git a/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-121.png b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-121.png new file mode 100644 index 0000000..6c379e7 Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-121.png differ diff --git a/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-152.png b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-152.png new file mode 100644 index 0000000..b0daa96 Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-152.png differ diff --git a/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-167.png b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-167.png new file mode 100644 index 0000000..2697143 Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-167.png differ diff --git a/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-180.png b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-180.png new file mode 100644 index 0000000..ab1243b Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-180.png differ diff --git a/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-20.png b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-20.png new file mode 100644 index 0000000..6a7215f Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-20.png differ diff --git a/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-29.png b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-29.png new file mode 100644 index 0000000..2df3393 Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-29.png differ diff --git a/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-40.png b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-40.png new file mode 100644 index 0000000..1aa8f61 Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-40.png differ diff --git a/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-41.png b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-41.png new file mode 100644 index 0000000..1aa8f61 Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-41.png differ diff --git a/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-42.png b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-42.png new file mode 100644 index 0000000..1aa8f61 Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-42.png differ diff --git a/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-58.png b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-58.png new file mode 100644 index 0000000..8edda1d Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-58.png differ diff --git a/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-59.png b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-59.png new file mode 100644 index 0000000..8edda1d Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-59.png differ diff --git a/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-60.png b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-60.png new file mode 100644 index 0000000..21516ab Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-60.png differ diff --git a/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-76.png b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-76.png new file mode 100644 index 0000000..184c532 Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-76.png differ diff --git a/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-80.png b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-80.png new file mode 100644 index 0000000..3f2ac1e Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-80.png differ diff --git a/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-81.png b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-81.png new file mode 100644 index 0000000..3f2ac1e Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-81.png differ diff --git a/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-87.png b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-87.png new file mode 100644 index 0000000..e5531a4 Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/AppIcon.appiconset/Icon-87.png differ diff --git a/Prototype/Prototype/Assets.xcassets/Contents.json b/Prototype/Prototype/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Prototype/Prototype/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Prototype/Prototype/Assets.xcassets/image-0.imageset/Contents.json b/Prototype/Prototype/Assets.xcassets/image-0.imageset/Contents.json new file mode 100644 index 0000000..044adfc --- /dev/null +++ b/Prototype/Prototype/Assets.xcassets/image-0.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "image-0.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Prototype/Prototype/Assets.xcassets/image-0.imageset/image-0.jpg b/Prototype/Prototype/Assets.xcassets/image-0.imageset/image-0.jpg new file mode 100644 index 0000000..5430479 Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/image-0.imageset/image-0.jpg differ diff --git a/Prototype/Prototype/Assets.xcassets/image-1.imageset/Contents.json b/Prototype/Prototype/Assets.xcassets/image-1.imageset/Contents.json new file mode 100644 index 0000000..b0b39ec --- /dev/null +++ b/Prototype/Prototype/Assets.xcassets/image-1.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "image-1.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Prototype/Prototype/Assets.xcassets/image-1.imageset/image-1.jpg b/Prototype/Prototype/Assets.xcassets/image-1.imageset/image-1.jpg new file mode 100644 index 0000000..10bef47 Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/image-1.imageset/image-1.jpg differ diff --git a/Prototype/Prototype/Assets.xcassets/image-2.imageset/Contents.json b/Prototype/Prototype/Assets.xcassets/image-2.imageset/Contents.json new file mode 100644 index 0000000..fde9a98 --- /dev/null +++ b/Prototype/Prototype/Assets.xcassets/image-2.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "image-2.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Prototype/Prototype/Assets.xcassets/image-2.imageset/image-2.jpg b/Prototype/Prototype/Assets.xcassets/image-2.imageset/image-2.jpg new file mode 100644 index 0000000..233c8f5 Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/image-2.imageset/image-2.jpg differ diff --git a/Prototype/Prototype/Assets.xcassets/image-3.imageset/Contents.json b/Prototype/Prototype/Assets.xcassets/image-3.imageset/Contents.json new file mode 100644 index 0000000..455757b --- /dev/null +++ b/Prototype/Prototype/Assets.xcassets/image-3.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "image-3.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Prototype/Prototype/Assets.xcassets/image-3.imageset/image-3.jpg b/Prototype/Prototype/Assets.xcassets/image-3.imageset/image-3.jpg new file mode 100644 index 0000000..6c40d62 Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/image-3.imageset/image-3.jpg differ diff --git a/Prototype/Prototype/Assets.xcassets/image-4.imageset/Contents.json b/Prototype/Prototype/Assets.xcassets/image-4.imageset/Contents.json new file mode 100644 index 0000000..049c6c2 --- /dev/null +++ b/Prototype/Prototype/Assets.xcassets/image-4.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "image-4.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Prototype/Prototype/Assets.xcassets/image-4.imageset/image-4.jpg b/Prototype/Prototype/Assets.xcassets/image-4.imageset/image-4.jpg new file mode 100644 index 0000000..fea17fd Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/image-4.imageset/image-4.jpg differ diff --git a/Prototype/Prototype/Assets.xcassets/image-5.imageset/Contents.json b/Prototype/Prototype/Assets.xcassets/image-5.imageset/Contents.json new file mode 100644 index 0000000..6c2a924 --- /dev/null +++ b/Prototype/Prototype/Assets.xcassets/image-5.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "image-5.jpg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Prototype/Prototype/Assets.xcassets/image-5.imageset/image-5.jpg b/Prototype/Prototype/Assets.xcassets/image-5.imageset/image-5.jpg new file mode 100644 index 0000000..685196a Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/image-5.imageset/image-5.jpg differ diff --git a/Prototype/Prototype/Assets.xcassets/pin.imageset/Contents.json b/Prototype/Prototype/Assets.xcassets/pin.imageset/Contents.json new file mode 100644 index 0000000..ab7c630 --- /dev/null +++ b/Prototype/Prototype/Assets.xcassets/pin.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "pin.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "pin@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "pin@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Prototype/Prototype/Assets.xcassets/pin.imageset/pin.png b/Prototype/Prototype/Assets.xcassets/pin.imageset/pin.png new file mode 100644 index 0000000..6acdd61 Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/pin.imageset/pin.png differ diff --git a/Prototype/Prototype/Assets.xcassets/pin.imageset/pin@2x.png b/Prototype/Prototype/Assets.xcassets/pin.imageset/pin@2x.png new file mode 100644 index 0000000..770daef Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/pin.imageset/pin@2x.png differ diff --git a/Prototype/Prototype/Assets.xcassets/pin.imageset/pin@3x.png b/Prototype/Prototype/Assets.xcassets/pin.imageset/pin@3x.png new file mode 100644 index 0000000..b1b2b54 Binary files /dev/null and b/Prototype/Prototype/Assets.xcassets/pin.imageset/pin@3x.png differ diff --git a/Prototype/Prototype/Base.lproj/LaunchScreen.storyboard b/Prototype/Prototype/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..865e932 --- /dev/null +++ b/Prototype/Prototype/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Prototype/Prototype/Base.lproj/Main.storyboard b/Prototype/Prototype/Base.lproj/Main.storyboard new file mode 100644 index 0000000..7432c60 --- /dev/null +++ b/Prototype/Prototype/Base.lproj/Main.storyboard @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Prototype/Prototype/FeedImageCell.swift b/Prototype/Prototype/FeedImageCell.swift new file mode 100644 index 0000000..f8e30ea --- /dev/null +++ b/Prototype/Prototype/FeedImageCell.swift @@ -0,0 +1,79 @@ +// +// FeedImageCell.swift +// Prototype +// +// Created by Cristian Felipe Patiño Rojas on 10/4/25. +// + +import UIKit + +final class FeedImageCell: UITableViewCell { + @IBOutlet private(set) var locationContainer: UIView! + @IBOutlet private(set) var locationLabel: UILabel! + @IBOutlet private(set) var feedImageContainer: UIView! + @IBOutlet private(set) var feedImageView: UIImageView! + @IBOutlet private(set) var descriptionLabel: UILabel! + + override func awakeFromNib() { + super.awakeFromNib() + feedImageView.alpha = 0 + feedImageContainer.startShimmering() + } + + override func prepareForReuse() { + super.prepareForReuse() + + feedImageView.alpha = 0 + feedImageContainer.startShimmering() + } + + func fadeIn(_ image: UIImage?) { + feedImageView.image = image + + UIView.animate( + withDuration: 0.25, + delay: 1.25, + options: [], + animations: { + self.feedImageView.alpha = 1 + }, completion: { completed in + if completed { + self.feedImageContainer.stopShimmering() + } + } + ) + } +} + + +private extension UIView { + private var shimmerAnimationKey: String { + "shimmer" + } + + func startShimmering() { + let white = UIColor.white.cgColor + let alpha = UIColor.white.withAlphaComponent(0.7).cgColor + let width = bounds.width + let height = bounds.height + + let gradient = CAGradientLayer() + gradient.colors = [alpha, white, alpha] + gradient.startPoint = CGPoint(x: 0.0, y: 0.4) + gradient.endPoint = CGPoint(x: 1.0, y: 0.6) + gradient.locations = [0.4, 0.5, 0.6] + gradient.frame = CGRect(x: -width, y: 0, width: width*3, height: height) + layer.mask = gradient + + let animation = CABasicAnimation(keyPath: #keyPath(CAGradientLayer.locations)) + animation.fromValue = [0.0, 0.1, 0.2] + animation.toValue = [0.8, 0.9, 1.0] + animation.duration = 1 + animation.repeatCount = .infinity + gradient.add(animation, forKey: shimmerAnimationKey) + } + + func stopShimmering() { + layer.mask = nil + } +} diff --git a/Prototype/Prototype/FeedImageViewModel+PrototypeData.swift b/Prototype/Prototype/FeedImageViewModel+PrototypeData.swift new file mode 100644 index 0000000..a3bd34f --- /dev/null +++ b/Prototype/Prototype/FeedImageViewModel+PrototypeData.swift @@ -0,0 +1,63 @@ +// +// FeedImageViewModel+PrototypeData.swift +// Prototype +// +// Created by Cristian Felipe Patiño Rojas on 10/4/25. +// + +extension FeedImageViewModel { + static var prototypeFeed: [FeedImageViewModel] { + return [ + FeedImageViewModel( + description: "Golden hour hitting the rooftops of Barcelona — orange light, long shadows, and that calm before the night takes over.", + location: "Barcelona, Spain", + imageName: "image-0" + ), + FeedImageViewModel( + description: "Silence.", + location: "Kyoto\nJapan", + imageName: "image-1" + ), + FeedImageViewModel( + description: nil, + location: "San Francisco, USA", + imageName: "image-2" + ), + FeedImageViewModel( + description: "Layers of mist rolling over mountain ridges, snow barely visible under the soft light of sunrise. The air is cold but the view makes you forget your fingertips.", + location: "Banff National Park\nCanada", + imageName: "image-3" + ), + FeedImageViewModel( + description: "Books scattered across a wooden table. Coffee stains on old notebooks. Background jazz. A Sunday morning where nothing moves fast — and everything is perfect.", + location: "Amsterdam, Netherlands", + imageName: "image-4" + ), + FeedImageViewModel( + description: "Endless sea, salty wind, and the sound of waves crashing like a heartbeat. If you sit quietly enough, the horizon starts to whisper back.", + location: "Santorini\nGreece", + imageName: "image-5" + ), + FeedImageViewModel( + description: nil, + location: nil, + imageName: "image-0" + ), + FeedImageViewModel( + description: "One step, two. Breath fogs in the cold air. Stone streets echo under leather soles. Lisbon wakes slowly.", + location: "Lisbon\nPortugal", + imageName: "image-1" + ), + FeedImageViewModel( + description: "This one is intentionally long to test how far a UILabel can stretch in a self-sizing UITableViewCell without breaking layout or overflowing constraints. The purpose is to simulate real-world scenarios where user-generated content might push the UI to its limits. Think testimonials, posts, or freeform descriptions. If this description wraps to approximately six lines, we’ll know our layout is solid and resilient. If not... well, we’ll see constraints crash and burn in the console logs like fallen soldiers of Interface Builder.", + location: "New York\nUSA", + imageName: "image-2" + ), + FeedImageViewModel( + description: "In the quiet of a snowy morning, a bicycle leaned against a café wall. The scent of roasted coffee sneaked out through the door as locals shuffled in with scarves and sleepy smiles. Somewhere, a dog barked. Somewhere else, someone opened a notebook and began to write.", + location: nil, + imageName: "image-3" + ) + ] + } +} diff --git a/Prototype/Prototype/FeedViewController.swift b/Prototype/Prototype/FeedViewController.swift new file mode 100644 index 0000000..334627e --- /dev/null +++ b/Prototype/Prototype/FeedViewController.swift @@ -0,0 +1,60 @@ +// +// FeedViewController.swift +// Prototype +// +// Created by Cristian Felipe Patiño Rojas on 10/4/25. +// + +import UIKit + +struct FeedImageViewModel { + let description: String? + let location: String? + let imageName: String +} + +final class FeedViewController: UITableViewController { + private var feed = [FeedImageViewModel]() + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + + refresh() + tableView.setContentOffset(CGPoint(x: 0, y: -tableView.adjustedContentInset.top), animated: false) + } + + @IBAction func refresh() { + refreshControl?.beginRefreshing() + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + if self.feed.isEmpty { + self.feed = FeedImageViewModel.prototypeFeed + self.tableView.reloadData() + } + self.refreshControl?.endRefreshing() + } + } + + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return feed.count + } + + override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "FeedImageCell") as! FeedImageCell + let model = feed[indexPath.row] + cell.configure(with: model) + return cell + } +} + +extension FeedImageCell { + func configure(with model: FeedImageViewModel) { + locationLabel.text = model.location + locationContainer.isHidden = model.location == nil + + descriptionLabel.text = model.description + descriptionLabel.isHidden = model.description == nil + + fadeIn(UIImage(named: model.imageName)) + } +} diff --git a/Prototype/Prototype/Info.plist b/Prototype/Prototype/Info.plist new file mode 100644 index 0000000..dd3c9af --- /dev/null +++ b/Prototype/Prototype/Info.plist @@ -0,0 +1,25 @@ + + + + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + + diff --git a/Prototype/Prototype/SceneDelegate.swift b/Prototype/Prototype/SceneDelegate.swift new file mode 100644 index 0000000..ab7f525 --- /dev/null +++ b/Prototype/Prototype/SceneDelegate.swift @@ -0,0 +1,52 @@ +// +// SceneDelegate.swift +// Prototype +// +// Created by Cristian Felipe Patiño Rojas on 10/4/25. +// + +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. + } + + +} +