diff --git a/CountriesSwiftUI.xcodeproj/project.pbxproj b/CountriesSwiftUI.xcodeproj/project.pbxproj index de4e770..98ef3bb 100644 --- a/CountriesSwiftUI.xcodeproj/project.pbxproj +++ b/CountriesSwiftUI.xcodeproj/project.pbxproj @@ -3,601 +3,185 @@ archiveVersion = 1; classes = { }; - objectVersion = 52; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ - 52333A932654463B0034072B /* UIOpenURLContext_Init.m in Sources */ = {isa = PBXBuildFile; fileRef = 52333A922654463B0034072B /* UIOpenURLContext_Init.m */; }; - F60829712369CE0100DB292E /* RequestMocking.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60829702369CE0100DB292E /* RequestMocking.swift */; }; - F60829732369CE5300DB292E /* MockedResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60829722369CE5300DB292E /* MockedResponse.swift */; }; - F60829762369D58A00DB292E /* CountriesWebRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60829752369D58A00DB292E /* CountriesWebRepositoryTests.swift */; }; - F60829782369DCD200DB292E /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60829772369DCD200DB292E /* TestHelpers.swift */; }; - F60B5E3D2438DA47009BCBB3 /* CancelBag.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60B5E3C2438DA47009BCBB3 /* CancelBag.swift */; }; - F60B5E3F2438DA57009BCBB3 /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60B5E3E2438DA57009BCBB3 /* Store.swift */; }; - F60B5E412438DAF6009BCBB3 /* NetworkingHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60B5E402438DAF6009BCBB3 /* NetworkingHelpers.swift */; }; - F60CC503236C3CA8007E84B2 /* CountriesListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60CC502236C3CA8007E84B2 /* CountriesListTests.swift */; }; - F60CC507236C5CFA007E84B2 /* CountryDetailsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60CC506236C5CFA007E84B2 /* CountryDetailsTests.swift */; }; - F60CC509236C6084007E84B2 /* ViewPreviewsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60CC508236C6084007E84B2 /* ViewPreviewsTests.swift */; }; - F60CC50B236C622C007E84B2 /* ModalDetailsViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60CC50A236C622C007E84B2 /* ModalDetailsViewTests.swift */; }; - F6218DB22362FD3100917938 /* CountryCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6218DB12362FD3100917938 /* CountryCell.swift */; }; - F6218DB42363051D00917938 /* CountryDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6218DB32363051D00917938 /* CountryDetails.swift */; }; - F6218DB623634D4100917938 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6218DB523634D4100917938 /* ErrorView.swift */; }; - F6238A91244337D400DD30EA /* CoreDataHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6238A90244337D400DD30EA /* CoreDataHelpers.swift */; }; - F6238A952444627D00DD30EA /* CountriesDBRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6238A942444627D00DD30EA /* CountriesDBRepository.swift */; }; - F6238A9624449DC200DD30EA /* db_model_v1.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = F633BA5024432ACE00402237 /* db_model_v1.xcdatamodeld */; }; - F62822B4236478DE00823BA1 /* ModalDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F62822B3236478DE00823BA1 /* ModalDetailsView.swift */; }; - F64495E52360D66400C9BB1F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F64495E42360D66400C9BB1F /* AppDelegate.swift */; }; - F64495E72360D66400C9BB1F /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F64495E62360D66400C9BB1F /* SceneDelegate.swift */; }; - F64495E92360D66400C9BB1F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F64495E82360D66400C9BB1F /* ContentView.swift */; }; - F64495EB2360D66700C9BB1F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F64495EA2360D66700C9BB1F /* Assets.xcassets */; }; - F64495EE2360D66700C9BB1F /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F64495ED2360D66700C9BB1F /* Preview Assets.xcassets */; }; - F64495F12360D66700C9BB1F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F64495EF2360D66700C9BB1F /* LaunchScreen.storyboard */; }; - F64495FD2360D81F00C9BB1F /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F64495FC2360D81F00C9BB1F /* AppState.swift */; }; - F64496002360D8B000C9BB1F /* CountriesInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F64495FF2360D8B000C9BB1F /* CountriesInteractor.swift */; }; - F64496032360D8EB00C9BB1F /* Loadable.swift in Sources */ = {isa = PBXBuildFile; fileRef = F64496022360D8EB00C9BB1F /* Loadable.swift */; }; - F64496082360DFB700C9BB1F /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = F64496072360DFB700C9BB1F /* Models.swift */; }; - F644960C2360EAD000C9BB1F /* APICall.swift in Sources */ = {isa = PBXBuildFile; fileRef = F644960B2360EAD000C9BB1F /* APICall.swift */; }; - F644960E2360EF6C00C9BB1F /* WebRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = F644960D2360EF6C00C9BB1F /* WebRepository.swift */; }; - F64496102361B64700C9BB1F /* CountriesList.swift in Sources */ = {isa = PBXBuildFile; fileRef = F644960F2361B64700C9BB1F /* CountriesList.swift */; }; - F64496122361BB4900C9BB1F /* InteractorsContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F64496112361BB4900C9BB1F /* InteractorsContainer.swift */; }; - F6500B4A23C8F2AC0086FD70 /* DeepLinkUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6500B4923C8F2AC0086FD70 /* DeepLinkUITests.swift */; }; - F661F2B6237734FA0014E142 /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = F661F2B5237734FA0014E142 /* AppEnvironment.swift */; }; - F661F2B8237738CE0014E142 /* RootViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F661F2B7237738CE0014E142 /* RootViewModifier.swift */; }; - F661F2BC237757040014E142 /* ImageWebRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F661F2BB237757040014E142 /* ImageWebRepositoryTests.swift */; }; - F661F2C5237772CE0014E142 /* ImagesInteractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F661F2C4237772CE0014E142 /* ImagesInteractorTests.swift */; }; - F661F2CA23777D440014E142 /* ImageViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F661F2C923777D440014E142 /* ImageViewTests.swift */; }; - F661F2CC23783E360014E142 /* Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = F661F2CB23783E360014E142 /* Helpers.swift */; }; - F6621FE9244B3DE100DC583F /* MockedDBRepositories.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6621FE7244B324200DC583F /* MockedDBRepositories.swift */; }; - F66EDB6823F1599F00A01B9F /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = F66EDB6A23F1599F00A01B9F /* Localizable.strings */; }; - F67451F5243A4CC200A4B498 /* RootViewAppearanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F67451F4243A4CC200A4B498 /* RootViewAppearanceTests.swift */; }; - F674964A244B059A00E1D76B /* LazyList.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6749649244B059A00E1D76B /* LazyList.swift */; }; - F67B3B1F244B667B00DA7FA6 /* LazyListTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F67B3B1E244B667B00DA7FA6 /* LazyListTests.swift */; }; - F67B3B22244C5B8700DA7FA6 /* CoreDataStackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F67B3B21244C5B8700DA7FA6 /* CoreDataStackTests.swift */; }; - F67D1272244C7AA2006A8CC4 /* CountriesDBRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F67D1271244C7AA2006A8CC4 /* CountriesDBRepositoryTests.swift */; }; - F67D1274244CA019006A8CC4 /* MockedPersistentStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F67D1273244CA019006A8CC4 /* MockedPersistentStore.swift */; }; - F67DBD5223663BCD00C83258 /* SystemEventsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F67DBD5123663BCD00C83258 /* SystemEventsHandler.swift */; }; - F67DBD542366E2E200C83258 /* DependencyInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = F67DBD532366E2E200C83258 /* DependencyInjector.swift */; }; - F67DBD652368875A00C83258 /* CountriesWebRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = F67DBD642368875A00C83258 /* CountriesWebRepository.swift */; }; - F67F62B324430AE800A6ED11 /* CoreDataStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = F67F62B224430AE800A6ED11 /* CoreDataStack.swift */; }; - F67F62B52443276000A6ED11 /* Models+CoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F67F62B42443276000A6ED11 /* Models+CoreData.swift */; }; - F6863E462364FCC800E9B227 /* MockedData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6863E452364FCC800E9B227 /* MockedData.swift */; }; - F68B530523743D8600D6337C /* MockedInteractors.swift in Sources */ = {isa = PBXBuildFile; fileRef = F68B530423743D8600D6337C /* MockedInteractors.swift */; }; - F68B5308237441AD00D6337C /* Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = F68B5307237441AD00D6337C /* Mock.swift */; }; - F68B530A2376EE8C00D6337C /* ImageWebRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = F68B53092376EE8C00D6337C /* ImageWebRepository.swift */; }; - F68B530C23771A9400D6337C /* ImagesInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F68B530B23771A9400D6337C /* ImagesInteractor.swift */; }; - F695DF8624559F8D00A65A04 /* PushNotificationsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F695DF8524559F8D00A65A04 /* PushNotificationsHandler.swift */; }; - F695DF892455AD9300A65A04 /* DeepLinksHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F695DF872455A28200A65A04 /* DeepLinksHandler.swift */; }; - F695DF8B2455B04100A65A04 /* UserPermissionsInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = F695DF8A2455B04100A65A04 /* UserPermissionsInteractor.swift */; }; - F695DF8D2455D36900A65A04 /* PushTokenWebRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = F695DF8C2455D36900A65A04 /* PushTokenWebRepository.swift */; }; - F695DF8F2455DEDE00A65A04 /* MockedSystemEventsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = F695DF8E2455DEDE00A65A04 /* MockedSystemEventsHandler.swift */; }; - F695DF932455F09100A65A04 /* AppDelegateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F695DF922455F09100A65A04 /* AppDelegateTests.swift */; }; - F695DF952455F2BB00A65A04 /* SceneDelegateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F695DF942455F2BB00A65A04 /* SceneDelegateTests.swift */; }; - F695DF972455F4CB00A65A04 /* PushNotificationsHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F695DF962455F4CB00A65A04 /* PushNotificationsHandlerTests.swift */; }; - F695DF992456111300A65A04 /* DeepLinksHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F695DF982456111300A65A04 /* DeepLinksHandlerTests.swift */; }; - F6B6212423B52AE600CD00C7 /* ViewInspector in Frameworks */ = {isa = PBXBuildFile; productRef = F6B6212323B52AE600CD00C7 /* ViewInspector */; }; - F6B8832124561C5E00EA4067 /* PushTokenWebRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6B8832024561C5E00EA4067 /* PushTokenWebRepositoryTests.swift */; }; - F6B8832324561DDB00EA4067 /* UserPermissionsInteractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6B8832224561DDB00EA4067 /* UserPermissionsInteractorTests.swift */; }; - F6B883252456326D00EA4067 /* HelpersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6B883242456326D00EA4067 /* HelpersTests.swift */; }; - F6BDB91623636865003E69F2 /* DetailRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6BDB91523636865003E69F2 /* DetailRow.swift */; }; - F6BDB91823637C5F003E69F2 /* ActivityIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6BDB91723637C5F003E69F2 /* ActivityIndicatorView.swift */; }; - F6BDB91A236382E7003E69F2 /* ImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6BDB919236382E7003E69F2 /* ImageView.swift */; }; - F6C1F9D723CE3034005A98C8 /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6C1F9D623CE3034005A98C8 /* SearchBar.swift */; }; - F6E7ACE223F5D1EC00AB48AB /* EnvironmentOverrides in Frameworks */ = {isa = PBXBuildFile; productRef = F6E7ACE123F5D1EC00AB48AB /* EnvironmentOverrides */; }; - F6E7ACE523F83BEB00AB48AB /* ContentViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6E7ACE423F83BEB00AB48AB /* ContentViewTests.swift */; }; - F6F003AF236A290E00AAC7C6 /* WebRepositoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6F003AE236A290E00AAC7C6 /* WebRepositoryTests.swift */; }; - F6F003B1236AF31400AAC7C6 /* SystemEventsHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6F003B0236AF31400AAC7C6 /* SystemEventsHandlerTests.swift */; }; - F6F003B3236AF9F100AAC7C6 /* LoadableTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6F003B2236AF9F100AAC7C6 /* LoadableTests.swift */; }; - F6F003B6236B036800AAC7C6 /* CountriesInteractorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6F003B5236B036800AAC7C6 /* CountriesInteractorTests.swift */; }; - F6F003B8236B04B400AAC7C6 /* MockedWebRepositories.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6F003B7236B04B400AAC7C6 /* MockedWebRepositories.swift */; }; - F6F606A823CF25EC00F36F5D /* SearchBarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6F606A723CF25EC00F36F5D /* SearchBarTests.swift */; }; + 4819F10E2CDF5819003CA0AE /* EnvironmentOverrides in Frameworks */ = {isa = PBXBuildFile; productRef = 4819F10D2CDF5819003CA0AE /* EnvironmentOverrides */; }; + 4819F1172CDF6DD4003CA0AE /* ViewInspector in Frameworks */ = {isa = PBXBuildFile; productRef = 4819F1162CDF6DD4003CA0AE /* ViewInspector */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - F67833D72369CCBD0065272F /* PBXContainerItemProxy */ = { + 4887342A2CDCA2DD00B400A3 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; - containerPortal = F64495D92360D66400C9BB1F /* Project object */; + containerPortal = 4887340F2CDCA2DB00B400A3 /* Project object */; proxyType = 1; - remoteGlobalIDString = F64495E02360D66400C9BB1F; + remoteGlobalIDString = 488734162CDCA2DB00B400A3; remoteInfo = CountriesSwiftUI; }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 52333A90265445F30034072B /* UnitTests-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UnitTests-Bridging-Header.h"; sourceTree = ""; }; - 52333A91265445F30034072B /* UIOpenURLContext_Init.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UIOpenURLContext_Init.h; sourceTree = ""; }; - 52333A922654463B0034072B /* UIOpenURLContext_Init.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UIOpenURLContext_Init.m; sourceTree = ""; }; - F60829702369CE0100DB292E /* RequestMocking.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RequestMocking.swift; sourceTree = ""; }; - F60829722369CE5300DB292E /* MockedResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockedResponse.swift; sourceTree = ""; }; - F60829752369D58A00DB292E /* CountriesWebRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountriesWebRepositoryTests.swift; sourceTree = ""; }; - F60829772369DCD200DB292E /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = ""; }; - F60B5E3C2438DA47009BCBB3 /* CancelBag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CancelBag.swift; sourceTree = ""; }; - F60B5E3E2438DA57009BCBB3 /* Store.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = ""; }; - F60B5E402438DAF6009BCBB3 /* NetworkingHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkingHelpers.swift; sourceTree = ""; }; - F60CC502236C3CA8007E84B2 /* CountriesListTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountriesListTests.swift; sourceTree = ""; }; - F60CC506236C5CFA007E84B2 /* CountryDetailsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountryDetailsTests.swift; sourceTree = ""; }; - F60CC508236C6084007E84B2 /* ViewPreviewsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewPreviewsTests.swift; sourceTree = ""; }; - F60CC50A236C622C007E84B2 /* ModalDetailsViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalDetailsViewTests.swift; sourceTree = ""; }; - F6218DB12362FD3100917938 /* CountryCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountryCell.swift; sourceTree = ""; }; - F6218DB32363051D00917938 /* CountryDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountryDetails.swift; sourceTree = ""; }; - F6218DB523634D4100917938 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; - F6238A90244337D400DD30EA /* CoreDataHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataHelpers.swift; sourceTree = ""; }; - F6238A942444627D00DD30EA /* CountriesDBRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountriesDBRepository.swift; sourceTree = ""; }; - F62822B3236478DE00823BA1 /* ModalDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModalDetailsView.swift; sourceTree = ""; }; - F633BA5124432ACE00402237 /* db_model_v1.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = db_model_v1.xcdatamodel; sourceTree = ""; }; - F64495E12360D66400C9BB1F /* CountriesSwiftUI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CountriesSwiftUI.app; sourceTree = BUILT_PRODUCTS_DIR; }; - F64495E42360D66400C9BB1F /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - F64495E62360D66400C9BB1F /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - F64495E82360D66400C9BB1F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; - F64495EA2360D66700C9BB1F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - F64495ED2360D66700C9BB1F /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; - F64495F22360D66700C9BB1F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - F64495FC2360D81F00C9BB1F /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; - F64495FF2360D8B000C9BB1F /* CountriesInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountriesInteractor.swift; sourceTree = ""; }; - F64496022360D8EB00C9BB1F /* Loadable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Loadable.swift; sourceTree = ""; }; - F64496072360DFB700C9BB1F /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; - F644960B2360EAD000C9BB1F /* APICall.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APICall.swift; sourceTree = ""; }; - F644960D2360EF6C00C9BB1F /* WebRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRepository.swift; sourceTree = ""; }; - F644960F2361B64700C9BB1F /* CountriesList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountriesList.swift; sourceTree = ""; }; - F64496112361BB4900C9BB1F /* InteractorsContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractorsContainer.swift; sourceTree = ""; }; - F6500B4923C8F2AC0086FD70 /* DeepLinkUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkUITests.swift; sourceTree = ""; }; - F661F2B5237734FA0014E142 /* AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvironment.swift; sourceTree = ""; }; - F661F2B7237738CE0014E142 /* RootViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewModifier.swift; sourceTree = ""; }; - F661F2BB237757040014E142 /* ImageWebRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageWebRepositoryTests.swift; sourceTree = ""; }; - F661F2C4237772CE0014E142 /* ImagesInteractorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesInteractorTests.swift; sourceTree = ""; }; - F661F2C923777D440014E142 /* ImageViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewTests.swift; sourceTree = ""; }; - F661F2CB23783E360014E142 /* Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Helpers.swift; sourceTree = ""; }; - F6621FE7244B324200DC583F /* MockedDBRepositories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockedDBRepositories.swift; sourceTree = ""; }; - F66EDB6723F1599800A01B9F /* en */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = en; path = en.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - F66EDB6923F1599F00A01B9F /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; - F66EDB6C23F168FF00A01B9F /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; - F66EDB6D23F1691400A01B9F /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; - F66EDB6E23F1692000A01B9F /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; - F67451F4243A4CC200A4B498 /* RootViewAppearanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootViewAppearanceTests.swift; sourceTree = ""; }; - F6749649244B059A00E1D76B /* LazyList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyList.swift; sourceTree = ""; }; - F67833D22369CCBD0065272F /* UnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - F67833D62369CCBD0065272F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - F67B3B1E244B667B00DA7FA6 /* LazyListTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyListTests.swift; sourceTree = ""; }; - F67B3B21244C5B8700DA7FA6 /* CoreDataStackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStackTests.swift; sourceTree = ""; }; - F67D1271244C7AA2006A8CC4 /* CountriesDBRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountriesDBRepositoryTests.swift; sourceTree = ""; }; - F67D1273244CA019006A8CC4 /* MockedPersistentStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockedPersistentStore.swift; sourceTree = ""; }; - F67DBD5123663BCD00C83258 /* SystemEventsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemEventsHandler.swift; sourceTree = ""; }; - F67DBD532366E2E200C83258 /* DependencyInjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependencyInjector.swift; sourceTree = ""; }; - F67DBD642368875A00C83258 /* CountriesWebRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountriesWebRepository.swift; sourceTree = ""; }; - F67F62B224430AE800A6ED11 /* CoreDataStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataStack.swift; sourceTree = ""; }; - F67F62B42443276000A6ED11 /* Models+CoreData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Models+CoreData.swift"; sourceTree = ""; }; - F6863E452364FCC800E9B227 /* MockedData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockedData.swift; sourceTree = ""; }; - F6863E4723650E7900E9B227 /* CountriesSwiftUI.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CountriesSwiftUI.entitlements; sourceTree = ""; }; - F68B530423743D8600D6337C /* MockedInteractors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockedInteractors.swift; sourceTree = ""; }; - F68B5307237441AD00D6337C /* Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Mock.swift; sourceTree = ""; }; - F68B53092376EE8C00D6337C /* ImageWebRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageWebRepository.swift; sourceTree = ""; }; - F68B530B23771A9400D6337C /* ImagesInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagesInteractor.swift; sourceTree = ""; }; - F695DF8524559F8D00A65A04 /* PushNotificationsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationsHandler.swift; sourceTree = ""; }; - F695DF872455A28200A65A04 /* DeepLinksHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinksHandler.swift; sourceTree = ""; }; - F695DF8A2455B04100A65A04 /* UserPermissionsInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPermissionsInteractor.swift; sourceTree = ""; }; - F695DF8C2455D36900A65A04 /* PushTokenWebRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushTokenWebRepository.swift; sourceTree = ""; }; - F695DF8E2455DEDE00A65A04 /* MockedSystemEventsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockedSystemEventsHandler.swift; sourceTree = ""; }; - F695DF922455F09100A65A04 /* AppDelegateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegateTests.swift; sourceTree = ""; }; - F695DF942455F2BB00A65A04 /* SceneDelegateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegateTests.swift; sourceTree = ""; }; - F695DF962455F4CB00A65A04 /* PushNotificationsHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationsHandlerTests.swift; sourceTree = ""; }; - F695DF982456111300A65A04 /* DeepLinksHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinksHandlerTests.swift; sourceTree = ""; }; - F6B8832024561C5E00EA4067 /* PushTokenWebRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushTokenWebRepositoryTests.swift; sourceTree = ""; }; - F6B8832224561DDB00EA4067 /* UserPermissionsInteractorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserPermissionsInteractorTests.swift; sourceTree = ""; }; - F6B883242456326D00EA4067 /* HelpersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpersTests.swift; sourceTree = ""; }; - F6BDB91523636865003E69F2 /* DetailRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailRow.swift; sourceTree = ""; }; - F6BDB91723637C5F003E69F2 /* ActivityIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityIndicatorView.swift; sourceTree = ""; }; - F6BDB919236382E7003E69F2 /* ImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageView.swift; sourceTree = ""; }; - F6C1F9D623CE3034005A98C8 /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBar.swift; sourceTree = ""; }; - F6E7ACE423F83BEB00AB48AB /* ContentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentViewTests.swift; sourceTree = ""; }; - F6F003AE236A290E00AAC7C6 /* WebRepositoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebRepositoryTests.swift; sourceTree = ""; }; - F6F003B0236AF31400AAC7C6 /* SystemEventsHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemEventsHandlerTests.swift; sourceTree = ""; }; - F6F003B2236AF9F100AAC7C6 /* LoadableTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableTests.swift; sourceTree = ""; }; - F6F003B5236B036800AAC7C6 /* CountriesInteractorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountriesInteractorTests.swift; sourceTree = ""; }; - F6F003B7236B04B400AAC7C6 /* MockedWebRepositories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockedWebRepositories.swift; sourceTree = ""; }; - F6F606A723CF25EC00F36F5D /* SearchBarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBarTests.swift; sourceTree = ""; }; + 488734172CDCA2DB00B400A3 /* CountriesSwiftUI.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CountriesSwiftUI.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 488734292CDCA2DD00B400A3 /* UnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = UnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 488734192CDCA2DB00B400A3 /* CountriesSwiftUI */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = CountriesSwiftUI; + sourceTree = ""; + }; + 4887342C2CDCA2DD00B400A3 /* UnitTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = UnitTests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ - F64495DE2360D66400C9BB1F /* Frameworks */ = { + 488734142CDCA2DB00B400A3 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - F6E7ACE223F5D1EC00AB48AB /* EnvironmentOverrides in Frameworks */, + 4819F10E2CDF5819003CA0AE /* EnvironmentOverrides in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - F67833CF2369CCBD0065272F /* Frameworks */ = { + 488734262CDCA2DD00B400A3 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - F6B6212423B52AE600CD00C7 /* ViewInspector in Frameworks */, + 4819F1172CDF6DD4003CA0AE /* ViewInspector in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - F60829742369D55700DB292E /* Repositories */ = { + 4887340E2CDCA2DB00B400A3 = { isa = PBXGroup; children = ( - F6F003AE236A290E00AAC7C6 /* WebRepositoryTests.swift */, - F661F2BB237757040014E142 /* ImageWebRepositoryTests.swift */, - F60829752369D58A00DB292E /* CountriesWebRepositoryTests.swift */, - F6B8832024561C5E00EA4067 /* PushTokenWebRepositoryTests.swift */, - F67D1271244C7AA2006A8CC4 /* CountriesDBRepositoryTests.swift */, + 488734192CDCA2DB00B400A3 /* CountriesSwiftUI */, + 4887342C2CDCA2DD00B400A3 /* UnitTests */, + 488734182CDCA2DB00B400A3 /* Products */, ); - path = Repositories; sourceTree = ""; }; - F60CC501236C3C80007E84B2 /* UI */ = { + 488734182CDCA2DB00B400A3 /* Products */ = { isa = PBXGroup; children = ( - F6E7ACE423F83BEB00AB48AB /* ContentViewTests.swift */, - F60CC502236C3CA8007E84B2 /* CountriesListTests.swift */, - F60CC506236C5CFA007E84B2 /* CountryDetailsTests.swift */, - F6500B4923C8F2AC0086FD70 /* DeepLinkUITests.swift */, - F60CC50A236C622C007E84B2 /* ModalDetailsViewTests.swift */, - F67451F4243A4CC200A4B498 /* RootViewAppearanceTests.swift */, - F661F2C923777D440014E142 /* ImageViewTests.swift */, - F6F606A723CF25EC00F36F5D /* SearchBarTests.swift */, - F60CC508236C6084007E84B2 /* ViewPreviewsTests.swift */, - ); - path = UI; - sourceTree = ""; - }; - F60CC50E236C8963007E84B2 /* Resources */ = { - isa = PBXGroup; - children = ( - F67833D62369CCBD0065272F /* Info.plist */, - ); - path = Resources; - sourceTree = ""; - }; - F62822B523647F8300823BA1 /* Screens */ = { - isa = PBXGroup; - children = ( - F64495E82360D66400C9BB1F /* ContentView.swift */, - F644960F2361B64700C9BB1F /* CountriesList.swift */, - F6218DB32363051D00917938 /* CountryDetails.swift */, - F62822B3236478DE00823BA1 /* ModalDetailsView.swift */, - ); - path = Screens; - sourceTree = ""; - }; - F62822B623647FA700823BA1 /* Components */ = { - isa = PBXGroup; - children = ( - F6218DB12362FD3100917938 /* CountryCell.swift */, - F6BDB91523636865003E69F2 /* DetailRow.swift */, - F6218DB523634D4100917938 /* ErrorView.swift */, - F6BDB91723637C5F003E69F2 /* ActivityIndicatorView.swift */, - F6BDB919236382E7003E69F2 /* ImageView.swift */, - F6C1F9D623CE3034005A98C8 /* SearchBar.swift */, - ); - path = Components; - sourceTree = ""; - }; - F64495D82360D66400C9BB1F = { - isa = PBXGroup; - children = ( - F64495E32360D66400C9BB1F /* CountriesSwiftUI */, - F67833D32369CCBD0065272F /* UnitTests */, - F64495E22360D66400C9BB1F /* Products */, - ); - sourceTree = ""; - }; - F64495E22360D66400C9BB1F /* Products */ = { - isa = PBXGroup; - children = ( - F64495E12360D66400C9BB1F /* CountriesSwiftUI.app */, - F67833D22369CCBD0065272F /* UnitTests.xctest */, + 488734172CDCA2DB00B400A3 /* CountriesSwiftUI.app */, + 488734292CDCA2DD00B400A3 /* UnitTests.xctest */, ); name = Products; sourceTree = ""; }; - F64495E32360D66400C9BB1F /* CountriesSwiftUI */ = { - isa = PBXGroup; - children = ( - F67F62AE24422EE900A6ED11 /* Persistence */, - F64495F82360D67A00C9BB1F /* System */, - F64496012360D8D800C9BB1F /* Utilities */, - F64495FB2360D80B00C9BB1F /* Injected */, - F67DBD632368873500C83258 /* Repositories */, - F64495FE2360D88D00C9BB1F /* Interactors */, - F64496062360DFAD00C9BB1F /* Models */, - F64495F92360D68D00C9BB1F /* UI */, - F64495FA2360D69600C9BB1F /* Resources */, - ); - path = CountriesSwiftUI; - sourceTree = ""; - }; - F64495F82360D67A00C9BB1F /* System */ = { - isa = PBXGroup; - children = ( - F64495E42360D66400C9BB1F /* AppDelegate.swift */, - F64495E62360D66400C9BB1F /* SceneDelegate.swift */, - F661F2B5237734FA0014E142 /* AppEnvironment.swift */, - F695DF872455A28200A65A04 /* DeepLinksHandler.swift */, - F695DF8524559F8D00A65A04 /* PushNotificationsHandler.swift */, - F67DBD5123663BCD00C83258 /* SystemEventsHandler.swift */, - ); - path = System; - sourceTree = ""; - }; - F64495F92360D68D00C9BB1F /* UI */ = { - isa = PBXGroup; - children = ( - F661F2B7237738CE0014E142 /* RootViewModifier.swift */, - F62822B523647F8300823BA1 /* Screens */, - F62822B623647FA700823BA1 /* Components */, - ); - path = UI; - sourceTree = ""; - }; - F64495FA2360D69600C9BB1F /* Resources */ = { - isa = PBXGroup; - children = ( - F64495EA2360D66700C9BB1F /* Assets.xcassets */, - F64495ED2360D66700C9BB1F /* Preview Assets.xcassets */, - F64495EF2360D66700C9BB1F /* LaunchScreen.storyboard */, - F64495F22360D66700C9BB1F /* Info.plist */, - F6863E4723650E7900E9B227 /* CountriesSwiftUI.entitlements */, - F66EDB6A23F1599F00A01B9F /* Localizable.strings */, - ); - path = Resources; - sourceTree = ""; - }; - F64495FB2360D80B00C9BB1F /* Injected */ = { - isa = PBXGroup; - children = ( - F64495FC2360D81F00C9BB1F /* AppState.swift */, - F64496112361BB4900C9BB1F /* InteractorsContainer.swift */, - F67DBD532366E2E200C83258 /* DependencyInjector.swift */, - ); - path = Injected; - sourceTree = ""; - }; - F64495FE2360D88D00C9BB1F /* Interactors */ = { - isa = PBXGroup; - children = ( - F64495FF2360D8B000C9BB1F /* CountriesInteractor.swift */, - F68B530B23771A9400D6337C /* ImagesInteractor.swift */, - F695DF8A2455B04100A65A04 /* UserPermissionsInteractor.swift */, - ); - path = Interactors; - sourceTree = ""; - }; - F64496012360D8D800C9BB1F /* Utilities */ = { - isa = PBXGroup; - children = ( - F60B5E3E2438DA57009BCBB3 /* Store.swift */, - F60B5E3C2438DA47009BCBB3 /* CancelBag.swift */, - F64496022360D8EB00C9BB1F /* Loadable.swift */, - F6749649244B059A00E1D76B /* LazyList.swift */, - F644960B2360EAD000C9BB1F /* APICall.swift */, - F644960D2360EF6C00C9BB1F /* WebRepository.swift */, - F60B5E402438DAF6009BCBB3 /* NetworkingHelpers.swift */, - F661F2CB23783E360014E142 /* Helpers.swift */, - ); - path = Utilities; - sourceTree = ""; - }; - F64496062360DFAD00C9BB1F /* Models */ = { - isa = PBXGroup; - children = ( - F64496072360DFB700C9BB1F /* Models.swift */, - F67F62B42443276000A6ED11 /* Models+CoreData.swift */, - F6863E452364FCC800E9B227 /* MockedData.swift */, - ); - path = Models; - sourceTree = ""; - }; - F67833D32369CCBD0065272F /* UnitTests */ = { - isa = PBXGroup; - children = ( - F695DF912455EF1400A65A04 /* Persistence */, - F695DF902455EEE100A65A04 /* System */, - F67B3B20244C5AF500DA7FA6 /* Utilities */, - F60829742369D55700DB292E /* Repositories */, - F6F003B4236B034A00AAC7C6 /* Interactors */, - F60CC501236C3C80007E84B2 /* UI */, - F67833DE2369CD020065272F /* Mocks */, - F68B53062374417200D6337C /* NetworkMocking */, - F60CC50E236C8963007E84B2 /* Resources */, - F60829772369DCD200DB292E /* TestHelpers.swift */, - ); - path = UnitTests; - sourceTree = ""; - }; - F67833DE2369CD020065272F /* Mocks */ = { - isa = PBXGroup; - children = ( - F68B5307237441AD00D6337C /* Mock.swift */, - F68B530423743D8600D6337C /* MockedInteractors.swift */, - F6F003B7236B04B400AAC7C6 /* MockedWebRepositories.swift */, - F6621FE7244B324200DC583F /* MockedDBRepositories.swift */, - F67D1273244CA019006A8CC4 /* MockedPersistentStore.swift */, - F695DF8E2455DEDE00A65A04 /* MockedSystemEventsHandler.swift */, - ); - path = Mocks; - sourceTree = ""; - }; - F67B3B20244C5AF500DA7FA6 /* Utilities */ = { - isa = PBXGroup; - children = ( - F6F003B2236AF9F100AAC7C6 /* LoadableTests.swift */, - F67B3B1E244B667B00DA7FA6 /* LazyListTests.swift */, - F6B883242456326D00EA4067 /* HelpersTests.swift */, - ); - path = Utilities; - sourceTree = ""; - }; - F67DBD632368873500C83258 /* Repositories */ = { - isa = PBXGroup; - children = ( - F6238A942444627D00DD30EA /* CountriesDBRepository.swift */, - F67DBD642368875A00C83258 /* CountriesWebRepository.swift */, - F68B53092376EE8C00D6337C /* ImageWebRepository.swift */, - F695DF8C2455D36900A65A04 /* PushTokenWebRepository.swift */, - ); - path = Repositories; - sourceTree = ""; - }; - F67F62AE24422EE900A6ED11 /* Persistence */ = { - isa = PBXGroup; - children = ( - F67F62B224430AE800A6ED11 /* CoreDataStack.swift */, - F6238A90244337D400DD30EA /* CoreDataHelpers.swift */, - F633BA5024432ACE00402237 /* db_model_v1.xcdatamodeld */, - ); - path = Persistence; - sourceTree = ""; - }; - F68B53062374417200D6337C /* NetworkMocking */ = { - isa = PBXGroup; - children = ( - F60829722369CE5300DB292E /* MockedResponse.swift */, - F60829702369CE0100DB292E /* RequestMocking.swift */, - ); - path = NetworkMocking; - sourceTree = ""; - }; - F695DF902455EEE100A65A04 /* System */ = { - isa = PBXGroup; - children = ( - F695DF922455F09100A65A04 /* AppDelegateTests.swift */, - F695DF942455F2BB00A65A04 /* SceneDelegateTests.swift */, - F6F003B0236AF31400AAC7C6 /* SystemEventsHandlerTests.swift */, - F695DF962455F4CB00A65A04 /* PushNotificationsHandlerTests.swift */, - F695DF982456111300A65A04 /* DeepLinksHandlerTests.swift */, - 52333A91265445F30034072B /* UIOpenURLContext_Init.h */, - 52333A922654463B0034072B /* UIOpenURLContext_Init.m */, - 52333A90265445F30034072B /* UnitTests-Bridging-Header.h */, - ); - path = System; - sourceTree = ""; - }; - F695DF912455EF1400A65A04 /* Persistence */ = { - isa = PBXGroup; - children = ( - F67B3B21244C5B8700DA7FA6 /* CoreDataStackTests.swift */, - ); - path = Persistence; - sourceTree = ""; - }; - F6F003B4236B034A00AAC7C6 /* Interactors */ = { - isa = PBXGroup; - children = ( - F6F003B5236B036800AAC7C6 /* CountriesInteractorTests.swift */, - F661F2C4237772CE0014E142 /* ImagesInteractorTests.swift */, - F6B8832224561DDB00EA4067 /* UserPermissionsInteractorTests.swift */, - ); - path = Interactors; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - F64495E02360D66400C9BB1F /* CountriesSwiftUI */ = { + 488734162CDCA2DB00B400A3 /* CountriesSwiftUI */ = { isa = PBXNativeTarget; - buildConfigurationList = F64495F52360D66700C9BB1F /* Build configuration list for PBXNativeTarget "CountriesSwiftUI" */; + buildConfigurationList = 4887343D2CDCA2DD00B400A3 /* Build configuration list for PBXNativeTarget "CountriesSwiftUI" */; buildPhases = ( - F62DF39A237AFE9400CE1234 /* Run swiftlint */, - F64495DD2360D66400C9BB1F /* Sources */, - F64495DE2360D66400C9BB1F /* Frameworks */, - F64495DF2360D66400C9BB1F /* Resources */, + 488734132CDCA2DB00B400A3 /* Sources */, + 488734142CDCA2DB00B400A3 /* Frameworks */, + 488734152CDCA2DB00B400A3 /* Resources */, ); buildRules = ( ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + 488734192CDCA2DB00B400A3 /* CountriesSwiftUI */, + ); name = CountriesSwiftUI; packageProductDependencies = ( - F6E7ACE123F5D1EC00AB48AB /* EnvironmentOverrides */, + 4819F10D2CDF5819003CA0AE /* EnvironmentOverrides */, ); productName = CountriesSwiftUI; - productReference = F64495E12360D66400C9BB1F /* CountriesSwiftUI.app */; + productReference = 488734172CDCA2DB00B400A3 /* CountriesSwiftUI.app */; productType = "com.apple.product-type.application"; }; - F67833D12369CCBD0065272F /* UnitTests */ = { + 488734282CDCA2DD00B400A3 /* UnitTests */ = { isa = PBXNativeTarget; - buildConfigurationList = F67833D92369CCBD0065272F /* Build configuration list for PBXNativeTarget "UnitTests" */; + buildConfigurationList = 488734402CDCA2DD00B400A3 /* Build configuration list for PBXNativeTarget "UnitTests" */; buildPhases = ( - F67833CE2369CCBD0065272F /* Sources */, - F67833CF2369CCBD0065272F /* Frameworks */, - F67833D02369CCBD0065272F /* Resources */, + 488734252CDCA2DD00B400A3 /* Sources */, + 488734262CDCA2DD00B400A3 /* Frameworks */, + 488734272CDCA2DD00B400A3 /* Resources */, ); buildRules = ( ); dependencies = ( - F67833D82369CCBD0065272F /* PBXTargetDependency */, + 4887342B2CDCA2DD00B400A3 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 4887342C2CDCA2DD00B400A3 /* UnitTests */, ); name = UnitTests; packageProductDependencies = ( - F6B6212323B52AE600CD00C7 /* ViewInspector */, + 4819F1162CDF6DD4003CA0AE /* ViewInspector */, ); - productName = UnitTests; - productReference = F67833D22369CCBD0065272F /* UnitTests.xctest */; + productName = CountriesSwiftUITests; + productReference = 488734292CDCA2DD00B400A3 /* UnitTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ - F64495D92360D66400C9BB1F /* Project object */ = { + 4887340F2CDCA2DB00B400A3 /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 1110; - LastUpgradeCheck = 1200; + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1610; + LastUpgradeCheck = 1610; ORGANIZATIONNAME = "Alexey Naumov"; TargetAttributes = { - F64495E02360D66400C9BB1F = { - CreatedOnToolsVersion = 11.1; + 488734162CDCA2DB00B400A3 = { + CreatedOnToolsVersion = 16.1; }; - F67833D12369CCBD0065272F = { - CreatedOnToolsVersion = 11.1; - LastSwiftMigration = 1250; - TestTargetID = F64495E02360D66400C9BB1F; + 488734282CDCA2DD00B400A3 = { + CreatedOnToolsVersion = 16.1; + TestTargetID = 488734162CDCA2DB00B400A3; }; }; }; - buildConfigurationList = F64495DC2360D66400C9BB1F /* Build configuration list for PBXProject "CountriesSwiftUI" */; - compatibilityVersion = "Xcode 9.3"; + buildConfigurationList = 488734122CDCA2DB00B400A3 /* Build configuration list for PBXProject "CountriesSwiftUI" */; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, - fr, - es, ja, + de, ); - mainGroup = F64495D82360D66400C9BB1F; + mainGroup = 4887340E2CDCA2DB00B400A3; + minimizedProjectReferenceProxies = 1; packageReferences = ( - F6B6212223B52AE600CD00C7 /* XCRemoteSwiftPackageReference "ViewInspector" */, - F6E7ACE023F5D1EC00AB48AB /* XCRemoteSwiftPackageReference "EnvironmentOverrides" */, + 4819F10C2CDF5819003CA0AE /* XCRemoteSwiftPackageReference "EnvironmentOverrides" */, + 4819F1152CDF6DD4003CA0AE /* XCRemoteSwiftPackageReference "ViewInspector" */, ); - productRefGroup = F64495E22360D66400C9BB1F /* Products */; + preferredProjectObjectVersion = 77; + productRefGroup = 488734182CDCA2DB00B400A3 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( - F64495E02360D66400C9BB1F /* CountriesSwiftUI */, - F67833D12369CCBD0065272F /* UnitTests */, + 488734162CDCA2DB00B400A3 /* CountriesSwiftUI */, + 488734282CDCA2DD00B400A3 /* UnitTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - F64495DF2360D66400C9BB1F /* Resources */ = { + 488734152CDCA2DB00B400A3 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - F64495F12360D66700C9BB1F /* LaunchScreen.storyboard in Resources */, - F66EDB6823F1599F00A01B9F /* Localizable.strings in Resources */, - F64495EE2360D66700C9BB1F /* Preview Assets.xcassets in Resources */, - F64495EB2360D66700C9BB1F /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; - F67833D02369CCBD0065272F /* Resources */ = { + 488734272CDCA2DD00B400A3 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -606,161 +190,40 @@ }; /* End PBXResourcesBuildPhase section */ -/* Begin PBXShellScriptBuildPhase section */ - F62DF39A237AFE9400CE1234 /* Run swiftlint */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "Run swiftlint"; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "if command -v swiftlint > /dev/null; then\n swiftlint lint --config \"${SRCROOT}/.swiftlint.yml\"\nfi\n"; - }; -/* End PBXShellScriptBuildPhase section */ - /* Begin PBXSourcesBuildPhase section */ - F64495DD2360D66400C9BB1F /* Sources */ = { + 488734132CDCA2DB00B400A3 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - F6238A952444627D00DD30EA /* CountriesDBRepository.swift in Sources */, - F6863E462364FCC800E9B227 /* MockedData.swift in Sources */, - F64495E52360D66400C9BB1F /* AppDelegate.swift in Sources */, - F6218DB623634D4100917938 /* ErrorView.swift in Sources */, - F674964A244B059A00E1D76B /* LazyList.swift in Sources */, - F6238A9624449DC200DD30EA /* db_model_v1.xcdatamodeld in Sources */, - F6218DB22362FD3100917938 /* CountryCell.swift in Sources */, - F67DBD5223663BCD00C83258 /* SystemEventsHandler.swift in Sources */, - F64496102361B64700C9BB1F /* CountriesList.swift in Sources */, - F695DF8624559F8D00A65A04 /* PushNotificationsHandler.swift in Sources */, - F67F62B324430AE800A6ED11 /* CoreDataStack.swift in Sources */, - F6BDB91623636865003E69F2 /* DetailRow.swift in Sources */, - F661F2CC23783E360014E142 /* Helpers.swift in Sources */, - F60B5E3D2438DA47009BCBB3 /* CancelBag.swift in Sources */, - F60B5E412438DAF6009BCBB3 /* NetworkingHelpers.swift in Sources */, - F64495E72360D66400C9BB1F /* SceneDelegate.swift in Sources */, - F67F62B52443276000A6ED11 /* Models+CoreData.swift in Sources */, - F64495E92360D66400C9BB1F /* ContentView.swift in Sources */, - F67DBD652368875A00C83258 /* CountriesWebRepository.swift in Sources */, - F644960C2360EAD000C9BB1F /* APICall.swift in Sources */, - F695DF8D2455D36900A65A04 /* PushTokenWebRepository.swift in Sources */, - F64496122361BB4900C9BB1F /* InteractorsContainer.swift in Sources */, - F6218DB42363051D00917938 /* CountryDetails.swift in Sources */, - F60B5E3F2438DA57009BCBB3 /* Store.swift in Sources */, - F62822B4236478DE00823BA1 /* ModalDetailsView.swift in Sources */, - F695DF8B2455B04100A65A04 /* UserPermissionsInteractor.swift in Sources */, - F68B530C23771A9400D6337C /* ImagesInteractor.swift in Sources */, - F6C1F9D723CE3034005A98C8 /* SearchBar.swift in Sources */, - F6BDB91823637C5F003E69F2 /* ActivityIndicatorView.swift in Sources */, - F67DBD542366E2E200C83258 /* DependencyInjector.swift in Sources */, - F695DF892455AD9300A65A04 /* DeepLinksHandler.swift in Sources */, - F6238A91244337D400DD30EA /* CoreDataHelpers.swift in Sources */, - F64496082360DFB700C9BB1F /* Models.swift in Sources */, - F64496032360D8EB00C9BB1F /* Loadable.swift in Sources */, - F661F2B8237738CE0014E142 /* RootViewModifier.swift in Sources */, - F6BDB91A236382E7003E69F2 /* ImageView.swift in Sources */, - F661F2B6237734FA0014E142 /* AppEnvironment.swift in Sources */, - F68B530A2376EE8C00D6337C /* ImageWebRepository.swift in Sources */, - F644960E2360EF6C00C9BB1F /* WebRepository.swift in Sources */, - F64496002360D8B000C9BB1F /* CountriesInteractor.swift in Sources */, - F64495FD2360D81F00C9BB1F /* AppState.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; - F67833CE2369CCBD0065272F /* Sources */ = { + 488734252CDCA2DD00B400A3 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - F60CC507236C5CFA007E84B2 /* CountryDetailsTests.swift in Sources */, - F60829712369CE0100DB292E /* RequestMocking.swift in Sources */, - F67D1274244CA019006A8CC4 /* MockedPersistentStore.swift in Sources */, - F67451F5243A4CC200A4B498 /* RootViewAppearanceTests.swift in Sources */, - F6B8832124561C5E00EA4067 /* PushTokenWebRepositoryTests.swift in Sources */, - F661F2C5237772CE0014E142 /* ImagesInteractorTests.swift in Sources */, - F6F003B3236AF9F100AAC7C6 /* LoadableTests.swift in Sources */, - F695DF992456111300A65A04 /* DeepLinksHandlerTests.swift in Sources */, - F60829762369D58A00DB292E /* CountriesWebRepositoryTests.swift in Sources */, - F6F003B6236B036800AAC7C6 /* CountriesInteractorTests.swift in Sources */, - F60CC509236C6084007E84B2 /* ViewPreviewsTests.swift in Sources */, - F68B530523743D8600D6337C /* MockedInteractors.swift in Sources */, - F6F003B1236AF31400AAC7C6 /* SystemEventsHandlerTests.swift in Sources */, - F6F003B8236B04B400AAC7C6 /* MockedWebRepositories.swift in Sources */, - F60829732369CE5300DB292E /* MockedResponse.swift in Sources */, - F60CC503236C3CA8007E84B2 /* CountriesListTests.swift in Sources */, - F67B3B1F244B667B00DA7FA6 /* LazyListTests.swift in Sources */, - F60829782369DCD200DB292E /* TestHelpers.swift in Sources */, - F661F2BC237757040014E142 /* ImageWebRepositoryTests.swift in Sources */, - F695DF932455F09100A65A04 /* AppDelegateTests.swift in Sources */, - F67D1272244C7AA2006A8CC4 /* CountriesDBRepositoryTests.swift in Sources */, - F6F003AF236A290E00AAC7C6 /* WebRepositoryTests.swift in Sources */, - F6E7ACE523F83BEB00AB48AB /* ContentViewTests.swift in Sources */, - F60CC50B236C622C007E84B2 /* ModalDetailsViewTests.swift in Sources */, - F695DF8F2455DEDE00A65A04 /* MockedSystemEventsHandler.swift in Sources */, - 52333A932654463B0034072B /* UIOpenURLContext_Init.m in Sources */, - F695DF972455F4CB00A65A04 /* PushNotificationsHandlerTests.swift in Sources */, - F6500B4A23C8F2AC0086FD70 /* DeepLinkUITests.swift in Sources */, - F6621FE9244B3DE100DC583F /* MockedDBRepositories.swift in Sources */, - F68B5308237441AD00D6337C /* Mock.swift in Sources */, - F67B3B22244C5B8700DA7FA6 /* CoreDataStackTests.swift in Sources */, - F661F2CA23777D440014E142 /* ImageViewTests.swift in Sources */, - F6F606A823CF25EC00F36F5D /* SearchBarTests.swift in Sources */, - F6B8832324561DDB00EA4067 /* UserPermissionsInteractorTests.swift in Sources */, - F6B883252456326D00EA4067 /* HelpersTests.swift in Sources */, - F695DF952455F2BB00A65A04 /* SceneDelegateTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - F67833D82369CCBD0065272F /* PBXTargetDependency */ = { + 4887342B2CDCA2DD00B400A3 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = F64495E02360D66400C9BB1F /* CountriesSwiftUI */; - targetProxy = F67833D72369CCBD0065272F /* PBXContainerItemProxy */; + target = 488734162CDCA2DB00B400A3 /* CountriesSwiftUI */; + targetProxy = 4887342A2CDCA2DD00B400A3 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ -/* Begin PBXVariantGroup section */ - F64495EF2360D66700C9BB1F /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - F66EDB6723F1599800A01B9F /* en */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; - F66EDB6A23F1599F00A01B9F /* Localizable.strings */ = { - isa = PBXVariantGroup; - children = ( - F66EDB6923F1599F00A01B9F /* en */, - F66EDB6C23F168FF00A01B9F /* fr */, - F66EDB6D23F1691400A01B9F /* es */, - F66EDB6E23F1692000A01B9F /* ja */, - ); - name = Localizable.strings; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - /* Begin XCBuildConfiguration section */ - F64495F32360D66700C9BB1F /* Debug */ = { + 4887343B2CDCA2DD00B400A3 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; @@ -790,7 +253,8 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; @@ -804,25 +268,26 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.1; + 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; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; }; - F64495F42360D66700C9BB1F /* Release */ = { + 4887343C2CDCA2DD00B400A3 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; - CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; @@ -852,7 +317,8 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; + 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; @@ -860,145 +326,147 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.1; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_EMIT_LOC_STRINGS = YES; VALIDATE_PRODUCT = YES; }; name = Release; }; - F64495F62360D66700C9BB1F /* Debug */ = { + 4887343E2CDCA2DD00B400A3 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = CountriesSwiftUI/Resources/CountriesSwiftUI.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = YES; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"CountriesSwiftUI/Resources\""; - DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; - INFOPLIST_FILE = CountriesSwiftUI/Resources/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 2.0; - PRODUCT_BUNDLE_IDENTIFIER = com.countries.swiftui; + MARKETING_VERSION = 3.0; + PRODUCT_BUNDLE_IDENTIFIER = com.swiftui.CountriesSwiftUI; PRODUCT_NAME = "$(TARGET_NAME)"; - SUPPORTS_MACCATALYST = YES; + REGISTER_APP_GROUPS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; - F64495F72360D66700C9BB1F /* Release */ = { + 4887343F2CDCA2DD00B400A3 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CODE_SIGN_ENTITLEMENTS = CountriesSwiftUI/Resources/CountriesSwiftUI.entitlements; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = YES; + CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"CountriesSwiftUI/Resources\""; - DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; - INFOPLIST_FILE = CountriesSwiftUI/Resources/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; - MARKETING_VERSION = 2.0; - PRODUCT_BUNDLE_IDENTIFIER = com.countries.swiftui; + MARKETING_VERSION = 3.0; + PRODUCT_BUNDLE_IDENTIFIER = com.swiftui.CountriesSwiftUI; PRODUCT_NAME = "$(TARGET_NAME)"; - SUPPORTS_MACCATALYST = YES; + REGISTER_APP_GROUPS = NO; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; - F67833DA2369CCBD0065272F /* Debug */ = { + 488734412CDCA2DD00B400A3 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CLANG_ENABLE_MODULES = YES; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; - INFOPLIST_FILE = UnitTests/Resources/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - MACOSX_DEPLOYMENT_TARGET = 12.0; - PRODUCT_BUNDLE_IDENTIFIER = com.countries.swiftui.UnitTests; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.1; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.swiftui.CountriesSwiftUITests; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "UnitTests/System/UnitTests-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CountriesSwiftUI.app/CountriesSwiftUI"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CountriesSwiftUI.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CountriesSwiftUI"; }; name = Debug; }; - F67833DB2369CCBD0065272F /* Release */ = { + 488734422CDCA2DD00B400A3 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; - CLANG_ENABLE_MODULES = YES; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = ""; - INFOPLIST_FILE = UnitTests/Resources/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - "@loader_path/Frameworks", - ); - MACOSX_DEPLOYMENT_TARGET = 12.0; - PRODUCT_BUNDLE_IDENTIFIER = com.countries.swiftui.UnitTests; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.1; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.swiftui.CountriesSwiftUITests; PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "UnitTests/System/UnitTests-Bridging-Header.h"; + SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CountriesSwiftUI.app/CountriesSwiftUI"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CountriesSwiftUI.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CountriesSwiftUI"; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - F64495DC2360D66400C9BB1F /* Build configuration list for PBXProject "CountriesSwiftUI" */ = { + 488734122CDCA2DB00B400A3 /* Build configuration list for PBXProject "CountriesSwiftUI" */ = { isa = XCConfigurationList; buildConfigurations = ( - F64495F32360D66700C9BB1F /* Debug */, - F64495F42360D66700C9BB1F /* Release */, + 4887343B2CDCA2DD00B400A3 /* Debug */, + 4887343C2CDCA2DD00B400A3 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - F64495F52360D66700C9BB1F /* Build configuration list for PBXNativeTarget "CountriesSwiftUI" */ = { + 4887343D2CDCA2DD00B400A3 /* Build configuration list for PBXNativeTarget "CountriesSwiftUI" */ = { isa = XCConfigurationList; buildConfigurations = ( - F64495F62360D66700C9BB1F /* Debug */, - F64495F72360D66700C9BB1F /* Release */, + 4887343E2CDCA2DD00B400A3 /* Debug */, + 4887343F2CDCA2DD00B400A3 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - F67833D92369CCBD0065272F /* Build configuration list for PBXNativeTarget "UnitTests" */ = { + 488734402CDCA2DD00B400A3 /* Build configuration list for PBXNativeTarget "UnitTests" */ = { isa = XCConfigurationList; buildConfigurations = ( - F67833DA2369CCBD0065272F /* Debug */, - F67833DB2369CCBD0065272F /* Release */, + 488734412CDCA2DD00B400A3 /* Debug */, + 488734422CDCA2DD00B400A3 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; @@ -1006,49 +474,36 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - F6B6212223B52AE600CD00C7 /* XCRemoteSwiftPackageReference "ViewInspector" */ = { + 4819F10C2CDF5819003CA0AE /* XCRemoteSwiftPackageReference "EnvironmentOverrides" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/nalexn/ViewInspector"; + repositoryURL = "https://github.com/nalexn/EnvironmentOverrides"; requirement = { - kind = exactVersion; - version = 0.9.1; + kind = upToNextMajorVersion; + minimumVersion = 0.0.4; }; }; - F6E7ACE023F5D1EC00AB48AB /* XCRemoteSwiftPackageReference "EnvironmentOverrides" */ = { + 4819F1152CDF6DD4003CA0AE /* XCRemoteSwiftPackageReference "ViewInspector" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/nalexn/EnvironmentOverrides"; + repositoryURL = "https://github.com/nalexn/ViewInspector"; requirement = { - kind = exactVersion; - version = 0.0.4; + kind = upToNextMajorVersion; + minimumVersion = 0.10.0; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - F6B6212323B52AE600CD00C7 /* ViewInspector */ = { + 4819F10D2CDF5819003CA0AE /* EnvironmentOverrides */ = { isa = XCSwiftPackageProductDependency; - package = F6B6212223B52AE600CD00C7 /* XCRemoteSwiftPackageReference "ViewInspector" */; - productName = ViewInspector; + package = 4819F10C2CDF5819003CA0AE /* XCRemoteSwiftPackageReference "EnvironmentOverrides" */; + productName = EnvironmentOverrides; }; - F6E7ACE123F5D1EC00AB48AB /* EnvironmentOverrides */ = { + 4819F1162CDF6DD4003CA0AE /* ViewInspector */ = { isa = XCSwiftPackageProductDependency; - package = F6E7ACE023F5D1EC00AB48AB /* XCRemoteSwiftPackageReference "EnvironmentOverrides" */; - productName = EnvironmentOverrides; + package = 4819F1152CDF6DD4003CA0AE /* XCRemoteSwiftPackageReference "ViewInspector" */; + productName = ViewInspector; }; /* End XCSwiftPackageProductDependency section */ - -/* Begin XCVersionGroup section */ - F633BA5024432ACE00402237 /* db_model_v1.xcdatamodeld */ = { - isa = XCVersionGroup; - children = ( - F633BA5124432ACE00402237 /* db_model_v1.xcdatamodel */, - ); - currentVersion = F633BA5124432ACE00402237 /* db_model_v1.xcdatamodel */; - path = db_model_v1.xcdatamodeld; - sourceTree = ""; - versionGroupType = wrapper.xcdatamodel; - }; -/* End XCVersionGroup section */ }; - rootObject = F64495D92360D66400C9BB1F /* Project object */; + rootObject = 4887340F2CDCA2DB00B400A3 /* Project object */; } diff --git a/CountriesSwiftUI.xcodeproj/xcshareddata/xcschemes/CountriesSwiftUI.xcscheme b/CountriesSwiftUI.xcodeproj/xcshareddata/xcschemes/CountriesSwiftUI.xcscheme index 3a4643d..c612aab 100644 --- a/CountriesSwiftUI.xcodeproj/xcshareddata/xcschemes/CountriesSwiftUI.xcscheme +++ b/CountriesSwiftUI.xcodeproj/xcshareddata/xcschemes/CountriesSwiftUI.xcscheme @@ -1,10 +1,11 @@ + LastUpgradeVersion = "1610" + version = "1.7"> + buildImplicitDependencies = "YES" + buildArchitectures = "Automatic"> @@ -27,23 +28,14 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" - codeCoverageEnabled = "YES" - onlyGenerateCoverageForSpecifiedTargets = "YES"> - - - - + shouldAutocreateTestPlan = "YES"> + skipped = "NO" + parallelizable = "YES"> @@ -65,7 +57,7 @@ runnableDebuggingMode = "0"> @@ -82,7 +74,7 @@ runnableDebuggingMode = "0"> diff --git a/CountriesSwiftUI/Core/App.swift b/CountriesSwiftUI/Core/App.swift new file mode 100644 index 0000000..d83c145 --- /dev/null +++ b/CountriesSwiftUI/Core/App.swift @@ -0,0 +1,50 @@ +// +// CountriesApp.swift +// CountriesSwiftUI +// +// Created by Alexey on 7/11/24. +// Copyright © 2024 Alexey Naumov. All rights reserved. +// + +import SwiftUI +import EnvironmentOverrides + +@main +struct MainApp: App { + + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + WindowGroup { + appDelegate.rootView + } + } +} + +extension AppEnvironment { + var rootView: some View { + VStack { + if isRunningTests { + Text("Running unit tests") + } else { + CountriesList() + .modifier(RootViewAppearance()) + .modelContainer(modelContainer) + .attachEnvironmentOverrides(onChange: onChangeHandler) + .inject(diContainer) + if modelContainer.isStub { + Text("⚠️ There is an issue with local database") + .font(.caption2) + } + } + } + } + + private var onChangeHandler: (EnvironmentValues.Diff) -> Void { + return { diff in + if !diff.isDisjoint(with: [.locale, .sizeCategory]) { + self.diContainer.appState[\.routing] = AppState.ViewRouting() + } + } + } +} diff --git a/CountriesSwiftUI/Core/AppDelegate.swift b/CountriesSwiftUI/Core/AppDelegate.swift new file mode 100644 index 0000000..053f005 --- /dev/null +++ b/CountriesSwiftUI/Core/AppDelegate.swift @@ -0,0 +1,73 @@ +// +// AppDelegate.swift +// CountriesSwiftUI +// +// Created by Alexey Naumov on 23.10.2019. +// Copyright © 2019 Alexey Naumov. All rights reserved. +// + +import UIKit +import SwiftUI +import Combine +import Foundation + +@MainActor +final class AppDelegate: UIResponder, UIApplicationDelegate { + + private lazy var environment = AppEnvironment.bootstrap() + private var systemEventsHandler: SystemEventsHandler { environment.systemEventsHandler } + + var rootView: some View { + environment.rootView + } + + func application(_ application: UIApplication, didFinishLaunchingWithOptions + launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + return true + } + + func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { + let config: UISceneConfiguration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) + config.delegateClass = SceneDelegate.self + SceneDelegate.register(systemEventsHandler) + return config + } + + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + systemEventsHandler.handlePushRegistration(result: .success(deviceToken)) + } + + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + systemEventsHandler.handlePushRegistration(result: .failure(error)) + } + + func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any]) async -> UIBackgroundFetchResult { + return await systemEventsHandler + .appDidReceiveRemoteNotification(payload: userInfo) + } +} + +// MARK: - SceneDelegate + +@MainActor +final class SceneDelegate: UIResponder, UIWindowSceneDelegate, ObservableObject { + + private static var systemEventsHandler: SystemEventsHandler? + private var systemEventsHandler: SystemEventsHandler? { Self.systemEventsHandler } + + static func register(_ systemEventsHandler: SystemEventsHandler?) { + Self.systemEventsHandler = systemEventsHandler + } + + func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { + systemEventsHandler?.sceneOpenURLContexts(URLContexts) + } + + func sceneDidBecomeActive(_ scene: UIScene) { + systemEventsHandler?.sceneDidBecomeActive() + } + + func sceneWillResignActive(_ scene: UIScene) { + systemEventsHandler?.sceneWillResignActive() + } +} diff --git a/CountriesSwiftUI/Injected/AppState.swift b/CountriesSwiftUI/Core/AppState.swift similarity index 57% rename from CountriesSwiftUI/Injected/AppState.swift rename to CountriesSwiftUI/Core/AppState.swift index adaeba6..809569d 100644 --- a/CountriesSwiftUI/Injected/AppState.swift +++ b/CountriesSwiftUI/Core/AppState.swift @@ -10,23 +10,11 @@ import SwiftUI import Combine struct AppState: Equatable { - var userData = UserData() var routing = ViewRouting() var system = System() var permissions = Permissions() } -extension AppState { - struct UserData: Equatable { - /* - The list of countries (Loadable<[Country]>) used to be stored here. - It was removed for performing countries' search by name inside a database, - which made the resulting variable used locally by just one screen (CountriesList) - Otherwise, the list of countries could have remained here, available for the entire app. - */ - } -} - extension AppState { struct ViewRouting: Equatable { var countriesList = CountriesList.Routing() @@ -45,7 +33,7 @@ extension AppState { struct Permissions: Equatable { var push: Permission.Status = .unknown } - + static func permissionKeyPath(for permission: Permission) -> WritableKeyPath { let pathToPermissions = \AppState.permissions switch permission { @@ -56,18 +44,7 @@ extension AppState { } func == (lhs: AppState, rhs: AppState) -> Bool { - return lhs.userData == rhs.userData && - lhs.routing == rhs.routing && - lhs.system == rhs.system && - lhs.permissions == rhs.permissions -} - -#if DEBUG -extension AppState { - static var preview: AppState { - var state = AppState() - state.system.isActive = true - return state - } + return lhs.routing == rhs.routing + && lhs.system == rhs.system + && lhs.permissions == rhs.permissions } -#endif diff --git a/CountriesSwiftUI/System/DeepLinksHandler.swift b/CountriesSwiftUI/Core/DeepLinksHandler.swift similarity index 81% rename from CountriesSwiftUI/System/DeepLinksHandler.swift rename to CountriesSwiftUI/Core/DeepLinksHandler.swift index 81e3b4f..b443e5b 100644 --- a/CountriesSwiftUI/System/DeepLinksHandler.swift +++ b/CountriesSwiftUI/Core/DeepLinksHandler.swift @@ -10,8 +10,8 @@ import Foundation enum DeepLink: Equatable { - case showCountryFlag(alpha3Code: Country.Code) - + case showCountryFlag(alpha3Code: String) + init?(url: URL) { guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true), @@ -20,7 +20,7 @@ enum DeepLink: Equatable { else { return nil } if let item = query.first(where: { $0.name == "alpha3code" }), let alpha3Code = item.value { - self = .showCountryFlag(alpha3Code: Country.Code(alpha3Code)) + self = .showCountryFlag(alpha3Code: alpha3Code) return } return nil @@ -29,6 +29,7 @@ enum DeepLink: Equatable { // MARK: - DeepLinksHandler +@MainActor protocol DeepLinksHandler { func open(deepLink: DeepLink) } @@ -46,7 +47,7 @@ struct RealDeepLinksHandler: DeepLinksHandler { case let .showCountryFlag(alpha3Code): let routeToDestination = { self.container.appState.bulkUpdate { - $0.routing.countriesList.countryDetails = alpha3Code + $0.routing.countriesList.countryCode = alpha3Code $0.routing.countryDetails.detailsSheet = true } } @@ -58,7 +59,8 @@ struct RealDeepLinksHandler: DeepLinksHandler { let defaultRouting = AppState.ViewRouting() if container.appState.value.routing != defaultRouting { self.container.appState[\.routing] = defaultRouting - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5, execute: routeToDestination) + let delay: DispatchTime = .now() + (ProcessInfo.processInfo.isRunningTests ? 0 : 1.5) + DispatchQueue.main.asyncAfter(deadline: delay, execute: routeToDestination) } else { routeToDestination() } diff --git a/CountriesSwiftUI/System/PushNotificationsHandler.swift b/CountriesSwiftUI/Core/PushNotificationsHandler.swift similarity index 80% rename from CountriesSwiftUI/System/PushNotificationsHandler.swift rename to CountriesSwiftUI/Core/PushNotificationsHandler.swift index 39f889d..b9c667d 100644 --- a/CountriesSwiftUI/System/PushNotificationsHandler.swift +++ b/CountriesSwiftUI/Core/PushNotificationsHandler.swift @@ -10,7 +10,7 @@ import UserNotifications protocol PushNotificationsHandler { } -class RealPushNotificationsHandler: NSObject, PushNotificationsHandler { +final class RealPushNotificationsHandler: NSObject, PushNotificationsHandler { private let deepLinksHandler: DeepLinksHandler @@ -40,12 +40,14 @@ extension RealPushNotificationsHandler: UNUserNotificationCenterDelegate { } func handleNotification(userInfo: [AnyHashable: Any], completionHandler: @escaping () -> Void) { - guard let payload = userInfo["aps"] as? NotificationPayload, - let countryCode = payload["country"] as? Country.Code else { + guard let payload = userInfo["aps"] as? [AnyHashable: Any], + let countryCode = payload["country"] as? String else { completionHandler() return } - deepLinksHandler.open(deepLink: .showCountryFlag(alpha3Code: countryCode)) - completionHandler() + Task { @MainActor in + deepLinksHandler.open(deepLink: .showCountryFlag(alpha3Code: countryCode)) + completionHandler() + } } } diff --git a/CountriesSwiftUI/System/SystemEventsHandler.swift b/CountriesSwiftUI/Core/SystemEventsHandler.swift similarity index 76% rename from CountriesSwiftUI/System/SystemEventsHandler.swift rename to CountriesSwiftUI/Core/SystemEventsHandler.swift index 84a19a0..990f9e3 100644 --- a/CountriesSwiftUI/System/SystemEventsHandler.swift +++ b/CountriesSwiftUI/Core/SystemEventsHandler.swift @@ -9,37 +9,38 @@ import UIKit import Combine +@MainActor protocol SystemEventsHandler { func sceneOpenURLContexts(_ urlContexts: Set) func sceneDidBecomeActive() func sceneWillResignActive() func handlePushRegistration(result: Result) - func appDidReceiveRemoteNotification(payload: NotificationPayload, - fetchCompletion: @escaping FetchCompletion) + @MainActor + func appDidReceiveRemoteNotification(payload: [AnyHashable: Any]) async -> UIBackgroundFetchResult } struct RealSystemEventsHandler: SystemEventsHandler { - + let container: DIContainer let deepLinksHandler: DeepLinksHandler let pushNotificationsHandler: PushNotificationsHandler let pushTokenWebRepository: PushTokenWebRepository - private var cancelBag = CancelBag() - + private let cancelBag = CancelBag() + init(container: DIContainer, deepLinksHandler: DeepLinksHandler, pushNotificationsHandler: PushNotificationsHandler, pushTokenWebRepository: PushTokenWebRepository) { - + self.container = container self.deepLinksHandler = deepLinksHandler self.pushNotificationsHandler = pushNotificationsHandler self.pushTokenWebRepository = pushTokenWebRepository - + installKeyboardHeightObserver() installPushNotificationsSubscriberOnLaunch() } - + private func installKeyboardHeightObserver() { let appState = container.appState NotificationCenter.default.keyboardHeightPublisher @@ -48,9 +49,9 @@ struct RealSystemEventsHandler: SystemEventsHandler { } .store(in: cancelBag) } - + private func installPushNotificationsSubscriberOnLaunch() { - weak var permissions = container.interactors.userPermissionsInteractor + weak var permissions = container.interactors.userPermissions container.appState .updates(for: AppState.permissionKeyPath(for: .pushNotifications)) .first(where: { $0 != .unknown }) @@ -63,43 +64,32 @@ struct RealSystemEventsHandler: SystemEventsHandler { } .store(in: cancelBag) } - + func sceneOpenURLContexts(_ urlContexts: Set) { guard let url = urlContexts.first?.url else { return } handle(url: url) } - + private func handle(url: URL) { guard let deepLink = DeepLink(url: url) else { return } deepLinksHandler.open(deepLink: deepLink) } - + func sceneDidBecomeActive() { container.appState[\.system.isActive] = true - container.interactors.userPermissionsInteractor.resolveStatus(for: .pushNotifications) + container.interactors.userPermissions.resolveStatus(for: .pushNotifications) } - + func sceneWillResignActive() { container.appState[\.system.isActive] = false } - + func handlePushRegistration(result: Result) { - if let pushToken = try? result.get() { - pushTokenWebRepository - .register(devicePushToken: pushToken) - .sinkToResult { _ in } - .store(in: cancelBag) - } + } - - func appDidReceiveRemoteNotification(payload: NotificationPayload, - fetchCompletion: @escaping FetchCompletion) { - container.interactors.countriesInteractor - .refreshCountriesList() - .sinkToResult { result in - fetchCompletion(result.isSuccess ? .newData : .failed) - } - .store(in: cancelBag) + + func appDidReceiveRemoteNotification(payload: [AnyHashable: Any]) async -> UIBackgroundFetchResult { + return .noData } } diff --git a/CountriesSwiftUI/DependencyInjection/AppEnvironment.swift b/CountriesSwiftUI/DependencyInjection/AppEnvironment.swift new file mode 100644 index 0000000..5758cef --- /dev/null +++ b/CountriesSwiftUI/DependencyInjection/AppEnvironment.swift @@ -0,0 +1,112 @@ +// +// AppEnvironment.swift +// CountriesSwiftUI +// +// Created by Alexey on 7/11/24. +// Copyright © 2024 Alexey Naumov. All rights reserved. +// + +import UIKit +import SwiftData + +@MainActor +struct AppEnvironment { + let isRunningTests: Bool + let diContainer: DIContainer + let modelContainer: ModelContainer + let systemEventsHandler: SystemEventsHandler +} + +extension AppEnvironment { + + static func bootstrap() -> AppEnvironment { + let appState = Store(AppState()) + /* + To see the deep linking in action: + + 1. Launch the app in iOS 13.4 simulator (or newer) + 2. Subscribe on Push Notifications with "Allow Push" button + 3. Minimize the app + 4. Drag & drop "push_with_deeplink.apns" into the Simulator window + 5. Tap on the push notification + + Alternatively, just copy the code below before the "return" and launch: + + DispatchQueue.main.async { + deepLinksHandler.open(deepLink: .showCountryFlag(alpha3Code: "AFG")) + } + */ + let session = configuredURLSession() + let webRepositories = configuredWebRepositories(session: session) + let modelContainer = configuredModelContainer() + let dbRepositories = configuredDBRepositories(modelContainer: modelContainer) + let interactors = configuredInteractors(appState: appState, webRepositories: webRepositories, dbRepositories: dbRepositories) + let diContainer = DIContainer(appState: appState, interactors: interactors) + let deepLinksHandler = RealDeepLinksHandler(container: diContainer) + let pushNotificationsHandler = RealPushNotificationsHandler(deepLinksHandler: deepLinksHandler) + let systemEventsHandler = RealSystemEventsHandler( + container: diContainer, + deepLinksHandler: deepLinksHandler, + pushNotificationsHandler: pushNotificationsHandler, + pushTokenWebRepository: webRepositories.pushToken) + return AppEnvironment( + isRunningTests: ProcessInfo.processInfo.isRunningTests, + diContainer: diContainer, + modelContainer: modelContainer, + systemEventsHandler: systemEventsHandler) + } + + private static func configuredURLSession() -> URLSession { + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 60 + configuration.timeoutIntervalForResource = 120 + configuration.waitsForConnectivity = true + configuration.httpMaximumConnectionsPerHost = 5 + configuration.requestCachePolicy = .returnCacheDataElseLoad + configuration.urlCache = .shared + return URLSession(configuration: configuration) + } + + private static func configuredWebRepositories(session: URLSession) -> DIContainer.WebRepositories { + let images = RealImagesWebRepository(session: session) + let countries = RealCountriesWebRepository(session: session) + let pushToken = RealPushTokenWebRepository(session: session) + return .init(images: images, + countries: countries, + pushToken: pushToken) + } + + private static func configuredDBRepositories(modelContainer: ModelContainer) -> DIContainer.DBRepositories { + let mainDBRepository = MainDBRepository(modelContainer: modelContainer) + return .init(countries: mainDBRepository) + } + + private static func configuredModelContainer() -> ModelContainer { + do { + return try ModelContainer.appModelContainer() + } catch { + // Log the error + return ModelContainer.stub + } + } + + private static func configuredInteractors( + appState: Store, + webRepositories: DIContainer.WebRepositories, + dbRepositories: DIContainer.DBRepositories + ) -> DIContainer.Interactors { + let images = RealImagesInteractor(webRepository: webRepositories.images) + let countries = RealCountriesInteractor( + webRepository: webRepositories.countries, + dbRepository: dbRepositories.countries) + let userPermissions = RealUserPermissionsInteractor( + appState: appState, openAppSettings: { + URL(string: UIApplication.openSettingsURLString).flatMap { + UIApplication.shared.open($0, options: [:], completionHandler: nil) + } + }) + return .init(images: images, + countries: countries, + userPermissions: userPermissions) + } +} diff --git a/CountriesSwiftUI/DependencyInjection/DIContainer.swift b/CountriesSwiftUI/DependencyInjection/DIContainer.swift new file mode 100644 index 0000000..c58d9d2 --- /dev/null +++ b/CountriesSwiftUI/DependencyInjection/DIContainer.swift @@ -0,0 +1,58 @@ +// +// DIContainer.swift +// CountriesSwiftUI +// +// Created by Alexey on 7/11/24. +// Copyright © 2024 Alexey Naumov. All rights reserved. +// + +import SwiftUI +import SwiftData + +struct DIContainer { + + let appState: Store + let interactors: Interactors + + init(appState: Store = .init(AppState()), interactors: Interactors) { + self.appState = appState + self.interactors = interactors + } + + init(appState: AppState, interactors: Interactors) { + self.init(appState: Store(appState), interactors: interactors) + } +} + +extension DIContainer { + struct WebRepositories { + let images: ImagesWebRepository + let countries: CountriesWebRepository + let pushToken: PushTokenWebRepository + } + struct DBRepositories { + let countries: CountriesDBRepository + } + struct Interactors { + let images: ImagesInteractor + let countries: CountriesInteractor + let userPermissions: UserPermissionsInteractor + + static var stub: Self { + .init(images: StubImagesInteractor(), + countries: StubCountriesInteractor(), + userPermissions: StubUserPermissionsInteractor()) + } + } +} + +extension EnvironmentValues { + @Entry var injected: DIContainer = DIContainer(appState: AppState(), interactors: .stub) +} + +extension View { + func inject(_ container: DIContainer) -> some View { + return self + .environment(\.injected, container) + } +} diff --git a/CountriesSwiftUI/Injected/DependencyInjector.swift b/CountriesSwiftUI/Injected/DependencyInjector.swift deleted file mode 100644 index 76af750..0000000 --- a/CountriesSwiftUI/Injected/DependencyInjector.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// DependencyInjector.swift -// CountriesSwiftUI -// -// Created by Alexey Naumov on 28.10.2019. -// Copyright © 2019 Alexey Naumov. All rights reserved. -// - -import SwiftUI -import Combine - -// MARK: - DIContainer - -struct DIContainer: EnvironmentKey { - - let appState: Store - let interactors: Interactors - - init(appState: Store, interactors: Interactors) { - self.appState = appState - self.interactors = interactors - } - - init(appState: AppState, interactors: Interactors) { - self.init(appState: Store(appState), interactors: interactors) - } - - static var defaultValue: Self { Self.default } - - private static let `default` = Self(appState: AppState(), interactors: .stub) -} - -extension EnvironmentValues { - var injected: DIContainer { - get { self[DIContainer.self] } - set { self[DIContainer.self] = newValue } - } -} - -#if DEBUG -extension DIContainer { - static var preview: Self { - .init(appState: .init(AppState.preview), interactors: .stub) - } -} -#endif - -// MARK: - Injection in the view hierarchy - -extension View { - - func inject(_ appState: AppState, - _ interactors: DIContainer.Interactors) -> some View { - let container = DIContainer(appState: .init(appState), - interactors: interactors) - return inject(container) - } - - func inject(_ container: DIContainer) -> some View { - return self - .modifier(RootViewAppearance()) - .environment(\.injected, container) - } -} diff --git a/CountriesSwiftUI/Injected/InteractorsContainer.swift b/CountriesSwiftUI/Injected/InteractorsContainer.swift deleted file mode 100644 index 2479286..0000000 --- a/CountriesSwiftUI/Injected/InteractorsContainer.swift +++ /dev/null @@ -1,29 +0,0 @@ -// -// DIContainer.Interactors.swift -// CountriesSwiftUI -// -// Created by Alexey Naumov on 24.10.2019. -// Copyright © 2019 Alexey Naumov. All rights reserved. -// - -extension DIContainer { - struct Interactors { - let countriesInteractor: CountriesInteractor - let imagesInteractor: ImagesInteractor - let userPermissionsInteractor: UserPermissionsInteractor - - init(countriesInteractor: CountriesInteractor, - imagesInteractor: ImagesInteractor, - userPermissionsInteractor: UserPermissionsInteractor) { - self.countriesInteractor = countriesInteractor - self.imagesInteractor = imagesInteractor - self.userPermissionsInteractor = userPermissionsInteractor - } - - static var stub: Self { - .init(countriesInteractor: StubCountriesInteractor(), - imagesInteractor: StubImagesInteractor(), - userPermissionsInteractor: StubUserPermissionsInteractor()) - } - } -} diff --git a/CountriesSwiftUI/Interactors/CountriesInteractor.swift b/CountriesSwiftUI/Interactors/CountriesInteractor.swift index e8603be..5aca234 100644 --- a/CountriesSwiftUI/Interactors/CountriesInteractor.swift +++ b/CountriesSwiftUI/Interactors/CountriesInteractor.swift @@ -2,108 +2,47 @@ // CountriesInteractor.swift // CountriesSwiftUI // -// Created by Alexey Naumov on 23.10.2019. -// Copyright © 2019 Alexey Naumov. All rights reserved. +// Created by Alexey on 7/11/24. +// Copyright © 2024 Alexey Naumov. All rights reserved. // -import Combine -import Foundation -import SwiftUI - protocol CountriesInteractor { - func refreshCountriesList() -> AnyPublisher - func load(countries: LoadableSubject>, search: String, locale: Locale) - func load(countryDetails: LoadableSubject, country: Country) + func refreshCountriesList() async throws + func loadCountryDetails(country: DBModel.Country, forceReload: Bool) async throws -> DBModel.CountryDetails } struct RealCountriesInteractor: CountriesInteractor { - + let webRepository: CountriesWebRepository let dbRepository: CountriesDBRepository - let appState: Store - - init(webRepository: CountriesWebRepository, dbRepository: CountriesDBRepository, appState: Store) { - self.webRepository = webRepository - self.dbRepository = dbRepository - self.appState = appState - } - func load(countries: LoadableSubject>, search: String, locale: Locale) { - - let cancelBag = CancelBag() - countries.wrappedValue.setIsLoading(cancelBag: cancelBag) - - Just - .withErrorType(Error.self) - .flatMap { [dbRepository] _ -> AnyPublisher in - dbRepository.hasLoadedCountries() - } - .flatMap { hasLoaded -> AnyPublisher in - if hasLoaded { - return Just.withErrorType(Error.self) - } else { - return self.refreshCountriesList() - } - } - .flatMap { [dbRepository] in - dbRepository.countries(search: search, locale: locale) - } - .sinkToLoadable { countries.wrappedValue = $0 } - .store(in: cancelBag) + func refreshCountriesList() async throws { + let apiCountries = try await webRepository.countries() + try await dbRepository.store(countries: apiCountries) } - - func refreshCountriesList() -> AnyPublisher { - return webRepository - .loadCountries() - .ensureTimeSpan(requestHoldBackTimeInterval) - .flatMap { [dbRepository] in - dbRepository.store(countries: $0) - } - .eraseToAnyPublisher() - } - - func load(countryDetails: LoadableSubject, country: Country) { - - let cancelBag = CancelBag() - countryDetails.wrappedValue.setIsLoading(cancelBag: cancelBag) - dbRepository - .countryDetails(country: country) - .flatMap { details -> AnyPublisher in - if details != nil { - return Just.withErrorType(details, Error.self) - } else { - return self.loadAndStoreCountryDetailsFromWeb(country: country) - } - } - .sinkToLoadable { countryDetails.wrappedValue = $0.unwrap() } - .store(in: cancelBag) - } - - private func loadAndStoreCountryDetailsFromWeb(country: Country) -> AnyPublisher { - return webRepository - .loadCountryDetails(country: country) - .ensureTimeSpan(requestHoldBackTimeInterval) - .flatMap { [dbRepository] in - dbRepository.store(countryDetails: $0, for: country) - } - .eraseToAnyPublisher() - } - - private var requestHoldBackTimeInterval: TimeInterval { - return ProcessInfo.processInfo.isRunningTests ? 0 : 0.5 + func loadCountryDetails( + country: DBModel.Country, forceReload: Bool + ) async throws -> DBModel.CountryDetails { + if !forceReload, + let stored = try? await dbRepository.countryDetails(for: country) { + return stored + } + let details = try await webRepository.details(country: country) + try await dbRepository.store(countryDetails: details, for: country) + guard let stored = try? await dbRepository.countryDetails(for: country) else { + throw ValueIsMissingError() + } + return stored } } struct StubCountriesInteractor: CountriesInteractor { - - func refreshCountriesList() -> AnyPublisher { - return Just.withErrorType(Error.self) - } - - func load(countries: LoadableSubject>, search: String, locale: Locale) { + + func refreshCountriesList() async throws { } - - func load(countryDetails: LoadableSubject, country: Country) { + + func loadCountryDetails(country: DBModel.Country, forceReload: Bool) async throws -> DBModel.CountryDetails { + throw ValueIsMissingError() } } diff --git a/CountriesSwiftUI/Interactors/ImagesInteractor.swift b/CountriesSwiftUI/Interactors/ImagesInteractor.swift index 2501a00..4857fac 100644 --- a/CountriesSwiftUI/Interactors/ImagesInteractor.swift +++ b/CountriesSwiftUI/Interactors/ImagesInteractor.swift @@ -16,23 +16,19 @@ protocol ImagesInteractor { struct RealImagesInteractor: ImagesInteractor { - let webRepository: ImageWebRepository + let webRepository: ImagesWebRepository - init(webRepository: ImageWebRepository) { + init(webRepository: ImagesWebRepository) { self.webRepository = webRepository } func load(image: LoadableSubject, url: URL?) { - guard let url = url else { + guard let url else { image.wrappedValue = .notRequested; return } - let cancelBag = CancelBag() - image.wrappedValue.setIsLoading(cancelBag: cancelBag) - webRepository.load(imageURL: url) - .sinkToLoadable { - image.wrappedValue = $0 - } - .store(in: cancelBag) + image.load { + try await webRepository.loadImage(url: url) + } } } diff --git a/CountriesSwiftUI/Interactors/UserPermissionsInteractor.swift b/CountriesSwiftUI/Interactors/UserPermissionsInteractor.swift index 45e4a24..9dcd59c 100644 --- a/CountriesSwiftUI/Interactors/UserPermissionsInteractor.swift +++ b/CountriesSwiftUI/Interactors/UserPermissionsInteractor.swift @@ -27,31 +27,52 @@ protocol UserPermissionsInteractor: AnyObject { func request(permission: Permission) } +protocol SystemNotificationsSettings { + var authorizationStatus: UNAuthorizationStatus { get } +} + +protocol SystemNotificationsCenter { + func currentSettings() async -> SystemNotificationsSettings + func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool +} + +extension UNNotificationSettings: SystemNotificationsSettings { } +extension UNUserNotificationCenter: SystemNotificationsCenter { + func currentSettings() async -> any SystemNotificationsSettings { + return await notificationSettings() + } +} + // MARK: - RealUserPermissionsInteractor final class RealUserPermissionsInteractor: UserPermissionsInteractor { - + private let appState: Store private let openAppSettings: () -> Void - - init(appState: Store, openAppSettings: @escaping () -> Void) { + private let notificationCenter: SystemNotificationsCenter + + init(appState: Store, + notificationCenter: SystemNotificationsCenter = UNUserNotificationCenter.current(), + openAppSettings: @escaping () -> Void + ) { self.appState = appState + self.notificationCenter = notificationCenter self.openAppSettings = openAppSettings } - + func resolveStatus(for permission: Permission) { let keyPath = AppState.permissionKeyPath(for: permission) let currentStatus = appState[keyPath] guard currentStatus == .unknown else { return } - let onResolve: (Permission.Status) -> Void = { [weak appState] status in - appState?[keyPath] = status - } + let appState = appState switch permission { case .pushNotifications: - pushNotificationsPermissionStatus(onResolve) + Task { @MainActor in + appState[keyPath] = await pushNotificationsPermissionStatus() + } } } - + func request(permission: Permission) { let keyPath = AppState.permissionKeyPath(for: permission) let currentStatus = appState[keyPath] @@ -61,11 +82,13 @@ final class RealUserPermissionsInteractor: UserPermissionsInteractor { } switch permission { case .pushNotifications: - requestPushNotificationsPermission() + Task { + await requestPushNotificationsPermission() + } } } } - + // MARK: - Push Notifications extension UNAuthorizationStatus { @@ -80,32 +103,27 @@ extension UNAuthorizationStatus { } private extension RealUserPermissionsInteractor { - - func pushNotificationsPermissionStatus(_ resolve: @escaping (Permission.Status) -> Void) { - let center = UNUserNotificationCenter.current() - center.getNotificationSettings { settings in - DispatchQueue.main.async { - resolve(settings.authorizationStatus.map) - } - } + + func pushNotificationsPermissionStatus() async -> Permission.Status { + return await notificationCenter + .currentSettings() + .authorizationStatus.map } - - func requestPushNotificationsPermission() { - let center = UNUserNotificationCenter.current() - center.requestAuthorization(options: [.alert, .sound]) { (isGranted, error) in - DispatchQueue.main.async { - self.appState[\.permissions.push] = isGranted ? .granted : .denied - } - } + + func requestPushNotificationsPermission() async { + let center = notificationCenter + let isGranted = (try? await center.requestAuthorization(options: [.alert, .sound])) ?? false + appState[\.permissions.push] = isGranted ? .granted : .denied } } // MARK: - final class StubUserPermissionsInteractor: UserPermissionsInteractor { - + func resolveStatus(for permission: Permission) { } func request(permission: Permission) { } } + diff --git a/CountriesSwiftUI/Models/MockedData.swift b/CountriesSwiftUI/Models/MockedData.swift deleted file mode 100644 index d8ec295..0000000 --- a/CountriesSwiftUI/Models/MockedData.swift +++ /dev/null @@ -1,42 +0,0 @@ -// -// MockedModel.swift -// CountriesSwiftUI -// -// Created by Alexey Naumov on 27.10.2019. -// Copyright © 2019 Alexey Naumov. All rights reserved. -// - -import Foundation - -#if DEBUG - -extension Country { - static let mockedData: [Country] = [ - Country(name: "United States", translations: [:], population: 125000000, - flag: URL(string: "https://flagcdn.com/w640/us.jpg"), alpha3Code: "USA"), - Country(name: "Georgia", translations: [:], population: 2340000, flag: nil, alpha3Code: "GEO"), - Country(name: "Canada", translations: [:], population: 57600000, flag: nil, alpha3Code: "CAN") - ] -} - -extension Country.Details { - static var mockedData: [Country.Details] = { - let neighbors = Country.mockedData - return [ - Country.Details(capital: "Sin City", currencies: Country.Currency.mockedData, neighbors: neighbors), - Country.Details(capital: "Los Angeles", currencies: Country.Currency.mockedData, neighbors: []), - Country.Details(capital: "New York", currencies: [], neighbors: []), - Country.Details(capital: "Moscow", currencies: [], neighbors: neighbors) - ] - }() -} - -extension Country.Currency { - static let mockedData: [Country.Currency] = [ - Country.Currency(code: "USD", symbol: "$", name: "US Dollar"), - Country.Currency(code: "EUR", symbol: "€", name: "Euro"), - Country.Currency(code: "RUB", symbol: "‡", name: "Rouble") - ] -} - -#endif diff --git a/CountriesSwiftUI/Models/Models+CoreData.swift b/CountriesSwiftUI/Models/Models+CoreData.swift deleted file mode 100644 index d71f261..0000000 --- a/CountriesSwiftUI/Models/Models+CoreData.swift +++ /dev/null @@ -1,126 +0,0 @@ -// -// Models+CoreData.swift -// CountriesSwiftUI -// -// Created by Alexey Naumov on 12.04.2020. -// Copyright © 2020 Alexey Naumov. All rights reserved. -// - -import Foundation -import CoreData - -extension CountryMO: ManagedEntity { } -extension NameTranslationMO: ManagedEntity { } -extension CountryDetailsMO: ManagedEntity { } -extension CurrencyMO: ManagedEntity { } - -extension Locale { - static var backendDefault: Locale { - return Locale(identifier: "en") - } - - var shortIdentifier: String { - return String(identifier.prefix(2)) - } -} - -extension Country.Details { - - init?(managedObject: CountryDetailsMO) { - guard let capital = managedObject.capital - else { return nil } - - let currencies = (managedObject.currencies ?? NSSet()) - .toArray(of: CurrencyMO.self) - .compactMap { Country.Currency(managedObject: $0) } - - let borders = (managedObject.borders ?? NSSet()) - .toArray(of: CountryMO.self) - .compactMap { Country(managedObject: $0) } - .sorted(by: { $0.name < $1.name }) - - self.init(capital: capital, currencies: currencies, neighbors: borders) - } -} - -extension Country.Details.Intermediate { - - @discardableResult - func store(in context: NSManagedObjectContext, - country: CountryMO, borders: [CountryMO]) -> CountryDetailsMO? { - guard let details = CountryDetailsMO.insertNew(in: context) - else { return nil } - details.capital = capital - let storedCurrencies = currencies.compactMap { $0.store(in: context) } - details.currencies = NSSet(array: storedCurrencies) - details.borders = NSSet(array: borders) - country.countryDetails = details - return details - } -} - -extension Country.Currency { - - init?(managedObject: CurrencyMO) { - guard let code = managedObject.code, - let name = managedObject.name - else { return nil } - self.init(code: code, symbol: managedObject.symbol, name: name) - } - - @discardableResult - func store(in context: NSManagedObjectContext) -> CurrencyMO? { - guard let currency = CurrencyMO.insertNew(in: context) - else { return nil } - currency.code = code - currency.name = name - currency.symbol = symbol - return currency - } -} - -extension Country { - - @discardableResult - func store(in context: NSManagedObjectContext) -> CountryMO? { - guard let country = CountryMO.insertNew(in: context) - else { return nil } - country.name = name - country.alpha3code = alpha3Code - country.population = Int32(population) - country.flagURL = flag?.absoluteString - let translations = self.translations - .compactMap { (locale, name) -> NameTranslationMO? in - guard let name = name, - let translation = NameTranslationMO.insertNew(in: context) - else { return nil } - translation.name = name - translation.locale = locale - return translation - } - country.nameTranslations = NSSet(array: translations) - return country - } - - init?(managedObject: CountryMO) { - guard let nameTranslations = managedObject.nameTranslations - else { return nil } - let translations: [String: String?] = nameTranslations - .toArray(of: NameTranslationMO.self) - .reduce([:], { (dict, record) -> [String: String?] in - guard let locale = record.locale, let name = record.name - else { return dict } - var dict = dict - dict[locale] = name - return dict - }) - guard let name = managedObject.name, - let alpha3code = managedObject.alpha3code - else { return nil } - - self.init(name: name, translations: translations, - population: Int(managedObject.population), - flag: managedObject.flagURL.flatMap { URL(string: $0) }, - alpha3Code: alpha3code) - } -} diff --git a/CountriesSwiftUI/Models/Models.swift b/CountriesSwiftUI/Models/Models.swift deleted file mode 100644 index 1d134fb..0000000 --- a/CountriesSwiftUI/Models/Models.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// Models.swift -// CountriesSwiftUI -// -// Created by Alexey Naumov on 23.10.2019. -// Copyright © 2019 Alexey Naumov. All rights reserved. -// - -import Foundation - -struct Country: Codable, Equatable { - let name: String - let translations: [String: String?] - let population: Int - let flag: URL? - let alpha3Code: Code - - typealias Code = String - - enum CodingKeys: String, CodingKey { - case name - case translations - case population - case flag = "alpha2Code" - case alpha3Code - } - - init(name: String, translations: [String: String?], population: Int, flag: URL?, alpha3Code: Code) { - self.name = name - self.translations = translations - self.population = population - self.flag = flag - self.alpha3Code = alpha3Code - } - - init(from decoder: Decoder) throws { - let values = try decoder.container(keyedBy: CodingKeys.self) - name = try values.decode(String.self, forKey: .name) - translations = try values.decode([String: String?].self, forKey: .translations) - population = try values.decode(Int.self, forKey: .population) - if let alpha2orFlagURL = try? values.decode(Code.self, forKey: .flag) { - let urlString = alpha2orFlagURL.count == 2 ? - "https://flagcdn.com/w640//(alpha2orFlagURL.lowercased()).jpg" : alpha2orFlagURL - flag = URL(string: urlString) - } else { flag = nil } - alpha3Code = try values.decode(Code.self, forKey: .alpha3Code) - } -} - -extension Country { - struct Details: Codable, Equatable { - let capital: String - let currencies: [Currency] - let neighbors: [Country] - } -} - -extension Country.Details { - struct Intermediate: Codable, Equatable { - let capital: String - let currencies: [Country.Currency] - let borders: [String] - } -} - -extension Country { - struct Currency: Codable, Equatable { - let code: String - let symbol: String? - let name: String - } -} - -// MARK: - Helpers - -extension Country: Identifiable { - var id: String { alpha3Code } -} - -extension Country.Currency: Identifiable { - var id: String { code } -} - -extension Country { - func name(locale: Locale) -> String { - let localeId = locale.shortIdentifier - if let value = translations[localeId], let localizedName = value { - return localizedName - } - return name - } -} diff --git a/CountriesSwiftUI/Persistence/CoreDataHelpers.swift b/CountriesSwiftUI/Persistence/CoreDataHelpers.swift deleted file mode 100644 index fd78248..0000000 --- a/CountriesSwiftUI/Persistence/CoreDataHelpers.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// CoreDataHelpers.swift -// CountriesSwiftUI -// -// Created by Alexey Naumov on 12.04.2020. -// Copyright © 2020 Alexey Naumov. All rights reserved. -// - -import CoreData -import Combine - -// MARK: - ManagedEntity - -protocol ManagedEntity: NSFetchRequestResult { } - -extension ManagedEntity where Self: NSManagedObject { - - static var entityName: String { - let nameMO = String(describing: Self.self) - let suffixIndex = nameMO.index(nameMO.endIndex, offsetBy: -2) - return String(nameMO[.. Self? { - return NSEntityDescription - .insertNewObject(forEntityName: entityName, into: context) as? Self - } - - static func newFetchRequest() -> NSFetchRequest { - return .init(entityName: entityName) - } -} - -// MARK: - NSManagedObjectContext - -extension NSManagedObjectContext { - - func configureAsReadOnlyContext() { - automaticallyMergesChangesFromParent = true - mergePolicy = NSRollbackMergePolicy - undoManager = nil - shouldDeleteInaccessibleFaults = true - } - - func configureAsUpdateContext() { - mergePolicy = NSOverwriteMergePolicy - undoManager = nil - } -} - -// MARK: - Misc - -extension NSSet { - func toArray(of type: T.Type) -> [T] { - allObjects.compactMap { $0 as? T } - } -} diff --git a/CountriesSwiftUI/Persistence/CoreDataStack.swift b/CountriesSwiftUI/Persistence/CoreDataStack.swift deleted file mode 100644 index 0e32cf9..0000000 --- a/CountriesSwiftUI/Persistence/CoreDataStack.swift +++ /dev/null @@ -1,158 +0,0 @@ -// -// CoreDataStack.swift -// CountriesSwiftUI -// -// Created by Alexey Naumov on 12.04.2020. -// Copyright © 2020 Alexey Naumov. All rights reserved. -// - -import CoreData -import Combine - -protocol PersistentStore { - typealias DBOperation = (NSManagedObjectContext) throws -> Result - - func count(_ fetchRequest: NSFetchRequest) -> AnyPublisher - func fetch(_ fetchRequest: NSFetchRequest, - map: @escaping (T) throws -> V?) -> AnyPublisher, Error> - func update(_ operation: @escaping DBOperation) -> AnyPublisher -} - -struct CoreDataStack: PersistentStore { - - private let container: NSPersistentContainer - private let isStoreLoaded = CurrentValueSubject(false) - private let bgQueue = DispatchQueue(label: "coredata") - - init(directory: FileManager.SearchPathDirectory = .documentDirectory, - domainMask: FileManager.SearchPathDomainMask = .userDomainMask, - version vNumber: UInt) { - let version = Version(vNumber) - container = NSPersistentContainer(name: version.modelName) - if let url = version.dbFileURL(directory, domainMask) { - let store = NSPersistentStoreDescription(url: url) - container.persistentStoreDescriptions = [store] - } - bgQueue.async { [weak isStoreLoaded, weak container] in - container?.loadPersistentStores { (storeDescription, error) in - DispatchQueue.main.async { - if let error = error { - isStoreLoaded?.send(completion: .failure(error)) - } else { - container?.viewContext.configureAsReadOnlyContext() - isStoreLoaded?.value = true - } - } - } - } - } - - func count(_ fetchRequest: NSFetchRequest) -> AnyPublisher { - return onStoreIsReady - .flatMap { [weak container] in - Future { promise in - do { - let count = try container?.viewContext.count(for: fetchRequest) ?? 0 - promise(.success(count)) - } catch { - promise(.failure(error)) - } - } - } - .eraseToAnyPublisher() - } - - func fetch(_ fetchRequest: NSFetchRequest, - map: @escaping (T) throws -> V?) -> AnyPublisher, Error> { - assert(Thread.isMainThread) - let fetch = Future, Error> { [weak container] promise in - guard let context = container?.viewContext else { return } - context.performAndWait { - do { - let managedObjects = try context.fetch(fetchRequest) - let results = LazyList(count: managedObjects.count, - useCache: true) { [weak context] in - let object = managedObjects[$0] - let mapped = try map(object) - if let mo = object as? NSManagedObject { - // Turning object into a fault - context?.refresh(mo, mergeChanges: false) - } - return mapped - } - promise(.success(results)) - } catch { - promise(.failure(error)) - } - } - } - return onStoreIsReady - .flatMap { fetch } - .eraseToAnyPublisher() - } - - func update(_ operation: @escaping DBOperation) -> AnyPublisher { - let update = Future { [weak bgQueue, weak container] promise in - bgQueue?.async { - guard let context = container?.newBackgroundContext() else { return } - context.configureAsUpdateContext() - context.performAndWait { - do { - let result = try operation(context) - if context.hasChanges { - try context.save() - } - context.reset() - promise(.success(result)) - } catch { - context.reset() - promise(.failure(error)) - } - } - } - } - return onStoreIsReady - .flatMap { update } -// .subscribe(on: bgQueue) // Does not work as stated in the docs. Using `bgQueue.async` - .receive(on: DispatchQueue.main) - .eraseToAnyPublisher() - } - - private var onStoreIsReady: AnyPublisher { - return isStoreLoaded - .filter { $0 } - .map { _ in } - .eraseToAnyPublisher() - } -} - -// MARK: - Versioning - -extension CoreDataStack.Version { - static var actual: UInt { 1 } -} - -extension CoreDataStack { - struct Version { - private let number: UInt - - init(_ number: UInt) { - self.number = number - } - - var modelName: String { - return "db_model_v1" - } - - func dbFileURL(_ directory: FileManager.SearchPathDirectory, - _ domainMask: FileManager.SearchPathDomainMask) -> URL? { - return FileManager.default - .urls(for: directory, in: domainMask).first? - .appendingPathComponent(subpathToDB) - } - - private var subpathToDB: String { - return "db.sql" - } - } -} diff --git a/CountriesSwiftUI/Persistence/db_model_v1.xcdatamodeld/db_model_v1.xcdatamodel/contents b/CountriesSwiftUI/Persistence/db_model_v1.xcdatamodeld/db_model_v1.xcdatamodel/contents deleted file mode 100644 index 9c35bf8..0000000 --- a/CountriesSwiftUI/Persistence/db_model_v1.xcdatamodeld/db_model_v1.xcdatamodel/contents +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/CountriesSwiftUI/Repositories/CountriesDBRepository.swift b/CountriesSwiftUI/Repositories/CountriesDBRepository.swift deleted file mode 100644 index 24aaa76..0000000 --- a/CountriesSwiftUI/Repositories/CountriesDBRepository.swift +++ /dev/null @@ -1,120 +0,0 @@ -// -// CountriesDBRepository.swift -// CountriesSwiftUI -// -// Created by Alexey Naumov on 13.04.2020. -// Copyright © 2020 Alexey Naumov. All rights reserved. -// - -import CoreData -import Combine - -protocol CountriesDBRepository { - func hasLoadedCountries() -> AnyPublisher - - func store(countries: [Country]) -> AnyPublisher - func countries(search: String, locale: Locale) -> AnyPublisher, Error> - - func store(countryDetails: Country.Details.Intermediate, - for country: Country) -> AnyPublisher - func countryDetails(country: Country) -> AnyPublisher -} - -struct RealCountriesDBRepository: CountriesDBRepository { - - let persistentStore: PersistentStore - - func hasLoadedCountries() -> AnyPublisher { - let fetchRequest = CountryMO.justOneCountry() - return persistentStore - .count(fetchRequest) - .map { $0 > 0 } - .eraseToAnyPublisher() - } - - func store(countries: [Country]) -> AnyPublisher { - return persistentStore - .update { context in - countries.forEach { - $0.store(in: context) - } - } - } - - func countries(search: String, locale: Locale) -> AnyPublisher, Error> { - let fetchRequest = CountryMO.countries(search: search, locale: locale) - return persistentStore - .fetch(fetchRequest) { - Country(managedObject: $0) - } - .eraseToAnyPublisher() - } - - func store(countryDetails: Country.Details.Intermediate, - for country: Country) -> AnyPublisher { - return persistentStore - .update { context in - let parentRequest = CountryMO.countries(alpha3codes: [country.alpha3Code]) - guard let parent = try context.fetch(parentRequest).first - else { return nil } - let neighbors = CountryMO.countries(alpha3codes: countryDetails.borders) - let borders = try context.fetch(neighbors) - let details = countryDetails.store(in: context, country: parent, borders: borders) - return details.flatMap { Country.Details(managedObject: $0) } - } - } - - func countryDetails(country: Country) -> AnyPublisher { - let fetchRequest = CountryDetailsMO.details(country: country) - return persistentStore - .fetch(fetchRequest) { - Country.Details(managedObject: $0) - } - .map { $0.first } - .eraseToAnyPublisher() - } -} - -// MARK: - Fetch Requests - -extension CountryMO { - - static func justOneCountry() -> NSFetchRequest { - let request = newFetchRequest() - request.predicate = NSPredicate(format: "alpha3code == %@", "USA") - request.fetchLimit = 1 - return request - } - - static func countries(search: String, locale: Locale) -> NSFetchRequest { - let request = newFetchRequest() - if search.count == 0 { - request.predicate = NSPredicate(value: true) - } else { - let localeId = locale.shortIdentifier - let nameMatch = NSPredicate(format: "name CONTAINS[cd] %@", search) - let localizedMatch = NSPredicate(format: - "(SUBQUERY(nameTranslations,$t,$t.locale == %@ AND $t.name CONTAINS[cd] %@).@count > 0)", localeId, search) - request.predicate = NSCompoundPredicate(type: .or, subpredicates: [nameMatch, localizedMatch]) - } - request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] - request.fetchBatchSize = 10 - return request - } - - static func countries(alpha3codes: [String]) -> NSFetchRequest { - let request = newFetchRequest() - request.predicate = NSPredicate(format: "alpha3code in %@", alpha3codes) - request.fetchLimit = alpha3codes.count - return request - } -} - -extension CountryDetailsMO { - static func details(country: Country) -> NSFetchRequest { - let request = newFetchRequest() - request.predicate = NSPredicate(format: "country.alpha3code == %@", country.alpha3Code) - request.fetchLimit = 1 - return request - } -} diff --git a/CountriesSwiftUI/Repositories/CountriesWebRepository.swift b/CountriesSwiftUI/Repositories/CountriesWebRepository.swift deleted file mode 100644 index 4364c53..0000000 --- a/CountriesSwiftUI/Repositories/CountriesWebRepository.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// CountriesWebRepository.swift -// CountriesSwiftUI -// -// Created by Alexey Naumov on 29.10.2019. -// Copyright © 2019 Alexey Naumov. All rights reserved. -// - -import Combine -import Foundation - -protocol CountriesWebRepository: WebRepository { - func loadCountries() -> AnyPublisher<[Country], Error> - func loadCountryDetails(country: Country) -> AnyPublisher -} - -struct RealCountriesWebRepository: CountriesWebRepository { - - let session: URLSession - let baseURL: String - let bgQueue = DispatchQueue(label: "bg_parse_queue") - - init(session: URLSession, baseURL: String) { - self.session = session - self.baseURL = baseURL - } - - func loadCountries() -> AnyPublisher<[Country], Error> { - return call(endpoint: API.allCountries) - } - - func loadCountryDetails(country: Country) -> AnyPublisher { - let request: AnyPublisher<[Country.Details.Intermediate], Error> = call(endpoint: API.countryDetails(country)) - return request - .tryMap { array -> Country.Details.Intermediate in - guard let details = array.first - else { throw APIError.unexpectedResponse } - return details - } - .eraseToAnyPublisher() - } -} - -// MARK: - Endpoints - -extension RealCountriesWebRepository { - enum API { - case allCountries - case countryDetails(Country) - } -} - -extension RealCountriesWebRepository.API: APICall { - var path: String { - switch self { - case .allCountries: - return "/all" - case let .countryDetails(country): - let encodedName = country.name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) - return "/name/\(encodedName ?? country.name)" - } - } - var method: String { - switch self { - case .allCountries, .countryDetails: - return "GET" - } - } - var headers: [String: String]? { - return ["Accept": "application/json"] - } - func body() throws -> Data? { - return nil - } -} diff --git a/CountriesSwiftUI/Repositories/Database/CountriesDBRepository.swift b/CountriesSwiftUI/Repositories/Database/CountriesDBRepository.swift new file mode 100644 index 0000000..c145abf --- /dev/null +++ b/CountriesSwiftUI/Repositories/Database/CountriesDBRepository.swift @@ -0,0 +1,73 @@ +// +// CountriesDBRepository.swift +// CountriesSwiftUI +// +// Created by Alexey on 7/11/24. +// Copyright © 2024 Alexey Naumov. All rights reserved. +// + +import SwiftData +import Foundation + +protocol CountriesDBRepository { + @MainActor + func countryDetails(for country: DBModel.Country) async throws -> DBModel.CountryDetails? + func store(countries: [ApiModel.Country]) async throws + func store(countryDetails: ApiModel.CountryDetails, for country: DBModel.Country) async throws +} + +extension MainDBRepository: CountriesDBRepository { + + @MainActor + func countryDetails(for country: DBModel.Country) async throws -> DBModel.CountryDetails? { + let alpha3Code = country.alpha3Code + let fetchDescriptor = FetchDescriptor(predicate: #Predicate { + $0.alpha3Code == alpha3Code + }) + return try modelContainer.mainContext.fetch(fetchDescriptor).first + } + + func store(countries: [ApiModel.Country]) async throws { + try modelContext.transaction { + countries + .map { $0.dbModel() } + .forEach { + modelContext.insert($0) + } + } + } + + func store(countryDetails: ApiModel.CountryDetails, for country: DBModel.Country) async throws { + let alpha3Code = country.alpha3Code + try modelContext.transaction { + let currencies = countryDetails.currencies.map { $0.dbModel() } + let neighborsFetch = FetchDescriptor(predicate: #Predicate { + countryDetails.borders.contains($0.alpha3Code) + }) + let neighbors = try modelContext.fetch(neighborsFetch) + currencies.forEach { + modelContext.insert($0) + } + let object = DBModel.CountryDetails( + alpha3Code: alpha3Code, + capital: countryDetails.capital, + currencies: currencies, + neighbors: neighbors) + modelContext.insert(object) + } + } +} + +internal extension ApiModel.Country { + func dbModel() -> DBModel.Country { + return .init(name: name, translations: translations, + population: population, flag: flag, + alpha3Code: alpha3Code) + } +} + +internal extension ApiModel.Currency { + func dbModel() -> DBModel.Currency { + return .init(code: code, symbol: symbol, name: name) + } +} diff --git a/CountriesSwiftUI/Repositories/Database/ModelContainer.swift b/CountriesSwiftUI/Repositories/Database/ModelContainer.swift new file mode 100644 index 0000000..cc6901e --- /dev/null +++ b/CountriesSwiftUI/Repositories/Database/ModelContainer.swift @@ -0,0 +1,31 @@ +// +// ModelContainer.swift +// CountriesSwiftUI +// +// Created by Alexey on 7/11/24. +// Copyright © 2024 Alexey Naumov. All rights reserved. +// + +import SwiftData + +extension ModelContainer { + + static func appModelContainer( + inMemoryOnly: Bool = false, isStub: Bool = false + ) throws -> ModelContainer { + let schema = Schema.appSchema + let modelConfiguration = ModelConfiguration(isStub ? "stub" : nil, schema: schema, isStoredInMemoryOnly: inMemoryOnly) + return try ModelContainer(for: schema, configurations: [modelConfiguration]) + } + + static var stub: ModelContainer { + try! appModelContainer(inMemoryOnly: true, isStub: true) + } + + var isStub: Bool { + return configurations.first?.name == "stub" + } +} + +@ModelActor +final actor MainDBRepository { } diff --git a/CountriesSwiftUI/Repositories/ImageWebRepository.swift b/CountriesSwiftUI/Repositories/ImageWebRepository.swift deleted file mode 100644 index 03d0691..0000000 --- a/CountriesSwiftUI/Repositories/ImageWebRepository.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// ImageWebRepository.swift -// CountriesSwiftUI -// -// Created by Alexey Naumov on 09.11.2019. -// Copyright © 2019 Alexey Naumov. All rights reserved. -// - -import Combine -import UIKit - -protocol ImageWebRepository: WebRepository { - func load(imageURL: URL) -> AnyPublisher -} - -struct RealImageWebRepository: ImageWebRepository { - - let session: URLSession - let baseURL: String - let bgQueue = DispatchQueue(label: "bg_parse_queue") - - init(session: URLSession, baseURL: String) { - self.session = session - self.baseURL = baseURL - } - - func load(imageURL: URL) -> AnyPublisher { - return download(rawImageURL: imageURL) - .subscribe(on: bgQueue) - .receive(on: DispatchQueue.main) - .extractUnderlyingError() - .eraseToAnyPublisher() - } - - private func download(rawImageURL: URL) -> AnyPublisher { - let urlRequest = URLRequest(url: rawImageURL) - return session.dataTaskPublisher(for: urlRequest) - .requestData() - .tryMap { data -> UIImage in - guard let image = UIImage(data: data) else { - throw APIError.imageDeserialization - } - return image - } - .eraseToAnyPublisher() - } -} diff --git a/CountriesSwiftUI/Repositories/Models/AppSchema.swift b/CountriesSwiftUI/Repositories/Models/AppSchema.swift new file mode 100644 index 0000000..2afff40 --- /dev/null +++ b/CountriesSwiftUI/Repositories/Models/AppSchema.swift @@ -0,0 +1,23 @@ +// +// AppSchema.swift +// CountriesSwiftUI +// +// Created by Alexey on 7/11/24. +// Copyright © 2024 Alexey Naumov. All rights reserved. +// + +import SwiftData + +enum DBModel { } + +extension Schema { + private static var actualVersion: Schema.Version = Version(1, 0, 0) + + static var appSchema: Schema { + Schema([ + DBModel.Country.self, + DBModel.CountryDetails.self, + DBModel.Currency.self, + ], version: actualVersion) + } +} diff --git a/CountriesSwiftUI/Repositories/Models/Country.swift b/CountriesSwiftUI/Repositories/Models/Country.swift new file mode 100644 index 0000000..3c63775 --- /dev/null +++ b/CountriesSwiftUI/Repositories/Models/Country.swift @@ -0,0 +1,84 @@ +// +// Country.swift +// CountriesSwiftUI +// +// Created by Alexey on 7/11/24. +// Copyright © 2024 Alexey Naumov. All rights reserved. +// + +import Foundation +import SwiftData + +// MARK: - Database Model + +extension DBModel { + + @Model final class Country { + + var name: String + var translations: [String: String?] + var population: Int + var flag: URL? + @Attribute(.unique) var alpha3Code: String + @Relationship(inverse: \CountryDetails.neighbors) var neighbors: [CountryDetails] = [] + + init(name: String, translations: [String: String?], population: Int, flag: URL? = nil, alpha3Code: String) { + self.name = name + self.translations = translations + self.population = population + self.flag = flag + self.alpha3Code = alpha3Code + } + + func name(locale: Locale) -> String { + let localeId = locale.shortIdentifier + if let value = translations[localeId], let localizedName = value { + return localizedName + } + return name + } + } +} + +// MARK: - Web API Model + +extension ApiModel { + + struct Country: Codable, Equatable { + + let name: String + let translations: [String: String?] + let population: Int + let flag: URL? + let alpha3Code: String + + enum CodingKeys: String, CodingKey { + case name + case translations + case population + case flag = "alpha2Code" + case alpha3Code + } + + init(name: String, translations: [String: String?], population: Int, flag: URL?, alpha3Code: String) { + self.name = name + self.translations = translations + self.population = population + self.flag = flag + self.alpha3Code = alpha3Code + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + name = try values.decode(String.self, forKey: .name) + translations = try values.decode([String: String?].self, forKey: .translations) + population = try values.decode(Int.self, forKey: .population) + if let alpha2orFlagURL = try? values.decode(String.self, forKey: .flag) { + let urlString = alpha2orFlagURL.count == 2 ? + "https://flagcdn.com/w640//(alpha2orFlagURL.lowercased()).jpg" : alpha2orFlagURL + flag = URL(string: urlString) + } else { flag = nil } + alpha3Code = try values.decode(String.self, forKey: .alpha3Code) + } + } +} diff --git a/CountriesSwiftUI/Repositories/Models/CountryCurrency.swift b/CountriesSwiftUI/Repositories/Models/CountryCurrency.swift new file mode 100644 index 0000000..ee5b7c7 --- /dev/null +++ b/CountriesSwiftUI/Repositories/Models/CountryCurrency.swift @@ -0,0 +1,36 @@ +// +// CountryCurrency.swift +// CountriesSwiftUI +// +// Created by Alexey on 8/11/24. +// Copyright © 2024 Alexey Naumov. All rights reserved. +// + +import SwiftData + +// MARK: - Database Model + +extension DBModel { + @Model final class Currency { + @Relationship(inverse: \CountryDetails.currencies) var countries: [CountryDetails] = [] + @Attribute(.unique) var code: String + var symbol: String? + var name: String + + init(code: String, symbol: String?, name: String) { + self.code = code + self.symbol = symbol + self.name = name + } + } +} + +// MARK: - Web API Model + +extension ApiModel { + struct Currency: Codable, Equatable { + let code: String + let symbol: String? + let name: String + } +} diff --git a/CountriesSwiftUI/Repositories/Models/CountryDetails.swift b/CountriesSwiftUI/Repositories/Models/CountryDetails.swift new file mode 100644 index 0000000..a6efa7b --- /dev/null +++ b/CountriesSwiftUI/Repositories/Models/CountryDetails.swift @@ -0,0 +1,38 @@ +// +// CountryDetails.swift +// CountriesSwiftUI +// +// Created by Alexey on 7/11/24. +// Copyright © 2024 Alexey Naumov. All rights reserved. +// + +import SwiftData + +// MARK: - Database Model + +extension DBModel { + + @Model final class CountryDetails { + @Attribute(.unique) var alpha3Code: String + var capital: String + var currencies: [Currency] + var neighbors: [Country] + + init(alpha3Code: String, capital: String, currencies: [Currency], neighbors: [Country]) { + self.alpha3Code = alpha3Code + self.capital = capital + self.currencies = currencies + self.neighbors = neighbors + } + } +} + +// MARK: - Web API Model + +extension ApiModel { + struct CountryDetails: Codable, Equatable { + let capital: String + let currencies: [Currency] + let borders: [String] + } +} diff --git a/CountriesSwiftUI/Repositories/Models/MockedData.swift b/CountriesSwiftUI/Repositories/Models/MockedData.swift new file mode 100644 index 0000000..a8f0f8f --- /dev/null +++ b/CountriesSwiftUI/Repositories/Models/MockedData.swift @@ -0,0 +1,45 @@ +// +// MockedModel.swift +// CountriesSwiftUI +// +// Created by Alexey Naumov on 27.10.2019. +// Copyright © 2019 Alexey Naumov. All rights reserved. +// + +import Foundation + +#if DEBUG + +@MainActor +extension ApiModel.Country { + static let mockedData: [ApiModel.Country] = [ + ApiModel.Country(name: "United States", translations: [:], population: 125000000, + flag: URL(string: "https://flagcdn.com/w640/us.jpg"), alpha3Code: "USA"), + ApiModel.Country(name: "Georgia", translations: [:], population: 2340000, flag: nil, alpha3Code: "GEO"), + ApiModel.Country(name: "Canada", translations: [:], population: 57600000, flag: nil, alpha3Code: "CAN") + ] +} + +@MainActor +extension ApiModel.CountryDetails { + static var mockedData: [ApiModel.CountryDetails] = { + let neighbors = ApiModel.Country.mockedData + return [ + ApiModel.CountryDetails(capital: "Sin City", currencies: ApiModel.Currency.mockedData, borders: ["abc"]), + ApiModel.CountryDetails(capital: "Los Angeles", currencies: ApiModel.Currency.mockedData, borders: []), + ApiModel.CountryDetails(capital: "New York", currencies: [], borders: []), + ApiModel.CountryDetails(capital: "Moscow", currencies: [], borders: ["xyz"]) + ] + }() +} + +@MainActor +extension ApiModel.Currency { + static let mockedData: [ApiModel.Currency] = [ + ApiModel.Currency(code: "USD", symbol: "$", name: "US Dollar"), + ApiModel.Currency(code: "EUR", symbol: "€", name: "Euro"), + ApiModel.Currency(code: "RUB", symbol: "‡", name: "Rouble") + ] +} + +#endif diff --git a/CountriesSwiftUI/Repositories/WebAPI/CountriesWebRepository.swift b/CountriesSwiftUI/Repositories/WebAPI/CountriesWebRepository.swift new file mode 100644 index 0000000..ed91db9 --- /dev/null +++ b/CountriesSwiftUI/Repositories/WebAPI/CountriesWebRepository.swift @@ -0,0 +1,70 @@ +// +// CountriesWebRepository.swift +// CountriesSwiftUI +// +// Created by Alexey on 7/11/24. +// Copyright © 2024 Alexey Naumov. All rights reserved. +// + +import Foundation + +protocol CountriesWebRepository: WebRepository { + func countries() async throws -> [ApiModel.Country] + func details(country: DBModel.Country) async throws -> ApiModel.CountryDetails +} + +struct RealCountriesWebRepository: CountriesWebRepository { + + let session: URLSession + let baseURL: String + + init(session: URLSession) { + self.session = session + self.baseURL = "https://restcountries.com/v2" + } + + func countries() async throws -> [ApiModel.Country] { + return try await call(endpoint: API.allCountries) + } + + func details(country: DBModel.Country) async throws -> ApiModel.CountryDetails { + let response: [ApiModel.CountryDetails] = try await call(endpoint: API.countryDetails(countryName: country.name)) + guard let details = response.first else { + throw APIError.unexpectedResponse + } + return details + } +} + +// MARK: - Endpoints + +extension RealCountriesWebRepository { + enum API { + case allCountries + case countryDetails(countryName: String) + } +} + +extension RealCountriesWebRepository.API: APICall { + var path: String { + switch self { + case .allCountries: + return "/all" + case let .countryDetails(countryName): + let encodedName = countryName.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) + return "/name/\(encodedName ?? countryName)" + } + } + var method: String { + switch self { + case .allCountries, .countryDetails: + return "GET" + } + } + var headers: [String: String]? { + return ["Accept": "application/json"] + } + func body() throws -> Data? { + return nil + } +} diff --git a/CountriesSwiftUI/Repositories/WebAPI/ImagesWebRepository.swift b/CountriesSwiftUI/Repositories/WebAPI/ImagesWebRepository.swift new file mode 100644 index 0000000..bbc05f2 --- /dev/null +++ b/CountriesSwiftUI/Repositories/WebAPI/ImagesWebRepository.swift @@ -0,0 +1,34 @@ +// +// ImageWebRepository.swift +// CountriesSwiftUI +// +// Created by Alexey Naumov on 09.11.2019. +// Copyright © 2019 Alexey Naumov. All rights reserved. +// + +import Combine +import UIKit + +protocol ImagesWebRepository: WebRepository { + func loadImage(url: URL) async throws -> UIImage +} + +struct RealImagesWebRepository: ImagesWebRepository { + + let session: URLSession + let baseURL: String + + init(session: URLSession) { + self.session = session + self.baseURL = "" + } + + func loadImage(url: URL) async throws -> UIImage { + let (localURL, _) = try await session.download(from: url) + let data = try Data(contentsOf: localURL) + guard let image = UIImage(data: data) else { + throw APIError.imageDeserialization + } + return image + } +} diff --git a/CountriesSwiftUI/Repositories/PushTokenWebRepository.swift b/CountriesSwiftUI/Repositories/WebAPI/PushTokenWebRepository.swift similarity index 56% rename from CountriesSwiftUI/Repositories/PushTokenWebRepository.swift rename to CountriesSwiftUI/Repositories/WebAPI/PushTokenWebRepository.swift index c3de78c..db1a78c 100644 --- a/CountriesSwiftUI/Repositories/PushTokenWebRepository.swift +++ b/CountriesSwiftUI/Repositories/WebAPI/PushTokenWebRepository.swift @@ -6,26 +6,24 @@ // Copyright © 2020 Alexey Naumov. All rights reserved. // -import Combine import Foundation protocol PushTokenWebRepository: WebRepository { - func register(devicePushToken: Data) -> AnyPublisher + func register(devicePushToken: Data) async throws } struct RealPushTokenWebRepository: PushTokenWebRepository { let session: URLSession let baseURL: String - let bgQueue = DispatchQueue(label: "bg_parse_queue") - init(session: URLSession, baseURL: String) { + init(session: URLSession) { self.session = session - self.baseURL = baseURL + self.baseURL = "https://your-server.com/api/push-token" } - func register(devicePushToken: Data) -> AnyPublisher { + func register(devicePushToken: Data) async throws { // upload the push token to your server - return Just.withErrorType(Error.self) + // you can as well call a third party library here instead } } diff --git a/CountriesSwiftUI/Utilities/APICall.swift b/CountriesSwiftUI/Repositories/WebAPI/WebRepository.swift similarity index 56% rename from CountriesSwiftUI/Utilities/APICall.swift rename to CountriesSwiftUI/Repositories/WebAPI/WebRepository.swift index 7081f03..cdd7235 100644 --- a/CountriesSwiftUI/Utilities/APICall.swift +++ b/CountriesSwiftUI/Repositories/WebAPI/WebRepository.swift @@ -1,5 +1,5 @@ // -// APICall.swift +// WebRepository.swift // CountriesSwiftUI // // Created by Alexey Naumov on 23.10.2019. @@ -7,6 +7,40 @@ // import Foundation +import Combine + +enum ApiModel { } + +protocol WebRepository { + var session: URLSession { get } + var baseURL: String { get } +} + +extension WebRepository { + func call( + endpoint: APICall, + decoder: Decoder = JSONDecoder(), + httpCodes: HTTPCodes = .success + ) async throws -> Value + where Value: Decodable, Decoder: TopLevelDecoder, Decoder.Input == Data { + + let request = try endpoint.urlRequest(baseURL: baseURL) + let (data, response) = try await session.data(for: request) + guard let code = (response as? HTTPURLResponse)?.statusCode else { + throw APIError.unexpectedResponse + } + guard httpCodes.contains(code) else { + throw APIError.httpCode(code) + } + do { + return try decoder.decode(Value.self, from: data) + } catch { + throw APIError.unexpectedResponse + } + } +} + +// MARK: - APICall protocol APICall { var path: String { get } @@ -15,7 +49,7 @@ protocol APICall { func body() throws -> Data? } -enum APIError: Swift.Error { +enum APIError: Swift.Error, Equatable { case invalidURL case httpCode(HTTPCode) case unexpectedResponse diff --git a/CountriesSwiftUI/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/CountriesSwiftUI/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/CountriesSwiftUI/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/CountriesSwiftUI/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/CountriesSwiftUI/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json index d8db8d6..2305880 100644 --- a/CountriesSwiftUI/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/CountriesSwiftUI/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,98 +1,35 @@ { "images" : [ { - "idiom" : "iphone", - "size" : "20x20", - "scale" : "2x" - }, - { - "idiom" : "iphone", - "size" : "20x20", - "scale" : "3x" - }, - { - "idiom" : "iphone", - "size" : "29x29", - "scale" : "2x" - }, - { - "idiom" : "iphone", - "size" : "29x29", - "scale" : "3x" - }, - { - "idiom" : "iphone", - "size" : "40x40", - "scale" : "2x" - }, - { - "idiom" : "iphone", - "size" : "40x40", - "scale" : "3x" - }, - { - "idiom" : "iphone", - "size" : "60x60", - "scale" : "2x" - }, - { - "idiom" : "iphone", - "size" : "60x60", - "scale" : "3x" - }, - { - "idiom" : "ipad", - "size" : "20x20", - "scale" : "1x" - }, - { - "idiom" : "ipad", - "size" : "20x20", - "scale" : "2x" - }, - { - "idiom" : "ipad", - "size" : "29x29", - "scale" : "1x" - }, - { - "idiom" : "ipad", - "size" : "29x29", - "scale" : "2x" - }, - { - "idiom" : "ipad", - "size" : "40x40", - "scale" : "1x" - }, - { - "idiom" : "ipad", - "size" : "40x40", - "scale" : "2x" - }, - { - "idiom" : "ipad", - "size" : "76x76", - "scale" : "1x" - }, - { - "idiom" : "ipad", - "size" : "76x76", - "scale" : "2x" - }, - { - "idiom" : "ipad", - "size" : "83.5x83.5", - "scale" : "2x" - }, - { - "idiom" : "ios-marketing", - "size" : "1024x1024", - "scale" : "1x" + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" } ], "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/CountriesSwiftUI/Resources/Assets.xcassets/Contents.json b/CountriesSwiftUI/Resources/Assets.xcassets/Contents.json index da4a164..73c0059 100644 --- a/CountriesSwiftUI/Resources/Assets.xcassets/Contents.json +++ b/CountriesSwiftUI/Resources/Assets.xcassets/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/CountriesSwiftUI/Resources/CountriesSwiftUI.entitlements b/CountriesSwiftUI/Resources/CountriesSwiftUI.entitlements deleted file mode 100644 index ee95ab7..0000000 --- a/CountriesSwiftUI/Resources/CountriesSwiftUI.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.network.client - - - diff --git a/CountriesSwiftUI/Resources/Info.plist b/CountriesSwiftUI/Resources/Info.plist deleted file mode 100644 index fa24fdc..0000000 --- a/CountriesSwiftUI/Resources/Info.plist +++ /dev/null @@ -1,66 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - $(MARKETING_VERSION) - CFBundleVersion - 1 - LSRequiresIPhoneOS - - NSPhotoLibraryAddUsageDescription - EnvironmentOverrides screenshots saving - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneConfigurationName - Default Configuration - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - - - - - UIBackgroundModes - - remote-notification - - UILaunchStoryboardName - LaunchScreen - UIRequiredDeviceCapabilities - - armv7 - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/CountriesSwiftUI/Resources/Localizable.xcstrings b/CountriesSwiftUI/Resources/Localizable.xcstrings new file mode 100644 index 0000000..46360e1 --- /dev/null +++ b/CountriesSwiftUI/Resources/Localizable.xcstrings @@ -0,0 +1,345 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "" : { + + }, + "%@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + } + } + }, + "%lld" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld" + } + } + } + }, + "⚠️ There is an issue with local database" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️ Es gibt ein Problem mit der lokalen Datenbank" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "⚠️ ローカルデータベースに問題があります" + } + } + } + }, + "Allow Push" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Push-Benachrichtigungen erlauben" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "プッシュ通知を許可する" + } + } + } + }, + "An Error Occured" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ein Fehler ist aufgetreten" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "エラーが発生しました" + } + } + } + }, + "Basic Info" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grundlegende Informationen" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "基本情報" + } + } + } + }, + "Cancel loading" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Laden abbrechen" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "読み込みをキャンセル" + } + } + } + }, + "Canceled by user" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vom Benutzer abgebrochen" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ユーザーによってキャンセルされました" + } + } + } + }, + "Capital" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hauptstadt" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "首都" + } + } + } + }, + "Close" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schließen" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "閉じる" + } + } + } + }, + "Code" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Code" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "コード" + } + } + } + }, + "Countries" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Länder" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "国々" + } + } + } + }, + "Currencies" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Währungen" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "通貨" + } + } + } + }, + "Data is missing" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Daten fehlen" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "データが不足しています" + } + } + } + }, + "Neighboring countries" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nachbarländer" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "隣接する国々" + } + } + } + }, + "No matches found" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Übereinstimmungen gefunden" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "一致するものが見つかりません" + } + } + } + }, + "Population" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bevölkerung" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "人口" + } + } + } + }, + "Population %lld" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bevölkerung %lld" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "人口 %lld" + } + } + } + }, + "Retry" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erneut versuchen" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "再試行" + } + } + } + }, + "Running unit tests" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Führen von Unit-Tests" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ユニットテストを実行中" + } + } + } + }, + "Unable to load image" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bild kann nicht geladen werden" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "画像を読み込めません" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/CountriesSwiftUI/Resources/Preview Assets.xcassets/Contents.json b/CountriesSwiftUI/Resources/Preview Assets.xcassets/Contents.json index da4a164..73c0059 100644 --- a/CountriesSwiftUI/Resources/Preview Assets.xcassets/Contents.json +++ b/CountriesSwiftUI/Resources/Preview Assets.xcassets/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/CountriesSwiftUI/Resources/en.lproj/LaunchScreen.storyboard b/CountriesSwiftUI/Resources/en.lproj/LaunchScreen.storyboard deleted file mode 100644 index 865e932..0000000 --- a/CountriesSwiftUI/Resources/en.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/CountriesSwiftUI/Resources/en.lproj/Localizable.strings b/CountriesSwiftUI/Resources/en.lproj/Localizable.strings deleted file mode 100644 index 0d4868e..0000000 --- a/CountriesSwiftUI/Resources/en.lproj/Localizable.strings +++ /dev/null @@ -1,17 +0,0 @@ - -"Running unit tests" = "Running unit tests"; -"Countries" = "Countries"; -"Basic Info" = "Basic Info"; -"Code" = "Code"; -"Population" = "Population"; -"Capital" = "Capital"; -"Currencies" = "Currencies"; -"Neighboring countries" = "Neighboring countries"; -"Close" = "Close"; -"Population %lld" = "Population %lld"; -"An Error Occured" = "An Error Occured"; -"Retry" = "Retry"; -"Unable to load image" = "Unable to load image"; -"Back" = "Back"; -"Cancel loading" = "Cancel loading"; -"Canceled by user" = "Canceled by user"; diff --git a/CountriesSwiftUI/Resources/es.lproj/Localizable.strings b/CountriesSwiftUI/Resources/es.lproj/Localizable.strings deleted file mode 100644 index ed61284..0000000 --- a/CountriesSwiftUI/Resources/es.lproj/Localizable.strings +++ /dev/null @@ -1,17 +0,0 @@ - -"Running unit tests" = "Ejecución de pruebas unitarias"; -"Countries" = "Países"; -"Basic Info" = "Información básica"; -"Code" = "Código"; -"Population" = "Población"; -"Capital" = "Capital"; -"Currencies" = "Monedas"; -"Neighboring countries" = "Países vecinos"; -"Close" = "Cerca"; -"Population %lld" = "Población %lld"; -"An Error Occured" = "Ocurrió un error"; -"Retry" = "Procesar de nuevo"; -"Unable to load image" = "No se puede cargar la imagen"; -"Back" = "Atrás"; -"Cancel loading" = "Cancelar carga"; -"Canceled by user" = "Cancelado por el usuario"; diff --git a/CountriesSwiftUI/Resources/fr.lproj/Localizable.strings b/CountriesSwiftUI/Resources/fr.lproj/Localizable.strings deleted file mode 100644 index 42f3d87..0000000 --- a/CountriesSwiftUI/Resources/fr.lproj/Localizable.strings +++ /dev/null @@ -1,17 +0,0 @@ - -"Running unit tests" = "Exécution de tests unitaires"; -"Countries" = "Des pays"; -"Basic Info" = "Informations de base"; -"Code" = "Code"; -"Population" = "Population"; -"Capital" = "Capitale"; -"Currencies" = "Devises"; -"Neighboring countries" = "Pays voisins"; -"Close" = "Fermer"; -"Population %lld" = "Population %lld"; -"An Error Occured" = "Une erreur s'est produite"; -"Retry" = "Retenter"; -"Unable to load image" = "Impossible de charger l'image"; -"Back" = "Retour"; -"Cancel loading" = "Annuler le chargement"; -"Canceled by user" = "Annulé par l'utilisateur"; diff --git a/CountriesSwiftUI/Resources/ja.lproj/Localizable.strings b/CountriesSwiftUI/Resources/ja.lproj/Localizable.strings deleted file mode 100644 index a5a79e5..0000000 --- a/CountriesSwiftUI/Resources/ja.lproj/Localizable.strings +++ /dev/null @@ -1,17 +0,0 @@ - -"Running unit tests" = "単体テストの実行"; -"Countries" = "国"; -"Basic Info" = "基本情報"; -"Code" = "コード"; -"Population" = "人口"; -"Capital" = "資本"; -"Currencies" = "通貨"; -"Neighboring countries" = "近隣諸国"; -"Close" = "閉じる"; -"Population %lld" = "人口 %lld"; -"An Error Occured" = "エラーが発生しました"; -"Retry" = "リトライ"; -"Unable to load image" = "画像を読み込めません"; -"Back" = "バック"; -"Cancel loading" = "読み込みをキャンセル"; -"Canceled by user" = "ユーザーによりキャンセルされました"; diff --git a/CountriesSwiftUI/System/AppDelegate.swift b/CountriesSwiftUI/System/AppDelegate.swift deleted file mode 100644 index a694a66..0000000 --- a/CountriesSwiftUI/System/AppDelegate.swift +++ /dev/null @@ -1,51 +0,0 @@ -// -// AppDelegate.swift -// CountriesSwiftUI -// -// Created by Alexey Naumov on 23.10.2019. -// Copyright © 2019 Alexey Naumov. All rights reserved. -// - -import UIKit -import Combine - -typealias NotificationPayload = [AnyHashable: Any] -typealias FetchCompletion = (UIBackgroundFetchResult) -> Void - -@UIApplicationMain -final class AppDelegate: UIResponder, UIApplicationDelegate { - - lazy var systemEventsHandler: SystemEventsHandler? = { - self.systemEventsHandler(UIApplication.shared) - }() - - func application(_ application: UIApplication, didFinishLaunchingWithOptions - launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { - return true - } - - func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { - systemEventsHandler?.handlePushRegistration(result: .success(deviceToken)) - } - - func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { - systemEventsHandler?.handlePushRegistration(result: .failure(error)) - } - - func application(_ application: UIApplication, - didReceiveRemoteNotification userInfo: NotificationPayload, - fetchCompletionHandler completionHandler: @escaping FetchCompletion) { - systemEventsHandler? - .appDidReceiveRemoteNotification(payload: userInfo, fetchCompletion: completionHandler) - } - - private func systemEventsHandler(_ application: UIApplication) -> SystemEventsHandler? { - return sceneDelegate(application)?.systemEventsHandler - } - - private func sceneDelegate(_ application: UIApplication) -> SceneDelegate? { - return application.windows - .compactMap({ $0.windowScene?.delegate as? SceneDelegate }) - .first - } -} diff --git a/CountriesSwiftUI/System/AppEnvironment.swift b/CountriesSwiftUI/System/AppEnvironment.swift deleted file mode 100644 index 6640afc..0000000 --- a/CountriesSwiftUI/System/AppEnvironment.swift +++ /dev/null @@ -1,121 +0,0 @@ -// -// AppEnvironment.swift -// CountriesSwiftUI -// -// Created by Alexey Naumov on 09.11.2019. -// Copyright © 2019 Alexey Naumov. All rights reserved. -// - -import UIKit -import Combine - -struct AppEnvironment { - let container: DIContainer - let systemEventsHandler: SystemEventsHandler -} - -extension AppEnvironment { - - static func bootstrap() -> AppEnvironment { - let appState = Store(AppState()) - /* - To see the deep linking in action: - - 1. Launch the app in iOS 13.4 simulator (or newer) - 2. Subscribe on Push Notifications with "Allow Push" button - 3. Minimize the app - 4. Drag & drop "push_with_deeplink.apns" into the Simulator window - 5. Tap on the push notification - - Alternatively, just copy the code below before the "return" and launch: - - DispatchQueue.main.async { - deepLinksHandler.open(deepLink: .showCountryFlag(alpha3Code: "AFG")) - } - */ - let session = configuredURLSession() - let webRepositories = configuredWebRepositories(session: session) - let dbRepositories = configuredDBRepositories(appState: appState) - let interactors = configuredInteractors(appState: appState, - dbRepositories: dbRepositories, - webRepositories: webRepositories) - let diContainer = DIContainer(appState: appState, interactors: interactors) - let deepLinksHandler = RealDeepLinksHandler(container: diContainer) - let pushNotificationsHandler = RealPushNotificationsHandler(deepLinksHandler: deepLinksHandler) - let systemEventsHandler = RealSystemEventsHandler( - container: diContainer, deepLinksHandler: deepLinksHandler, - pushNotificationsHandler: pushNotificationsHandler, - pushTokenWebRepository: webRepositories.pushTokenWebRepository) - return AppEnvironment(container: diContainer, - systemEventsHandler: systemEventsHandler) - } - - private static func configuredURLSession() -> URLSession { - let configuration = URLSessionConfiguration.default - configuration.timeoutIntervalForRequest = 60 - configuration.timeoutIntervalForResource = 120 - configuration.waitsForConnectivity = true - configuration.httpMaximumConnectionsPerHost = 5 - configuration.requestCachePolicy = .returnCacheDataElseLoad - configuration.urlCache = .shared - return URLSession(configuration: configuration) - } - - private static func configuredWebRepositories(session: URLSession) -> DIContainer.WebRepositories { - let countriesWebRepository = RealCountriesWebRepository( - session: session, - baseURL: "https://restcountries.com/v2") - let imageWebRepository = RealImageWebRepository( - session: session, - baseURL: "https://ezgif.com/") - let pushTokenWebRepository = RealPushTokenWebRepository( - session: session, - baseURL: "https://fake.backend.com/") - return .init(imageRepository: imageWebRepository, - countriesRepository: countriesWebRepository, - pushTokenWebRepository: pushTokenWebRepository) - } - - private static func configuredDBRepositories(appState: Store) -> DIContainer.DBRepositories { - let persistentStore = CoreDataStack(version: CoreDataStack.Version.actual) - let countriesDBRepository = RealCountriesDBRepository(persistentStore: persistentStore) - return .init(countriesRepository: countriesDBRepository) - } - - private static func configuredInteractors(appState: Store, - dbRepositories: DIContainer.DBRepositories, - webRepositories: DIContainer.WebRepositories - ) -> DIContainer.Interactors { - - let countriesInteractor = RealCountriesInteractor( - webRepository: webRepositories.countriesRepository, - dbRepository: dbRepositories.countriesRepository, - appState: appState) - - let imagesInteractor = RealImagesInteractor( - webRepository: webRepositories.imageRepository) - - let userPermissionsInteractor = RealUserPermissionsInteractor( - appState: appState, openAppSettings: { - URL(string: UIApplication.openSettingsURLString).flatMap { - UIApplication.shared.open($0, options: [:], completionHandler: nil) - } - }) - - return .init(countriesInteractor: countriesInteractor, - imagesInteractor: imagesInteractor, - userPermissionsInteractor: userPermissionsInteractor) - } -} - -extension DIContainer { - struct WebRepositories { - let imageRepository: ImageWebRepository - let countriesRepository: CountriesWebRepository - let pushTokenWebRepository: PushTokenWebRepository - } - - struct DBRepositories { - let countriesRepository: CountriesDBRepository - } -} diff --git a/CountriesSwiftUI/System/SceneDelegate.swift b/CountriesSwiftUI/System/SceneDelegate.swift deleted file mode 100644 index 5ef90aa..0000000 --- a/CountriesSwiftUI/System/SceneDelegate.swift +++ /dev/null @@ -1,46 +0,0 @@ -// -// SceneDelegate.swift -// CountriesSwiftUI -// -// Created by Alexey Naumov on 23.10.2019. -// Copyright © 2019 Alexey Naumov. All rights reserved. -// - -import UIKit -import SwiftUI -import Combine -import Foundation - -final class SceneDelegate: UIResponder, UIWindowSceneDelegate { - - var window: UIWindow? - var systemEventsHandler: SystemEventsHandler? - - func scene(_ scene: UIScene, willConnectTo session: UISceneSession, - options connectionOptions: UIScene.ConnectionOptions) { - let environment = AppEnvironment.bootstrap() - let contentView = ContentView(container: environment.container) - if let windowScene = scene as? UIWindowScene { - let window = UIWindow(windowScene: windowScene) - window.rootViewController = UIHostingController(rootView: contentView) - self.window = window - window.makeKeyAndVisible() - } - self.systemEventsHandler = environment.systemEventsHandler - if !connectionOptions.urlContexts.isEmpty { - systemEventsHandler?.sceneOpenURLContexts(connectionOptions.urlContexts) - } - } - - func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { - systemEventsHandler?.sceneOpenURLContexts(URLContexts) - } - - func sceneDidBecomeActive(_ scene: UIScene) { - systemEventsHandler?.sceneDidBecomeActive() - } - - func sceneWillResignActive(_ scene: UIScene) { - systemEventsHandler?.sceneWillResignActive() - } -} diff --git a/CountriesSwiftUI/UI/Components/ErrorView.swift b/CountriesSwiftUI/UI/Common/ErrorView.swift similarity index 69% rename from CountriesSwiftUI/UI/Components/ErrorView.swift rename to CountriesSwiftUI/UI/Common/ErrorView.swift index b6ca040..1fde1fd 100644 --- a/CountriesSwiftUI/UI/Components/ErrorView.swift +++ b/CountriesSwiftUI/UI/Common/ErrorView.swift @@ -25,12 +25,8 @@ struct ErrorView: View { } } -#if DEBUG -struct ErrorView_Previews: PreviewProvider { - static var previews: some View { - ErrorView(error: NSError(domain: "", code: 0, userInfo: [ - NSLocalizedDescriptionKey: "Something went wrong"]), - retryAction: { }) - } +#Preview { + ErrorView(error: NSError(domain: "", code: 0, userInfo: [ + NSLocalizedDescriptionKey: "Something went wrong"]), + retryAction: { }) } -#endif diff --git a/CountriesSwiftUI/UI/Components/ImageView.swift b/CountriesSwiftUI/UI/Common/ImageView.swift similarity index 72% rename from CountriesSwiftUI/UI/Components/ImageView.swift rename to CountriesSwiftUI/UI/Common/ImageView.swift index 50a0c52..39c6f8b 100644 --- a/CountriesSwiftUI/UI/Components/ImageView.swift +++ b/CountriesSwiftUI/UI/Common/ImageView.swift @@ -11,7 +11,7 @@ import Combine struct ImageView: View { - let imageURL: URL + private let imageURL: URL @Environment(\.injected) var injected: DIContainer @State private var image: Loadable let inspection = Inspection() @@ -29,9 +29,9 @@ struct ImageView: View { @ViewBuilder private var content: some View { switch image { case .notRequested: - notRequestedView + defaultView() case .isLoading: - loadingView + loadingView() case let .loaded(image): loadedView(image) case let .failed(error): @@ -44,7 +44,7 @@ struct ImageView: View { private extension ImageView { func loadImage() { - injected.interactors.imagesInteractor + injected.interactors.images .load(image: $image, url: imageURL) } } @@ -52,14 +52,15 @@ private extension ImageView { // MARK: - Content private extension ImageView { - var notRequestedView: some View { + func defaultView() -> some View { Text("").onAppear { self.loadImage() } } - var loadingView: some View { - ActivityIndicatorView() + func loadingView() -> some View { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) } func failedView(_ error: Error) -> some View { @@ -76,14 +77,10 @@ private extension ImageView { } } -#if DEBUG -struct ImageView_Previews: PreviewProvider { - static var previews: some View { - VStack { - ImageView(imageURL: URL(string: "https://flagcdn.com/w640/us.jpg")!) - ImageView(imageURL: URL(string: "https://flagcdn.com/w640/al.jpg")!) - ImageView(imageURL: URL(string: "https://flagcdn.com/w640/ru.jpg")!) - } +#Preview { + VStack { + ImageView(imageURL: URL(string: "https://flagcdn.com/w640/us.jpg")!) + ImageView(imageURL: URL(string: "https://flagcdn.com/w640/al.jpg")!) + ImageView(imageURL: URL(string: "https://flagcdn.com/w640/ru.jpg")!) } } -#endif diff --git a/CountriesSwiftUI/UI/Common/Query+Search.swift b/CountriesSwiftUI/UI/Common/Query+Search.swift new file mode 100644 index 0000000..2c357bd --- /dev/null +++ b/CountriesSwiftUI/UI/Common/Query+Search.swift @@ -0,0 +1,62 @@ +// +// Query+Search.swift +// CountriesSwiftUI +// +// Created by Alexey on 8/11/24. +// Copyright © 2024 Alexey Naumov. All rights reserved. +// + +import SwiftUI +import SwiftData + +extension View { + /** + Allows for recreating the @Query each time a searchText changes + */ + func query( + searchText: String, + results: Binding<[T]>, + _ builder: @escaping (String) -> Query + ) -> some View { + background { + QueryViewContainer(searchText: searchText, builder: builder) { _, values in + results.wrappedValue = values + }.equatable() + } + } +} + +/** + This view serves as a "shield" over QueryView to avoid dual query + */ +private struct QueryViewContainer: View, Equatable { + + let searchText: String + let builder: (String) -> Query + let results: ([T], [T]) -> Void + + var body: some View { + QueryView(query: builder(searchText), results: results) + } + + static func == (lhs: QueryViewContainer, rhs: QueryViewContainer) -> Bool { + return lhs.searchText == rhs.searchText + } +} + +private struct QueryView: View { + + @Query var query: [T] + let results: ([T], [T]) -> Void + + init(query: Query, results: @escaping ([T], [T]) -> Void) { + _query = query + self.results = results + } + + var body: some View { + Rectangle() + .hidden() + .onChange(of: query, initial: true, results) + } +} diff --git a/CountriesSwiftUI/UI/Components/ActivityIndicatorView.swift b/CountriesSwiftUI/UI/Components/ActivityIndicatorView.swift deleted file mode 100644 index cd51304..0000000 --- a/CountriesSwiftUI/UI/Components/ActivityIndicatorView.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// ActivityIndicatorView.swift -// CountriesSwiftUI -// -// Created by Alexey Naumov on 25.10.2019. -// Copyright © 2019 Alexey Naumov. All rights reserved. -// - -import SwiftUI - -struct ActivityIndicatorView: UIViewRepresentable { - - func makeUIView(context: UIViewRepresentableContext) -> UIActivityIndicatorView { - return UIActivityIndicatorView(style: .large) - } - - func updateUIView(_ uiView: UIActivityIndicatorView, context: UIViewRepresentableContext) { - uiView.startAnimating() - } -} diff --git a/CountriesSwiftUI/UI/Components/SearchBar.swift b/CountriesSwiftUI/UI/Components/SearchBar.swift deleted file mode 100644 index 3baa135..0000000 --- a/CountriesSwiftUI/UI/Components/SearchBar.swift +++ /dev/null @@ -1,60 +0,0 @@ -// -// SearchBar.swift -// CountriesSwiftUI -// -// Created by Alexey Naumov on 14.01.2020. -// Copyright © 2020 Alexey Naumov. All rights reserved. -// - -import UIKit -import SwiftUI - -struct SearchBar: UIViewRepresentable { - - @Binding var text: String - - func makeUIView(context: UIViewRepresentableContext) -> UISearchBar { - let searchBar = UISearchBar(frame: .zero) - searchBar.delegate = context.coordinator - return searchBar - } - - func updateUIView(_ uiView: UISearchBar, context: UIViewRepresentableContext) { - uiView.text = text - } - - func makeCoordinator() -> SearchBar.Coordinator { - return Coordinator(text: $text) - } -} - -extension SearchBar { - final class Coordinator: NSObject, UISearchBarDelegate { - - let text: Binding - - init(text: Binding) { - self.text = text - } - - func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { - text.wrappedValue = searchText - } - - func searchBarShouldBeginEditing(_ searchBar: UISearchBar) -> Bool { - searchBar.setShowsCancelButton(true, animated: true) - return true - } - - func searchBarShouldEndEditing(_ searchBar: UISearchBar) -> Bool { - searchBar.setShowsCancelButton(false, animated: true) - return true - } - - func searchBarCancelButtonClicked(_ searchBar: UISearchBar) { - searchBar.endEditing(true) - searchBar.text = "" - text.wrappedValue = "" - } - } -} diff --git a/CountriesSwiftUI/UI/CountriesList/CountriesListView.swift b/CountriesSwiftUI/UI/CountriesList/CountriesListView.swift new file mode 100644 index 0000000..908d68a --- /dev/null +++ b/CountriesSwiftUI/UI/CountriesList/CountriesListView.swift @@ -0,0 +1,179 @@ +// +// CountriesList.swift +// CountriesSwiftUI +// +// Created by Alexey on 7/11/24. +// Copyright © 2024 Alexey Naumov. All rights reserved. +// + +import SwiftUI +import SwiftData +import Combine + +struct CountriesList: View { + + @State private var countries: [DBModel.Country] = [] + @State private(set) var countriesState: Loadable + @State private var canRequestPushPermission: Bool = false + @State internal var searchText = "" + @State internal var navigationPath = NavigationPath() + @State private var routingState: Routing = .init() + private var routingBinding: Binding { + $routingState.dispatched(to: injected.appState, \.routing.countriesList) + } + @Environment(\.injected) private var injected: DIContainer + @Environment(\.locale) private var locale: Locale + private let localeContainer = LocaleReader.Container() + + let inspection = Inspection() + + init(state: Loadable = .notRequested) { + self._countriesState = .init(initialValue: state) + } + + var body: some View { + NavigationStack(path: $navigationPath) { + content + .query(searchText: searchText, results: $countries, { search in + Query(filter: #Predicate { country in + if search.isEmpty { + return true + } else { + return country.name.localizedStandardContains(search) + } + }, sort: \DBModel.Country.name) + }) + .navigationTitle("Countries") + } + .modifier(LocaleReader(container: localeContainer)) + .onReceive(routingUpdate) { self.routingState = $0 } + .onReceive(canRequestPushPermissionUpdate) { self.canRequestPushPermission = $0 } + .onReceive(inspection.notice) { self.inspection.visit(self, $0) } + .flipsForRightToLeftLayoutDirection(true) + } + + @ViewBuilder private var content: some View { + switch countriesState { + case .notRequested: + defaultView() + case .isLoading: + loadingView() + case .loaded: + loadedView() + case let .failed(error): + failedView(error) + } + } + + @ViewBuilder private var permissionsButton: some View { + if canRequestPushPermission { + Button(action: requestPushPermission, label: { Text("Allow Push") }) + } + } +} + +// MARK: - Loading Content + +private extension CountriesList { + func defaultView() -> some View { + Text("").onAppear { + if !countries.isEmpty { + countriesState = .loaded(()) + } + loadCountriesList(forceReload: false) + } + } + + func loadingView() -> some View { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + } + + func failedView(_ error: Error) -> some View { + ErrorView(error: error, retryAction: { + loadCountriesList(forceReload: true) + }) + } +} + +// MARK: - Displaying Content + +@MainActor +private extension CountriesList { + @ViewBuilder + func loadedView() -> some View { + if countries.isEmpty && !searchText.isEmpty { + Text("No matches found") + .font(.footnote) + } + List(countries, id: \.alpha3Code) { country in + NavigationLink(value: country) { + CountryCell(country: country) + } + } + .navigationDestination(for: DBModel.Country.self) { country in + CountryDetails(country: country) + } + .searchable(text: $searchText) + .refreshable { + loadCountriesList(forceReload: true) + } + .toolbar { + ToolbarItem { + permissionsButton + } + } + .onChange(of: routingState.countryCode, initial: true, { _, code in + guard let code, + let country = countries.first(where: { $0.alpha3Code == code}) + else { return } + navigationPath.append(country) + }) + .onChange(of: navigationPath, { _, path in + if !path.isEmpty { + routingBinding.wrappedValue.countryCode = nil + } + }) + } +} + +// MARK: - Side Effects + +private extension CountriesList { + + private func loadCountriesList(forceReload: Bool) { + guard forceReload || countries.isEmpty else { return } + $countriesState.load { + try await injected.interactors.countries + .refreshCountriesList() + } + } + + private func requestPushPermission() { + injected.interactors.userPermissions + .request(permission: .pushNotifications) + } +} + +// MARK: - Routing + +extension CountriesList { + struct Routing: Equatable { + var countryCode: String? + } +} + +// MARK: - State Updates + +private extension CountriesList { + + private var routingUpdate: AnyPublisher { + injected.appState.updates(for: \.routing.countriesList) + } + + private var canRequestPushPermissionUpdate: AnyPublisher { + injected.appState.updates(for: AppState.permissionKeyPath(for: .pushNotifications)) + .map { $0 == .notRequested || $0 == .denied } + .eraseToAnyPublisher() + } +} diff --git a/CountriesSwiftUI/UI/Components/CountryCell.swift b/CountriesSwiftUI/UI/CountriesList/CountryCell.swift similarity index 57% rename from CountriesSwiftUI/UI/Components/CountryCell.swift rename to CountriesSwiftUI/UI/CountriesList/CountryCell.swift index 8663537..e0b58bf 100644 --- a/CountriesSwiftUI/UI/Components/CountryCell.swift +++ b/CountriesSwiftUI/UI/CountriesList/CountryCell.swift @@ -2,17 +2,17 @@ // CountryCell.swift // CountriesSwiftUI // -// Created by Alexey Naumov on 25.10.2019. -// Copyright © 2019 Alexey Naumov. All rights reserved. +// Created by Alexey on 7/11/24. +// Copyright © 2024 Alexey Naumov. All rights reserved. // import SwiftUI struct CountryCell: View { - - let country: Country + + let country: DBModel.Country @Environment(\.locale) var locale: Locale - + var body: some View { VStack(alignment: .leading) { Text(country.name(locale: locale)) @@ -24,12 +24,3 @@ struct CountryCell: View { .frame(maxWidth: .infinity, maxHeight: 60, alignment: .leading) } } - -#if DEBUG -struct CountryCell_Previews: PreviewProvider { - static var previews: some View { - CountryCell(country: Country.mockedData[0]) - .previewLayout(.fixed(width: 375, height: 60)) - } -} -#endif diff --git a/CountriesSwiftUI/UI/CountriesList/LocaleReader.swift b/CountriesSwiftUI/UI/CountriesList/LocaleReader.swift new file mode 100644 index 0000000..3046231 --- /dev/null +++ b/CountriesSwiftUI/UI/CountriesList/LocaleReader.swift @@ -0,0 +1,38 @@ +// +// LocaleReader.swift +// CountriesSwiftUI +// +// Created by Alexey on 8/11/24. +// Copyright © 2024 Alexey Naumov. All rights reserved. +// + +import SwiftUI + +extension CountriesList { + + struct LocaleReader: EnvironmentalModifier { + + /** + Retains the locale, provided by the Environment. + Variable `@Environment(\.locale) var locale: Locale` + from the view is not accessible when searching by name + */ + final class Container { + var locale: Locale = .backendDefault + } + let container: Container + + func resolve(in environment: EnvironmentValues) -> some ViewModifier { + container.locale = environment.locale + return DummyViewModifier() + } + + private struct DummyViewModifier: ViewModifier { + func body(content: Content) -> some View { + // Cannot return just `content` because SwiftUI + // flattens modifiers that do nothing to the `content` + content.onAppear() + } + } + } +} diff --git a/CountriesSwiftUI/UI/Screens/CountryDetails.swift b/CountriesSwiftUI/UI/CountryDetails/CountryDetailsView.swift similarity index 65% rename from CountriesSwiftUI/UI/Screens/CountryDetails.swift rename to CountriesSwiftUI/UI/CountryDetails/CountryDetailsView.swift index b97e856..7a147a3 100644 --- a/CountriesSwiftUI/UI/Screens/CountryDetails.swift +++ b/CountriesSwiftUI/UI/CountryDetails/CountryDetailsView.swift @@ -8,21 +8,23 @@ import SwiftUI import Combine +import SwiftData +@MainActor struct CountryDetails: View { - let country: Country - + private let country: DBModel.Country + @Environment(\.locale) var locale: Locale @Environment(\.injected) private var injected: DIContainer - @State private var details: Loadable + @State private var details: Loadable @State private var routingState: Routing = .init() private var routingBinding: Binding { $routingState.dispatched(to: injected.appState, \.routing.countryDetails) } let inspection = Inspection() - init(country: Country, details: Loadable = .notRequested) { + init(country: DBModel.Country, details: Loadable = .notRequested) { self.country = country self._details = .init(initialValue: details) } @@ -37,9 +39,9 @@ struct CountryDetails: View { @ViewBuilder private var content: some View { switch details { case .notRequested: - notRequestedView + defaultView() case .isLoading: - loadingView + loadingView() case let .loaded(countryDetails): loadedView(countryDetails) case let .failed(error): @@ -51,9 +53,12 @@ struct CountryDetails: View { // MARK: - Side Effects private extension CountryDetails { - func loadCountryDetails() { - injected.interactors.countriesInteractor - .load(countryDetails: $details, country: country) + + func loadCountryDetails(forceReload: Bool) { + $details.load { + try await injected.interactors.countries + .loadCountryDetails(country: country, forceReload: forceReload) + } } func showCountryDetailsSheet() { @@ -64,15 +69,16 @@ private extension CountryDetails { // MARK: - Loading Content private extension CountryDetails { - var notRequestedView: some View { + func defaultView() -> some View { Text("").onAppear { - self.loadCountryDetails() + loadCountryDetails(forceReload: false) } } - var loadingView: some View { + func loadingView() -> some View { VStack { - ActivityIndicatorView() + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) Button(action: { self.details.cancelLoading() }, label: { Text("Cancel loading") }) @@ -81,15 +87,16 @@ private extension CountryDetails { func failedView(_ error: Error) -> some View { ErrorView(error: error, retryAction: { - self.loadCountryDetails() + self.loadCountryDetails(forceReload: true) }) } } // MARK: - Displaying Content +@MainActor private extension CountryDetails { - func loadedView(_ countryDetails: Country.Details) -> some View { + func loadedView(_ countryDetails: DBModel.CountryDetails) -> some View { List { country.flag.map { url in flagView(url: url) @@ -103,8 +110,8 @@ private extension CountryDetails { } } .listStyle(GroupedListStyle()) - .sheet(isPresented: routingBinding.detailsSheet, - content: { self.modalDetailsView() }) + .sheet2(isPresented: routingBinding.detailsSheet, + content: { self.modalDetailsView() }) } func flagView(url: URL) -> some View { @@ -119,7 +126,7 @@ private extension CountryDetails { } } - func basicInfoSectionView(countryDetails: Country.Details) -> some View { + func basicInfoSectionView(countryDetails: DBModel.CountryDetails) -> some View { Section(header: Text("Basic Info")) { DetailRow(leftLabel: Text(country.alpha3Code), rightLabel: "Code") DetailRow(leftLabel: Text("\(country.population)"), rightLabel: "Population") @@ -127,7 +134,7 @@ private extension CountryDetails { } } - func currenciesSectionView(currencies: [Country.Currency]) -> some View { + func currenciesSectionView(currencies: [DBModel.Currency]) -> some View { Section(header: Text("Currencies")) { ForEach(currencies) { currency in DetailRow(leftLabel: Text(currency.title), rightLabel: Text(currency.code)) @@ -135,7 +142,7 @@ private extension CountryDetails { } } - func neighborsSectionView(neighbors: [Country]) -> some View { + func neighborsSectionView(neighbors: [DBModel.Country]) -> some View { Section(header: Text("Neighboring countries")) { ForEach(neighbors) { country in NavigationLink(destination: self.neighbourDetailsView(country: country)) { @@ -145,20 +152,20 @@ private extension CountryDetails { } } - func neighbourDetailsView(country: Country) -> some View { + func neighbourDetailsView(country: DBModel.Country) -> some View { CountryDetails(country: country) } func modalDetailsView() -> some View { - ModalDetailsView(country: country, - isDisplayed: routingBinding.detailsSheet) + ModalFlagView(country: country, + isDisplayed: routingBinding.detailsSheet) .inject(injected) } } // MARK: - Helpers -private extension Country.Currency { +private extension DBModel.Currency { var title: String { return name + (symbol.map {" " + $0} ?? "") } @@ -181,13 +188,23 @@ private extension CountryDetails { } } -// MARK: - Preview +// MARK: - ViewInspector helper +// https://github.com/nalexn/ViewInspector/blob/master/guide_popups.md#sheet + +extension View { + func sheet2(isPresented: Binding, onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping () -> Sheet + ) -> some View where Sheet: View { + return self.modifier(InspectableSheet(isPresented: isPresented, onDismiss: onDismiss, popupBuilder: content)) + } +} + +struct InspectableSheet: ViewModifier where Sheet: View { + + let isPresented: Binding + let onDismiss: (() -> Void)? + let popupBuilder: () -> Sheet -#if DEBUG -struct CountryDetails_Previews: PreviewProvider { - static var previews: some View { - CountryDetails(country: Country.mockedData[0]) - .inject(.preview) + func body(content: Self.Content) -> some View { + content.sheet(isPresented: isPresented, onDismiss: onDismiss, content: popupBuilder) } } -#endif diff --git a/CountriesSwiftUI/UI/Components/DetailRow.swift b/CountriesSwiftUI/UI/CountryDetails/DetailRow.swift similarity index 72% rename from CountriesSwiftUI/UI/Components/DetailRow.swift rename to CountriesSwiftUI/UI/CountryDetails/DetailRow.swift index 5e82827..7873072 100644 --- a/CountriesSwiftUI/UI/Components/DetailRow.swift +++ b/CountriesSwiftUI/UI/CountryDetails/DetailRow.swift @@ -9,8 +9,8 @@ import SwiftUI struct DetailRow: View { - let leftLabel: Text - let rightLabel: Text + private let leftLabel: Text + private let rightLabel: Text init(leftLabel: Text, rightLabel: Text) { self.leftLabel = leftLabel @@ -35,11 +35,6 @@ struct DetailRow: View { } } -#if DEBUG -struct DetailRow_Previews: PreviewProvider { - static var previews: some View { - DetailRow(leftLabel: Text("Rate"), rightLabel: Text("$123.99")) - .previewLayout(.fixed(width: 375, height: 40)) - } +#Preview(traits: .fixedLayout(width: 375, height: 40)) { + DetailRow(leftLabel: Text("Rate"), rightLabel: Text("$123.99")) } -#endif diff --git a/CountriesSwiftUI/UI/CountryDetails/ModalFlagView.swift b/CountriesSwiftUI/UI/CountryDetails/ModalFlagView.swift new file mode 100644 index 0000000..c65ccb5 --- /dev/null +++ b/CountriesSwiftUI/UI/CountryDetails/ModalFlagView.swift @@ -0,0 +1,45 @@ +// +// ModalFlagView.swift +// CountriesSwiftUI +// +// Created by Alexey Naumov on 26.10.2019. +// Copyright © 2019 Alexey Naumov. All rights reserved. +// + +import SwiftUI +import EnvironmentOverrides + +struct ModalFlagView: View { + + let country: DBModel.Country + @Binding var isDisplayed: Bool + let inspection = Inspection() + + var body: some View { + NavigationStack { + country.flag.map { url in + HStack { + Spacer() + ImageView(imageURL: url) + .frame(width: 300, height: 200) + Spacer() + } + } + .navigationTitle(country.name) + .toolbar { + ToolbarItem { + closeButton + } + } + } + .navigationViewStyle(StackNavigationViewStyle()) + .onReceive(inspection.notice) { self.inspection.visit(self, $0) } + .attachEnvironmentOverrides() + } + + private var closeButton: some View { + Button(action: { + self.isDisplayed = false + }, label: { Text("Close") }) + } +} diff --git a/CountriesSwiftUI/UI/RootViewModifier.swift b/CountriesSwiftUI/UI/RootViewModifier.swift index a041f5f..0c68e9c 100644 --- a/CountriesSwiftUI/UI/RootViewModifier.swift +++ b/CountriesSwiftUI/UI/RootViewModifier.swift @@ -20,6 +20,7 @@ struct RootViewAppearance: ViewModifier { func body(content: Content) -> some View { content .blur(radius: isActive ? 0 : 10) + .ignoresSafeArea() .onReceive(stateUpdate) { self.isActive = $0 } .onReceive(inspection.notice) { self.inspection.visit(self, $0) } } diff --git a/CountriesSwiftUI/UI/Screens/ContentView.swift b/CountriesSwiftUI/UI/Screens/ContentView.swift deleted file mode 100644 index 702960f..0000000 --- a/CountriesSwiftUI/UI/Screens/ContentView.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// ContentView.swift -// CountriesSwiftUI -// -// Created by Alexey Naumov on 23.10.2019. -// Copyright © 2019 Alexey Naumov. All rights reserved. -// - -import SwiftUI -import Combine -import EnvironmentOverrides - -struct ContentView: View { - - private let container: DIContainer - private let isRunningTests: Bool - - init(container: DIContainer, isRunningTests: Bool = ProcessInfo.processInfo.isRunningTests) { - self.container = container - self.isRunningTests = isRunningTests - } - - var body: some View { - Group { - if isRunningTests { - Text("Running unit tests") - } else { - CountriesList() - .attachEnvironmentOverrides(onChange: onChangeHandler) - .inject(container) - } - } - } - - var onChangeHandler: (EnvironmentValues.Diff) -> Void { - return { diff in - if !diff.isDisjoint(with: [.locale, .sizeCategory]) { - self.container.appState[\.routing] = AppState.ViewRouting() - } - } - } -} - -// MARK: - Preview - -#if DEBUG -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView(container: .preview) - } -} -#endif diff --git a/CountriesSwiftUI/UI/Screens/CountriesList.swift b/CountriesSwiftUI/UI/Screens/CountriesList.swift deleted file mode 100644 index 1c7809b..0000000 --- a/CountriesSwiftUI/UI/Screens/CountriesList.swift +++ /dev/null @@ -1,223 +0,0 @@ -// -// CountriesList.swift -// CountriesSwiftUI -// -// Created by Alexey Naumov on 24.10.2019. -// Copyright © 2019 Alexey Naumov. All rights reserved. -// - -import SwiftUI -import Combine - -struct CountriesList: View { - - @State private var countriesSearch = CountriesSearch() - @State private(set) var countries: Loadable> - @State private var routingState: Routing = .init() - private var routingBinding: Binding { - $routingState.dispatched(to: injected.appState, \.routing.countriesList) - } - @State private var canRequestPushPermission: Bool = false - @Environment(\.injected) private var injected: DIContainer - @Environment(\.locale) private var locale: Locale - private let localeContainer = LocaleReader.Container() - - let inspection = Inspection() - - init(countries: Loadable> = .notRequested) { - self._countries = .init(initialValue: countries) - } - - var body: some View { - GeometryReader { geometry in - NavigationView { - self.content - .navigationBarItems(trailing: self.permissionsButton) - .navigationBarTitle("Countries") - .navigationBarHidden(self.countriesSearch.keyboardHeight > 0) - .animation(.easeOut(duration: 0.3)) - } - .navigationViewStyle(DoubleColumnNavigationViewStyle()) - } - .modifier(LocaleReader(container: localeContainer)) - .onReceive(keyboardHeightUpdate) { self.countriesSearch.keyboardHeight = $0 } - .onReceive(routingUpdate) { self.routingState = $0 } - .onReceive(canRequestPushPermissionUpdate) { self.canRequestPushPermission = $0 } - .onReceive(inspection.notice) { self.inspection.visit(self, $0) } - } - - @ViewBuilder private var content: some View { - switch countries { - case .notRequested: - notRequestedView - case let .isLoading(last, _): - loadingView(last) - case let .loaded(countries): - loadedView(countries, showSearch: true, showLoading: false) - case let .failed(error): - failedView(error) - } - } - - private var permissionsButton: some View { - Group { - if canRequestPushPermission { - Button(action: requestPushPermission, label: { Text("Allow Push") }) - } else { - EmptyView() - } - } - } -} - -private extension CountriesList { - - struct LocaleReader: EnvironmentalModifier { - - /** - Retains the locale, provided by the Environment. - Variable `@Environment(\.locale) var locale: Locale` - from the view is not accessible when searching by name - */ - class Container { - var locale: Locale = .backendDefault - } - let container: Container - - func resolve(in environment: EnvironmentValues) -> some ViewModifier { - container.locale = environment.locale - return DummyViewModifier() - } - - private struct DummyViewModifier: ViewModifier { - func body(content: Content) -> some View { - // Cannot return just `content` because SwiftUI - // flattens modifiers that do nothing to the `content` - content.onAppear() - } - } - } -} - -// MARK: - Side Effects - -private extension CountriesList { - func reloadCountries() { - injected.interactors.countriesInteractor - .load(countries: $countries, - search: countriesSearch.searchText, - locale: localeContainer.locale) - } - - func requestPushPermission() { - injected.interactors.userPermissionsInteractor - .request(permission: .pushNotifications) - } -} - -// MARK: - Loading Content - -private extension CountriesList { - var notRequestedView: some View { - Text("").onAppear(perform: reloadCountries) - } - - func loadingView(_ previouslyLoaded: LazyList?) -> some View { - if let countries = previouslyLoaded { - return AnyView(loadedView(countries, showSearch: true, showLoading: true)) - } else { - return AnyView(ActivityIndicatorView().padding()) - } - } - - func failedView(_ error: Error) -> some View { - ErrorView(error: error, retryAction: { - self.reloadCountries() - }) - } -} - -// MARK: - Displaying Content - -private extension CountriesList { - func loadedView(_ countries: LazyList, showSearch: Bool, showLoading: Bool) -> some View { - VStack { - if showSearch { - SearchBar(text: $countriesSearch.searchText - .onSet { _ in - self.reloadCountries() - } - ) - } - if showLoading { - ActivityIndicatorView().padding() - } - List(countries) { country in - NavigationLink( - destination: self.detailsView(country: country), - tag: country.alpha3Code, - selection: self.routingBinding.countryDetails) { - CountryCell(country: country) - } - } - .id(countries.count) - }.padding(.bottom, bottomInset) - } - - func detailsView(country: Country) -> some View { - CountryDetails(country: country) - } - - var bottomInset: CGFloat { - if #available(iOS 14, *) { - return 0 - } else { - return countriesSearch.keyboardHeight - } - } -} - -// MARK: - Search State - -extension CountriesList { - struct CountriesSearch { - var searchText: String = "" - var keyboardHeight: CGFloat = 0 - } -} - -// MARK: - Routing - -extension CountriesList { - struct Routing: Equatable { - var countryDetails: Country.Code? - } -} - -// MARK: - State Updates - -private extension CountriesList { - - var routingUpdate: AnyPublisher { - injected.appState.updates(for: \.routing.countriesList) - } - - var keyboardHeightUpdate: AnyPublisher { - injected.appState.updates(for: \.system.keyboardHeight) - } - - var canRequestPushPermissionUpdate: AnyPublisher { - injected.appState.updates(for: AppState.permissionKeyPath(for: .pushNotifications)) - .map { $0 == .notRequested || $0 == .denied } - .eraseToAnyPublisher() - } -} - -#if DEBUG -struct CountriesList_Previews: PreviewProvider { - static var previews: some View { - CountriesList(countries: .loaded(Country.mockedData.lazyList)) - .inject(.preview) - } -} -#endif diff --git a/CountriesSwiftUI/UI/Screens/ModalDetailsView.swift b/CountriesSwiftUI/UI/Screens/ModalDetailsView.swift deleted file mode 100644 index 07d19d2..0000000 --- a/CountriesSwiftUI/UI/Screens/ModalDetailsView.swift +++ /dev/null @@ -1,54 +0,0 @@ -// -// ModalDetailsView.swift -// CountriesSwiftUI -// -// Created by Alexey Naumov on 26.10.2019. -// Copyright © 2019 Alexey Naumov. All rights reserved. -// - -import SwiftUI - -struct ModalDetailsView: View { - - let country: Country - @Binding var isDisplayed: Bool - let inspection = Inspection() - - var body: some View { - NavigationView { - VStack { - country.flag.map { url in - HStack { - Spacer() - ImageView(imageURL: url) - .frame(width: 300, height: 200) - Spacer() - } - } - closeButton.padding(.top, 40) - } - .navigationBarTitle(Text(country.name), displayMode: .inline) - } - .navigationViewStyle(StackNavigationViewStyle()) - .onReceive(inspection.notice) { self.inspection.visit(self, $0) } - .attachEnvironmentOverrides() - } - - private var closeButton: some View { - Button(action: { - self.isDisplayed = false - }, label: { Text("Close") }) - } -} - -#if DEBUG -struct ModalDetailsView_Previews: PreviewProvider { - - @State static var isDisplayed: Bool = true - - static var previews: some View { - ModalDetailsView(country: Country.mockedData[0], isDisplayed: $isDisplayed) - .inject(.preview) - } -} -#endif diff --git a/CountriesSwiftUI/Utilities/CancelBag.swift b/CountriesSwiftUI/Utilities/CancelBag.swift index d0925cf..5a8ea02 100644 --- a/CountriesSwiftUI/Utilities/CancelBag.swift +++ b/CountriesSwiftUI/Utilities/CancelBag.swift @@ -9,16 +9,27 @@ import Combine final class CancelBag { - fileprivate(set) var subscriptions = Set() + fileprivate(set) var subscriptions = [any Cancellable]() + private let equalToAny: Bool + + init(equalToAny: Bool = false) { + self.equalToAny = equalToAny + } func cancel() { subscriptions.removeAll() } + + func isEqual(to other: CancelBag) -> Bool { + return other === self || other.equalToAny || self.equalToAny + } } -extension AnyCancellable { +extension Cancellable { func store(in cancelBag: CancelBag) { - cancelBag.subscriptions.insert(self) + cancelBag.subscriptions.append(self) } } + +extension Task: @retroactive Cancellable { } diff --git a/CountriesSwiftUI/Utilities/Helpers.swift b/CountriesSwiftUI/Utilities/Helpers.swift index c3d0ef1..4f13371 100644 --- a/CountriesSwiftUI/Utilities/Helpers.swift +++ b/CountriesSwiftUI/Utilities/Helpers.swift @@ -2,15 +2,13 @@ // Helpers.swift // CountriesSwiftUI // -// Created by Alexey Naumov on 10.11.2019. -// Copyright © 2019 Alexey Naumov. All rights reserved. +// Created by Alexey on 7/11/24. +// Copyright © 2024 Alexey Naumov. All rights reserved. // -import SwiftUI +import Foundation import Combine -// MARK: - General - extension ProcessInfo { var isRunningTests: Bool { environment["XCTestConfigurationFilePath"] != nil @@ -28,6 +26,16 @@ extension String { } } +extension Locale { + static var backendDefault: Locale { + return Locale(identifier: "en") + } + + var shortIdentifier: String { + return String(identifier.prefix(2)) + } +} + extension Result { var isSuccess: Bool { switch self { @@ -42,7 +50,7 @@ extension Result { internal final class Inspection { let notice = PassthroughSubject() var callbacks = [UInt: (V) -> Void]() - + func visit(_ view: V, _ line: UInt) { if let callback = callbacks.removeValue(forKey: line) { callback(view) diff --git a/CountriesSwiftUI/Utilities/LazyList.swift b/CountriesSwiftUI/Utilities/LazyList.swift deleted file mode 100644 index ec27d00..0000000 --- a/CountriesSwiftUI/Utilities/LazyList.swift +++ /dev/null @@ -1,164 +0,0 @@ -// -// LazyList.swift -// CountriesSwiftUI -// -// Created by Alexey Naumov on 18.04.2020. -// Copyright © 2020 Alexey Naumov. All rights reserved. -// - -import Foundation - -struct LazyList { - - typealias Access = (Int) throws -> T? - private let access: Access - private let useCache: Bool - private var cache = Cache() - - let count: Int - - init(count: Int, useCache: Bool, _ access: @escaping Access) { - self.count = count - self.useCache = useCache - self.access = access - } - - func element(at index: Int) throws -> T { - guard useCache else { - return try get(at: index) - } - return try cache.sync { elements in - if let element = elements[index] { - return element - } - let element = try get(at: index) - elements[index] = element - return element - } - } - - private func get(at index: Int) throws -> T { - guard let element = try access(index) else { - throw Error.elementIsNil(index: index) - } - return element - } - - static var empty: Self { - return .init(count: 0, useCache: false) { index in - throw Error.elementIsNil(index: index) - } - } -} - -private extension LazyList { - class Cache { - - private var elements = [Int: T]() - - func sync(_ access: (inout [Int: T]) throws -> T) throws -> T { - guard Thread.isMainThread else { - var result: T! - try DispatchQueue.main.sync { - result = try access(&elements) - } - return result - } - return try access(&elements) - } - } -} - -extension LazyList: Sequence { - - enum Error: LocalizedError { - case elementIsNil(index: Int) - - var localizedDescription: String { - switch self { - case let .elementIsNil(index): - return "Element at index \(index) is nil" - } - } - } - - struct Iterator: IteratorProtocol { - typealias Element = T - private var index = -1 - private var list: LazyList - - init(list: LazyList) { - self.list = list - } - - mutating func next() -> Element? { - index += 1 - guard index < list.count else { - return nil - } - do { - return try list.element(at: index) - } catch _ { - return nil - } - } - } - - func makeIterator() -> Iterator { - .init(list: self) - } - - var underestimatedCount: Int { count } -} - -extension LazyList: RandomAccessCollection { - - typealias Index = Int - var startIndex: Index { 0 } - var endIndex: Index { count } - - subscript(index: Index) -> Iterator.Element { - do { - return try element(at: index) - } catch let error { - fatalError("\(error)") - } - } - - public func index(after index: Index) -> Index { - return index + 1 - } - - public func index(before index: Index) -> Index { - return index - 1 - } -} - -extension LazyList: Equatable where T: Equatable { - static func == (lhs: LazyList, rhs: LazyList) -> Bool { - guard lhs.count == rhs.count else { return false } - return zip(lhs, rhs).first(where: { $0 != $1 }) == nil - } -} - -extension LazyList: CustomStringConvertible { - var description: String { - let elements = self.reduce("", { str, element in - if str.count == 0 { - return "\(element)" - } - return str + ", \(element)" - }) - return "LazyList<[\(elements)]>" - } -} - -extension RandomAccessCollection { - var lazyList: LazyList { - return .init(count: self.count, useCache: false) { - guard $0 < self.count else { return nil } - let index = self.index(self.startIndex, offsetBy: $0) - return self[index] - } - } -} diff --git a/CountriesSwiftUI/Utilities/Loadable.swift b/CountriesSwiftUI/Utilities/Loadable.swift index 135e34d..42232f9 100644 --- a/CountriesSwiftUI/Utilities/Loadable.swift +++ b/CountriesSwiftUI/Utilities/Loadable.swift @@ -9,7 +9,7 @@ import Foundation import SwiftUI -typealias LoadableSubject = Binding> +typealias LoadableSubject = Binding> enum Loadable { @@ -48,8 +48,7 @@ extension Loadable { } else { let error = NSError( domain: NSCocoaErrorDomain, code: NSUserCancelledError, - userInfo: [NSLocalizedDescriptionKey: NSLocalizedString("Canceled by user", - comment: "")]) + userInfo: [NSLocalizedDescriptionKey: NSLocalizedString("Canceled by user", comment: "")]) self = .failed(error) } default: break @@ -103,7 +102,8 @@ extension Loadable: Equatable where T: Equatable { static func == (lhs: Loadable, rhs: Loadable) -> Bool { switch (lhs, rhs) { case (.notRequested, .notRequested): return true - case let (.isLoading(lhsV, _), .isLoading(rhsV, _)): return lhsV == rhsV + case let (.isLoading(lhsV, lhsC), .isLoading(rhsV, rhsC)): + return lhsV == rhsV && lhsC.isEqual(to: rhsC) case let (.loaded(lhsV), .loaded(rhsV)): return lhsV == rhsV case let (.failed(lhsE), .failed(rhsE)): return lhsE.localizedDescription == rhsE.localizedDescription @@ -111,3 +111,18 @@ extension Loadable: Equatable where T: Equatable { } } } + +extension LoadableSubject { + func load(_ resource: @escaping () async throws -> T) where Value == Loadable { + let cancelBag = CancelBag() + wrappedValue.setIsLoading(cancelBag: cancelBag) + let task = Task { + do { + wrappedValue = .loaded(try await resource()) + } catch { + wrappedValue = .failed(error) + } + } + task.store(in: cancelBag) + } +} diff --git a/CountriesSwiftUI/Utilities/NetworkingHelpers.swift b/CountriesSwiftUI/Utilities/NetworkingHelpers.swift deleted file mode 100644 index 7be6e02..0000000 --- a/CountriesSwiftUI/Utilities/NetworkingHelpers.swift +++ /dev/null @@ -1,92 +0,0 @@ -// -// NetworkingHelpers.swift -// CountriesSwiftUI -// -// Created by Alexey Naumov on 04.04.2020. -// Copyright © 2020 Alexey Naumov. All rights reserved. -// - -import SwiftUI -import Combine -import Foundation - -extension Just where Output == Void { - static func withErrorType(_ errorType: E.Type) -> AnyPublisher { - return withErrorType((), E.self) - } -} - -extension Just { - static func withErrorType(_ value: Output, _ errorType: E.Type - ) -> AnyPublisher { - return Just(value) - .setFailureType(to: E.self) - .eraseToAnyPublisher() - } -} - -extension Publisher { - func sinkToResult(_ result: @escaping (Result) -> Void) -> AnyCancellable { - return sink(receiveCompletion: { completion in - switch completion { - case let .failure(error): - result(.failure(error)) - default: break - } - }, receiveValue: { value in - result(.success(value)) - }) - } - - func sinkToLoadable(_ completion: @escaping (Loadable) -> Void) -> AnyCancellable { - return sink(receiveCompletion: { subscriptionCompletion in - if let error = subscriptionCompletion.error { - completion(.failed(error)) - } - }, receiveValue: { value in - completion(.loaded(value)) - }) - } - - func extractUnderlyingError() -> Publishers.MapError { - mapError { - ($0.underlyingError as? Failure) ?? $0 - } - } - - /// Holds the downstream delivery of output until the specified time interval passed after the subscription - /// Does not hold the output if it arrives later than the time threshold - /// - /// - Parameters: - /// - interval: The minimum time interval that should elapse after the subscription. - /// - Returns: A publisher that optionally delays delivery of elements to the downstream receiver. - - func ensureTimeSpan(_ interval: TimeInterval) -> AnyPublisher { - let timer = Just(()) - .delay(for: .seconds(interval), scheduler: RunLoop.main) - .setFailureType(to: Failure.self) - return zip(timer) - .map { $0.0 } - .eraseToAnyPublisher() - } -} - -private extension Error { - var underlyingError: Error? { - let nsError = self as NSError - if nsError.domain == NSURLErrorDomain && nsError.code == -1009 { - // "The Internet connection appears to be offline." - return self - } - return nsError.userInfo[NSUnderlyingErrorKey] as? Error - } -} - -extension Subscribers.Completion { - var error: Failure? { - switch self { - case let .failure(error): return error - default: return nil - } - } -} diff --git a/CountriesSwiftUI/Utilities/Store.swift b/CountriesSwiftUI/Utilities/Store.swift index e0bd100..7b0b5d1 100644 --- a/CountriesSwiftUI/Utilities/Store.swift +++ b/CountriesSwiftUI/Utilities/Store.swift @@ -12,7 +12,7 @@ import Combine typealias Store = CurrentValueSubject extension Store { - + subscript(keyPath: WritableKeyPath) -> T where T: Equatable { get { value[keyPath: keyPath] } set { @@ -23,13 +23,13 @@ extension Store { } } } - + func bulkUpdate(_ update: (inout Output) -> Void) { var value = self.value update(&value) self.value = value } - + func updates(for keyPath: KeyPath) -> AnyPublisher where Value: Equatable { return map(keyPath).removeDuplicates().eraseToAnyPublisher() @@ -47,7 +47,7 @@ extension Binding where Value: Equatable { extension Binding where Value: Equatable { typealias ValueClosure = (Value) -> Void - + func onSet(_ perform: @escaping ValueClosure) -> Self { return .init(get: { () -> Value in self.wrappedValue @@ -59,3 +59,4 @@ extension Binding where Value: Equatable { }) } } + diff --git a/CountriesSwiftUI/Utilities/WebRepository.swift b/CountriesSwiftUI/Utilities/WebRepository.swift deleted file mode 100644 index 8f56307..0000000 --- a/CountriesSwiftUI/Utilities/WebRepository.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// WebRepository.swift -// CountriesSwiftUI -// -// Created by Alexey Naumov on 23.10.2019. -// Copyright © 2019 Alexey Naumov. All rights reserved. -// - -import Foundation -import Combine - -protocol WebRepository { - var session: URLSession { get } - var baseURL: String { get } - var bgQueue: DispatchQueue { get } -} - -extension WebRepository { - func call(endpoint: APICall, httpCodes: HTTPCodes = .success) -> AnyPublisher - where Value: Decodable { - do { - let request = try endpoint.urlRequest(baseURL: baseURL) - return session - .dataTaskPublisher(for: request) - .requestJSON(httpCodes: httpCodes) - } catch let error { - return Fail(error: error).eraseToAnyPublisher() - } - } -} - -// MARK: - Helpers - -extension Publisher where Output == URLSession.DataTaskPublisher.Output { - func requestData(httpCodes: HTTPCodes = .success) -> AnyPublisher { - return tryMap { - assert(!Thread.isMainThread) - guard let code = ($0.1 as? HTTPURLResponse)?.statusCode else { - throw APIError.unexpectedResponse - } - guard httpCodes.contains(code) else { - throw APIError.httpCode(code) - } - return $0.0 - } - .extractUnderlyingError() - .eraseToAnyPublisher() - } -} - -private extension Publisher where Output == URLSession.DataTaskPublisher.Output { - func requestJSON(httpCodes: HTTPCodes) -> AnyPublisher where Value: Decodable { - return requestData(httpCodes: httpCodes) - .decode(type: Value.self, decoder: JSONDecoder()) - .receive(on: DispatchQueue.main) - .eraseToAnyPublisher() - } -} diff --git a/PushNotificationPayload/push_with_deeplink.apns b/PushNotificationPayload/push_with_deeplink.apns deleted file mode 100644 index 3fdda61..0000000 --- a/PushNotificationPayload/push_with_deeplink.apns +++ /dev/null @@ -1,10 +0,0 @@ -{ - "Simulator Target Bundle": "com.countries.swiftui", - "aps" : { - "alert" : { - "title": "SwiftUI Countries", - "body" : "🇦🇩Tap to see Andorra's flag" - }, - "country" : "AND" - } -} \ No newline at end of file diff --git a/README.md b/README.md index e0a3e47..0228ed9 100644 --- a/README.md +++ b/README.md @@ -16,24 +16,23 @@ The app uses the [restcountries.com](https://restcountries.com/) REST API to sho For the example of handling the **authentication state** in the app, you can refer to my [other tiny project](https://github.com/nalexn/uikit-swiftui) that harnesses the locks and keys principle for solving this problem. -![platforms](https://img.shields.io/badge/platforms-iPhone%20%7C%20iPad%20%7C%20macOS-lightgrey) [![Build Status](https://travis-ci.com/nalexn/clean-architecture-swiftui.svg?branch=master)](https://travis-ci.com/nalexn/clean-architecture-swiftui) [![codecov](https://codecov.io/gh/nalexn/clean-architecture-swiftui/branch/master/graph/badge.svg)](https://codecov.io/gh/nalexn/clean-architecture-swiftui) [![codebeat badge](https://codebeat.co/badges/db33561b-0b2b-4ee1-a941-a08efbd0ebd7)](https://codebeat.co/projects/github-com-nalexn-clean-architecture-swiftui-master) +![platforms](https://img.shields.io/badge/platforms-iPhone%20%7C%20iPad%20%7C%20macOS-lightgrey) [![codecov](https://codecov.io/gh/nalexn/clean-architecture-swiftui/branch/master/graph/badge.svg)](https://codecov.io/gh/nalexn/clean-architecture-swiftui) [![codebeat badge](https://codebeat.co/badges/db33561b-0b2b-4ee1-a941-a08efbd0ebd7)](https://codebeat.co/projects/github-com-nalexn-clean-architecture-swiftui-master)

Diagram

## Key features -* Vanilla **SwiftUI** + **Combine** implementation +* End of 2024 update: the project was fully revamped to use modern iOS stack technologies * Decoupled **Presentation**, **Business Logic**, and **Data Access** layers -* Full test coverage, including the UI (thanks to the [ViewInspector](https://github.com/nalexn/ViewInspector)) -* **Redux**-like centralized `AppState` as the single source of truth -* Data persistence with **CoreData** +* Programmatic navigation. Push notifications with deep link +* Redux-like centralized `AppState` as the single source of truth * Native SwiftUI dependency injection -* **Programmatic navigation**. Push notifications with deep link -* Simple yet flexible networking layer built on Generics * Handling of the system events (such as `didBecomeActive`, `willResignActive`) -* Built with SOLID, DRY, KISS, YAGNI in mind -* Designed for scalability. It can be used as a reference for building large production apps +* Full test coverage, including the UI (thanks to the [ViewInspector](https://github.com/nalexn/ViewInspector)) +* Simple yet flexible networking layer built on async - await +* UI - vanilla **SwiftUI** + **Combine** +* Data persistence with **SwiftData** ## Architecture overview @@ -73,4 +72,4 @@ Repositories provide asynchronous API (`Publisher` from Combine) for making [CRU --- -[![Twitter](https://img.shields.io/badge/twitter-nallexn-blue)](https://twitter.com/nallexn) [![blog](https://img.shields.io/badge/blog-github-blue)](https://nalexn.github.io/?utm_source=nalexn_github) [![venmo](https://img.shields.io/badge/%F0%9F%8D%BA-Venmo-brightgreen)](https://venmo.com/nallexn) +[![Twitter](https://img.shields.io/badge/twitter-nallexn-blue)](https://twitter.com/nallexn) [![blog](https://img.shields.io/badge/blog-github-blue)](https://nalexn.github.io/?utm_source=nalexn_github) diff --git a/UnitTests/Interactors/CountriesInteractorTests.swift b/UnitTests/Interactors/CountriesInteractorTests.swift deleted file mode 100644 index fb77c77..0000000 --- a/UnitTests/Interactors/CountriesInteractorTests.swift +++ /dev/null @@ -1,394 +0,0 @@ -// -// CountriesInteractorTests.swift -// UnitTests -// -// Created by Alexey Naumov on 31.10.2019. -// Copyright © 2019 Alexey Naumov. All rights reserved. -// - -import XCTest -import SwiftUI -import Combine -@testable import CountriesSwiftUI - -class CountriesInteractorTests: XCTestCase { - - let appState = CurrentValueSubject(AppState()) - var mockedWebRepo: MockedCountriesWebRepository! - var mockedDBRepo: MockedCountriesDBRepository! - var subscriptions = Set() - var sut: RealCountriesInteractor! - - override func setUp() { - appState.value = AppState() - mockedWebRepo = MockedCountriesWebRepository() - mockedDBRepo = MockedCountriesDBRepository() - sut = RealCountriesInteractor(webRepository: mockedWebRepo, - dbRepository: mockedDBRepo, - appState: appState) - } - - override func tearDown() { - subscriptions = Set() - } -} - -// MARK: - load(countries: search: locale:) - -final class LoadCountriesTests: CountriesInteractorTests { - - func test_filledDB_successfulSearch() { - let list = Country.mockedData - - // Configuring expected actions on repositories - - mockedWebRepo.actions = .init(expected: [ - ]) - mockedDBRepo.actions = .init(expected: [ - .hasLoadedCountries, - .fetchCountries(search: "abc", locale: .backendDefault) - ]) - - // Configuring responses from repositories - - mockedDBRepo.hasLoadedCountriesResult = .success(true) - mockedDBRepo.fetchCountriesResult = .success(list.lazyList) - - let countries = BindingWithPublisher(value: Loadable>.notRequested) - sut.load(countries: countries.binding, search: "abc", locale: .backendDefault) - let exp = XCTestExpectation(description: #function) - countries.updatesRecorder.sink { updates in - XCTAssertEqual(updates, [ - .notRequested, - .isLoading(last: nil, cancelBag: CancelBag()), - .loaded(list.lazyList) - ], removing: Country.prefixes) - self.mockedWebRepo.verify() - self.mockedDBRepo.verify() - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 2) - } - - func test_filledDB_failedSearch() { - let error = NSError.test - - // Configuring expected actions on repositories - - mockedWebRepo.actions = .init(expected: [ - ]) - mockedDBRepo.actions = .init(expected: [ - .hasLoadedCountries, - .fetchCountries(search: "abc", locale: .backendDefault) - ]) - - // Configuring responses from repositories - - mockedDBRepo.hasLoadedCountriesResult = .success(true) - mockedDBRepo.fetchCountriesResult = .failure(error) - - let countries = BindingWithPublisher(value: Loadable>.notRequested) - sut.load(countries: countries.binding, search: "abc", locale: .backendDefault) - let exp = XCTestExpectation(description: #function) - countries.updatesRecorder.sink { updates in - XCTAssertEqual(updates, [ - .notRequested, - .isLoading(last: nil, cancelBag: CancelBag()), - .failed(error) - ], removing: Country.prefixes) - self.mockedWebRepo.verify() - self.mockedDBRepo.verify() - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 2) - } - - func test_emptyDB_failedRequest() { - let error = NSError.test - - // Configuring expected actions on repositories - - mockedWebRepo.actions = .init(expected: [ - .loadCountries - ]) - mockedDBRepo.actions = .init(expected: [ - .hasLoadedCountries - ]) - - // Configuring responses from repositories - - mockedWebRepo.countriesResponse = .failure(error) - mockedDBRepo.hasLoadedCountriesResult = .success(false) - - let countries = BindingWithPublisher(value: Loadable>.notRequested) - sut.load(countries: countries.binding, search: "abc", locale: .backendDefault) - let exp = XCTestExpectation(description: #function) - countries.updatesRecorder.sink { updates in - XCTAssertEqual(updates, [ - .notRequested, - .isLoading(last: nil, cancelBag: CancelBag()), - .failed(error) - ], removing: Country.prefixes) - self.mockedWebRepo.verify() - self.mockedDBRepo.verify() - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 2) - } - - func test_emptyDB_successfulRequest_successfulStoring() { - let list = Country.mockedData - - // Configuring expected actions on repositories - - mockedWebRepo.actions = .init(expected: [ - .loadCountries - ]) - mockedDBRepo.actions = .init(expected: [ - .hasLoadedCountries, - .storeCountries(list), - .fetchCountries(search: "abc", locale: .backendDefault) - ]) - - // Configuring responses from repositories - - mockedWebRepo.countriesResponse = .success(list) - mockedDBRepo.hasLoadedCountriesResult = .success(false) - mockedDBRepo.storeCountriesResult = .success(()) - mockedDBRepo.fetchCountriesResult = .success(list.lazyList) - - let countries = BindingWithPublisher(value: Loadable>.notRequested) - sut.load(countries: countries.binding, search: "abc", locale: .backendDefault) - let exp = XCTestExpectation(description: #function) - countries.updatesRecorder.sink { updates in - XCTAssertEqual(updates, [ - .notRequested, - .isLoading(last: nil, cancelBag: CancelBag()), - .loaded(list.lazyList) - ], removing: Country.prefixes) - self.mockedWebRepo.verify() - self.mockedDBRepo.verify() - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 2) - } - - func test_emptyDB_successfulRequest_failedStoring() { - let list = Country.mockedData - let error = NSError.test - - // Configuring expected actions on repositories - - mockedWebRepo.actions = .init(expected: [ - .loadCountries - ]) - mockedDBRepo.actions = .init(expected: [ - .hasLoadedCountries, - .storeCountries(list) - ]) - - // Configuring responses from repositories - - mockedWebRepo.countriesResponse = .success(list) - mockedDBRepo.hasLoadedCountriesResult = .success(false) - mockedDBRepo.storeCountriesResult = .failure(error) - - let countries = BindingWithPublisher(value: Loadable>.notRequested) - sut.load(countries: countries.binding, search: "abc", locale: .backendDefault) - let exp = XCTestExpectation(description: #function) - countries.updatesRecorder.sink { updates in - XCTAssertEqual(updates, [ - .notRequested, - .isLoading(last: nil, cancelBag: CancelBag()), - .failed(error) - ], removing: Country.prefixes) - self.mockedWebRepo.verify() - self.mockedDBRepo.verify() - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 2) - } -} - -// MARK: - load(countryDetails: country: ) - -final class LoadCountryDetailsTests: CountriesInteractorTests { - - func test_filledDB_successfulSearch() { - let country = Country.mockedData[0] - let data = countryDetails(neighbors: []) - - // Configuring expected actions on repositories - - mockedWebRepo.actions = .init(expected: [ - ]) - mockedDBRepo.actions = .init(expected: [ - .fetchCountryDetails(country) - ]) - - // Configuring responses from repositories - - mockedDBRepo.fetchCountryDetailsResult = .success(data.details) - - let details = BindingWithPublisher(value: Loadable.notRequested) - sut.load(countryDetails: details.binding, country: country) - let exp = XCTestExpectation(description: #function) - details.updatesRecorder.sink { updates in - XCTAssertEqual(updates, [ - .notRequested, - .isLoading(last: nil, cancelBag: CancelBag()), - .loaded(data.details) - ], removing: Country.prefixes) - self.mockedWebRepo.verify() - self.mockedDBRepo.verify() - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 2) - } - - func test_filledDB_dataNotFound_failedRequest() { - let country = Country.mockedData[0] - let error = NSError.test - - // Configuring expected actions on repositories - - mockedWebRepo.actions = .init(expected: [ - .loadCountryDetails(country) - ]) - mockedDBRepo.actions = .init(expected: [ - .fetchCountryDetails(country) - ]) - - // Configuring responses from repositories - - mockedDBRepo.fetchCountryDetailsResult = .success(nil) - mockedWebRepo.detailsResponse = .failure(error) - - let details = BindingWithPublisher(value: Loadable.notRequested) - sut.load(countryDetails: details.binding, country: country) - let exp = XCTestExpectation(description: #function) - details.updatesRecorder.sink { updates in - XCTAssertEqual(updates, [ - .notRequested, - .isLoading(last: nil, cancelBag: CancelBag()), - .failed(error) - ], removing: Country.prefixes) - self.mockedWebRepo.verify() - self.mockedDBRepo.verify() - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 2) - } - - func test_filledDB_dataNotFound_successfulRequest_failedStoring() { - let country = Country.mockedData[0] - let data = countryDetails(neighbors: []) - let error = NSError.test - - // Configuring expected actions on repositories - - mockedWebRepo.actions = .init(expected: [ - .loadCountryDetails(country) - ]) - mockedDBRepo.actions = .init(expected: [ - .fetchCountryDetails(country), - .storeCountryDetails(data.intermediate) - ]) - - // Configuring responses from repositories - - mockedDBRepo.fetchCountryDetailsResult = .success(nil) - mockedWebRepo.detailsResponse = .success(data.intermediate) - mockedDBRepo.storeCountryDetailsResult = .failure(error) - - let details = BindingWithPublisher(value: Loadable.notRequested) - sut.load(countryDetails: details.binding, country: country) - let exp = XCTestExpectation(description: #function) - details.updatesRecorder.sink { updates in - XCTAssertEqual(updates, [ - .notRequested, - .isLoading(last: nil, cancelBag: CancelBag()), - .failed(error) - ], removing: Country.prefixes) - self.mockedWebRepo.verify() - self.mockedDBRepo.verify() - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 2) - } - - func test_filledDB_dataNotFound_successfulRequest_successfulStoring() { - let country = Country.mockedData[0] - let data = countryDetails(neighbors: []) - - // Configuring expected actions on repositories - - mockedWebRepo.actions = .init(expected: [ - .loadCountryDetails(country) - ]) - mockedDBRepo.actions = .init(expected: [ - .fetchCountryDetails(country), - .storeCountryDetails(data.intermediate) - ]) - - // Configuring responses from repositories - - mockedDBRepo.fetchCountryDetailsResult = .success(nil) - mockedWebRepo.detailsResponse = .success(data.intermediate) - mockedDBRepo.storeCountryDetailsResult = .success(data.details) - - let details = BindingWithPublisher(value: Loadable.notRequested) - sut.load(countryDetails: details.binding, country: country) - let exp = XCTestExpectation(description: #function) - details.updatesRecorder.sink { updates in - XCTAssertEqual(updates, [ - .notRequested, - .isLoading(last: nil, cancelBag: CancelBag()), - .loaded(data.details) - ], removing: Country.prefixes) - self.mockedWebRepo.verify() - self.mockedDBRepo.verify() - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 2) - } - - func test_stubInteractor() { - let sut = StubCountriesInteractor() - sut.refreshCountriesList().sinkToResult({ _ in }).store(in: &subscriptions) - let countries = BindingWithPublisher(value: Loadable>.notRequested) - sut.load(countries: countries.binding, search: "", locale: .backendDefault) - let details = BindingWithPublisher(value: Loadable.notRequested) - sut.load(countryDetails: details.binding, country: Country.mockedData[0]) - } - - // MARK: - Helper - - private func recordAppStateUserDataUpdates(for timeInterval: TimeInterval = 0.5) - -> AnyPublisher<[AppState.UserData], Never> { - return Future<[AppState.UserData], Never> { (completion) in - var updates = [AppState.UserData]() - self.appState.map(\.userData) - .sink { updates.append($0 )} - .store(in: &self.subscriptions) - DispatchQueue.main.asyncAfter(deadline: .now() + timeInterval) { - completion(.success(updates)) - } - }.eraseToAnyPublisher() - } - - private func countryDetails(neighbors: [Country]) - -> (intermediate: Country.Details.Intermediate, details: Country.Details) { - let intermediate = Country.Details.Intermediate( - capital: "London", - currencies: [Country.Currency(code: "12", symbol: "$", name: "US dollar")], - borders: neighbors.map { $0.alpha3Code }) - let details = Country.Details(capital: intermediate.capital, - currencies: intermediate.currencies, - neighbors: neighbors) - return (intermediate, details) - } -} - -extension Country: PrefixRemovable { } -extension Country.Details: PrefixRemovable { } diff --git a/UnitTests/Interactors/ImagesInteractorTests.swift b/UnitTests/Interactors/ImagesInteractorTests.swift deleted file mode 100644 index e63d490..0000000 --- a/UnitTests/Interactors/ImagesInteractorTests.swift +++ /dev/null @@ -1,112 +0,0 @@ -// -// ImagesInteractorTests.swift -// UnitTests -// -// Created by Alexey Naumov on 10.11.2019. -// Copyright © 2019 Alexey Naumov. All rights reserved. -// - -import XCTest -import Combine -@testable import CountriesSwiftUI - -final class ImagesInteractorTests: XCTestCase { - - var sut: RealImagesInteractor! - var mockedWebRepository: MockedImageWebRepository! - var subscriptions = Set() - let testImageURL = URL(string: "https://test.com/test.png")! - let testImage = UIColor.red.image(CGSize(width: 40, height: 40)) - - override func setUp() { - mockedWebRepository = MockedImageWebRepository() - sut = RealImagesInteractor(webRepository: mockedWebRepository) - subscriptions = Set() - } - - func expectRepoActions(_ actions: [MockedImageWebRepository.Action]) { - mockedWebRepository.actions = .init(expected: actions) - } - - func verifyRepoActions(file: StaticString = #file, line: UInt = #line) { - mockedWebRepository.verify(file: file, line: line) - } - - func test_loadImage_nilURL() { - let image = BindingWithPublisher(value: Loadable.notRequested) - expectRepoActions([]) - sut.load(image: image.binding, url: nil) - let exp = XCTestExpectation(description: "Completion") - image.updatesRecorder.sink { updates in - XCTAssertEqual(updates, [ - .notRequested, - .notRequested - ]) - self.verifyRepoActions() - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 1) - } - - func test_loadImage_loadedFromWeb() { - let image = BindingWithPublisher(value: Loadable.notRequested) - mockedWebRepository.imageResponse = .success(testImage) - expectRepoActions([.loadImage(testImageURL)]) - sut.load(image: image.binding, url: testImageURL) - let exp = XCTestExpectation(description: "Completion") - image.updatesRecorder.sink { updates in - XCTAssertEqual(updates, [ - .notRequested, - .isLoading(last: nil, cancelBag: CancelBag()), - .loaded(self.testImage) - ]) - self.verifyRepoActions() - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 1) - } - - func test_loadImage_failed() { - let image = BindingWithPublisher(value: Loadable.notRequested) - let error = NSError.test - mockedWebRepository.imageResponse = .failure(error) - expectRepoActions([.loadImage(testImageURL)]) - sut.load(image: image.binding, url: testImageURL) - let exp = XCTestExpectation(description: "Completion") - image.updatesRecorder.sink { updates in - XCTAssertEqual(updates, [ - .notRequested, - .isLoading(last: nil, cancelBag: CancelBag()), - .failed(error) - ]) - self.verifyRepoActions() - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 1) - } - - func test_loadImage_hadLoadedImage() { - let image = BindingWithPublisher(value: Loadable.loaded(testImage)) - let error = NSError.test - mockedWebRepository.imageResponse = .failure(error) - expectRepoActions([.loadImage(testImageURL)]) - sut.load(image: image.binding, url: testImageURL) - let exp = XCTestExpectation(description: "Completion") - image.updatesRecorder.sink { updates in - XCTAssertEqual(updates, [ - .loaded(self.testImage), - .isLoading(last: self.testImage, cancelBag: CancelBag()), - .failed(error) - ]) - self.verifyRepoActions() - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 1) - } - - func test_stubInteractor() { - let sut = StubImagesInteractor() - let image = BindingWithPublisher(value: Loadable.notRequested) - sut.load(image: image.binding, url: testImageURL) - } -} diff --git a/UnitTests/Interactors/UserPermissionsInteractorTests.swift b/UnitTests/Interactors/UserPermissionsInteractorTests.swift deleted file mode 100644 index d830135..0000000 --- a/UnitTests/Interactors/UserPermissionsInteractorTests.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// UserPermissionsInteractorTests.swift -// UnitTests -// -// Created by Alexey Naumov on 26.04.2020. -// Copyright © 2020 Alexey Naumov. All rights reserved. -// - -import XCTest -import Combine -@testable import CountriesSwiftUI - -class UserPermissionsInteractorTests: XCTestCase { - - var state = Store(AppState()) - var sut: RealUserPermissionsInteractor! - - override func setUp() { - state.bulkUpdate { $0 = AppState() } - } - - func delay(_ closure: @escaping () -> Void) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: closure) - } - - func test_noSideEffectOnInit() { - let exp = XCTestExpectation(description: #function) - sut = RealUserPermissionsInteractor(appState: state) { - XCTFail() - } - delay { - XCTAssertEqual(self.state.value, AppState()) - exp.fulfill() - } - wait(for: [exp], timeout: 0.5) - } - - // MARK: - Push - - func test_pushFirstResolveStatus() { - XCTAssertEqual(AppState().permissions.push, .unknown) - let exp = XCTestExpectation(description: #function) - sut = RealUserPermissionsInteractor(appState: state) { - XCTFail() - } - sut.resolveStatus(for: .pushNotifications) - delay { - XCTAssertNotEqual(self.state.value.permissions.push, .unknown) - exp.fulfill() - } - wait(for: [exp], timeout: 0.5) - } - - func test_pushSecondResolveStatus() { - XCTAssertEqual(AppState().permissions.push, .unknown) - let exp = XCTestExpectation(description: #function) - sut = RealUserPermissionsInteractor(appState: state) { - XCTFail() - } - sut.resolveStatus(for: .pushNotifications) - delay { - self.sut.resolveStatus(for: .pushNotifications) - XCTAssertNotEqual(self.state.value.permissions.push, .unknown) - exp.fulfill() - } - wait(for: [exp], timeout: 0.5) - } - - func test_pushRequestPermissionNotDetermined() { - state[\.permissions.push] = .notRequested - let exp = XCTestExpectation(description: #function) - sut = RealUserPermissionsInteractor(appState: state) { - XCTFail() - } - sut.request(permission: .pushNotifications) - delay { - XCTAssertNotEqual(self.state.value.permissions.push, .unknown) - exp.fulfill() - } - wait(for: [exp], timeout: 0.5) - } - - func test_pushRequestPermissionDenied() { - state[\.permissions.push] = .denied - let exp = XCTestExpectation(description: #function) - sut = RealUserPermissionsInteractor(appState: state) { - XCTAssertEqual(self.state.value.permissions.push, .denied) - exp.fulfill() - } - sut.request(permission: .pushNotifications) - wait(for: [exp], timeout: 0.5) - } - - func test_authorizationStatusMapping() { - XCTAssertEqual(UNAuthorizationStatus.notDetermined.map, .notRequested) - XCTAssertEqual(UNAuthorizationStatus.provisional.map, .notRequested) - XCTAssertEqual(UNAuthorizationStatus.denied.map, .denied) - XCTAssertEqual(UNAuthorizationStatus.authorized.map, .granted) - XCTAssertEqual(UNAuthorizationStatus(rawValue: 10)?.map, .notRequested) - } - - // MARK: - Stub - - func test_stubUserPermissionsInteractor() { - let sut = StubUserPermissionsInteractor() - sut.request(permission: .pushNotifications) - sut.resolveStatus(for: .pushNotifications) - } -} diff --git a/UnitTests/Mocks/Interactors/CountriesInteractorTests.swift b/UnitTests/Mocks/Interactors/CountriesInteractorTests.swift new file mode 100644 index 0000000..75a00a7 --- /dev/null +++ b/UnitTests/Mocks/Interactors/CountriesInteractorTests.swift @@ -0,0 +1,262 @@ +// +// CountriesInteractorTests.swift +// UnitTests +// +// Created by Alexey Naumov on 31.10.2019. +// Copyright © 2019 Alexey Naumov. All rights reserved. +// + +import Testing +import SwiftUI +@testable import CountriesSwiftUI + +@MainActor +@Suite class CountriesInteractorTests { + + let mockedWebRepo: MockedCountriesWebRepository + let mockedDBRepo: MockedCountriesDBRepository + let sut: RealCountriesInteractor + + init() { + mockedWebRepo = MockedCountriesWebRepository() + mockedDBRepo = MockedCountriesDBRepository() + sut = RealCountriesInteractor(webRepository: mockedWebRepo, + dbRepository: mockedDBRepo) + } +} + +// MARK: - refreshCountriesList() + +final class RefreshCountriesListTests: CountriesInteractorTests { + + @Test func happyPath() async throws { + let countries = ApiModel.Country.mockedData + mockedWebRepo.actions = .init(expected: [ + .countries + ]) + mockedWebRepo.countriesResponses = [.success(countries)] + mockedDBRepo.actions = .init(expected: [ + .storeCountries(countries) + ]) + mockedDBRepo.storeCountriesResults = [.success(())] + try await sut.refreshCountriesList() + mockedWebRepo.verify() + mockedDBRepo.verify() + } + + @Test func dbFailure() async throws { + let countries = ApiModel.Country.mockedData + mockedWebRepo.actions = .init(expected: [ + .countries + ]) + mockedWebRepo.countriesResponses = [.success(countries)] + mockedDBRepo.actions = .init(expected: [ + .storeCountries(countries) + ]) + let error = NSError.test + mockedDBRepo.storeCountriesResults = [.failure(error)] + await #expect(throws: error) { + try await sut.refreshCountriesList() + } + mockedWebRepo.verify() + mockedDBRepo.verify() + } + + @Test func webFailure() async throws { + mockedWebRepo.actions = .init(expected: [ + .countries + ]) + let error = NSError.test + mockedWebRepo.countriesResponses = [.failure(error)] + mockedDBRepo.actions = .init(expected: []) + await #expect(throws: error) { + try await sut.refreshCountriesList() + } + mockedWebRepo.verify() + mockedDBRepo.verify() + } +} + +// MARK: - loadCountryDetails(country: DBModel.Country, forceReload: Bool) + +final class LoadCountryDetailsTests: CountriesInteractorTests { + + @Test func happyPathCachedData() async throws { + let country = ApiModel.Country.mockedData[0].dbModel() + let details = ApiModel.CountryDetails.mockedData[0] + mockedWebRepo.actions = .init(expected: []) + mockedDBRepo.actions = .init(expected: [ + .fetchCountryDetails(country), + ]) + let dbDetails = DBModel.CountryDetails( + alpha3Code: country.alpha3Code, + capital: details.capital, + currencies: details.currencies.map({ $0.dbModel() }), + neighbors: []) + mockedDBRepo.countryDetailsResults = [ + .success(dbDetails) + ] + let result = try await sut.loadCountryDetails(country: country, forceReload: false) + #expect(result == dbDetails) + mockedWebRepo.verify() + mockedDBRepo.verify() + } + + @Test func happyPathCachedDataForceReload() async throws { + let country = ApiModel.Country.mockedData[0].dbModel() + let details = ApiModel.CountryDetails.mockedData[0] + mockedWebRepo.actions = .init(expected: [ + .details(country: country), + ]) + mockedWebRepo.detailsResponses = [.success(details)] + mockedDBRepo.actions = .init(expected: [ + .storeDetails(details, country: country), + .fetchCountryDetails(country), + ]) + let dbDetails = DBModel.CountryDetails( + alpha3Code: country.alpha3Code, + capital: details.capital, + currencies: details.currencies.map({ $0.dbModel() }), + neighbors: []) + mockedDBRepo.countryDetailsResults = [ + .success(dbDetails) + ] + mockedDBRepo.storeCountryDetailsResults = [.success(())] + let result = try await sut.loadCountryDetails(country: country, forceReload: true) + #expect(result == dbDetails) + mockedWebRepo.verify() + mockedDBRepo.verify() + } + + @Test func happyPathNoCache() async throws { + let country = ApiModel.Country.mockedData[0].dbModel() + let details = ApiModel.CountryDetails.mockedData[0] + mockedWebRepo.actions = .init(expected: [ + .details(country: country), + ]) + mockedWebRepo.detailsResponses = [.success(details)] + mockedDBRepo.actions = .init(expected: [ + .fetchCountryDetails(country), + .storeDetails(details, country: country), + .fetchCountryDetails(country), + ]) + let dbDetails = DBModel.CountryDetails( + alpha3Code: country.alpha3Code, + capital: details.capital, + currencies: details.currencies.map({ $0.dbModel() }), + neighbors: []) + mockedDBRepo.countryDetailsResults = [ + .success(nil), + .success(dbDetails) + ] + mockedDBRepo.storeCountryDetailsResults = [.success(())] + let result = try await sut.loadCountryDetails(country: country, forceReload: false) + #expect(result == dbDetails) + mockedWebRepo.verify() + mockedDBRepo.verify() + } + + @Test func cacheDBFailure() async throws { + let country = ApiModel.Country.mockedData[0].dbModel() + let details = ApiModel.CountryDetails.mockedData[0] + mockedWebRepo.actions = .init(expected: [ + .details(country: country), + ]) + mockedWebRepo.detailsResponses = [.success(details)] + mockedDBRepo.actions = .init(expected: [ + .fetchCountryDetails(country), + .storeDetails(details, country: country), + .fetchCountryDetails(country), + ]) + let dbDetails = DBModel.CountryDetails( + alpha3Code: country.alpha3Code, + capital: details.capital, + currencies: details.currencies.map({ $0.dbModel() }), + neighbors: []) + mockedDBRepo.countryDetailsResults = [ + .failure(NSError.test), + .success(dbDetails) + ] + mockedDBRepo.storeCountryDetailsResults = [.success(())] + let result = try await sut.loadCountryDetails(country: country, forceReload: false) + #expect(result == dbDetails) + mockedWebRepo.verify() + mockedDBRepo.verify() + } + + @Test func fetchAfterStoringDBFailure() async throws { + let country = ApiModel.Country.mockedData[0].dbModel() + let details = ApiModel.CountryDetails.mockedData[0] + mockedWebRepo.actions = .init(expected: [ + .details(country: country), + ]) + mockedWebRepo.detailsResponses = [.success(details)] + mockedDBRepo.actions = .init(expected: [ + .fetchCountryDetails(country), + .storeDetails(details, country: country), + .fetchCountryDetails(country), + ]) + let error = NSError.test + mockedDBRepo.countryDetailsResults = [ + .success(nil), + .failure(error) + ] + mockedDBRepo.storeCountryDetailsResults = [.success(())] + await #expect(throws: ValueIsMissingError.self) { + try await sut.loadCountryDetails(country: country, forceReload: false) + } + mockedWebRepo.verify() + mockedDBRepo.verify() + } + + @Test func storingDBFailure() async throws { + let country = ApiModel.Country.mockedData[0].dbModel() + let details = ApiModel.CountryDetails.mockedData[0] + mockedWebRepo.actions = .init(expected: [ + .details(country: country), + ]) + mockedWebRepo.detailsResponses = [.success(details)] + mockedDBRepo.actions = .init(expected: [ + .fetchCountryDetails(country), + .storeDetails(details, country: country), + ]) + let error = NSError.test + mockedDBRepo.countryDetailsResults = [.success(nil)] + mockedDBRepo.storeCountryDetailsResults = [.failure(error)] + await #expect(throws: error) { + try await sut.loadCountryDetails(country: country, forceReload: false) + } + mockedWebRepo.verify() + mockedDBRepo.verify() + } + + @Test func webFailure() async throws { + let country = ApiModel.Country.mockedData[0].dbModel() + let error = NSError.test + mockedWebRepo.actions = .init(expected: [ + .details(country: country), + ]) + mockedWebRepo.detailsResponses = [.failure(error)] + mockedDBRepo.actions = .init(expected: [ + .fetchCountryDetails(country), + ]) + mockedDBRepo.countryDetailsResults = [.success(nil)] + await #expect(throws: error) { + try await sut.loadCountryDetails(country: country, forceReload: false) + } + mockedWebRepo.verify() + mockedDBRepo.verify() + } +} + +final class StubCountriesInteractorTests: CountriesInteractorTests { + + @Test func stubInteractor() async throws { + let country = ApiModel.Country.mockedData[0].dbModel() + let sut = StubCountriesInteractor() + try await sut.refreshCountriesList() + await #expect(throws: ValueIsMissingError.self) { + try await sut.loadCountryDetails(country: country, forceReload: false) + } + } +} diff --git a/UnitTests/Mocks/Interactors/ImagesInteractorTests.swift b/UnitTests/Mocks/Interactors/ImagesInteractorTests.swift new file mode 100644 index 0000000..1e66bb3 --- /dev/null +++ b/UnitTests/Mocks/Interactors/ImagesInteractorTests.swift @@ -0,0 +1,95 @@ +// +// ImagesInteractorTests.swift +// UnitTests +// +// Created by Alexey Naumov on 10.11.2019. +// Copyright © 2019 Alexey Naumov. All rights reserved. +// + +import Testing +import UIKit +import Combine +@testable import CountriesSwiftUI + +@Suite struct ImagesInteractorTests { + + let sut: RealImagesInteractor + let mockedWebRepository: MockedImageWebRepository + let testImageURL = URL(string: "https://test.com/test.png")! + let testImage = UIColor.red.image(CGSize(width: 40, height: 40)) + + init() { + mockedWebRepository = MockedImageWebRepository() + sut = RealImagesInteractor(webRepository: mockedWebRepository) + } + + func expectRepoActions(_ actions: [MockedImageWebRepository.Action]) { + mockedWebRepository.actions = .init(expected: actions) + } + + func verifyRepoActions(sourceLocation: SourceLocation = #_sourceLocation) { + mockedWebRepository.verify(sourceLocation: sourceLocation) + } + + @Test func loadImageNilURL() async throws { + let state = BindingWithHistory(value: Loadable.notRequested) + expectRepoActions([]) + sut.load(image: state.binding, url: nil) + try await SuspendingClock().sleep(for: .seconds(0.5)) + #expect(state.history == [.notRequested, .notRequested]) + verifyRepoActions() + } + + @Test func loadImageLoadedFromWeb() async throws { + let state = BindingWithHistory(value: Loadable.notRequested) + mockedWebRepository.imageResponses = [.success(testImage)] + expectRepoActions([.loadImage(testImageURL)]) + sut.load(image: state.binding, url: testImageURL) + try await SuspendingClock().sleep(for: .seconds(0.5)) + #expect(state.history == [ + .notRequested, + .isLoading(last: nil, cancelBag: .test), + .loaded(testImage) + ]) + verifyRepoActions() + } + + @Test func loadImageFailed() async throws { + let state = BindingWithHistory(value: Loadable.notRequested) + let error = NSError.test + mockedWebRepository.imageResponses = [.failure(error)] + expectRepoActions([.loadImage(testImageURL)]) + sut.load(image: state.binding, url: testImageURL) + try await SuspendingClock().sleep(for: .seconds(0.5)) + #expect(state.history == [ + .notRequested, + .isLoading(last: nil, cancelBag: .test), + .failed(error) + ]) + verifyRepoActions() + } + + @Test func loadImageHadLoadedImage() async throws { + let state = BindingWithHistory(value: Loadable.loaded(testImage)) + let error = NSError.test + mockedWebRepository.imageResponses = [.failure(error)] + expectRepoActions([.loadImage(testImageURL)]) + sut.load(image: state.binding, url: testImageURL) + try await SuspendingClock().sleep(for: .seconds(0.5)) + #expect(state.history == [ + .loaded(self.testImage), + .isLoading(last: self.testImage, cancelBag: .test), + .failed(error) + ]) + verifyRepoActions() + } + + @Test func stubInteractor() async throws { + let sut = StubImagesInteractor() + let state = BindingWithHistory(value: Loadable.notRequested) + sut.load(image: state.binding, url: testImageURL) + try await SuspendingClock().sleep(for: .seconds(0.5)) + #expect(state.history == [.notRequested]) + verifyRepoActions() + } +} diff --git a/UnitTests/Mocks/Interactors/UserPermissionsInteractorTests.swift b/UnitTests/Mocks/Interactors/UserPermissionsInteractorTests.swift new file mode 100644 index 0000000..1dc6e29 --- /dev/null +++ b/UnitTests/Mocks/Interactors/UserPermissionsInteractorTests.swift @@ -0,0 +1,114 @@ +// +// UserPermissionsInteractorTests.swift +// UnitTests +// +// Created by Alexey Naumov on 26.04.2020. +// Copyright © 2020 Alexey Naumov. All rights reserved. +// + +import Testing +import Combine +import UserNotifications +@testable import CountriesSwiftUI + +@Suite struct UserPermissionsInteractorTests { + + @Test func noSideEffectOnInit() async throws { + let state = Store(AppState()) + let notificationsCenter = MockedSystemPushNotifications(expected: []) + let sut = makeSUT(state: state, notificationsCenter: notificationsCenter) + try await SuspendingClock().sleep(for: .seconds(0.5)) + #expect(state.value == AppState()) + notificationsCenter.verify() + _ = sut + } + + // MARK: - Push + + @Test func pushFirstResolveStatus() async throws { + #expect(AppState().permissions.push == .unknown) + let state = Store(AppState()) + let notificationsCenter = MockedSystemPushNotifications(expected: [ + .currentSettings + ]) + notificationsCenter.getResponses = [.init(authorizationStatus: .authorized)] + let sut = makeSUT(state: state, notificationsCenter: notificationsCenter) + sut.resolveStatus(for: .pushNotifications) + try await SuspendingClock().sleep(for: .seconds(1)) + #expect(state.value.permissions.push == .granted) + notificationsCenter.verify() + } + + @Test func pushRequestPermissionGrant() async throws { + let state = Store(AppState()) + state[\.permissions.push] = .notRequested + let notificationsCenter = MockedSystemPushNotifications(expected: [ + .requestAuthorization([.alert, .sound]) + ]) + notificationsCenter.requestResponses = [.success(true)] + let sut = makeSUT(state: state, notificationsCenter: notificationsCenter) + sut.request(permission: .pushNotifications) + try await SuspendingClock().sleep(for: .seconds(0.5)) + #expect(state.value.permissions.push == .granted) + notificationsCenter.verify() + } + + @Test func pushRequestPermissionDeny() async throws { + let state = Store(AppState()) + state[\.permissions.push] = .notRequested + let notificationsCenter = MockedSystemPushNotifications(expected: [ + .requestAuthorization([.alert, .sound]) + ]) + notificationsCenter.requestResponses = [.failure(NSError.test)] + let sut = makeSUT(state: state, notificationsCenter: notificationsCenter) + sut.request(permission: .pushNotifications) + try await SuspendingClock().sleep(for: .seconds(0.5)) + #expect(state.value.permissions.push == .denied) + notificationsCenter.verify() + } + + @Test func pushRequestPermissionDeniedBefore() async throws { + let state = Store(AppState()) + state[\.permissions.push] = .denied + let exp = TestExpectation() + let notificationsCenter = MockedSystemPushNotifications(expected: []) + let sut = makeSUT(state: state, notificationsCenter: notificationsCenter) { + #expect(state.value.permissions.push == .denied) + exp.fulfill() + } + sut.request(permission: .pushNotifications) + await exp.fulfillment() + notificationsCenter.verify() + } + + @Test func authorizationStatusMapping() { + #expect(UNAuthorizationStatus.notDetermined.map == .notRequested) + #expect(UNAuthorizationStatus.provisional.map == .notRequested) + #expect(UNAuthorizationStatus.denied.map == .denied) + #expect(UNAuthorizationStatus.authorized.map == .granted) + #expect(UNAuthorizationStatus(rawValue: 10)?.map == .notRequested) + } + + // MARK: - Stub + + @Test func stubUserPermissionsInteractor() { + let sut = StubUserPermissionsInteractor() + sut.request(permission: .pushNotifications) + sut.resolveStatus(for: .pushNotifications) + } + + private func makeSUT(state: Store, + notificationsCenter: MockedSystemPushNotifications, + openAppSettings: (() -> Void)? = nil, + sourceLocation: SourceLocation = #_sourceLocation + ) -> RealUserPermissionsInteractor { + RealUserPermissionsInteractor( + appState: state, notificationCenter: notificationsCenter) { + if let openAppSettings { + openAppSettings() + } else { + Issue.record("openAppSettings callback not expected", sourceLocation: sourceLocation) + } + } + } +} diff --git a/UnitTests/Mocks/Mock.swift b/UnitTests/Mocks/Mock.swift index c62311a..4ebb779 100644 --- a/UnitTests/Mocks/Mock.swift +++ b/UnitTests/Mocks/Mock.swift @@ -6,7 +6,7 @@ // Copyright © 2019 Alexey Naumov. All rights reserved. // -import XCTest +import Testing @testable import CountriesSwiftUI protocol Mock { @@ -14,7 +14,7 @@ protocol Mock { var actions: MockActions { get } func register(_ action: Action) - func verify(file: StaticString, line: UInt) + func verify(sourceLocation: SourceLocation) } extension Mock { @@ -22,8 +22,8 @@ extension Mock { actions.register(action) } - func verify(file: StaticString = #file, line: UInt = #line) { - actions.verify(file: file, line: line) + func verify(sourceLocation: SourceLocation = #_sourceLocation) { + actions.verify(sourceLocation: sourceLocation) } } @@ -39,11 +39,11 @@ final class MockActions where Action: Equatable { factual.append(action) } - fileprivate func verify(file: StaticString, line: UInt) { - if factual == expected { return } + fileprivate func verify(sourceLocation: SourceLocation) { let factualNames = factual.map { "." + String(describing: $0) } let expectedNames = expected.map { "." + String(describing: $0) } - XCTFail("\(name)\n\nExpected:\n\n\(expectedNames)\n\nReceived:\n\n\(factualNames)", file: file, line: line) + let name = name + #expect(factual == expected, "\(name)\n\nExpected:\n\n\(expectedNames)\n\nReceived:\n\n\(factualNames)", sourceLocation: sourceLocation) } private var name: String { diff --git a/UnitTests/Mocks/MockedDBRepositories.swift b/UnitTests/Mocks/MockedDBRepositories.swift index 073252c..cbf427f 100644 --- a/UnitTests/Mocks/MockedDBRepositories.swift +++ b/UnitTests/Mocks/MockedDBRepositories.swift @@ -6,8 +6,7 @@ // Copyright © 2020 Alexey Naumov. All rights reserved. // -import XCTest -import Combine +import SwiftData @testable import CountriesSwiftUI // MARK: - CountriesWebRepository @@ -15,45 +14,41 @@ import Combine final class MockedCountriesDBRepository: Mock, CountriesDBRepository { enum Action: Equatable { - case hasLoadedCountries - case storeCountries([Country]) - case fetchCountries(search: String, locale: Locale) - case storeCountryDetails(Country.Details.Intermediate) - case fetchCountryDetails(Country) + case fetchCountryDetails(DBModel.Country) + case storeCountries([ApiModel.Country]) + case storeDetails(ApiModel.CountryDetails, country: DBModel.Country) } var actions = MockActions(expected: []) - - var hasLoadedCountriesResult: Result = .failure(MockError.valueNotSet) - var storeCountriesResult: Result = .failure(MockError.valueNotSet) - var fetchCountriesResult: Result, Error> = .failure(MockError.valueNotSet) - var storeCountryDetailsResult: Result = .failure(MockError.valueNotSet) - var fetchCountryDetailsResult: Result = .failure(MockError.valueNotSet) - + + var storeCountriesResults: [Result] = [] + var storeCountryDetailsResults: [Result] = [] + var countryDetailsResults: [Result] = [] + // MARK: - API - - func hasLoadedCountries() -> AnyPublisher { - register(.hasLoadedCountries) - return hasLoadedCountriesResult.publish() + + @MainActor + func countryDetails(for country: DBModel.Country) async throws -> DBModel.CountryDetails? { + register(.fetchCountryDetails(country)) + guard !countryDetailsResults.isEmpty else { throw MockError.valueNotSet } + return try countryDetailsResults.removeFirst().get() } - - func store(countries: [Country]) -> AnyPublisher { + + func store(countries: [ApiModel.Country]) async throws { register(.storeCountries(countries)) - return storeCountriesResult.publish() + guard !storeCountriesResults.isEmpty else { throw MockError.valueNotSet } + try storeCountriesResults.removeFirst().get() } - - func countries(search: String, locale: Locale) -> AnyPublisher, Error> { - register(.fetchCountries(search: search, locale: locale)) - return fetchCountriesResult.publish() - } - - func store(countryDetails: Country.Details.Intermediate, - for country: Country) -> AnyPublisher { - register(.storeCountryDetails(countryDetails)) - return storeCountryDetailsResult.publish() + + func store(countryDetails: ApiModel.CountryDetails, for country: DBModel.Country) async throws { + register(.storeDetails(countryDetails, country: country)) + guard !storeCountryDetailsResults.isEmpty else { throw MockError.valueNotSet } + try storeCountryDetailsResults.removeFirst().get() } - - func countryDetails(country: Country) -> AnyPublisher { - register(.fetchCountryDetails(country)) - return fetchCountryDetailsResult.publish() +} + +extension ModelContainer { + + static var mock: ModelContainer { + try! appModelContainer(inMemoryOnly: true, isStub: false) } } diff --git a/UnitTests/Mocks/MockedInteractors.swift b/UnitTests/Mocks/MockedInteractors.swift index abaa007..3088f39 100644 --- a/UnitTests/Mocks/MockedInteractors.swift +++ b/UnitTests/Mocks/MockedInteractors.swift @@ -6,30 +6,30 @@ // Copyright © 2019 Alexey Naumov. All rights reserved. // -import XCTest +import Testing import SwiftUI -import Combine import ViewInspector @testable import CountriesSwiftUI extension DIContainer.Interactors { static func mocked( - countriesInteractor: [MockedCountriesInteractor.Action] = [], - imagesInteractor: [MockedImagesInteractor.Action] = [], - permissionsInteractor: [MockedUserPermissionsInteractor.Action] = [] + countries: [MockedCountriesInteractor.Action] = [], + images: [MockedImagesInteractor.Action] = [], + permissions: [MockedUserPermissionsInteractor.Action] = [] ) -> DIContainer.Interactors { - .init(countriesInteractor: MockedCountriesInteractor(expected: countriesInteractor), - imagesInteractor: MockedImagesInteractor(expected: imagesInteractor), - userPermissionsInteractor: MockedUserPermissionsInteractor(expected: permissionsInteractor)) + self.init( + images: MockedImagesInteractor(expected: images), + countries: MockedCountriesInteractor(expected: countries), + userPermissions: MockedUserPermissionsInteractor(expected: permissions)) } - func verify(file: StaticString = #file, line: UInt = #line) { - (countriesInteractor as? MockedCountriesInteractor)? - .verify(file: file, line: line) - (imagesInteractor as? MockedImagesInteractor)? - .verify(file: file, line: line) - (userPermissionsInteractor as? MockedUserPermissionsInteractor)? - .verify(file: file, line: line) + func verify(sourceLocation: SourceLocation = #_sourceLocation) { + (countries as? MockedCountriesInteractor)? + .verify(sourceLocation: sourceLocation) + (images as? MockedImagesInteractor)? + .verify(sourceLocation: sourceLocation) + (userPermissions as? MockedUserPermissionsInteractor)? + .verify(sourceLocation: sourceLocation) } } @@ -39,27 +39,23 @@ struct MockedCountriesInteractor: Mock, CountriesInteractor { enum Action: Equatable { case refreshCountriesList - case loadCountries(search: String, locale: Locale) - case loadCountryDetails(Country) + case loadCountryDetails(country: DBModel.Country, forceReload: Bool) } let actions: MockActions - + var detailsResponse: Result = .failure(MockError.valueNotSet) + init(expected: [Action]) { self.actions = .init(expected: expected) } - - func refreshCountriesList() -> AnyPublisher { + + func refreshCountriesList() async throws { register(.refreshCountriesList) - return Just.withErrorType(Error.self) } - - func load(countries: LoadableSubject>, search: String, locale: Locale) { - register(.loadCountries(search: search, locale: locale)) - } - - func load(countryDetails: LoadableSubject, country: Country) { - register(.loadCountryDetails(country)) + + func loadCountryDetails(country: DBModel.Country, forceReload: Bool) async throws -> DBModel.CountryDetails { + register(.loadCountryDetails(country: country, forceReload: forceReload)) + return try detailsResponse.get() } } @@ -84,7 +80,7 @@ struct MockedImagesInteractor: Mock, ImagesInteractor { // MARK: - ImagesInteractor -class MockedUserPermissionsInteractor: Mock, UserPermissionsInteractor { +final class MockedUserPermissionsInteractor: Mock, UserPermissionsInteractor { enum Action: Equatable { case resolveStatus(Permission) diff --git a/UnitTests/Mocks/MockedPersistentStore.swift b/UnitTests/Mocks/MockedPersistentStore.swift deleted file mode 100644 index 20e7689..0000000 --- a/UnitTests/Mocks/MockedPersistentStore.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// MockedPersistentStore.swift -// UnitTests -// -// Created by Alexey Naumov on 19.04.2020. -// Copyright © 2020 Alexey Naumov. All rights reserved. -// - -import CoreData -import Combine -@testable import CountriesSwiftUI - -final class MockedPersistentStore: Mock, PersistentStore { - struct ContextSnapshot: Equatable { - let inserted: Int - let updated: Int - let deleted: Int - } - enum Action: Equatable { - case count - case fetchCountries(ContextSnapshot) - case fetchCountryDetails(ContextSnapshot) - case update(ContextSnapshot) - } - var actions = MockActions(expected: []) - - var countResult: Int = 0 - - deinit { - destroyDatabase() - } - - // MARK: - count - - func count(_ fetchRequest: NSFetchRequest) -> AnyPublisher { - register(.count) - return Just.withErrorType(countResult, Error.self).publish() - } - - // MARK: - fetch - - func fetch(_ fetchRequest: NSFetchRequest, - map: @escaping (T) throws -> V?) -> AnyPublisher, Error> { - do { - let context = container.viewContext - context.reset() - let result = try context.fetch(fetchRequest) - if T.self is CountryMO.Type { - register(.fetchCountries(context.snapshot)) - } else if T.self is CountryDetailsMO.Type { - register(.fetchCountryDetails(context.snapshot)) - } else { - fatalError("Add a case for \(String(describing: T.self))") - } - let list = LazyList(count: result.count, useCache: true, { index in - try map(result[index]) - }) - return Just>.withErrorType(list, Error.self).publish() - } catch { - return Fail, Error>(error: error).publish() - } - } - - // MARK: - update - - func update(_ operation: @escaping DBOperation) -> AnyPublisher { - do { - let context = container.viewContext - context.reset() - let result = try operation(context) - register(.update(context.snapshot)) - return Just(result).setFailureType(to: Error.self).publish() - } catch { - return Fail(error: error).publish() - } - } - - // MARK: - - - func preloadData(_ preload: (NSManagedObjectContext) throws -> Void) throws { - try preload(container.viewContext) - if container.viewContext.hasChanges { - try container.viewContext.save() - } - container.viewContext.reset() - } - - // MARK: - Database - - private let dbVersion = CoreDataStack.Version(CoreDataStack.Version.actual) - - private var dbURL: URL { - guard let url = dbVersion.dbFileURL(.cachesDirectory, .userDomainMask) - else { fatalError() } - return url - } - - private lazy var container: NSPersistentContainer = { - let container = NSPersistentContainer(name: dbVersion.modelName) - try? FileManager().removeItem(at: dbURL) - let store = NSPersistentStoreDescription(url: dbURL) - container.persistentStoreDescriptions = [store] - let group = DispatchGroup() - group.enter() - container.loadPersistentStores { (desc, error) in - if let error = error { - fatalError("\(error)") - } - group.leave() - } - group.wait() - container.viewContext.mergePolicy = NSOverwriteMergePolicy - container.viewContext.undoManager = nil - return container - }() - - private func destroyDatabase() { - try? container.persistentStoreCoordinator - .destroyPersistentStore(at: dbURL, ofType: NSSQLiteStoreType, options: nil) - try? FileManager().removeItem(at: dbURL) - } -} - -extension NSManagedObjectContext { - var snapshot: MockedPersistentStore.ContextSnapshot { - .init(inserted: insertedObjects.count, - updated: updatedObjects.count, - deleted: deletedObjects.count) - } -} diff --git a/UnitTests/Mocks/MockedSystemEventsHandler.swift b/UnitTests/Mocks/MockedSystemEventsHandler.swift index f338e3a..5f5aa49 100644 --- a/UnitTests/Mocks/MockedSystemEventsHandler.swift +++ b/UnitTests/Mocks/MockedSystemEventsHandler.swift @@ -6,13 +6,14 @@ // Copyright © 2020 Alexey Naumov. All rights reserved. // -import XCTest -import Combine +import Foundation +import UIKit @testable import CountriesSwiftUI // MARK: - SystemEventsHandler final class MockedSystemEventsHandler: Mock, SystemEventsHandler { + enum Action: Equatable { case openURL case becomeActive @@ -41,10 +42,10 @@ final class MockedSystemEventsHandler: Mock, SystemEventsHandler { func handlePushRegistration(result: Result) { register(.pushRegistration) } - - func appDidReceiveRemoteNotification(payload: NotificationPayload, - fetchCompletion: @escaping FetchCompletion) { + + func appDidReceiveRemoteNotification(payload: [AnyHashable: Any]) async -> UIBackgroundFetchResult { register(.recevieRemoteNotification) + return .noData } } diff --git a/UnitTests/Mocks/MockedSystemPermissions.swift b/UnitTests/Mocks/MockedSystemPermissions.swift new file mode 100644 index 0000000..5a432d2 --- /dev/null +++ b/UnitTests/Mocks/MockedSystemPermissions.swift @@ -0,0 +1,42 @@ +// +// MockedSystemPermissions.swift +// CountriesSwiftUI +// +// Created by Alexey on 22/11/24. +// Copyright © 2024 Alexey Naumov. All rights reserved. +// + +import Foundation +import UserNotifications +@testable import CountriesSwiftUI + +final class MockedSystemPushNotifications: Mock, SystemNotificationsCenter { + enum Action: Equatable { + case currentSettings + case requestAuthorization(UNAuthorizationOptions) + } + struct NotificationSettings: SystemNotificationsSettings { + var authorizationStatus: UNAuthorizationStatus + } + var actions = MockActions(expected: []) + var getResponses: [NotificationSettings] = [] + var requestResponses: [Result] = [] + + init(expected: [Action]) { + self.actions = .init(expected: expected) + } + + func currentSettings() async -> any SystemNotificationsSettings { + register(.currentSettings) + guard !getResponses.isEmpty else { + return NotificationSettings(authorizationStatus: .notDetermined) + } + return getResponses.removeFirst() + } + + func requestAuthorization(options: UNAuthorizationOptions) async throws -> Bool { + register(.requestAuthorization(options)) + guard !requestResponses.isEmpty else { throw MockError.valueNotSet } + return try requestResponses.removeFirst().get() + } +} diff --git a/UnitTests/Mocks/MockedWebRepositories.swift b/UnitTests/Mocks/MockedWebRepositories.swift index 2a444bc..1a12471 100644 --- a/UnitTests/Mocks/MockedWebRepositories.swift +++ b/UnitTests/Mocks/MockedWebRepositories.swift @@ -6,14 +6,13 @@ // Copyright © 2019 Alexey Naumov. All rights reserved. // -import XCTest -import Combine +import Foundation +import UIKit.UIImage @testable import CountriesSwiftUI class TestWebRepository: WebRepository { let session: URLSession = .mockedResponsesOnly let baseURL = "https://test.com/" - let bgQueue = DispatchQueue(label: "test") } // MARK: - CountriesWebRepository @@ -21,39 +20,42 @@ class TestWebRepository: WebRepository { final class MockedCountriesWebRepository: TestWebRepository, Mock, CountriesWebRepository { enum Action: Equatable { - case loadCountries - case loadCountryDetails(Country) + case countries + case details(country: DBModel.Country) } var actions = MockActions(expected: []) - var countriesResponse: Result<[Country], Error> = .failure(MockError.valueNotSet) - var detailsResponse: Result = .failure(MockError.valueNotSet) - - func loadCountries() -> AnyPublisher<[Country], Error> { - register(.loadCountries) - return countriesResponse.publish() + var countriesResponses: [Result<[ApiModel.Country], Error>] = [] + var detailsResponses: [Result] = [] + + func countries() async throws -> [ApiModel.Country] { + register(.countries) + guard !countriesResponses.isEmpty else { throw MockError.valueNotSet } + return try countriesResponses.removeFirst().get() } - - func loadCountryDetails(country: Country) -> AnyPublisher { - register(.loadCountryDetails(country)) - return detailsResponse.publish() + + func details(country: DBModel.Country) async throws -> ApiModel.CountryDetails { + register(.details(country: country)) + guard !detailsResponses.isEmpty else { throw MockError.valueNotSet } + return try detailsResponses.removeFirst().get() } } // MARK: - ImageWebRepository -final class MockedImageWebRepository: TestWebRepository, Mock, ImageWebRepository { - +final class MockedImageWebRepository: TestWebRepository, Mock, ImagesWebRepository { + enum Action: Equatable { - case loadImage(URL?) + case loadImage(URL) } var actions = MockActions(expected: []) - var imageResponse: Result = .failure(MockError.valueNotSet) - - func load(imageURL: URL) -> AnyPublisher { - register(.loadImage(imageURL)) - return imageResponse.publish() + var imageResponses: [Result] = [] + + func loadImage(url: URL) async throws -> UIImage { + register(.loadImage(url)) + guard !imageResponses.isEmpty else { throw MockError.valueNotSet } + return try imageResponses.removeFirst().get() } } @@ -63,14 +65,13 @@ final class MockedPushTokenWebRepository: TestWebRepository, Mock, PushTokenWebR enum Action: Equatable { case register(Data) } - var actions = MockActions(expected: []) - + let actions: MockActions + init(expected: [Action]) { - self.actions = .init(expected: expected) + self.actions = MockActions(expected: expected) } - func register(devicePushToken: Data) -> AnyPublisher { + func register(devicePushToken: Data) async throws { register(.register(devicePushToken)) - return Just.withErrorType(Error.self) } } diff --git a/UnitTests/NetworkMocking/MockedResponse.swift b/UnitTests/Mocks/NetworkMocking/MockedResponse.swift similarity index 89% rename from UnitTests/NetworkMocking/MockedResponse.swift rename to UnitTests/Mocks/NetworkMocking/MockedResponse.swift index 57fefef..419a000 100644 --- a/UnitTests/NetworkMocking/MockedResponse.swift +++ b/UnitTests/Mocks/NetworkMocking/MockedResponse.swift @@ -22,7 +22,9 @@ extension RequestMocking { extension RequestMocking.MockedResponse { enum Error: Swift.Error { - case failedMockCreation + case notMockedRequest(URLRequest) + case responseFactoryFailure + case mockInitializationFailure } init(apiCall: APICall, baseURL: String, @@ -32,7 +34,7 @@ extension RequestMocking.MockedResponse { loadingTime: TimeInterval = 0.1 ) throws where T: Encodable { guard let url = try apiCall.urlRequest(baseURL: baseURL).url - else { throw Error.failedMockCreation } + else { throw Error.mockInitializationFailure } self.url = url switch result { case let .success(value): @@ -48,7 +50,7 @@ extension RequestMocking.MockedResponse { init(apiCall: APICall, baseURL: String, customResponse: URLResponse) throws { guard let url = try apiCall.urlRequest(baseURL: baseURL).url - else { throw Error.failedMockCreation } + else { throw Error.mockInitializationFailure } self.url = url result = .success(Data()) httpCode = 200 diff --git a/UnitTests/NetworkMocking/RequestMocking.swift b/UnitTests/Mocks/NetworkMocking/RequestMocking.swift similarity index 84% rename from UnitTests/NetworkMocking/RequestMocking.swift rename to UnitTests/Mocks/NetworkMocking/RequestMocking.swift index 2915445..b7c056e 100644 --- a/UnitTests/NetworkMocking/RequestMocking.swift +++ b/UnitTests/Mocks/NetworkMocking/RequestMocking.swift @@ -19,18 +19,28 @@ extension URLSession { } extension RequestMocking { - static private var mocks: [MockedResponse] = [] - + private final class MocksContainer: @unchecked Sendable { + var mocks: [MockedResponse] = [] + } + static private let container = MocksContainer() + static private let lock = NSLock() + static func add(mock: MockedResponse) { - mocks.append(mock) + lock.withLock { + container.mocks.append(mock) + } } static func removeAllMocks() { - mocks.removeAll() + lock.withLock { + container.mocks.removeAll() + } } static private func mock(for request: URLRequest) -> MockedResponse? { - return mocks.first { $0.url == request.url } + return lock.withLock { + container.mocks.first { $0.url == request.url } + } } } @@ -61,16 +71,14 @@ final class RequestMocking: URLProtocol { httpVersion: "HTTP/1.1", headerFields: mock.headers) { DispatchQueue.main.asyncAfter(deadline: .now() + mock.loadingTime) { [weak self] in - guard let self = self else { return } + guard let self else { return } self.client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) switch mock.result { case let .success(data): self.client?.urlProtocol(self, didLoad: data) self.client?.urlProtocolDidFinishLoading(self) case let .failure(error): - let failure = NSError(domain: NSURLErrorDomain, code: 1, - userInfo: [NSUnderlyingErrorKey: error]) - self.client?.urlProtocol(self, didFailWithError: failure) + self.client?.urlProtocol(self, didFailWithError: error) } } } diff --git a/UnitTests/Persistence/CoreDataStackTests.swift b/UnitTests/Persistence/CoreDataStackTests.swift deleted file mode 100644 index 342b3fa..0000000 --- a/UnitTests/Persistence/CoreDataStackTests.swift +++ /dev/null @@ -1,153 +0,0 @@ -// -// CoreDataStackTests.swift -// UnitTests -// -// Created by Alexey Naumov on 19.04.2020. -// Copyright © 2020 Alexey Naumov. All rights reserved. -// - -import XCTest -import Combine -@testable import CountriesSwiftUI - -class CoreDataStackTests: XCTestCase { - - var sut: CoreDataStack! - let testDirectory: FileManager.SearchPathDirectory = .cachesDirectory - var dbVersion: UInt { fatalError("Override") } - var cancelBag = CancelBag() - - override func setUp() { - eraseDBFiles() - sut = CoreDataStack(directory: testDirectory, version: dbVersion) - } - - override func tearDown() { - cancelBag = CancelBag() - sut = nil - eraseDBFiles() - } - - func eraseDBFiles() { - let version = CoreDataStack.Version(dbVersion) - if let url = version.dbFileURL(testDirectory, .userDomainMask) { - try? FileManager().removeItem(at: url) - } - } -} - -// MARK: - Version 1 - -final class CoreDataStackV1Tests: CoreDataStackTests { - - override var dbVersion: UInt { 1 } - - func test_initialization() { - let exp = XCTestExpectation(description: #function) - let request = CountryMO.newFetchRequest() - request.predicate = NSPredicate(value: true) - request.fetchLimit = 1 - sut.fetch(request) { _ -> Int? in - return nil - } - .sinkToResult { result in - result.assertSuccess(value: LazyList.empty) - exp.fulfill() - } - .store(in: cancelBag) - wait(for: [exp], timeout: 1) - } - - func test_inaccessibleDirectory() { - let sut = CoreDataStack(directory: .adminApplicationDirectory, - domainMask: .systemDomainMask, version: dbVersion) - let exp = XCTestExpectation(description: #function) - let request = CountryMO.newFetchRequest() - request.predicate = NSPredicate(value: true) - request.fetchLimit = 1 - sut.fetch(request) { _ -> Int? in - return nil - } - .sinkToResult { result in - result.assertFailure() - exp.fulfill() - } - .store(in: cancelBag) - wait(for: [exp], timeout: 1) - } - - func test_counting_onEmptyStore() { - let request = CountryMO.newFetchRequest() - request.predicate = NSPredicate(value: true) - let exp = XCTestExpectation(description: #function) - sut.count(request) - .sinkToResult { result in - result.assertSuccess(value: 0) - exp.fulfill() - } - .store(in: cancelBag) - wait(for: [exp], timeout: 1) - } - - func test_storing_and_countring() { - let countries = Country.mockedData - - let request = CountryMO.newFetchRequest() - request.predicate = NSPredicate(value: true) - - let exp = XCTestExpectation(description: #function) - sut.update { context in - countries.forEach { - $0.store(in: context) - } - } - .flatMap { _ in - self.sut.count(request) - } - .sinkToResult { result in - result.assertSuccess(value: countries.count) - exp.fulfill() - } - .store(in: cancelBag) - wait(for: [exp], timeout: 1) - } - - func test_storing_exception() { - let exp = XCTestExpectation(description: #function) - sut.update { context in - throw NSError.test - } - .sinkToResult { result in - result.assertFailure(NSError.test.localizedDescription) - exp.fulfill() - } - .store(in: cancelBag) - wait(for: [exp], timeout: 1) - } - - func test_fetching() { - let countries = Country.mockedData - let exp = XCTestExpectation(description: #function) - sut - .update { context in - countries.forEach { - $0.store(in: context) - } - } - .flatMap { _ -> AnyPublisher, Error> in - let request = CountryMO.newFetchRequest() - request.predicate = NSPredicate(format: "alpha3code == %@", countries[0].alpha3Code) - return self.sut.fetch(request) { - Country(managedObject: $0) - } - } - .sinkToResult { result in - result.assertSuccess(value: LazyList( - count: 1, useCache: false, { _ in countries[0] }) - ) - exp.fulfill() - } - .store(in: cancelBag) - wait(for: [exp], timeout: 1) - } -} diff --git a/UnitTests/Repositories/CountriesDBRepositoryTests.swift b/UnitTests/Repositories/CountriesDBRepositoryTests.swift index f0a0ba8..feefd56 100644 --- a/UnitTests/Repositories/CountriesDBRepositoryTests.swift +++ b/UnitTests/Repositories/CountriesDBRepositoryTests.swift @@ -6,223 +6,47 @@ // Copyright © 2020 Alexey Naumov. All rights reserved. // -import XCTest -import Combine +import Testing +import SwiftData @testable import CountriesSwiftUI -class CountriesDBRepositoryTests: XCTestCase { - - var mockedStore: MockedPersistentStore! - var sut: RealCountriesDBRepository! - var cancelBag = CancelBag() - - override func setUp() { - mockedStore = MockedPersistentStore() - sut = RealCountriesDBRepository(persistentStore: mockedStore) - mockedStore.verify() - } - - override func tearDown() { - cancelBag = CancelBag() - sut = nil - mockedStore = nil - } -} +@MainActor +@Suite struct CountriesDBRepositoryTests { -// MARK: - Countries list - -final class CountriesListDBRepoTests: CountriesDBRepositoryTests { + let container: ModelContainer + let sut: CountriesDBRepository - func test_hasLoadedCountries() { - mockedStore.actions = .init(expected: [ - .count, - .count - ]) - let exp = XCTestExpectation(description: #function) - mockedStore.countResult = 0 - sut.hasLoadedCountries() - .flatMap { value -> AnyPublisher in - XCTAssertFalse(value) - self.mockedStore.countResult = 10 - return self.sut.hasLoadedCountries() - } - .sinkToResult { result in - result.assertSuccess(value: true) - self.mockedStore.verify() - exp.fulfill() - } - .store(in: cancelBag) - wait(for: [exp], timeout: 0.5) - } - - func test_storeCountries() { - let countries = Country.mockedData - mockedStore.actions = .init(expected: [ - .update(.init(inserted: countries.count, updated: 0, deleted: 0)) - ]) - let exp = XCTestExpectation(description: #function) - sut.store(countries: countries) - .sinkToResult { result in - result.assertSuccess() - self.mockedStore.verify() - exp.fulfill() - } - .store(in: cancelBag) - wait(for: [exp], timeout: 0.5) - } - - func test_fetchAllCountries() throws { - let countries = Country.mockedData - let sortedCountries = countries.sorted(by: { $0.name < $1.name }) - mockedStore.actions = .init(expected: [ - .fetchCountries(.init(inserted: 0, updated: 0, deleted: 0)) - ]) - try mockedStore.preloadData { context in - countries.forEach { $0.store(in: context) } - } - let exp = XCTestExpectation(description: #function) - sut - .countries(search: "", locale: .backendDefault) - .sinkToResult { result in - result.assertSuccess(value: sortedCountries.lazyList) - self.mockedStore.verify() - exp.fulfill() - } - .store(in: cancelBag) - wait(for: [exp], timeout: 0.5) - } - - func test_fetchInNames() throws { - let countries = Country.testLocalized - mockedStore.actions = .init(expected: [ - .fetchCountries(.init(inserted: 0, updated: 0, deleted: 0)) - ]) - try mockedStore.preloadData { context in - countries.forEach { $0.store(in: context) } - } - let exp = XCTestExpectation(description: #function) - sut - .countries(search: "nited stat", locale: Locale(identifier: "fr")) - .sinkToResult { result in - let expected = [countries[0]] - result.assertSuccess(value: expected.lazyList) - self.mockedStore.verify() - exp.fulfill() - } - .store(in: cancelBag) - wait(for: [exp], timeout: 0.5) + init() { + container = .mock + sut = MainDBRepository(modelContainer: container) } - - func test_fetchInTranspaltions() throws { - let countries = Country.testLocalized - mockedStore.actions = .init(expected: [ - .fetchCountries(.init(inserted: 0, updated: 0, deleted: 0)) - ]) - try mockedStore.preloadData { context in - countries.forEach { $0.store(in: context) } - } - let exp = XCTestExpectation(description: #function) - sut - .countries(search: "in frénch", locale: Locale(identifier: "fr")) - .sinkToResult { result in - let expected = [countries[2], countries[0]] - result.assertSuccess(value: expected.lazyList) - self.mockedStore.verify() - exp.fulfill() - } - .store(in: cancelBag) - wait(for: [exp], timeout: 0.5) - } -} -private extension Country { - static var testLocalized: [Country] { - [ - Country(name: "United States", - translations: ["fr": "United States in Frénch", - "ja": "Unitd States in Japaneese"], - population: 125000000, - flag: URL(string: "https://flagcdn.com/w640/us.jpg"), - alpha3Code: "USA"), - Country(name: "Canada", - translations: ["ja": "Canada not in French"], - population: 57600000, - flag: nil, - alpha3Code: "CAN"), - Country(name: "Georgia", - translations: ["fr": "Georgia in French", - "ja": "United States not in Japaneese"], - population: 2340000, - flag: nil, - alpha3Code: "GEO") - ] + @Test func storeCountries() async throws { + let countries = ApiModel.Country.mockedData + try await sut.store(countries: countries) + let results = try container.mainContext + .fetch(FetchDescriptor()) + #expect(results.count == countries.count) } -} -private extension Country.Details { - static var test: Country.Details { - return Country.Details( - capital: "Sin City", - currencies: [Country.Currency(code: "code", symbol: "$", name: "USD")], - neighbors: Array(Country.testLocalized[0..<2]) - .sorted(by: { $0.name < $1.name })) + @Test func storeCountryDetails() async throws { + let country = ApiModel.Country.mockedData[0] + let details = ApiModel.CountryDetails.mockedData[0] + try await sut.store(countryDetails: details, for: country.dbModel()) + let results = try container.mainContext + .fetch(FetchDescriptor()) + let stored = try #require(results.first) + #expect(stored.capital == details.capital) + #expect(stored.currencies.count == details.currencies.count) } -} -// MARK: - Countries list - -final class CountryDetailsDBRepoTests: CountriesDBRepositoryTests { - - func test_storeCountryDetails() throws { - let details = Country.Details.test - let intermediate = Country.Details.Intermediate( - capital: details.capital, currencies: details.currencies, - borders: details.neighbors.map { $0.alpha3Code }) - let parentCountry = Country.testLocalized[2] - mockedStore.actions = .init(expected: [ - .update(.init(inserted: 1 + details.currencies.count, // self + currencies - updated: details.neighbors.count + 1, // neighbors + parent - deleted: 0)) - ]) - try mockedStore.preloadData { context in - parentCountry.store(in: context) - details.neighbors.forEach { $0.store(in: context) } - } - let exp = XCTestExpectation(description: #function) - sut.store(countryDetails: intermediate, for: parentCountry) - .sinkToResult { result in - result.assertSuccess(value: details) - self.mockedStore.verify() - exp.fulfill() - } - .store(in: cancelBag) - wait(for: [exp], timeout: 0.5) - } - - func test_fetchCountryDetails() throws { - let details = Country.Details.test - let intermediate = Country.Details.Intermediate( - capital: details.capital, currencies: details.currencies, - borders: details.neighbors.map { $0.alpha3Code }) - let parentCountry = Country.testLocalized[2] - mockedStore.actions = .init(expected: [ - .fetchCountryDetails(.init(inserted: 0, updated: 0, deleted: 0)) - ]) - try mockedStore.preloadData { context in - let parent = parentCountry.store(in: context) - let neighbors = details.neighbors.compactMap { $0.store(in: context) } - _ = parent.flatMap { - intermediate.store(in: context, country: $0, borders: neighbors) - } - } - let exp = XCTestExpectation(description: #function) - sut.countryDetails(country: parentCountry) - .sinkToResult { result in - result.assertSuccess(value: details) - self.mockedStore.verify() - exp.fulfill() - } - .store(in: cancelBag) - wait(for: [exp], timeout: 0.5) + @Test func countryDetailsForCountry() async throws { + let country = ApiModel.Country.mockedData[0].dbModel() + let details = ApiModel.CountryDetails.mockedData[0] + try await sut.store(countryDetails: details, for: country) + let stored = try #require(try await sut.countryDetails(for: country)) + #expect(stored.capital == details.capital) + #expect(stored.currencies.count == details.currencies.count) } } + diff --git a/UnitTests/Repositories/CountriesWebRepositoryTests.swift b/UnitTests/Repositories/CountriesWebRepositoryTests.swift index 16ff589..4f74498 100644 --- a/UnitTests/Repositories/CountriesWebRepositoryTests.swift +++ b/UnitTests/Repositories/CountriesWebRepositoryTests.swift @@ -6,79 +6,56 @@ // Copyright © 2019 Alexey Naumov. All rights reserved. // -import XCTest -import Combine +import Testing @testable import CountriesSwiftUI -final class CountriesWebRepositoryTests: XCTestCase { - - private var sut: RealCountriesWebRepository! - private var subscriptions = Set() - +@Suite(.serialized) final class CountriesWebRepositoryTests { + + private let sut = RealCountriesWebRepository(session: .mockedResponsesOnly) + typealias API = RealCountriesWebRepository.API typealias Mock = RequestMocking.MockedResponse - override func setUp() { - subscriptions = Set() - sut = RealCountriesWebRepository(session: .mockedResponsesOnly, - baseURL: "https://test.com/") - } - - override func tearDown() { + deinit { RequestMocking.removeAllMocks() } - + // MARK: - All Countries - func test_allCountries() throws { - let data = Country.mockedData + @Test func allCountriesSuccess() async throws { + let data = await ApiModel.Country.mockedData try mock(.allCountries, result: .success(data)) - let exp = XCTestExpectation(description: "Completion") - sut.loadCountries().sinkToResult { result in - result.assertSuccess(value: data) - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 2) + let response = try await sut.countries() + #expect(response == data) } - - func test_countryDetails() throws { - let countries = Country.mockedData - let value = Country.Details.Intermediate( + + @Test func countryDetailsSuccess() async throws { + let countries = await ApiModel.Country.mockedData + let value = ApiModel.CountryDetails( capital: "London", - currencies: [Country.Currency(code: "12", symbol: "$", name: "US dollar")], + currencies: [ApiModel.Currency(code: "12", symbol: "$", name: "US dollar")], borders: countries.map({ $0.alpha3Code })) - try mock(.countryDetails(countries[0]), result: .success([value])) - let exp = XCTestExpectation(description: "Completion") - sut.loadCountryDetails(country: countries[0]).sinkToResult { result in - result.assertSuccess(value: value) - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 2) + let country = countries[0] + try mock(.countryDetails(countryName: country.name), result: .success([value])) + let response = try await sut.details(country: country.dbModel()) + #expect(response == value) } - - func test_countryDetails_whenDetailsAreEmpty() throws { - let countries = Country.mockedData - try mock(.countryDetails(countries[0]), result: .success([Country.Details.Intermediate]())) - let exp = XCTestExpectation(description: "Completion") - sut.loadCountryDetails(country: countries[0]).sinkToResult { result in - result.assertFailure(APIError.unexpectedResponse.localizedDescription) - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 2) - } - - func test_countryDetails_countryNameEncoding() { - let name = String(bytes: [0xD8, 0x00] as [UInt8], encoding: .utf16BigEndian)! - let country = Country(name: name, translations: [:], population: 1, flag: nil, alpha3Code: "ABC") - let apiCall = RealCountriesWebRepository.API.countryDetails(country) - XCTAssertTrue(apiCall.path.hasSuffix(name)) + + @Test func countryDetailsWhenDetailsAreEmpty() async throws { + let countries = await ApiModel.Country.mockedData + let country = countries[0] + try mock(.countryDetails(countryName: country.name), result: .success([ApiModel.CountryDetails]())) + await #expect(throws: APIError.unexpectedResponse) { + try await sut.details(country: country.dbModel()) + } } - + // MARK: - Helper - + private func mock(_ apiCall: API, result: Result, httpCode: HTTPCode = 200) throws where T: Encodable { let mock = try Mock(apiCall: apiCall, baseURL: sut.baseURL, result: result, httpCode: httpCode) RequestMocking.add(mock: mock) } } + diff --git a/UnitTests/Repositories/ImageWebRepositoryTests.swift b/UnitTests/Repositories/ImageWebRepositoryTests.swift index dee8084..554020b 100644 --- a/UnitTests/Repositories/ImageWebRepositoryTests.swift +++ b/UnitTests/Repositories/ImageWebRepositoryTests.swift @@ -6,58 +6,45 @@ // Copyright © 2019 Alexey Naumov. All rights reserved. // -import XCTest -import Combine +import Testing +import UIKit.UIImage @testable import CountriesSwiftUI -final class ImageWebRepositoryTests: XCTestCase { +@Suite(.serialized) final class ImageWebRepositoryTests { - private var sut: RealImageWebRepository! - private var subscriptions = Set() - private lazy var testImage = UIColor.red.image(CGSize(width: 40, height: 40)) - - typealias Mock = RequestMocking.MockedResponse + private let sut = RealImagesWebRepository(session: .mockedResponsesOnly) + private let testImage = UIColor.red.image(CGSize(width: 40, height: 40)) - override func setUp() { - subscriptions = Set() - sut = RealImageWebRepository(session: .mockedResponsesOnly, - baseURL: "https://test.com/") - } + typealias Mock = RequestMocking.MockedResponse - override func tearDown() { + deinit { RequestMocking.removeAllMocks() } - - func test_loadImage_success() throws { - - let imageURL = try XCTUnwrap(URL(string: "https://image.service.com/myimage.png")) - let responseData = try XCTUnwrap(testImage.pngData()) - let mock = Mock(url: imageURL, result: .success(responseData)) + + @Test func loadImageSuccess() async throws { + let imageURL = try #require(URL(string: "https://image.service.com/myimage.png")) + let imageRef = try #require(testImage.pngData()) + let mock = Mock(url: imageURL, result: .success(imageRef)) RequestMocking.add(mock: mock) - - let exp = XCTestExpectation(description: "Completion") - sut.load(imageURL: imageURL).sinkToResult { result in - switch result { - case let .success(resultValue): - XCTAssertEqual(resultValue.size, self.testImage.size) - case let .failure(error): - XCTFail("Unexpected error: \(error)") - } - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 2) + + let result = try await sut.loadImage(url: imageURL) + #expect(result.size == testImage.size) } - - func test_loadImage_failure() throws { - let imageURL = try XCTUnwrap(URL(string: "https://image.service.com/myimage.png")) - let mocks = [Mock(url: imageURL, result: .failure(APIError.unexpectedResponse))] - mocks.forEach { RequestMocking.add(mock: $0) } - - let exp = XCTestExpectation(description: "Completion") - sut.load(imageURL: imageURL).sinkToResult { result in - result.assertFailure(APIError.unexpectedResponse.localizedDescription) - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 2) + + @Test func loadImageFailure() async throws { + let imageURL = try #require(URL(string: "https://image.service.com/myimage.png")) + let errorRef = NSError.test + let mock = Mock(url: imageURL, result: .failure(errorRef)) + RequestMocking.add(mock: mock) + + do { + _ = try await sut.loadImage(url: imageURL) + Issue.record("Above should throw") + } catch { + let nsError = error as NSError + #expect(nsError.domain == errorRef.domain) + #expect(nsError.code == errorRef.code) + } } } + diff --git a/UnitTests/Repositories/PushTokenWebRepositoryTests.swift b/UnitTests/Repositories/PushTokenWebRepositoryTests.swift index f4075ec..aa23209 100644 --- a/UnitTests/Repositories/PushTokenWebRepositoryTests.swift +++ b/UnitTests/Repositories/PushTokenWebRepositoryTests.swift @@ -6,32 +6,16 @@ // Copyright © 2020 Alexey Naumov. All rights reserved. // -import XCTest -import Combine +import Testing +import Foundation @testable import CountriesSwiftUI -class PushTokenWebRepositoryTests: XCTestCase { +@Suite struct PushTokenWebRepositoryTests { - private var sut: RealPushTokenWebRepository! - private var cancelBag = CancelBag() - - override func setUp() { - sut = RealPushTokenWebRepository(session: .mockedResponsesOnly, - baseURL: "https://test.com/") - } - - override func tearDown() { - cancelBag = CancelBag() - } - - func test_register() { - let exp = XCTestExpectation(description: #function) - sut.register(devicePushToken: Data()) - .sinkToResult { result in - result.assertSuccess() - exp.fulfill() - } - .store(in: cancelBag) - wait(for: [exp], timeout: 0.1) + private let sut = RealPushTokenWebRepository(session: .mockedResponsesOnly) + + @Test func register() async throws { + try await sut.register(devicePushToken: Data()) } } + diff --git a/UnitTests/Repositories/WebRepositoryTests.swift b/UnitTests/Repositories/WebRepositoryTests.swift index 06855b9..e9b4525 100644 --- a/UnitTests/Repositories/WebRepositoryTests.swift +++ b/UnitTests/Repositories/WebRepositoryTests.swift @@ -6,123 +6,88 @@ // Copyright © 2019 Alexey Naumov. All rights reserved. // -import XCTest +import Testing import Combine +import Foundation @testable import CountriesSwiftUI -final class WebRepositoryTests: XCTestCase { - - private var sut: TestWebRepository! - private var subscriptions = Set() - +@Suite(.serialized) final class WebRepositoryTests { + + private let sut = TestWebRepository() + private typealias API = TestWebRepository.API typealias Mock = RequestMocking.MockedResponse - override func setUp() { - subscriptions = Set() - sut = TestWebRepository() - } - - override func tearDown() { + deinit { RequestMocking.removeAllMocks() } - - func test_webRepository_success() throws { + + @Test func loadSuccess() async throws { let data = TestWebRepository.TestData() try mock(.test, result: .success(data)) - let exp = XCTestExpectation(description: "Completion") - sut.load(.test).sinkToResult { result in - XCTAssertTrue(Thread.isMainThread) - result.assertSuccess(value: data) - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 2) + let result = try await sut.load(.test) + #expect(result == data) } - - func test_webRepository_parseError() throws { - let data = Country.mockedData + + @Test func loadParseError() async throws { + let data = await ApiModel.Country.mockedData try mock(.test, result: .success(data)) - let exp = XCTestExpectation(description: "Completion") - sut.load(.test).sinkToResult { result in - XCTAssertTrue(Thread.isMainThread) - result.assertFailure("The data couldn’t be read because it isn’t in the correct format.") - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 2) + await #expect(throws: APIError.unexpectedResponse) { + try await sut.load(.test) + } } - - func test_webRepository_httpCodeFailure() throws { + + @Test func loadHttpCodeFailure() async throws { let data = TestWebRepository.TestData() try mock(.test, result: .success(data), httpCode: 500) - let exp = XCTestExpectation(description: "Completion") - sut.load(.test).sinkToResult { result in - XCTAssertTrue(Thread.isMainThread) - result.assertFailure("Unexpected HTTP code: 500") - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 2) + await #expect(throws: APIError.httpCode(500)) { + try await sut.load(.test) + } } - - func test_webRepository_networkingError() throws { - let error = NSError.test - try mock(.test, result: Result.failure(error)) - let exp = XCTestExpectation(description: "Completion") - sut.load(.test).sinkToResult { result in - XCTAssertTrue(Thread.isMainThread) - result.assertFailure(error.localizedDescription) - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 2) + + @Test func loadNetworkingError() async throws { + let errorRef = NSError.test + try mock(.test, result: Result.failure(errorRef)) + do { + _ = try await sut.load(.test) + Issue.record("Above should throw") + } catch { + let nsError = error as NSError + #expect(nsError.domain == errorRef.domain) + #expect(nsError.code == errorRef.code) + } } - - func test_webRepository_requestURLError() { - let exp = XCTestExpectation(description: "Completion") - sut.load(.urlError).sinkToResult { result in - XCTAssertTrue(Thread.isMainThread) - result.assertFailure(APIError.invalidURL.localizedDescription) - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 2) + + @Test func loadRequestURLError() async { + await #expect(throws: APIError.invalidURL) { + try await sut.load(.urlError) + } } - - func test_webRepository_requestBodyError() { - let exp = XCTestExpectation(description: "Completion") - sut.load(.bodyError).sinkToResult { result in - XCTAssertTrue(Thread.isMainThread) - result.assertFailure(TestWebRepository.APIError.fail.localizedDescription) - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 2) + + @Test func loadRequestBodyError() async { + await #expect(throws: TestWebRepository.APIError.fail) { + try await sut.load(.bodyError) + } } - - func test_webRepository_loadableError() { - let exp = XCTestExpectation(description: "Completion") - let expected = APIError.invalidURL.localizedDescription - sut.load(.urlError) - .sinkToLoadable { loadable in - XCTAssertTrue(Thread.isMainThread) - XCTAssertEqual(loadable.error?.localizedDescription, expected) - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 2) + + @Test func loadLoadableError() async { + await #expect(throws: APIError.invalidURL) { + try await sut.load(.urlError) + } } - - func test_webRepository_noHttpCodeError() throws { + + @Test func loadNoHttpCodeError() async throws { let response = URLResponse(url: URL(fileURLWithPath: ""), mimeType: "example", expectedContentLength: 0, textEncodingName: nil) let mock = try Mock(apiCall: API.test, baseURL: sut.baseURL, customResponse: response) RequestMocking.add(mock: mock) - let exp = XCTestExpectation(description: "Completion") - sut.load(.test).sinkToResult { result in - XCTAssertTrue(Thread.isMainThread) - result.assertFailure(APIError.unexpectedResponse.localizedDescription) - exp.fulfill() - }.store(in: &subscriptions) - wait(for: [exp], timeout: 2) + await #expect(throws: APIError.unexpectedResponse) { + try await sut.load(.test) + } } - + // MARK: - Helper - + private func mock(_ apiCall: API, result: Result, httpCode: HTTPCode = 200) throws where T: Encodable { let mock = try Mock(apiCall: apiCall, baseURL: sut.baseURL, result: result, httpCode: httpCode) @@ -131,22 +96,22 @@ final class WebRepositoryTests: XCTestCase { } private extension TestWebRepository { - func load(_ api: API) -> AnyPublisher { - call(endpoint: api) + func load(_ api: API) async throws -> TestData { + try await call(endpoint: api) } } extension TestWebRepository { enum API: APICall { - + case test case urlError case bodyError case noHttpCodeError - + var path: String { if self == .urlError { - return "😋😋😋" + return "\\" } return "/test/path" } @@ -170,10 +135,11 @@ extension TestWebRepository { struct TestData: Codable, Equatable { let string: String let integer: Int - + init() { string = "some string" integer = 42 } } } + diff --git a/UnitTests/Resources/Info.plist b/UnitTests/Resources/Info.plist deleted file mode 100644 index 64d65ca..0000000 --- a/UnitTests/Resources/Info.plist +++ /dev/null @@ -1,22 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - $(PRODUCT_BUNDLE_PACKAGE_TYPE) - CFBundleShortVersionString - 1.0 - CFBundleVersion - 1 - - diff --git a/UnitTests/System/AppDelegateTests.swift b/UnitTests/System/AppDelegateTests.swift deleted file mode 100644 index ea93b17..0000000 --- a/UnitTests/System/AppDelegateTests.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// AppDelegateTests.swift -// UnitTests -// -// Created by Alexey Naumov on 26.04.2020. -// Copyright © 2020 Alexey Naumov. All rights reserved. -// - -import XCTest -import UIKit -@testable import CountriesSwiftUI - -final class AppDelegateTests: XCTestCase { - - func test_didFinishLaunching() { - let sut = AppDelegate() - let eventsHandler = MockedSystemEventsHandler(expected: []) - sut.systemEventsHandler = eventsHandler - _ = sut.application(UIApplication.shared, didFinishLaunchingWithOptions: [:]) - eventsHandler.verify() - } - - func test_pushRegistration() { - let sut = AppDelegate() - let eventsHandler = MockedSystemEventsHandler(expected: [ - .pushRegistration, .pushRegistration - ]) - sut.systemEventsHandler = eventsHandler - sut.application(UIApplication.shared, didRegisterForRemoteNotificationsWithDeviceToken: Data()) - sut.application(UIApplication.shared, didFailToRegisterForRemoteNotificationsWithError: NSError.test) - eventsHandler.verify() - } - - func test_didRecevieRemoteNotification() { - let sut = AppDelegate() - let eventsHandler = MockedSystemEventsHandler(expected: [ - .recevieRemoteNotification - ]) - sut.systemEventsHandler = eventsHandler - sut.application(UIApplication.shared, didReceiveRemoteNotification: [:], fetchCompletionHandler: { _ in }) - eventsHandler.verify() - } - - func test_systemEventsHandler() { - let sut = AppDelegate() - let handler = sut.systemEventsHandler - XCTAssertTrue(handler is RealSystemEventsHandler) - } -} diff --git a/UnitTests/System/DeepLinksHandlerTests.swift b/UnitTests/System/DeepLinksHandlerTests.swift index bcb9d4b..10e686e 100644 --- a/UnitTests/System/DeepLinksHandlerTests.swift +++ b/UnitTests/System/DeepLinksHandlerTests.swift @@ -6,55 +6,53 @@ // Copyright © 2020 Alexey Naumov. All rights reserved. // -import XCTest +import Testing @testable import CountriesSwiftUI -class DeepLinksHandlerTests: XCTestCase { +@MainActor +@Suite struct DeepLinksHandlerTests { - func test_noSideEffectOnInit() { + @Test func noSideEffectOnInit() { let interactors: DIContainer.Interactors = .mocked() let container = DIContainer(appState: AppState(), interactors: interactors) _ = RealDeepLinksHandler(container: container) interactors.verify() - XCTAssertEqual(container.appState.value, AppState()) + #expect(container.appState.value == AppState()) } - - func test_openingDeeplinkFromDefaultRouting() { + + @Test func openingDeeplinkFromDefaultRouting() { let interactors: DIContainer.Interactors = .mocked() let initialState = AppState() let container = DIContainer(appState: initialState, interactors: interactors) let sut = RealDeepLinksHandler(container: container) sut.open(deepLink: .showCountryFlag(alpha3Code: "ITA")) - XCTAssertNil(initialState.routing.countriesList.countryDetails) - XCTAssertFalse(initialState.routing.countryDetails.detailsSheet) + #expect(initialState.routing.countriesList.countryCode == nil) + #expect(!initialState.routing.countryDetails.detailsSheet) var expectedState = AppState() - expectedState.routing.countriesList.countryDetails = "ITA" + expectedState.routing.countriesList.countryCode = "ITA" expectedState.routing.countryDetails.detailsSheet = true interactors.verify() - XCTAssertEqual(container.appState.value, expectedState) + #expect(container.appState.value == expectedState) } - - func test_openingDeeplinkFromNonDefaultRouting() { + + @Test func openingDeeplinkFromNonDefaultRouting() async throws { let interactors: DIContainer.Interactors = .mocked() var initialState = AppState() - initialState.routing.countriesList.countryDetails = "FRA" + initialState.routing.countriesList.countryCode = "FRA" initialState.routing.countryDetails.detailsSheet = true let container = DIContainer(appState: initialState, interactors: interactors) let sut = RealDeepLinksHandler(container: container) sut.open(deepLink: .showCountryFlag(alpha3Code: "ITA")) - + let resettedState = AppState() var finalState = AppState() - finalState.routing.countriesList.countryDetails = "ITA" + finalState.routing.countriesList.countryCode = "ITA" finalState.routing.countryDetails.detailsSheet = true - - XCTAssertEqual(container.appState.value, resettedState) - let exp = XCTestExpectation(description: #function) - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - interactors.verify() - XCTAssertEqual(container.appState.value, finalState) - exp.fulfill() - } - wait(for: [exp], timeout: 2.5) + + #expect(container.appState.value == resettedState) + try await Task.sleep(nanoseconds: 10_000_000) + interactors.verify() + #expect(container.appState.value == finalState) } } + diff --git a/UnitTests/System/PushNotificationsHandlerTests.swift b/UnitTests/System/PushNotificationsHandlerTests.swift index bae7618..1275327 100644 --- a/UnitTests/System/PushNotificationsHandlerTests.swift +++ b/UnitTests/System/PushNotificationsHandlerTests.swift @@ -6,39 +6,38 @@ // Copyright © 2020 Alexey Naumov. All rights reserved. // -import XCTest +import Testing import UserNotifications @testable import CountriesSwiftUI -class PushNotificationsHandlerTests: XCTestCase { - - var sut: RealPushNotificationsHandler! +@MainActor +@Suite struct PushNotificationsHandlerTests { - func test_isCenterDelegate() { + @Test func isCenterDelegate() { let mockedHandler = MockedDeepLinksHandler(expected: []) - sut = RealPushNotificationsHandler(deepLinksHandler: mockedHandler) + let sut = RealPushNotificationsHandler(deepLinksHandler: mockedHandler) let center = UNUserNotificationCenter.current() - XCTAssertTrue(center.delegate === sut) + #expect(center.delegate === sut) mockedHandler.verify() } - func test_emptyPayload() { + @Test func emptyPayload() async throws { let mockedHandler = MockedDeepLinksHandler(expected: []) - sut = RealPushNotificationsHandler(deepLinksHandler: mockedHandler) - let exp = XCTestExpectation(description: #function) + let sut = RealPushNotificationsHandler(deepLinksHandler: mockedHandler) + let exp = TestExpectation() sut.handleNotification(userInfo: [:]) { mockedHandler.verify() exp.fulfill() } - wait(for: [exp], timeout: 0.1) + await exp.fulfillment() } - func test_deepLinkPayload() { + @Test func deepLinkPayload() async throws { let mockedHandler = MockedDeepLinksHandler(expected: [ .open(.showCountryFlag(alpha3Code: "USA")) ]) - sut = RealPushNotificationsHandler(deepLinksHandler: mockedHandler) - let exp = XCTestExpectation(description: #function) + let sut = RealPushNotificationsHandler(deepLinksHandler: mockedHandler) + let exp = TestExpectation() let userInfo: [String: Any] = [ "aps": ["country": "USA"] ] @@ -46,6 +45,6 @@ class PushNotificationsHandlerTests: XCTestCase { mockedHandler.verify() exp.fulfill() } - wait(for: [exp], timeout: 0.1) + await exp.fulfillment() } } diff --git a/UnitTests/System/SceneDelegateTests.swift b/UnitTests/System/SceneDelegateTests.swift deleted file mode 100644 index b311fa3..0000000 --- a/UnitTests/System/SceneDelegateTests.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// SceneDelegateTests.swift -// UnitTests -// -// Created by Alexey Naumov on 26.04.2020. -// Copyright © 2020 Alexey Naumov. All rights reserved. -// - -import XCTest -import UIKit -@testable import CountriesSwiftUI - -final class SceneDelegateTests: XCTestCase { - - private lazy var scene: UIScene = { - UIApplication.shared.connectedScenes.first! - }() - - func test_openURLContexts() { - let sut = SceneDelegate() - let eventsHandler = MockedSystemEventsHandler(expected: [ - .openURL - ]) - sut.systemEventsHandler = eventsHandler - sut.scene(scene, openURLContexts: .init()) - eventsHandler.verify() - } - - func test_didBecomeActive() { - let sut = SceneDelegate() - let eventsHandler = MockedSystemEventsHandler(expected: [ - .becomeActive - ]) - sut.systemEventsHandler = eventsHandler - sut.sceneDidBecomeActive(scene) - eventsHandler.verify() - } - - func test_willResignActive() { - let sut = SceneDelegate() - let eventsHandler = MockedSystemEventsHandler(expected: [ - .resignActive - ]) - sut.systemEventsHandler = eventsHandler - sut.sceneWillResignActive(scene) - eventsHandler.verify() - } -} diff --git a/UnitTests/System/SystemEventsHandlerTests.swift b/UnitTests/System/SystemEventsHandlerTests.swift deleted file mode 100644 index a8a87f0..0000000 --- a/UnitTests/System/SystemEventsHandlerTests.swift +++ /dev/null @@ -1,176 +0,0 @@ -// -// SystemEventsHandlerTests.swift -// UnitTests -// -// Created by Alexey Naumov on 31.10.2019. -// Copyright © 2019 Alexey Naumov. All rights reserved. -// - -import XCTest -import UIKit -@testable import CountriesSwiftUI - -final class SystemEventsHandlerTests: XCTestCase { - - var sut: RealSystemEventsHandler! - - var appState: AppState { - return sut.container.appState.value - } - var interactors: DIContainer.Interactors { - return sut.container.interactors - } - var deepLinksHandler: MockedDeepLinksHandler? { - return sut.deepLinksHandler as? MockedDeepLinksHandler - } - var pushTokenWebRepository: MockedPushTokenWebRepository? { - return sut.pushTokenWebRepository as? MockedPushTokenWebRepository - } - - func verify(appState: AppState = AppState(), file: StaticString = #file, line: UInt = #line) { - interactors.verify(file: file, line: line) - deepLinksHandler?.verify(file: file, line: line) - pushTokenWebRepository?.verify(file: file, line: line) - XCTAssertEqual(self.appState, appState, file: file, line: line) - } - - func setupSut(countries: [MockedCountriesInteractor.Action] = [], - permissions: [MockedUserPermissionsInteractor.Action] = [], - deepLink: [MockedDeepLinksHandler.Action] = [], - pushToken: [MockedPushTokenWebRepository.Action] = []) { - let interactors = DIContainer.Interactors( - countriesInteractor: MockedCountriesInteractor(expected: countries), - imagesInteractor: MockedImagesInteractor(expected: []), - userPermissionsInteractor: MockedUserPermissionsInteractor(expected: permissions)) - let container = DIContainer(appState: AppState(), - interactors: interactors) - let deepLinksHandler = MockedDeepLinksHandler(expected: deepLink) - let pushNotificationsHandler = DummyPushNotificationsHandler() - let pushTokenWebRepository = MockedPushTokenWebRepository(expected: pushToken) - sut = RealSystemEventsHandler(container: container, - deepLinksHandler: deepLinksHandler, - pushNotificationsHandler: pushNotificationsHandler, - pushTokenWebRepository: pushTokenWebRepository) - } - - func test_noSideEffectOnInit() { - setupSut() - sut.container.appState[\.permissions.push] = .denied - let reference = sut.container.appState.value - verify(appState: reference) - } - - func test_subscribesOnPushIfGranted() { - setupSut(permissions: [ - .request(.pushNotifications) - ]) - sut.container.appState[\.permissions.push] = .granted - let reference = sut.container.appState.value - verify(appState: reference) - } - - func test_didBecomeActive() { - setupSut(permissions: [ - .resolveStatus(.pushNotifications) - ]) - sut.sceneDidBecomeActive() - var reference = AppState() - XCTAssertFalse(reference.system.isActive) - reference.system.isActive = true - verify(appState: reference) - } - - func test_willResignActive() { - setupSut(permissions: [ - .resolveStatus(.pushNotifications) - ]) - sut.sceneDidBecomeActive() - sut.sceneWillResignActive() - verify() - } - - func test_openURLContexts_countryDeepLink() { - let countries = Country.mockedData - let code = countries[0].alpha3Code - let deepLinkURL = "https://www.example.com/?alpha3code=\(code)" - setupSut(deepLink: [.open(.showCountryFlag(alpha3Code: code))]) - let contexts = UIOpenURLContext.contexts(deepLinkURL) - sut.sceneOpenURLContexts(contexts) - verify() - } - - func test_openURLContexts_randomURL() { - let url1 = "https://www.example.com/link/?param=USD" - let contexts1 = UIOpenURLContext.contexts(url1) - let url2 = "https://www.domain.com/test/?alpha3code=USD" - let contexts2 = UIOpenURLContext.contexts(url2) - setupSut() - sut.sceneOpenURLContexts(contexts1) - sut.sceneOpenURLContexts(contexts2) - verify() - } - - func test_openURLContexts_emptyContexts() { - setupSut() - sut.sceneOpenURLContexts(Set()) - verify() - } - - #if os(iOS) && !targetEnvironment(macCatalyst) - func test_keyboardHeight() throws { - let textField = UITextField(frame: .zero) - let window = try XCTUnwrap(UIApplication.shared.windows.first, "Cannot extract the host view") - window.makeKeyAndVisible() - window.addSubview(textField) - setupSut() - XCTAssertEqual(appState.system.keyboardHeight, 0) - textField.becomeFirstResponder() - XCTAssertGreaterThan(appState.system.keyboardHeight, 0) - textField.removeFromSuperview() - verify() - } - #endif - - func test_handlePushRegistration() { - setupSut(pushToken: [ - .register(Data()) - ]) - sut.handlePushRegistration(result: .success(Data())) - verify() - } - - func test_silentRemoteNotificationSuccess() { - setupSut(countries: [ - .refreshCountriesList - ]) - let exp = XCTestExpectation(description: #function) - sut.appDidReceiveRemoteNotification(payload: [:]) { result in - XCTAssertEqual(result, .newData) - self.verify() - exp.fulfill() - } - wait(for: [exp], timeout: 0.1) - } -} - -private extension UIOpenURLContext { - static func contexts(_ urlString: String) -> Set { - UIOpenURLContext.createInstance() - return Set([Test.create(url: urlString)]) - } -} - -private extension UIOpenURLContext { - final class Test: UIOpenURLContext { - - var urlString: String = "" - override var url: URL { URL(string: urlString)! } - - static func create(url: String) -> Test { - let instance = createInstance() - instance.urlString = url - return instance - } - } - -} diff --git a/UnitTests/System/UIOpenURLContext_Init.h b/UnitTests/System/UIOpenURLContext_Init.h deleted file mode 100644 index 1252b14..0000000 --- a/UnitTests/System/UIOpenURLContext_Init.h +++ /dev/null @@ -1,19 +0,0 @@ -// -// UIOpenURLContext+UIOpenURLContext_Init.h -// UnitTests -// -// Created by Alexey on 18.05.2021. -// Copyright © 2021 Alexey Naumov. All rights reserved. -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface UIOpenURLContext (Init) - -+ (instancetype)createInstance; - -@end - -NS_ASSUME_NONNULL_END diff --git a/UnitTests/System/UIOpenURLContext_Init.m b/UnitTests/System/UIOpenURLContext_Init.m deleted file mode 100644 index 8b58496..0000000 --- a/UnitTests/System/UIOpenURLContext_Init.m +++ /dev/null @@ -1,17 +0,0 @@ -// -// UIOpenURLContext_Init.m -// UnitTests -// -// Created by Alexey on 18.05.2021. -// Copyright © 2021 Alexey Naumov. All rights reserved. -// - -#import "UIOpenURLContext_Init.h" - -@implementation UIOpenURLContext (Init) - -+ (instancetype)createInstance { - return [[self alloc] init]; -} - -@end diff --git a/UnitTests/System/UnitTests-Bridging-Header.h b/UnitTests/System/UnitTests-Bridging-Header.h deleted file mode 100644 index f02e085..0000000 --- a/UnitTests/System/UnitTests-Bridging-Header.h +++ /dev/null @@ -1,5 +0,0 @@ -// -// Use this file to import your target's public headers that you would like to expose to Swift. -// - -#import "UIOpenURLContext_Init.h" diff --git a/UnitTests/TestHelpers.swift b/UnitTests/TestHelpers.swift index cafc5ec..f21ee50 100644 --- a/UnitTests/TestHelpers.swift +++ b/UnitTests/TestHelpers.swift @@ -1,14 +1,13 @@ // // TestHelpers.swift -// UnitTests +// CountriesSwiftUI // -// Created by Alexey Naumov on 30.10.2019. -// Copyright © 2019 Alexey Naumov. All rights reserved. +// Created by Alexey on 15/11/24. +// Copyright © 2024 Alexey Naumov. All rights reserved. // -import XCTest +import UIKit.UIColor import SwiftUI -import Combine import ViewInspector @testable import CountriesSwiftUI @@ -25,127 +24,68 @@ extension UIColor { } } -// MARK: - Result +// MARK: - Errors -extension Result where Success: Equatable { - func assertSuccess(value: Success, file: StaticString = #file, line: UInt = #line) { - switch self { - case let .success(resultValue): - XCTAssertEqual(resultValue, value, file: file, line: line) - case let .failure(error): - XCTFail("Unexpected error: \(error)", file: file, line: line) - } - } +enum MockError: Swift.Error { + case valueNotSet + case codeDataModel } -extension Result where Success == Void { - func assertSuccess(file: StaticString = #file, line: UInt = #line) { - switch self { - case let .failure(error): - XCTFail("Unexpected error: \(error)", file: file, line: line) - case .success: - break - } +extension NSError { + static var test: NSError { + return NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Test error"]) } } -extension Result { - func assertFailure(_ message: String? = nil, file: StaticString = #file, line: UInt = #line) { - switch self { - case let .success(value): - XCTFail("Unexpected success: \(value)", file: file, line: line) - case let .failure(error): - if let message = message { - XCTAssertEqual(error.localizedDescription, message, file: file, line: line) - } - } - } -} +// MARK: - Misc -extension Result { - func publish() -> AnyPublisher { - return publisher.publish() +extension CancelBag { + static var test: CancelBag { + return CancelBag(equalToAny: true) } } -extension Publisher { - func publish() -> AnyPublisher { - delay(for: .milliseconds(10), scheduler: RunLoop.main) - .eraseToAnyPublisher() - } -} +struct TestExpectation { -// MARK: - XCTestCase - -func XCTAssertEqual(_ expression1: @autoclosure () throws -> T, - _ expression2: @autoclosure () throws -> T, - removing prefixes: [String], - file: StaticString = #file, line: UInt = #line) where T: Equatable { - do { - let exp1 = try expression1() - let exp2 = try expression2() - if exp1 != exp2 { - let desc1 = prefixes.reduce(String(describing: exp1), { (str, prefix) in - str.replacingOccurrences(of: prefix, with: "") - }) - let desc2 = prefixes.reduce(String(describing: exp2), { (str, prefix) in - str.replacingOccurrences(of: prefix, with: "") - }) - XCTFail("XCTAssertEqual failed:\n\n\(desc1)\n\nis not equal to\n\n\(desc2)", file: file, line: line) - } - } catch { - XCTFail("Unexpected exception: \(error)") - } -} + private let signal: AsyncStream.Continuation? + private let stream: AsyncStream + private let expectedCount: Int -protocol PrefixRemovable { } - -extension PrefixRemovable { - static var prefixes: [String] { - let name = String(reflecting: Self.self) - var components = name.components(separatedBy: ".") - let module = components.removeFirst() - let fullTypeName = components.joined(separator: ".") - return [ - "\(module).", - "Loadable<\(fullTypeName)>", - "Loadable>" - ] + init(expectedCount: Int = 1) { + precondition(expectedCount > 0) + self.expectedCount = expectedCount + var signal: AsyncStream.Continuation? + self.stream = AsyncStream { signal = $0 } + self.signal = signal } -} -// MARK: - BindingWithPublisher + func fulfill() { + signal?.yield() + } -struct BindingWithPublisher { - - let binding: Binding - let updatesRecorder: AnyPublisher<[Value], Never> - - init(value: Value, recordingTimeInterval: TimeInterval = 0.5) { - var value = value - var updates = [value] - binding = Binding( - get: { value }, - set: { value = $0; updates.append($0) }) - updatesRecorder = Future<[Value], Never> { completion in - DispatchQueue.main.asyncAfter(deadline: .now() + recordingTimeInterval) { - completion(.success(updates)) - } - }.eraseToAnyPublisher() + func fulfillment() async { + await stream + .dropFirst(expectedCount - 1) + .first(where: { _ in true }) } } -// MARK: - Error +final class BindingWithHistory { -enum MockError: Swift.Error { - case valueNotSet - case codeDataModel -} + private(set) var binding: Binding + private(set) var history: [Value] -extension NSError { - static var test: NSError { - return NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Test error"]) + init(value: Value) { + binding = .constant(value) + history = [value] + var value = value + binding = Binding(get: { + value + }, set: { [weak self] in + value = $0 + self?.history.append($0) + }) } } -extension Inspection: InspectionEmissary where V: Inspectable { } +extension Inspection: @retroactive InspectionEmissary { } diff --git a/UnitTests/UI/ContentViewTests.swift b/UnitTests/UI/ContentViewTests.swift deleted file mode 100644 index a9b5150..0000000 --- a/UnitTests/UI/ContentViewTests.swift +++ /dev/null @@ -1,39 +0,0 @@ -import XCTest -import ViewInspector -@testable import CountriesSwiftUI - -extension ContentView: Inspectable { } - -final class ContentViewTests: XCTestCase { - - func test_content_for_tests() throws { - let sut = ContentView(container: .defaultValue, isRunningTests: true) - XCTAssertNoThrow(try sut.inspect().group().text(0)) - } - - func test_content_for_build() throws { - let sut = ContentView(container: .defaultValue, isRunningTests: false) - XCTAssertNoThrow(try sut.inspect().group().view(CountriesList.self, 0)) - } - - func test_change_handler_for_colorScheme() throws { - var appState = AppState() - appState.routing.countriesList = .init(countryDetails: "USA") - let container = DIContainer(appState: .init(appState), interactors: .mocked()) - let sut = ContentView(container: container) - sut.onChangeHandler(.colorScheme) - XCTAssertEqual(container.appState.value, appState) - container.interactors.verify() - } - - func test_change_handler_for_sizeCategory() throws { - var appState = AppState() - appState.routing.countriesList = .init(countryDetails: "USA") - let container = DIContainer(appState: .init(appState), interactors: .mocked()) - let sut = ContentView(container: container) - XCTAssertEqual(container.appState.value, appState) - sut.onChangeHandler(.sizeCategory) - XCTAssertEqual(container.appState.value, AppState()) - container.interactors.verify() - } -} diff --git a/UnitTests/UI/CountriesListTests.swift b/UnitTests/UI/CountriesListTests.swift index 4f4d8b1..5a51b5b 100644 --- a/UnitTests/UI/CountriesListTests.swift +++ b/UnitTests/UI/CountriesListTests.swift @@ -6,141 +6,173 @@ // Copyright © 2019 Alexey Naumov. All rights reserved. // -import XCTest +import Testing import ViewInspector +import SwiftData import SwiftUI @testable import CountriesSwiftUI -extension CountriesList: Inspectable { } -extension ActivityIndicatorView: Inspectable { } -extension CountryCell: Inspectable { } -extension ErrorView: Inspectable { } +@MainActor +@Suite struct CountriesListTests { -final class CountriesListTests: XCTestCase { + let apiCountries: [ApiModel.Country] + let dbCountries: [DBModel.Country] - func test_countries_notRequested() { - let container = DIContainer(appState: AppState(), interactors: - .mocked( - countriesInteractor: [.loadCountries(search: "", locale: .current)] - )) - let sut = CountriesList(countries: .notRequested) - let exp = sut.inspection.inspect { view in - XCTAssertNoThrow(try view.content().text(0)) - XCTAssertEqual(container.appState.value, AppState()) - container.interactors.verify() + init() { + apiCountries = ApiModel.Country.mockedData + dbCountries = apiCountries.map { $0.dbModel() } + } + + @Test func noCachedCountries() async throws { + let container = DIContainer(interactors: .mocked(countries: [ + .refreshCountriesList, + ])) + let sut = CountriesList(state: .notRequested) + try await ViewHosting.host(sut.inject(container)) { + try await sut.inspection.inspect { view in + #expect(container.appState.value == AppState()) + container.interactors.verify() + } } - ViewHosting.host(view: sut.inject(container)) - wait(for: [exp], timeout: 2) } - - func test_countries_isLoading_initial() { - let container = DIContainer(appState: AppState(), interactors: .mocked()) - let sut = CountriesList(countries: .isLoading(last: nil, cancelBag: CancelBag())) - let exp = sut.inspection.inspect { view in - let content = try view.content() - XCTAssertNoThrow(try content.find(ActivityIndicatorView.self)) - XCTAssertEqual(container.appState.value, AppState()) - container.interactors.verify() + + @Test func cachedCountries() async throws { + let container = DIContainer(interactors: .mocked()) + let sut = CountriesList(state: .notRequested) + let modelContainer = ModelContainer.mock + let dbRepository = MainDBRepository(modelContainer: modelContainer) + try await dbRepository.store(countries: apiCountries) + let view = sut.inject(container).modelContainer(modelContainer) + try await ViewHosting.host(view) { + try await sut.inspection.inspect { view in + #expect(container.appState.value == AppState()) + container.interactors.verify() + } } - ViewHosting.host(view: sut.inject(container)) - wait(for: [exp], timeout: 2) } - - func test_countries_isLoading_refresh() { - let container = DIContainer(appState: AppState(), interactors: .mocked()) - let sut = CountriesList(countries: .isLoading( - last: Country.mockedData.lazyList, cancelBag: CancelBag())) - let exp = sut.inspection.inspect { view in - let content = try view.content() - XCTAssertNoThrow(try content.find(SearchBar.self)) - XCTAssertNoThrow(try content.find(ActivityIndicatorView.self)) - let cell = try content.find(CountryCell.self).actualView() - XCTAssertEqual(cell.country, Country.mockedData[0]) - XCTAssertEqual(container.appState.value, AppState()) - container.interactors.verify() + + @Test func noMatchesWhenSearching() async throws { + let container = DIContainer(interactors: .mocked()) + let sut = CountriesList(state: .loaded(())) + let modelContainer = ModelContainer.mock + let dbRepository = MainDBRepository(modelContainer: modelContainer) + try await dbRepository.store(countries: apiCountries) + let view = sut.inject(container).modelContainer(modelContainer) + try await ViewHosting.host(view) { + try await sut.inspection.inspect { view in + try view.actualView().searchText = "whatever" + } + try await sut.inspection.inspect { view in + #expect(throws: Never.self) { try view.find(text: "No matches found") } + container.interactors.verify() + } } - ViewHosting.host(view: sut.inject(container)) - wait(for: [exp], timeout: 2) } - - func test_countries_loaded() { - let container = DIContainer(appState: AppState(), interactors: .mocked()) - let sut = CountriesList(countries: .loaded(Country.mockedData.lazyList)) - let exp = sut.inspection.inspect { view in - let content = try view.content() - XCTAssertNoThrow(try content.find(SearchBar.self)) - XCTAssertThrowsError(try content.find(ActivityIndicatorView.self)) - let cell = try content.find(CountryCell.self).actualView() - XCTAssertEqual(cell.country, Country.mockedData[0]) - XCTAssertEqual(container.appState.value, AppState()) - container.interactors.verify() + + @Test func listRefresh() async throws { + let container = DIContainer(interactors: .mocked(countries: [ + .refreshCountriesList + ])) + let sut = CountriesList(state: .loaded(())) + let modelContainer = ModelContainer.mock + let dbRepository = MainDBRepository(modelContainer: modelContainer) + try await dbRepository.store(countries: apiCountries) + let view = sut.inject(container).modelContainer(modelContainer) + try await ViewHosting.host(view) { + try await sut.inspection.inspect { view in + let list = try view.find(ViewType.List.self) + try await list.callRefreshable() + container.interactors.verify() + } } - ViewHosting.host(view: sut.inject(container)) - wait(for: [exp], timeout: 2) } - - func test_countries_failed() { - let container = DIContainer(appState: AppState(), interactors: .mocked()) - let sut = CountriesList(countries: .failed(NSError.test)) - let exp = sut.inspection.inspect { view in - XCTAssertNoThrow(try view.content().view(ErrorView.self, 0)) - XCTAssertEqual(container.appState.value, AppState()) - container.interactors.verify() + + @Test func countriesIsLoadingInitial() async throws { + let container = DIContainer(interactors: .mocked()) + let sut = CountriesList(state: .isLoading(last: nil, cancelBag: .test)) + try await ViewHosting.host(sut.inject(container)) { + try await sut.inspection.inspect { view in + let content = try view.content() + #expect(throws: Never.self) { try content.find(ViewType.ProgressView.self) } + #expect(container.appState.value == AppState()) + container.interactors.verify() + } + } + } + + @Test func countriesLoaded() async throws { + let container = DIContainer(interactors: .mocked()) + let sut = CountriesList(state: .loaded(())) + let modelContainer = ModelContainer.mock + let dbRepository = MainDBRepository(modelContainer: modelContainer) + try await dbRepository.store(countries: apiCountries) + let view = sut.inject(container).modelContainer(modelContainer) + let firstRowCountry = try #require(dbCountries.sorted(by: { $0.name < $1.name }).first) + try await ViewHosting.host(view) { + try await sut.inspection.inspect { view in + let content = try view.content() + #expect(throws: (any Error).self) { try content.find(ViewType.ProgressView.self) } + let cell = try content.find(CountryCell.self).actualView() + #expect(cell.country.name == firstRowCountry.name) + #expect(container.appState.value == AppState()) + container.interactors.verify() + } } - ViewHosting.host(view: sut.inject(container)) - wait(for: [exp], timeout: 2) } - func test_countries_failed_retry() { - let container = DIContainer(appState: AppState(), interactors: .mocked( - countriesInteractor: [.loadCountries(search: "", locale: .current)] - )) - let sut = CountriesList(countries: .failed(NSError.test)) - let exp = sut.inspection.inspect { view in - let errorView = try view.content().view(ErrorView.self, 0) - try errorView.vStack().button(2).tap() - XCTAssertEqual(container.appState.value, AppState()) - container.interactors.verify() + @Test func countriesFailed() async throws { + let container = DIContainer(interactors: .mocked()) + let sut = CountriesList(state: .failed(NSError.test)) + try await ViewHosting.host(sut.inject(container)) { + try await sut.inspection.inspect { view in + #expect(throws: Never.self) { try view.content().implicitAnyView().implicitAnyView().implicitAnyView().view(ErrorView.self, 0) } + #expect(container.appState.value == AppState()) + container.interactors.verify() + } } - ViewHosting.host(view: sut.inject(container)) - wait(for: [exp], timeout: 2) } - func test_countries_navigation_to_details() { - let countries = Country.mockedData - let container = DIContainer(appState: AppState(), interactors: .mocked()) - XCTAssertNil(container.appState.value.routing.countriesList.countryDetails) - let sut = CountriesList(countries: .loaded(countries.lazyList)) - let exp = sut.inspection.inspect { view in - let firstCountryRow = try view.content().find(ViewType.NavigationLink.self) - try firstCountryRow.activate() - let selected = container.appState.value.routing.countriesList.countryDetails - XCTAssertEqual(selected, countries[0].alpha3Code) - _ = try firstCountryRow.find(where: { try $0.callOnAppear(); return true }) - container.interactors.verify() + @Test func countriesFailedRetry() async throws { + let container = DIContainer(interactors: .mocked()) + let sut = CountriesList(state: .failed(NSError.test)) + try await ViewHosting.host(sut.inject(container)) { + try await sut.inspection.inspect { view in + let errorView = try view.content().implicitAnyView().implicitAnyView().implicitAnyView().view(ErrorView.self, 0) + try errorView.implicitAnyView().vStack().button(2).tap() + #expect(container.appState.value == AppState()) + container.interactors.verify() + } + } + } + + @Test func requestPush() async throws { + let container = DIContainer(interactors: .mocked(permissions: [ + .request(.pushNotifications) + ])) + container.appState[\.permissions.push] = .notRequested + let sut = CountriesList(state: .loaded(())) + try await ViewHosting.host(sut.inject(container)) { + try await sut.inspection.inspect { view in + try view.find(button: "Allow Push").tap() + container.interactors.verify() + } } - ViewHosting.host(view: sut.inject(container)) - wait(for: [exp], timeout: 2) } } -final class LocalizationTests: XCTestCase { - func test_country_localized_name() { - let sut = Country(name: "Abc", translations: ["fr": "Xyz"], population: 0, flag: nil, alpha3Code: "") +@Suite struct LocalizationTests { + + @Test func countryLocalizedName() { + let sut = DBModel.Country(name: "Abc", translations: ["fr": "Xyz"], population: 0, flag: nil, alpha3Code: "") let locale = Locale(identifier: "fr") - XCTAssertEqual(sut.name(locale: locale), "Xyz") - } - - func test_string_for_locale() throws { - let sut = "Countries".localized(Locale(identifier: "fr")) - XCTAssertEqual(sut, "Des pays") + #expect(sut.name(locale: locale) == "Xyz") } } // MARK: - CountriesList inspection helper extension InspectableView where View == ViewType.View { - func content() throws -> InspectableView { - return try geometryReader().navigationView() + func content() throws -> InspectableView { + return try implicitAnyView().navigationStack() } } diff --git a/UnitTests/UI/CountryDetailsTests.swift b/UnitTests/UI/CountryDetailsTests.swift deleted file mode 100644 index 20bc7b6..0000000 --- a/UnitTests/UI/CountryDetailsTests.swift +++ /dev/null @@ -1,131 +0,0 @@ -// -// CountryDetailsTests.swift -// UnitTests -// -// Created by Alexey Naumov on 01.11.2019. -// Copyright © 2019 Alexey Naumov. All rights reserved. -// - -import XCTest -import ViewInspector -@testable import CountriesSwiftUI - -extension CountryDetails: Inspectable { } -extension DetailRow: Inspectable { } - -final class CountryDetailsTests: XCTestCase { - - let country = Country.mockedData[0] - - func test_details_notRequested() { - let interactors = DIContainer.Interactors.mocked( - countriesInteractor: [.loadCountryDetails(country)] - ) - let sut = CountryDetails(country: country, details: .notRequested) - let exp = sut.inspection.inspect { view in - XCTAssertNoThrow(try view.find(text: "")) - interactors.verify() - } - ViewHosting.host(view: sut.inject(AppState(), interactors)) - wait(for: [exp], timeout: 2) - } - - func test_details_isLoading_initial() { - let interactors = DIContainer.Interactors.mocked() - let sut = CountryDetails(country: country, details: - .isLoading(last: nil, cancelBag: CancelBag())) - let exp = sut.inspection.inspect { view in - XCTAssertNoThrow(try view.find(ActivityIndicatorView.self)) - interactors.verify() - } - ViewHosting.host(view: sut.inject(AppState(), interactors)) - wait(for: [exp], timeout: 2) - } - - func test_details_isLoading_refresh() { - let interactors = DIContainer.Interactors.mocked() - let sut = CountryDetails(country: country, details: - .isLoading(last: Country.Details.mockedData[0], cancelBag: CancelBag()) - ) - let exp = sut.inspection.inspect { view in - XCTAssertNoThrow(try view.find(ActivityIndicatorView.self)) - interactors.verify() - } - ViewHosting.host(view: sut.inject(AppState(), interactors)) - wait(for: [exp], timeout: 2) - } - - func test_details_isLoading_cancellation() { - let interactors = DIContainer.Interactors.mocked() - let sut = CountryDetails(country: country, details: - .isLoading(last: Country.Details.mockedData[0], cancelBag: CancelBag()) - ) - let exp = sut.inspection.inspect { view in - XCTAssertNoThrow(try view.find(ActivityIndicatorView.self)) - try view.find(button: "Cancel loading").tap() - interactors.verify() - } - ViewHosting.host(view: sut.inject(AppState(), interactors)) - wait(for: [exp], timeout: 2) - } - - func test_details_loaded() { - let interactors = DIContainer.Interactors.mocked( - imagesInteractor: [.loadImage(country.flag)] - ) - let sut = CountryDetails(country: country, details: - .loaded(Country.Details.mockedData[0]) - ) - let exp = sut.inspection.inspect { view in - XCTAssertNoThrow(try view.find(ImageView.self)) - XCTAssertNoThrow(try view.find(DetailRow.self).find(text: self.country.alpha3Code)) - interactors.verify() - } - ViewHosting.host(view: sut.inject(AppState(), interactors)) - wait(for: [exp], timeout: 3) - } - - func test_details_failed() { - let interactors = DIContainer.Interactors.mocked() - let sut = CountryDetails(country: country, details: .failed(NSError.test)) - let exp = sut.inspection.inspect { view in - XCTAssertNoThrow(try view.find(ErrorView.self)) - interactors.verify() - } - ViewHosting.host(view: sut.inject(AppState(), interactors)) - wait(for: [exp], timeout: 2) - } - - func test_details_failed_retry() { - let interactors = DIContainer.Interactors.mocked( - countriesInteractor: [.loadCountryDetails(country)] - ) - let sut = CountryDetails(country: country, details: .failed(NSError.test)) - let exp = sut.inspection.inspect { view in - let errorView = try view.find(ErrorView.self) - try errorView.vStack().button(2).tap() - interactors.verify() - } - ViewHosting.host(view: sut.inject(AppState(), interactors)) - wait(for: [exp], timeout: 2) - } - - func test_sheetPresentation() { - let images: [MockedImagesInteractor.Action] = [.loadImage(country.flag), .loadImage(country.flag)] - let interactors = DIContainer.Interactors.mocked( - imagesInteractor: images - ) - let container = DIContainer(appState: .init(AppState()), interactors: interactors) - XCTAssertFalse(container.appState.value.routing.countryDetails.detailsSheet) - let sut = CountryDetails(country: country, details: .loaded(Country.Details.mockedData[0])) - let exp1 = sut.inspection.inspect { view in - try view.find(ImageView.self).callOnTapGesture() - } - let exp2 = sut.inspection.inspect(after: 0.5) { view in - XCTAssertTrue(container.appState.value.routing.countryDetails.detailsSheet) - interactors.verify() - } - ViewHosting.host(view: sut.inject(container)) - wait(for: [exp1, exp2], timeout: 2) - } -} diff --git a/UnitTests/UI/DeepLinkUITests.swift b/UnitTests/UI/DeepLinkUITests.swift index 208110d..262b583 100644 --- a/UnitTests/UI/DeepLinkUITests.swift +++ b/UnitTests/UI/DeepLinkUITests.swift @@ -6,86 +6,98 @@ // Copyright © 2020 Alexey Naumov. All rights reserved. // -import XCTest +import Testing +import SwiftData import ViewInspector -import Combine +import UIKit.UIColor @testable import CountriesSwiftUI -final class DeepLinkUITests: XCTestCase { - - func test_countriesList_selectsCountry() { - +@MainActor +@Suite struct DeepLinkUITests { + + @Test func countriesListSelectsCountry() async throws { let store = appStateWithDeepLink() - let interactors = mockedInteractors(store: store) + let interactors = interactorsWithMockedRepos(store: store) + let modelContainer = ModelContainer.mock + let dbRepository = MainDBRepository(modelContainer: modelContainer) + let countries = ApiModel.Country.mockedData + try await dbRepository.store(countries: countries) let container = DIContainer(appState: store, interactors: interactors) let sut = CountriesList() - let exp = sut.inspection.inspect(after: 0.1) { view in - let firstRowLink = try view.content().find(ViewType.NavigationLink.self) - XCTAssertTrue(try firstRowLink.isActive()) + let view = sut.inject(container).modelContainer(modelContainer) + try await ViewHosting.host(view) { + try await sut.inspection.inspect { view in + let actualView = try view.actualView() + #expect(!actualView.navigationPath.isEmpty) + } } - ViewHosting.host(view: sut.inject(container)) - wait(for: [exp], timeout: 2) } - func test_countryDetails_presentsSheet() { - + @Test func countryDetailsPresentsSheet() async throws { let store = appStateWithDeepLink() - let interactors = mockedInteractors(store: store) + let interactors = interactorsWithMockedRepos(store: store) let container = DIContainer(appState: store, interactors: interactors) - let sut = CountryDetails(country: Country.mockedData[0]) - let exp = sut.inspection.inspect(after: 0.1) { view in - XCTAssertNoThrow(try view.find(ViewType.List.self)) - XCTAssertTrue(store.value.routing.countryDetails.detailsSheet) + let country = ApiModel.Country.mockedData[0].dbModel() + country.flag = URL(string: "https://sample.com/") + let countryDetails = DBModel.CountryDetails(alpha3Code: country.alpha3Code, capital: "Rome", currencies: [], neighbors: []) + let sut = CountryDetails(country: country, details: .loaded(countryDetails)) + let view = sut.inject(container) + try await ViewHosting.host(view) { + try await sut.inspection.inspect(after: .seconds(0.5)) { view in + #expect(throws: Never.self) { try view.find(ModalFlagView.self) } + #expect(store.value.routing.countryDetails.detailsSheet) + } } - ViewHosting.host(view: sut.inject(container)) - wait(for: [exp], timeout: 2) } } // MARK: - Setup +@MainActor private extension DeepLinkUITests { func appStateWithDeepLink() -> Store { - let countries = Country.mockedData + let countries = ApiModel.Country.mockedData var appState = AppState() - appState.routing.countriesList.countryDetails = countries[0].alpha3Code + appState.routing.countriesList.countryCode = countries[0].alpha3Code appState.routing.countryDetails.detailsSheet = true return Store(appState) } - func mockedInteractors(store: Store) -> DIContainer.Interactors { - - let countries = Country.mockedData + func interactorsWithMockedRepos(store: Store) -> DIContainer.Interactors { + + let countries = ApiModel.Country.mockedData let testImage = UIColor.red.image(CGSize(width: 40, height: 40)) - let detailsIntermediate = Country.Details.Intermediate(capital: "", currencies: [], borders: []) - let details = Country.Details(capital: "", currencies: [], neighbors: []) - + let detailsIntermediate = ApiModel.CountryDetails(capital: "", currencies: [], borders: []) + let details = DBModel.CountryDetails(alpha3Code: "", capital: "", currencies: [], neighbors: []) + let countriesDBRepo = MockedCountriesDBRepository() let countriesWebRepo = MockedCountriesWebRepository() let imagesRepo = MockedImageWebRepository() // Mocking successful loading the list of countries: - countriesDBRepo.hasLoadedCountriesResult = .success(false) - countriesWebRepo.countriesResponse = .success(countries) - countriesDBRepo.storeCountriesResult = .success(()) - countriesDBRepo.fetchCountriesResult = .success(countries.lazyList) - + countriesWebRepo.countriesResponses = [.success(countries)] + countriesDBRepo.storeCountriesResults = [.success(())] + // Mocking successful loading the country details: - countriesDBRepo.fetchCountryDetailsResult = .success(nil) - countriesWebRepo.detailsResponse = .success(detailsIntermediate) - countriesDBRepo.storeCountryDetailsResult = .success(details) - + countriesDBRepo.countryDetailsResults = [.success(nil), .success(details)] + countriesWebRepo.detailsResponses = [.success(detailsIntermediate)] + countriesDBRepo.storeCountryDetailsResults = [.success(())] + // Mocking successful loading of the flag: - imagesRepo.imageResponse = .success(testImage) - - let countriesInteractor = RealCountriesInteractor(webRepository: countriesWebRepo, - dbRepository: countriesDBRepo, - appState: store) + imagesRepo.imageResponses = [.success(testImage)] + + let countriesInteractor = RealCountriesInteractor( + webRepository: countriesWebRepo, + dbRepository: countriesDBRepo) let imagesInteractor = RealImagesInteractor(webRepository: imagesRepo) - let permissionsInteractor = RealUserPermissionsInteractor(appState: store, openAppSettings: { }) - return DIContainer.Interactors(countriesInteractor: countriesInteractor, - imagesInteractor: imagesInteractor, - userPermissionsInteractor: permissionsInteractor) + let permissionsInteractor = RealUserPermissionsInteractor( + appState: store, openAppSettings: { }) + return DIContainer.Interactors( + images: imagesInteractor, + countries: countriesInteractor, + userPermissions: permissionsInteractor) } } + +extension InspectableSheet: PopupPresenter { } diff --git a/UnitTests/UI/ImageViewTests.swift b/UnitTests/UI/ImageViewTests.swift index f69b95c..9e0abbf 100644 --- a/UnitTests/UI/ImageViewTests.swift +++ b/UnitTests/UI/ImageViewTests.swift @@ -6,75 +6,75 @@ // Copyright © 2019 Alexey Naumov. All rights reserved. // -import XCTest +import Testing import SwiftUI import ViewInspector @testable import CountriesSwiftUI -extension ImageView: Inspectable { } - -final class ImageViewTests: XCTestCase { +@MainActor +@Suite struct ImageViewTests { let url = URL(string: "https://test.com/test.png")! - func test_imageView_notRequested() { - let interactors = DIContainer.Interactors.mocked( - imagesInteractor: [.loadImage(url)]) + @Test func imageViewNotRequested() async throws { + let container = DIContainer(interactors: .mocked( + images: [.loadImage(url)] + )) let sut = ImageView(imageURL: url, image: .notRequested) - let exp = sut.inspection.inspect { view in - XCTAssertNoThrow(try view.find(text: "")) - interactors.verify() + try await ViewHosting.host(sut.inject(container)) { + try await sut.inspection.inspect { view in + #expect(throws: Never.self) { try view.find(text: "") } + container.interactors.verify() + } } - ViewHosting.host(view: sut.inject(AppState(), interactors)) - wait(for: [exp], timeout: 2) } - func test_imageView_isLoading_initial() { - let interactors = DIContainer.Interactors.mocked() + @Test func imageViewIsLoadingInitial() async throws { + let container = DIContainer(interactors: .mocked()) let sut = ImageView(imageURL: url, image: - .isLoading(last: nil, cancelBag: CancelBag())) - let exp = sut.inspection.inspect { view in - XCTAssertNoThrow(try view.find(ActivityIndicatorView.self)) - interactors.verify() + .isLoading(last: nil, cancelBag: .test)) + try await ViewHosting.host(sut.inject(container)) { + try await sut.inspection.inspect { view in + #expect(throws: Never.self) { try view.find(ViewType.ProgressView.self) } + container.interactors.verify() + } } - ViewHosting.host(view: sut.inject(AppState(), interactors)) - wait(for: [exp], timeout: 2) } - func test_imageView_isLoading_refresh() { - let interactors = DIContainer.Interactors.mocked() + @Test func imageViewIsLoadingRefresh() async throws { + let container = DIContainer(interactors: .mocked()) let image = UIColor.red.image(CGSize(width: 10, height: 10)) let sut = ImageView(imageURL: url, image: - .isLoading(last: image, cancelBag: CancelBag())) - let exp = sut.inspection.inspect { view in - XCTAssertNoThrow(try view.find(ActivityIndicatorView.self)) - interactors.verify() + .isLoading(last: image, cancelBag: .test)) + try await ViewHosting.host(sut.inject(container)) { + try await sut.inspection.inspect { view in + #expect(throws: Never.self) { try view.find(ViewType.ProgressView.self) } + container.interactors.verify() + } } - ViewHosting.host(view: sut.inject(AppState(), interactors)) - wait(for: [exp], timeout: 2) } - func test_imageView_loaded() { - let interactors = DIContainer.Interactors.mocked() + @Test func imageViewLoaded() async throws { + let container = DIContainer(interactors: .mocked()) let image = UIColor.red.image(CGSize(width: 10, height: 10)) let sut = ImageView(imageURL: url, image: .loaded(image)) - let exp = sut.inspection.inspect { view in - let loadedImage = try view.find(ViewType.Image.self).actualImage().uiImage() - XCTAssertEqual(loadedImage, image) - interactors.verify() + try await ViewHosting.host(sut.inject(container)) { + try await sut.inspection.inspect { view in + let loadedImage = try view.find(ViewType.Image.self).actualImage().uiImage() + #expect(loadedImage == image) + container.interactors.verify() + } } - ViewHosting.host(view: sut.inject(AppState(), interactors)) - wait(for: [exp], timeout: 3) } - func test_imageView_failed() { - let interactors = DIContainer.Interactors.mocked() + @Test func imageViewFailed() async throws { + let container = DIContainer(interactors: .mocked()) let sut = ImageView(imageURL: url, image: .failed(NSError.test)) - let exp = sut.inspection.inspect { view in - XCTAssertNoThrow(try view.find(text: "Unable to load image")) - interactors.verify() + try await ViewHosting.host(sut.inject(container)) { + try await sut.inspection.inspect { view in + #expect(throws: Never.self) { try view.find(text: "Unable to load image") } + container.interactors.verify() + } } - ViewHosting.host(view: sut.inject(AppState(), interactors)) - wait(for: [exp], timeout: 2) } } diff --git a/UnitTests/UI/ModalDetailsViewTests.swift b/UnitTests/UI/ModalDetailsViewTests.swift deleted file mode 100644 index 4923120..0000000 --- a/UnitTests/UI/ModalDetailsViewTests.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// ModalDetailsViewTests.swift -// UnitTests -// -// Created by Alexey Naumov on 01.11.2019. -// Copyright © 2019 Alexey Naumov. All rights reserved. -// - -import XCTest -import SwiftUI -import ViewInspector -@testable import CountriesSwiftUI - -extension ModalDetailsView: Inspectable { } - -final class ModalDetailsViewTests: XCTestCase { - - func test_modalDetails() { - let country = Country.mockedData[0] - let interactors = DIContainer.Interactors.mocked( - imagesInteractor: [.loadImage(country.flag)] - ) - let isDisplayed = Binding(wrappedValue: true) - let sut = ModalDetailsView(country: country, isDisplayed: isDisplayed) - let exp = sut.inspection.inspect { view in - XCTAssertNoThrow(try view.find(ImageView.self)) - XCTAssertNoThrow(try view.find(button: "Close")) - interactors.verify() - } - ViewHosting.host(view: sut.inject(AppState(), interactors)) - wait(for: [exp], timeout: 2) - } - - func test_modalDetails_close() { - let country = Country.mockedData[0] - let interactors = DIContainer.Interactors.mocked( - imagesInteractor: [.loadImage(country.flag)] - ) - let isDisplayed = Binding(wrappedValue: true) - let sut = ModalDetailsView(country: country, isDisplayed: isDisplayed) - let exp = sut.inspection.inspect { view in - XCTAssertTrue(isDisplayed.wrappedValue) - try view.find(button: "Close").tap() - XCTAssertFalse(isDisplayed.wrappedValue) - interactors.verify() - } - ViewHosting.host(view: sut.inject(AppState(), interactors)) - wait(for: [exp], timeout: 2) - } - - func test_modalDetails_close_localization() throws { - let isDisplayed = Binding(wrappedValue: true) - let sut = ModalDetailsView(country: Country.mockedData[0], isDisplayed: isDisplayed) - let labelText = try sut.inspect().find(text: "Close") - XCTAssertEqual(try labelText.string(), "Close") - XCTAssertEqual(try labelText.string(locale: Locale(identifier: "fr")), "Fermer") - } -} diff --git a/UnitTests/UI/ModalFlagViewTests.swift b/UnitTests/UI/ModalFlagViewTests.swift new file mode 100644 index 0000000..7172e12 --- /dev/null +++ b/UnitTests/UI/ModalFlagViewTests.swift @@ -0,0 +1,57 @@ +// +// ModalFlagViewTests.swift +// UnitTests +// +// Created by Alexey Naumov on 01.11.2019. +// Copyright © 2019 Alexey Naumov. All rights reserved. +// + +import Testing +import SwiftUI +import ViewInspector +@testable import CountriesSwiftUI + +@MainActor +@Suite struct ModalFlagViewTests { + + private let country: DBModel.Country = ApiModel.Country.mockedData[0].dbModel() + + @Test func modalDetails() async throws { + let container = DIContainer(interactors: .mocked( + images: [.loadImage(country.flag)] + )) + let isDisplayed = Binding(wrappedValue: true) + let sut = ModalFlagView(country: country, isDisplayed: isDisplayed) + try await ViewHosting.host(sut.inject(container)) { + try await sut.inspection.inspect { view in + #expect(throws: Never.self) { try view.find(ImageView.self) } + #expect(throws: Never.self) { try view.find(button: "Close") } + container.interactors.verify() + } + } + } + + @Test func modalDetailsClose() async throws { + let container = DIContainer(interactors: .mocked( + images: [.loadImage(country.flag)] + )) + let isDisplayed = Binding(wrappedValue: true) + let sut = ModalFlagView(country: country, isDisplayed: isDisplayed) + try await ViewHosting.host(sut.inject(container)) { + try await sut.inspection.inspect { view in + #expect(isDisplayed.wrappedValue) + try view.find(button: "Close").tap() + #expect(!isDisplayed.wrappedValue) + container.interactors.verify() + } + } + } + + @Test func modalDetailsCloseLocalization() throws { + let isDisplayed = Binding(wrappedValue: true) + let sut = ModalFlagView(country: country, isDisplayed: isDisplayed) + let labelText = try sut.inspect().find(text: "Close") + #expect(try labelText.string() == "Close") + #expect(try labelText.string(locale: Locale(identifier: "de")) == "Schließen") + } +} diff --git a/UnitTests/UI/RootViewAppearanceTests.swift b/UnitTests/UI/RootViewAppearanceTests.swift index 43df832..fa54394 100644 --- a/UnitTests/UI/RootViewAppearanceTests.swift +++ b/UnitTests/UI/RootViewAppearanceTests.swift @@ -6,43 +6,40 @@ // Copyright © 2020 Alexey Naumov. All rights reserved. // -import XCTest +import Testing import SwiftUI import ViewInspector @testable import CountriesSwiftUI -extension RootViewAppearance: Inspectable { } +@MainActor +@Suite struct RootViewAppearanceTests { -final class RootViewAppearanceTests: XCTestCase { - - func test_blur_whenInactive() { + @Test func blurWhenInactive() async throws { let sut = RootViewAppearance() - let container = DIContainer(appState: .init(AppState()), - interactors: .mocked()) - XCTAssertFalse(container.appState.value.system.isActive) - let exp = sut.inspection.inspect { modifier in - let content = try modifier.viewModifierContent() - XCTAssertEqual(try content.blur().radius, 10) - } + let container = DIContainer(interactors: .mocked()) + #expect(!container.appState.value.system.isActive) let view = EmptyView().modifier(sut) - .environment(\.injected, container) - ViewHosting.host(view: view) - wait(for: [exp], timeout: 0.1) + .inject(container) + try await ViewHosting.host(view) { + try await sut.inspection.inspect { modifier in + let content = try modifier.implicitAnyView().viewModifierContent() + #expect(try content.blur().radius == 10) + } + } } - func test_blur_whenActive() { + @Test func blurWhenActive() async throws { let sut = RootViewAppearance() - let container = DIContainer(appState: .init(AppState()), - interactors: .mocked()) + let container = DIContainer(interactors: .mocked()) container.appState[\.system.isActive] = true - XCTAssertTrue(container.appState.value.system.isActive) - let exp = sut.inspection.inspect { modifier in - let content = try modifier.viewModifierContent() - XCTAssertEqual(try content.blur().radius, 0) - } + #expect(container.appState.value.system.isActive) let view = EmptyView().modifier(sut) - .environment(\.injected, container) - ViewHosting.host(view: view) - wait(for: [exp], timeout: 0.1) + .inject(container) + try await ViewHosting.host(view) { + try await sut.inspection.inspect { modifier in + let content = try modifier.implicitAnyView().viewModifierContent() + #expect(try content.blur().radius == 0) + } + } } } diff --git a/UnitTests/UI/SearchBarTests.swift b/UnitTests/UI/SearchBarTests.swift deleted file mode 100644 index ce3628e..0000000 --- a/UnitTests/UI/SearchBarTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// SearchBarTests.swift -// UnitTests -// -// Created by Alexey Naumov on 15.01.2020. -// Copyright © 2020 Alexey Naumov. All rights reserved. -// - -import XCTest -import SwiftUI -import ViewInspector -@testable import CountriesSwiftUI - -extension SearchBar: Inspectable { } - -final class SearchBarTests: XCTestCase { - - func test_searchBarCoordinator_beginEditing() { - let text = Binding(wrappedValue: "abc") - let sut = SearchBar.Coordinator(text: text) - let searchBar = UISearchBar(frame: .zero) - searchBar.delegate = sut - XCTAssertTrue(sut.searchBarShouldBeginEditing(searchBar)) - XCTAssertTrue(searchBar.showsCancelButton) - XCTAssertEqual(text.wrappedValue, "abc") - } - - func test_searchBarCoordinator_endEditing() { - let text = Binding(wrappedValue: "abc") - let sut = SearchBar.Coordinator(text: text) - let searchBar = UISearchBar(frame: .zero) - searchBar.delegate = sut - XCTAssertTrue(sut.searchBarShouldEndEditing(searchBar)) - XCTAssertFalse(searchBar.showsCancelButton) - XCTAssertEqual(text.wrappedValue, "abc") - } - - func test_searchBarCoordinator_textDidChange() { - let text = Binding(wrappedValue: "abc") - let sut = SearchBar.Coordinator(text: text) - let searchBar = UISearchBar(frame: .zero) - searchBar.delegate = sut - sut.searchBar(searchBar, textDidChange: "test") - XCTAssertEqual(text.wrappedValue, "test") - } - - func test_searchBarCoordinator_cancelButtonClicked() { - let text = Binding(wrappedValue: "abc") - let sut = SearchBar.Coordinator(text: text) - let searchBar = UISearchBar(frame: .zero) - searchBar.text = text.wrappedValue - searchBar.delegate = sut - sut.searchBarCancelButtonClicked(searchBar) - XCTAssertEqual(searchBar.text, "") - XCTAssertEqual(text.wrappedValue, "") - } -} diff --git a/UnitTests/UI/ViewPreviewsTests.swift b/UnitTests/UI/ViewPreviewsTests.swift deleted file mode 100644 index 94c0342..0000000 --- a/UnitTests/UI/ViewPreviewsTests.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// ViewPreviewsTests.swift -// UnitTests -// -// Created by Alexey Naumov on 01.11.2019. -// Copyright © 2019 Alexey Naumov. All rights reserved. -// - -import XCTest -import ViewInspector -@testable import CountriesSwiftUI - -final class ViewPreviewsTests: XCTestCase { - - func test_contentView_previews() { - _ = ContentView_Previews.previews - } - - func test_countriesList_previews() { - _ = CountriesList_Previews.previews - } - - func test_countryDetails_previews() { - _ = CountryDetails_Previews.previews - } - - func test_modalDetailsView_previews() { - _ = ModalDetailsView_Previews.previews - } - - func test_countryCell_previews() { - _ = CountryCell_Previews.previews - } - - func test_detailRow_previews() { - _ = DetailRow_Previews.previews - } - - func test_errorView_previews() throws { - let view = ErrorView_Previews.previews - try view.inspect().view(ErrorView.self).actualView().retryAction() - } - - func test_imageView_previews() { - _ = ImageView_Previews.previews - } -} diff --git a/UnitTests/Utilities/HelpersTests.swift b/UnitTests/Utilities/HelpersTests.swift index 9553459..231ce1a 100644 --- a/UnitTests/Utilities/HelpersTests.swift +++ b/UnitTests/Utilities/HelpersTests.swift @@ -6,25 +6,31 @@ // Copyright © 2020 Alexey Naumov. All rights reserved. // -import XCTest +import Foundation +import Testing @testable import CountriesSwiftUI -class HelpersTests: XCTestCase { +@Suite struct HelpersTests { - func test_localized_knownLocale() { - let sut = "Countries".localized(Locale(identifier: "fr")) - XCTAssertEqual(sut, "Des pays") + @Test func localizedDefaultLocale() { + let sut = "Countries".localized(Locale.backendDefault) + #expect(sut == "Countries") } - - func test_localized_unknownLocale() { + + @Test func localizedKnownLocale() { + let sut = "Countries".localized(Locale(identifier: "de")) + #expect(sut == "Länder") + } + + @Test func localizedUnknownLocale() { let sut = "Countries".localized(Locale(identifier: "ch")) - XCTAssertEqual(sut, "Countries") + #expect(sut == "Countries") } - - func test_result_isSuccess() { + + @Test func resultIsSuccess() { let sut1 = Result.success(()) let sut2 = Result.failure(NSError.test) - XCTAssertTrue(sut1.isSuccess) - XCTAssertFalse(sut2.isSuccess) + #expect(sut1.isSuccess) + #expect(!sut2.isSuccess) } } diff --git a/UnitTests/Utilities/LazyListTests.swift b/UnitTests/Utilities/LazyListTests.swift deleted file mode 100644 index 16803de..0000000 --- a/UnitTests/Utilities/LazyListTests.swift +++ /dev/null @@ -1,117 +0,0 @@ -// -// LazyListTests.swift -// UnitTests -// -// Created by Alexey Naumov on 18.04.2020. -// Copyright © 2020 Alexey Naumov. All rights reserved. -// - -import XCTest -import Combine -@testable import CountriesSwiftUI - -final class LazyListTests: XCTestCase { - - func test_empty() { - let list = LazyList.empty - XCTAssertThrowsError(try list.element(at: 0)) - } - - func test_nil_element() { - let list1 = LazyList(count: 1, useCache: false, { _ in nil }) - XCTAssertThrowsError(try list1.element(at: 0)) - let list2 = [0, 1].lazyList - XCTAssertThrowsError(try list2.element(at: 2)) - } - - func test_nil_element_error() { - let error = LazyList.Error.elementIsNil(index: 5) - XCTAssertEqual(error.localizedDescription, "Element at index 5 is nil") - } - - func test_access_noCache() { - var counter = 0 - let list = LazyList(count: 3, useCache: false) { _ in - counter += 1 - return counter - } - [0, 1, 2, 0, 1, 2].forEach { index in - _ = list[index] - } - XCTAssertEqual(counter, 6) - } - - func test_access_withCache() { - var counter = 0 - let list = LazyList(count: 3, useCache: true) { _ in - counter += 1 - return counter - } - [0, 1, 2, 0, 1, 2].forEach { index in - _ = list[index] - } - XCTAssertEqual(counter, 3) - } - - let bgQueue1 = DispatchQueue(label: "bg1") - let bgQueue2 = DispatchQueue(label: "bg2") - - func test_concurrent_access() { - let indices = Array(stride(from: 0, to: 100, by: 1)) - var counter = 0 - let list = LazyList(count: indices.count, useCache: true) { index in - counter += 1 - return index - } - let exp1 = XCTestExpectation(description: "queue1") - let exp2 = XCTestExpectation(description: "queue2") - bgQueue1.async { - let result1 = indices.map { list[$0] } - XCTAssertEqual(result1, indices) - XCTAssertEqual(counter, indices.count) - exp1.fulfill() - } - bgQueue2.async { - let result2 = indices.map { list[$0] } - XCTAssertEqual(result2, indices) - XCTAssertEqual(counter, indices.count) - exp2.fulfill() - } - wait(for: [exp1, exp2], timeout: 0.5) - } - - func test_sequence() { - let indices = Array(stride(from: 0, to: 10, by: 1)) - let list = LazyList(count: indices.count, useCache: true) { $0 } - XCTAssertEqual(list.underestimatedCount, indices.count) - XCTAssertEqual(list.reversed(), indices.reversed()) - - let nilList = LazyList(count: 1, useCache: false) { _ in nil } - var iterator = nilList.makeIterator() - XCTAssertNil(iterator.next()) - } - - func test_randomAccessCollection() { - let list = LazyList(count: 10, useCache: true) { $0 } - XCTAssertEqual(list.firstIndex(of: 2), 2) - XCTAssertEqual(list.last, 9) - } - - func test_equatable() { - let list1 = LazyList(count: 10, useCache: true) { $0 } - let list2 = LazyList(count: 11, useCache: true) { $0 } - let list3 = Array(stride(from: 0, to: 10, by: 1)).lazyList - XCTAssertNotEqual(list1, list2) - XCTAssertEqual(list1, list1) - XCTAssertEqual(list1, list3) - } - - func test_description() { - let emptyList = LazyList.empty - let oneElementList = LazyList(count: 1, useCache: false) { $0 + 1 } - let nonEmptyList = LazyList(count: 3, useCache: false) { $0 * 2 } - XCTAssertEqual(emptyList.description, "LazyList<[]>") - XCTAssertEqual(oneElementList.description, "LazyList<[1]>") - XCTAssertEqual(nonEmptyList.description, "LazyList<[0, 2, 4]>") - } -} diff --git a/UnitTests/Utilities/LoadableTests.swift b/UnitTests/Utilities/LoadableTests.swift index fdb84f0..7b4f8a4 100644 --- a/UnitTests/Utilities/LoadableTests.swift +++ b/UnitTests/Utilities/LoadableTests.swift @@ -6,13 +6,16 @@ // Copyright © 2019 Alexey Naumov. All rights reserved. // -import XCTest +import Foundation +import Testing import Combine +import SwiftUI +import ViewInspector @testable import CountriesSwiftUI -final class LoadableTests: XCTestCase { +@Suite struct LoadableTests { - func test_equality() { + @Test func equality() { let possibleValues: [Loadable] = [ .notRequested, .isLoading(last: nil, cancelBag: CancelBag()), @@ -24,15 +27,15 @@ final class LoadableTests: XCTestCase { possibleValues.enumerated().forEach { (index1, value1) in possibleValues.enumerated().forEach { (index2, value2) in if index1 == index2 { - XCTAssertEqual(value1, value2) + #expect(value1 == value2) } else { - XCTAssertNotEqual(value1, value2) + #expect(value1 != value2) } } } } - - func test_cancelLoading() { + + @Test func cancelLoading() { let cancenBag1 = CancelBag(), cancenBag2 = CancelBag() let subject = PassthroughSubject() subject.sink { _ in } @@ -40,18 +43,18 @@ final class LoadableTests: XCTestCase { subject.sink { _ in } .store(in: cancenBag2) var sut1 = Loadable.isLoading(last: nil, cancelBag: cancenBag1) - XCTAssertEqual(cancenBag1.subscriptions.count, 1) + #expect(cancenBag1.subscriptions.count == 1) sut1.cancelLoading() - XCTAssertEqual(cancenBag1.subscriptions.count, 0) - XCTAssertNotNil(sut1.error) + #expect(cancenBag1.subscriptions.count == 0) + #expect(sut1.error != nil) var sut2 = Loadable.isLoading(last: 7, cancelBag: cancenBag2) - XCTAssertEqual(cancenBag2.subscriptions.count, 1) + #expect(cancenBag2.subscriptions.count == 1) sut2.cancelLoading() - XCTAssertEqual(cancenBag2.subscriptions.count, 0) - XCTAssertEqual(sut2.value, 7) + #expect(cancenBag2.subscriptions.count == 0) + #expect(sut2.value == 7) } - - func test_map() { + + @Test func map() { let values: [Loadable] = [ .notRequested, .isLoading(last: nil, cancelBag: CancelBag()), @@ -61,42 +64,84 @@ final class LoadableTests: XCTestCase { ] let expect: [Loadable] = [ .notRequested, - .isLoading(last: nil, cancelBag: CancelBag()), - .isLoading(last: "5", cancelBag: CancelBag()), + .isLoading(last: nil, cancelBag: .test), + .isLoading(last: "5", cancelBag: .test), .loaded("7"), .failed(NSError.test) ] let sut = values.map { value in value.map { "\($0)" } } - XCTAssertEqual(sut, expect) + #expect(sut == expect) + } + + @MainActor + @Test func loadSuccess() async { + let resource: () async throws -> String = { + try await Task.sleep(nanoseconds: 100_000_000) + return "test" + } + let exp = TestExpectation() + var values: [Loadable] = [] + let sut = Binding>.init(get: { + return values.last ?? .notRequested + }, set: { + values.append($0) + if $0.value != nil { + exp.fulfill() + } + }) + sut.load(resource) + await exp.fulfillment() + #expect(values == [.isLoading(last: nil, cancelBag: .test), .loaded("test")]) } - func test_helperFunctions() { + @MainActor + @Test func loadFailure() async { + let resource: () async throws -> String = { + try await Task.sleep(nanoseconds: 100_000_000) + throw NSError.test + } + let exp = TestExpectation() + var values: [Loadable] = [] + let sut = Binding>.init(get: { + return values.last ?? .notRequested + }, set: { + values.append($0) + if $0.error != nil { + exp.fulfill() + } + }) + sut.load(resource) + await exp.fulfillment() + #expect(values == [.isLoading(last: nil, cancelBag: .test), .failed(NSError.test)]) + } + + @Test func helperFunctions() { let notRequested = Loadable.notRequested let loadingNil = Loadable.isLoading(last: nil, cancelBag: CancelBag()) let loadingValue = Loadable.isLoading(last: 9, cancelBag: CancelBag()) let loaded = Loadable.loaded(5) let failedErrValue = Loadable.failed(NSError.test) [notRequested, loadingNil].forEach { - XCTAssertNil($0.value) + #expect($0.value == nil) } [loadingValue, loaded].forEach { - XCTAssertNotNil($0.value) + #expect($0.value != nil) } [notRequested, loadingNil, loadingValue, loaded].forEach { - XCTAssertNil($0.error) + #expect($0.error == nil) } - XCTAssertNotNil(failedErrValue.error) + #expect(failedErrValue.error != nil) } - - func test_throwingMap() { + + @Test func throwingMap() { let value = Loadable.loaded(5) let sut = value.map { _ in throw NSError.test } - XCTAssertNotNil(sut.error) + #expect(sut.error != nil) } - - func test_valueIsMissing() { - XCTAssertEqual(ValueIsMissingError().localizedDescription, "Data is missing") + + @Test func valueIsMissing() { + #expect(ValueIsMissingError().localizedDescription == "Data is missing") } }