From a820dde508095324f740892fabccdc9db6cf6706 Mon Sep 17 00:00:00 2001 From: Mathijs Bernson Date: Fri, 10 May 2024 17:41:23 +0200 Subject: [PATCH 1/7] Implement Clean Architecture An example implementation of the Clean Architecture, modeled after the one in the Android template repo. It consists of a 'home' view with back-end services that fetch a dummy 'user'. --- README.md | 87 +++++++- TemplateApp.xcodeproj/project.pbxproj | 187 +++++++++++++++++- .../xcshareddata/swiftpm/Package.resolved | 11 +- TemplateApp/Container.swift | 26 +++ TemplateApp/Data/User/UserEntity.swift | 12 ++ TemplateApp/Data/User/UserEntityMapper.swift | 14 ++ .../Data/User/UserLocalDataSource.swift | 14 ++ .../Data/User/UserRemoteDataSource.swift | 15 ++ .../Data/User/UserRepositoryImpl.swift | 21 ++ TemplateApp/Domain/TemplateAppError.swift | 16 ++ TemplateApp/Domain/User/GetUserUseCase.swift | 17 ++ TemplateApp/Domain/User/User.swift | 12 ++ TemplateApp/Domain/User/UserRepository.swift | 12 ++ TemplateApp/Extensions/TaskExtensions.swift | 14 ++ TemplateApp/Features/Home/HomeScreen.swift | 24 +++ TemplateApp/Features/Home/HomeView.swift | 66 +++++++ TemplateApp/Features/Home/HomeViewModel.swift | 26 +++ TemplateApp/Features/Home/HomeViewState.swift | 15 ++ TemplateApp/Localizable.xcstrings | 34 +++- TemplateApp/RootView.swift | 10 +- TemplateApp/UIComponents/AsyncButton.swift | 121 ++++++++++++ TemplateApp/ViewModifiers/ErrorAlert.swift | 58 ++++++ .../Behaviours/RefreshBehaviour.swift | 16 ++ TemplateAppUITests/ExampleUITests.swift | 21 +- TemplateAppUITests/ViewObjects/HomeView.swift | 21 ++ TemplateAppUITests/ViewObjects/RootView.swift | 15 -- 26 files changed, 837 insertions(+), 48 deletions(-) create mode 100644 TemplateApp/Container.swift create mode 100644 TemplateApp/Data/User/UserEntity.swift create mode 100644 TemplateApp/Data/User/UserEntityMapper.swift create mode 100644 TemplateApp/Data/User/UserLocalDataSource.swift create mode 100644 TemplateApp/Data/User/UserRemoteDataSource.swift create mode 100644 TemplateApp/Data/User/UserRepositoryImpl.swift create mode 100644 TemplateApp/Domain/TemplateAppError.swift create mode 100644 TemplateApp/Domain/User/GetUserUseCase.swift create mode 100644 TemplateApp/Domain/User/User.swift create mode 100644 TemplateApp/Domain/User/UserRepository.swift create mode 100644 TemplateApp/Extensions/TaskExtensions.swift create mode 100644 TemplateApp/Features/Home/HomeScreen.swift create mode 100644 TemplateApp/Features/Home/HomeView.swift create mode 100644 TemplateApp/Features/Home/HomeViewModel.swift create mode 100644 TemplateApp/Features/Home/HomeViewState.swift create mode 100644 TemplateApp/UIComponents/AsyncButton.swift create mode 100644 TemplateApp/ViewModifiers/ErrorAlert.swift create mode 100644 TemplateAppUITests/Behaviours/RefreshBehaviour.swift create mode 100644 TemplateAppUITests/ViewObjects/HomeView.swift delete mode 100644 TemplateAppUITests/ViewObjects/RootView.swift diff --git a/README.md b/README.md index f7b7758..baeab70 100644 --- a/README.md +++ b/README.md @@ -2,10 +2,13 @@ This is a template for creating iOS projects at Q42. It has opinionated defaults and boilerplate, based on how we do iOS projects at Q42. -## How to use it +## Using this template -1. In GitHub, press "use this template" to create a new repository. -2. Rename your project using the included Python script. +1. Click the green "Use this template" button in the Github repo to create your own repository with + this code template. +1. Clone the new repo locally on your machine. +1. Run `python ./scripts/rename-project.py` or `python3 ./scripts/rename-project.py` from the + project root, to change the project name. The script will ask for your new project name and update all references. ## Features @@ -24,6 +27,84 @@ The Xcode project is configured to use 4 spaces for indentation. For linting Swift source code, we use [SwiftLint](https://github.com/realm/SwiftLint). A configuration for [SwiftFormat](http://github.com/nicklockwood/SwiftFormat) is also included. +## App architecture + +### Core principles + +This app is built using SwiftUI and targets iOS 15 and higher. We use SwiftUI as much as possible, but fall back to UIKit views using view(controller) representables where needed. + +We try to stick to the Apple conventions and write idiomatic SwiftUI code. Do things the Apple way. Lean in to the platform instead of fighting it. + +Keep it simple. Less is more. + +### Architecture patterns + +We use the [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) pattern, combined with dependency injection. + +#### Clean Architecture layers + +- _UI_ with SwiftUI +- _Presentation_ with the ViewModel +- _Domain_ for domain models, UseCases and other domain logic. +- _Data_ for data storage and retrieval. + +#### Use cases + +- Use cases are single-purpose: GetUserUseCase, but also: GetUserWithArticlesUseCase. +- Use cases can call other use-cases. +- Use cases do not have state, state preferably lives in the data layer. + +#### Dependency injection + +Dependency injection (DI) means that objects don't instantiate the objects or configurations that they require themselves, but they are passed in from the outside. +This is useful, because it makes code easier to test and easier to change. It promotes good separation of concerns. + +We use [Factory](https://github.com/hmlongco/Factory) as a DI container. + +### Modules & libraries + +* Preferably use the Swift Package Manager for dependencies. Use other package managers only if there's no other option. +* Only extract code into a package if there are strong reasons to do so. For example: + * It is used from at least two different targets/packages, or is a candidate to be extracted to an open-source package. + * It is completely self-contained. + +When choosing a third-party library, prefer libraries that: + +* Are written in idiomatic Swift or Objective-C that sticks to best practices. +* Have as few dependencies of its own as possible. Preferably none. +* Aren't too big, in order to keep compile times and bloat in check. + +### Testing + +* For business logic, we write unit tests. +* For testing the user interface, we write UI tests in a behaviour-driven way using the [Salad](https://github.com/Q42/Salad) library. +* Tests are run on CI (GitHub Actions). Tests must pass before a PR may be merged and before any sort of build is created. + +### Views + +* Keep views small and focused. When a view becomes large, split it up into smaller views. +* Every view gets a UI preview if at all possible. The preview should show the view in different states using dummy data. +* We use [custom SF Symbols](https://developer.apple.com/documentation/uikit/uiimage/creating_custom_symbol_images_for_your_app/) whenever a custom icon is needed, so that they render in a consistent manner. + +### Accessibility + +* Every new component or control should be audited for basic accessibility support: + * Dynamic type size support + * VoiceOver support +* Also consider: + * Bold text support + * High contrast support +* Use `accessibilityRepresentation` on custom controls to make them accessible. + +### Localization + +String catalogs are used to localize the project. The default languages supported are English and Dutch. + +### Async code + +* Asynchronous code should be written using async/await whenever possible. +* [Combine](https://developer.apple.com/documentation/combine) should only be used when async/await or `AsyncSequence` fall short, and more complexity is needed to solve the problem at hand. + ## Continuous integration GitHub Actions is used for continuous integration (CI). The CI runs the automated tests when you make a pull request. diff --git a/TemplateApp.xcodeproj/project.pbxproj b/TemplateApp.xcodeproj/project.pbxproj index 8edbb94..0ce746a 100644 --- a/TemplateApp.xcodeproj/project.pbxproj +++ b/TemplateApp.xcodeproj/project.pbxproj @@ -13,11 +13,30 @@ D5284F372B57C6B700BB32E7 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D5284F362B57C6B700BB32E7 /* Preview Assets.xcassets */; }; D5284F412B57C6B700BB32E7 /* ExampleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5284F402B57C6B700BB32E7 /* ExampleTests.swift */; }; D5284F4B2B57C6B700BB32E7 /* ExampleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5284F4A2B57C6B700BB32E7 /* ExampleUITests.swift */; }; + D58BA4CE2BEE6501000D8420 /* Factory in Frameworks */ = {isa = PBXBuildFile; productRef = D58BA4CD2BEE6501000D8420 /* Factory */; }; + D58BA4D02BEE696D000D8420 /* Container.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4CF2BEE696D000D8420 /* Container.swift */; }; + D58BA4D42BEE700D000D8420 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4D32BEE700D000D8420 /* HomeView.swift */; }; + D58BA4D62BEE7012000D8420 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4D52BEE7012000D8420 /* HomeViewModel.swift */; }; + D58BA4D82BEE702A000D8420 /* HomeViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4D72BEE702A000D8420 /* HomeViewState.swift */; }; + D58BA4DB2BEE7095000D8420 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4DA2BEE7095000D8420 /* User.swift */; }; + D58BA4DD2BEE70A1000D8420 /* UserRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4DC2BEE70A1000D8420 /* UserRepository.swift */; }; + D58BA4DF2BEE70B8000D8420 /* GetUserUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4DE2BEE70B8000D8420 /* GetUserUseCase.swift */; }; + D58BA4E32BEE7101000D8420 /* UserLocalDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4E22BEE7101000D8420 /* UserLocalDataSource.swift */; }; + D58BA4E72BEE711B000D8420 /* UserRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4E62BEE711B000D8420 /* UserRepositoryImpl.swift */; }; + D58BA4EF2BEE7239000D8420 /* UserEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4EE2BEE7239000D8420 /* UserEntity.swift */; }; + D58BA4F12BEE725D000D8420 /* UserRemoteDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4F02BEE725D000D8420 /* UserRemoteDataSource.swift */; }; + D58BA4F42BEE7389000D8420 /* UserEntityMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4F32BEE7389000D8420 /* UserEntityMapper.swift */; }; + D58BA4F72BEE73A5000D8420 /* AsyncButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4F62BEE73A5000D8420 /* AsyncButton.swift */; }; + D58BA4FA2BEE73CB000D8420 /* ErrorAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4F92BEE73CB000D8420 /* ErrorAlert.swift */; }; + D58BA4FC2BEE73E4000D8420 /* TemplateAppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4FB2BEE73E4000D8420 /* TemplateAppError.swift */; }; + D58BA4FE2BEE74BF000D8420 /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4FD2BEE74BF000D8420 /* HomeScreen.swift */; }; + D58BA5012BEE75B2000D8420 /* TaskExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA5002BEE75B2000D8420 /* TaskExtensions.swift */; }; + D58BA5042BEE761D000D8420 /* RefreshBehaviour.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA5032BEE761D000D8420 /* RefreshBehaviour.swift */; }; D5F745E82BEE14870064F06A /* TemplateAppAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F745E72BEE14870064F06A /* TemplateAppAppDelegate.swift */; }; D5F745EA2BEE14DD0064F06A /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D5F745E92BEE14DD0064F06A /* Localizable.xcstrings */; }; D5F745EF2BEE15C20064F06A /* Launch Screen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = D5F745EE2BEE15C20064F06A /* Launch Screen.storyboard */; }; D5F745F52BEE48BC0064F06A /* Salad in Frameworks */ = {isa = PBXBuildFile; productRef = D5F745F42BEE48BC0064F06A /* Salad */; }; - D5F745F82BEE48F50064F06A /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F745F72BEE48F50064F06A /* RootView.swift */; }; + D5F745F82BEE48F50064F06A /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F745F72BEE48F50064F06A /* HomeView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -47,6 +66,24 @@ D5284F402B57C6B700BB32E7 /* ExampleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleTests.swift; sourceTree = ""; }; D5284F462B57C6B700BB32E7 /* TemplateAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TemplateAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D5284F4A2B57C6B700BB32E7 /* ExampleUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleUITests.swift; sourceTree = ""; }; + D58BA4CF2BEE696D000D8420 /* Container.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Container.swift; sourceTree = ""; }; + D58BA4D32BEE700D000D8420 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + D58BA4D52BEE7012000D8420 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; + D58BA4D72BEE702A000D8420 /* HomeViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewState.swift; sourceTree = ""; }; + D58BA4DA2BEE7095000D8420 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; + D58BA4DC2BEE70A1000D8420 /* UserRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRepository.swift; sourceTree = ""; }; + D58BA4DE2BEE70B8000D8420 /* GetUserUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetUserUseCase.swift; sourceTree = ""; }; + D58BA4E22BEE7101000D8420 /* UserLocalDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserLocalDataSource.swift; sourceTree = ""; }; + D58BA4E62BEE711B000D8420 /* UserRepositoryImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRepositoryImpl.swift; sourceTree = ""; }; + D58BA4EE2BEE7239000D8420 /* UserEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserEntity.swift; sourceTree = ""; }; + D58BA4F02BEE725D000D8420 /* UserRemoteDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRemoteDataSource.swift; sourceTree = ""; }; + D58BA4F32BEE7389000D8420 /* UserEntityMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserEntityMapper.swift; sourceTree = ""; }; + D58BA4F62BEE73A5000D8420 /* AsyncButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncButton.swift; sourceTree = ""; }; + D58BA4F92BEE73CB000D8420 /* ErrorAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorAlert.swift; sourceTree = ""; }; + D58BA4FB2BEE73E4000D8420 /* TemplateAppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateAppError.swift; sourceTree = ""; }; + D58BA4FD2BEE74BF000D8420 /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = ""; }; + D58BA5002BEE75B2000D8420 /* TaskExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskExtensions.swift; sourceTree = ""; }; + D58BA5032BEE761D000D8420 /* RefreshBehaviour.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshBehaviour.swift; sourceTree = ""; }; D5F745E72BEE14870064F06A /* TemplateAppAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateAppAppDelegate.swift; sourceTree = ""; }; D5F745E92BEE14DD0064F06A /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; D5F745EB2BEE15210064F06A /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; @@ -54,7 +91,7 @@ D5F745F02BEE15D70064F06A /* AllTests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = AllTests.xctestplan; sourceTree = ""; }; D5F745F12BEE16E60064F06A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; D5F745F22BEE16FB0064F06A /* TemplateApp.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TemplateApp.entitlements; sourceTree = ""; }; - D5F745F72BEE48F50064F06A /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; + D5F745F72BEE48F50064F06A /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -62,6 +99,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D58BA4CE2BEE6501000D8420 /* Factory in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -109,9 +147,16 @@ D5284F2E2B57C6B600BB32E7 /* TemplateApp */ = { isa = PBXGroup; children = ( + D58BA4CF2BEE696D000D8420 /* Container.swift */, D5284F2F2B57C6B600BB32E7 /* TemplateAppApp.swift */, D5F745E72BEE14870064F06A /* TemplateAppAppDelegate.swift */, D5284F312B57C6B600BB32E7 /* RootView.swift */, + D58BA4E12BEE70F1000D8420 /* Data */, + D58BA4D92BEE7084000D8420 /* Domain */, + D58BA4D12BEE7004000D8420 /* Features */, + D58BA4F52BEE739C000D8420 /* UIComponents */, + D58BA4F82BEE73C4000D8420 /* ViewModifiers */, + D58BA4FF2BEE75AD000D8420 /* Extensions */, D5F745E92BEE14DD0064F06A /* Localizable.xcstrings */, D5284F332B57C6B700BB32E7 /* Assets.xcassets */, D5F745ED2BEE15B80064F06A /* Supporting Files */, @@ -140,11 +185,102 @@ isa = PBXGroup; children = ( D5284F4A2B57C6B700BB32E7 /* ExampleUITests.swift */, + D58BA5022BEE7616000D8420 /* Behaviours */, D5F745F62BEE48EF0064F06A /* ViewObjects */, ); path = TemplateAppUITests; sourceTree = ""; }; + D58BA4D12BEE7004000D8420 /* Features */ = { + isa = PBXGroup; + children = ( + D58BA4D22BEE7008000D8420 /* Home */, + ); + path = Features; + sourceTree = ""; + }; + D58BA4D22BEE7008000D8420 /* Home */ = { + isa = PBXGroup; + children = ( + D58BA4FD2BEE74BF000D8420 /* HomeScreen.swift */, + D58BA4D32BEE700D000D8420 /* HomeView.swift */, + D58BA4D52BEE7012000D8420 /* HomeViewModel.swift */, + D58BA4D72BEE702A000D8420 /* HomeViewState.swift */, + ); + path = Home; + sourceTree = ""; + }; + D58BA4D92BEE7084000D8420 /* Domain */ = { + isa = PBXGroup; + children = ( + D58BA4E02BEE70EB000D8420 /* User */, + D58BA4FB2BEE73E4000D8420 /* TemplateAppError.swift */, + ); + path = Domain; + sourceTree = ""; + }; + D58BA4E02BEE70EB000D8420 /* User */ = { + isa = PBXGroup; + children = ( + D58BA4DA2BEE7095000D8420 /* User.swift */, + D58BA4DC2BEE70A1000D8420 /* UserRepository.swift */, + D58BA4DE2BEE70B8000D8420 /* GetUserUseCase.swift */, + ); + path = User; + sourceTree = ""; + }; + D58BA4E12BEE70F1000D8420 /* Data */ = { + isa = PBXGroup; + children = ( + D58BA4E82BEE711F000D8420 /* User */, + ); + path = Data; + sourceTree = ""; + }; + D58BA4E82BEE711F000D8420 /* User */ = { + isa = PBXGroup; + children = ( + D58BA4EE2BEE7239000D8420 /* UserEntity.swift */, + D58BA4E62BEE711B000D8420 /* UserRepositoryImpl.swift */, + D58BA4F32BEE7389000D8420 /* UserEntityMapper.swift */, + D58BA4F02BEE725D000D8420 /* UserRemoteDataSource.swift */, + D58BA4E22BEE7101000D8420 /* UserLocalDataSource.swift */, + ); + path = User; + sourceTree = ""; + }; + D58BA4F52BEE739C000D8420 /* UIComponents */ = { + isa = PBXGroup; + children = ( + D58BA4F62BEE73A5000D8420 /* AsyncButton.swift */, + ); + path = UIComponents; + sourceTree = ""; + }; + D58BA4F82BEE73C4000D8420 /* ViewModifiers */ = { + isa = PBXGroup; + children = ( + D58BA4F92BEE73CB000D8420 /* ErrorAlert.swift */, + ); + path = ViewModifiers; + sourceTree = ""; + }; + D58BA4FF2BEE75AD000D8420 /* Extensions */ = { + isa = PBXGroup; + children = ( + D58BA5002BEE75B2000D8420 /* TaskExtensions.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + D58BA5022BEE7616000D8420 /* Behaviours */ = { + isa = PBXGroup; + children = ( + D58BA5032BEE761D000D8420 /* RefreshBehaviour.swift */, + ); + path = Behaviours; + sourceTree = ""; + }; D5F745ED2BEE15B80064F06A /* Supporting Files */ = { isa = PBXGroup; children = ( @@ -159,7 +295,7 @@ D5F745F62BEE48EF0064F06A /* ViewObjects */ = { isa = PBXGroup; children = ( - D5F745F72BEE48F50064F06A /* RootView.swift */, + D5F745F72BEE48F50064F06A /* HomeView.swift */, ); path = ViewObjects; sourceTree = ""; @@ -180,6 +316,9 @@ dependencies = ( ); name = TemplateApp; + packageProductDependencies = ( + D58BA4CD2BEE6501000D8420 /* Factory */, + ); productName = TemplateApp; productReference = D5284F2C2B57C6B600BB32E7 /* TemplateApp.app */; productType = "com.apple.product-type.application"; @@ -231,7 +370,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1510; - LastUpgradeCheck = 1510; + LastUpgradeCheck = 1530; ORGANIZATIONNAME = Q42; TargetAttributes = { D5284F2B2B57C6B600BB32E7 = { @@ -259,6 +398,7 @@ mainGroup = D5284F232B57C6B600BB32E7; packageReferences = ( D5F745F32BEE48BC0064F06A /* XCRemoteSwiftPackageReference "Salad" */, + D58BA4CC2BEE6501000D8420 /* XCRemoteSwiftPackageReference "Factory" */, ); productRefGroup = D5284F2D2B57C6B600BB32E7 /* Products */; projectDirPath = ""; @@ -304,9 +444,26 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D58BA4E72BEE711B000D8420 /* UserRepositoryImpl.swift in Sources */, D5F745E82BEE14870064F06A /* TemplateAppAppDelegate.swift in Sources */, + D58BA4DF2BEE70B8000D8420 /* GetUserUseCase.swift in Sources */, + D58BA4FA2BEE73CB000D8420 /* ErrorAlert.swift in Sources */, + D58BA4E32BEE7101000D8420 /* UserLocalDataSource.swift in Sources */, + D58BA4D62BEE7012000D8420 /* HomeViewModel.swift in Sources */, + D58BA5012BEE75B2000D8420 /* TaskExtensions.swift in Sources */, + D58BA4DD2BEE70A1000D8420 /* UserRepository.swift in Sources */, + D58BA4F72BEE73A5000D8420 /* AsyncButton.swift in Sources */, + D58BA4EF2BEE7239000D8420 /* UserEntity.swift in Sources */, + D58BA4FE2BEE74BF000D8420 /* HomeScreen.swift in Sources */, + D58BA4D02BEE696D000D8420 /* Container.swift in Sources */, + D58BA4D82BEE702A000D8420 /* HomeViewState.swift in Sources */, + D58BA4DB2BEE7095000D8420 /* User.swift in Sources */, + D58BA4F42BEE7389000D8420 /* UserEntityMapper.swift in Sources */, D5284F322B57C6B600BB32E7 /* RootView.swift in Sources */, + D58BA4FC2BEE73E4000D8420 /* TemplateAppError.swift in Sources */, + D58BA4D42BEE700D000D8420 /* HomeView.swift in Sources */, D5284F302B57C6B600BB32E7 /* TemplateAppApp.swift in Sources */, + D58BA4F12BEE725D000D8420 /* UserRemoteDataSource.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -322,8 +479,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D5F745F82BEE48F50064F06A /* RootView.swift in Sources */, + D5F745F82BEE48F50064F06A /* HomeView.swift in Sources */, D5284F4B2B57C6B700BB32E7 /* ExampleUITests.swift in Sources */, + D58BA5042BEE761D000D8420 /* RefreshBehaviour.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -348,6 +506,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -395,7 +554,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -411,6 +570,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -452,7 +612,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 17.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; @@ -640,6 +800,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + D58BA4CC2BEE6501000D8420 /* XCRemoteSwiftPackageReference "Factory" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/hmlongco/Factory.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.3.2; + }; + }; D5F745F32BEE48BC0064F06A /* XCRemoteSwiftPackageReference "Salad" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Q42/Salad.git"; @@ -651,6 +819,11 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + D58BA4CD2BEE6501000D8420 /* Factory */ = { + isa = XCSwiftPackageProductDependency; + package = D58BA4CC2BEE6501000D8420 /* XCRemoteSwiftPackageReference "Factory" */; + productName = Factory; + }; D5F745F42BEE48BC0064F06A /* Salad */ = { isa = XCSwiftPackageProductDependency; package = D5F745F32BEE48BC0064F06A /* XCRemoteSwiftPackageReference "Salad" */; diff --git a/TemplateApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TemplateApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e3436c6..bbc553b 100644 --- a/TemplateApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TemplateApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "929d2090e52375231c5ebc33be1479d4266be85bbf192a8f578aa5ddb5af112c", + "originHash" : "df61673358a3dd22a0a9edeab8644d322d2a83e7c60f21b1fb5968be13760d2c", "pins" : [ + { + "identity" : "factory", + "kind" : "remoteSourceControl", + "location" : "https://github.com/hmlongco/Factory.git", + "state" : { + "revision" : "587995f7d5cc667951d635fbf6b4252324ba0439", + "version" : "2.3.2" + } + }, { "identity" : "salad", "kind" : "remoteSourceControl", diff --git a/TemplateApp/Container.swift b/TemplateApp/Container.swift new file mode 100644 index 0000000..bf4fbbc --- /dev/null +++ b/TemplateApp/Container.swift @@ -0,0 +1,26 @@ +// +// Container.swift +// TemplateApp +// +// Copyright © 2024 Q42. All rights reserved. +// + +import Foundation +import Factory + +extension Container { + + // MARK: User + + var userRepository: Factory { + Factory(self) { UserRepositoryImpl() } + } + + var userRemoteDataSource: Factory { + Factory(self) { UserRemoteDataSource() } + } + + var userLocalDataSource: Factory { + Factory(self) { UserLocalDataSource() } + } +} diff --git a/TemplateApp/Data/User/UserEntity.swift b/TemplateApp/Data/User/UserEntity.swift new file mode 100644 index 0000000..eb3cf79 --- /dev/null +++ b/TemplateApp/Data/User/UserEntity.swift @@ -0,0 +1,12 @@ +// +// UserEntity.swift +// TemplateApp +// +// Copyright © 2024 Q42. All rights reserved. +// + +import Foundation + +struct UserEntity { + let email: String +} diff --git a/TemplateApp/Data/User/UserEntityMapper.swift b/TemplateApp/Data/User/UserEntityMapper.swift new file mode 100644 index 0000000..e8df6ee --- /dev/null +++ b/TemplateApp/Data/User/UserEntityMapper.swift @@ -0,0 +1,14 @@ +// +// UserEntityMapper.swift +// TemplateApp +// +// Copyright © 2024 Q42. All rights reserved. +// + +import Foundation + +extension User { + init(userEntity: UserEntity) { + self.email = userEntity.email + } +} diff --git a/TemplateApp/Data/User/UserLocalDataSource.swift b/TemplateApp/Data/User/UserLocalDataSource.swift new file mode 100644 index 0000000..9bd4adf --- /dev/null +++ b/TemplateApp/Data/User/UserLocalDataSource.swift @@ -0,0 +1,14 @@ +// +// UserLocalDataSource.swift +// TemplateApp +// +// Copyright © 2024 Q42. All rights reserved. +// + +import Foundation + +class UserLocalDataSource { + func setUser(userEntity: UserEntity) { + // store in DB or UserDefaults here: use UserDefaults for simple key-values, for more complex objects, use a database like CoreData or SwiftData + } +} diff --git a/TemplateApp/Data/User/UserRemoteDataSource.swift b/TemplateApp/Data/User/UserRemoteDataSource.swift new file mode 100644 index 0000000..22b5080 --- /dev/null +++ b/TemplateApp/Data/User/UserRemoteDataSource.swift @@ -0,0 +1,15 @@ +// +// UserRemoteDataSource.swift +// TemplateApp +// +// Copyright © 2024 Q42. All rights reserved. +// + +import Foundation + +class UserRemoteDataSource { + func getUser() async throws -> UserEntity { + try await Task.sleep(seconds: 1.0) + return UserEntity(email: "example@q42.com") + } +} diff --git a/TemplateApp/Data/User/UserRepositoryImpl.swift b/TemplateApp/Data/User/UserRepositoryImpl.swift new file mode 100644 index 0000000..901cd9f --- /dev/null +++ b/TemplateApp/Data/User/UserRepositoryImpl.swift @@ -0,0 +1,21 @@ +// +// UserRepositoryImpl.swift +// TemplateApp +// +// Copyright © 2024 Q42. All rights reserved. +// + +import Foundation +import Factory + +class UserRepositoryImpl: UserRepository { + @Injected(\.userLocalDataSource) var userLocalDataSource + @Injected(\.userRemoteDataSource) var userRemoteDataSource + + func getUser() async throws -> User { + let userEntity = try await userRemoteDataSource.getUser() + userLocalDataSource.setUser(userEntity: userEntity) + let user = User(userEntity: userEntity) + return user + } +} diff --git a/TemplateApp/Domain/TemplateAppError.swift b/TemplateApp/Domain/TemplateAppError.swift new file mode 100644 index 0000000..faf44e5 --- /dev/null +++ b/TemplateApp/Domain/TemplateAppError.swift @@ -0,0 +1,16 @@ +// +// TemplateAppError.swift +// TemplateApp +// +// Copyright © 2024 Q42. All rights reserved. +// + +import Foundation + +struct TemplateAppError: Error { + let message: String +} + +extension TemplateAppError: LocalizedError { + var errorDescription: String? { message } +} diff --git a/TemplateApp/Domain/User/GetUserUseCase.swift b/TemplateApp/Domain/User/GetUserUseCase.swift new file mode 100644 index 0000000..1a9b66e --- /dev/null +++ b/TemplateApp/Domain/User/GetUserUseCase.swift @@ -0,0 +1,17 @@ +// +// GetUserUseCase.swift +// TemplateApp +// +// Copyright © 2024 Q42. All rights reserved. +// + +import Foundation +import Factory + +class GetUserUseCase { + @Injected(\.userRepository) var userRepository: UserRepository + + func invoke() async throws -> User { + try await userRepository.getUser() + } +} diff --git a/TemplateApp/Domain/User/User.swift b/TemplateApp/Domain/User/User.swift new file mode 100644 index 0000000..47aa0d5 --- /dev/null +++ b/TemplateApp/Domain/User/User.swift @@ -0,0 +1,12 @@ +// +// User.swift +// TemplateApp +// +// Copyright © 2024 Q42. All rights reserved. +// + +import Foundation + +struct User { + let email: String +} diff --git a/TemplateApp/Domain/User/UserRepository.swift b/TemplateApp/Domain/User/UserRepository.swift new file mode 100644 index 0000000..188ae0e --- /dev/null +++ b/TemplateApp/Domain/User/UserRepository.swift @@ -0,0 +1,12 @@ +// +// UserRepository.swift +// TemplateApp +// +// Copyright © 2024 Q42. All rights reserved. +// + +import Foundation + +protocol UserRepository { + func getUser() async throws -> User +} diff --git a/TemplateApp/Extensions/TaskExtensions.swift b/TemplateApp/Extensions/TaskExtensions.swift new file mode 100644 index 0000000..390bae2 --- /dev/null +++ b/TemplateApp/Extensions/TaskExtensions.swift @@ -0,0 +1,14 @@ +// +// TaskExtensions.swift +// TemplateApp +// +// Copyright © 2024 Q42. All rights reserved. +// + +import Foundation + +extension Task where Failure == Never, Success == Never { + static func sleep(seconds: Double) async throws { + try await sleep(nanoseconds: UInt64(seconds * Double(NSEC_PER_SEC))) + } +} diff --git a/TemplateApp/Features/Home/HomeScreen.swift b/TemplateApp/Features/Home/HomeScreen.swift new file mode 100644 index 0000000..2dd6af3 --- /dev/null +++ b/TemplateApp/Features/Home/HomeScreen.swift @@ -0,0 +1,24 @@ +// +// HomeScreen.swift +// TemplateApp +// +// Copyright © 2024 Q42. All rights reserved. +// + +import SwiftUI + +struct HomeScreen: View { + @StateObject var viewModel = HomeViewModel() + + var body: some View { + HomeView( + viewState: viewModel.uiState, + refresh: viewModel.refresh + ) + .navigationTitle("Home") + } +} + +#Preview { + HomeScreen() +} diff --git a/TemplateApp/Features/Home/HomeView.swift b/TemplateApp/Features/Home/HomeView.swift new file mode 100644 index 0000000..840fdc8 --- /dev/null +++ b/TemplateApp/Features/Home/HomeView.swift @@ -0,0 +1,66 @@ +// +// HomeView.swift +// TemplateApp +// +// Copyright © 2024 Q42. All rights reserved. +// + +import SwiftUI + +struct HomeView: View { + let viewState: HomeViewState + let refresh: () async -> Void + + var body: some View { + VStack { + switch viewState { + case .data(let userEmailTitle): + if let userEmailTitle { + Text(userEmailTitle) + .accessibilityIdentifier("userEmailLabel") + } + case .error(let error): + Text(error.localizedDescription) + .foregroundStyle(.red) + default: + EmptyView() + } + + AsyncButton("Refresh") { + await refresh() + } + .accessibilityIdentifier("refreshButton") + } + .padding() + .accessibilityElement(children: .contain) + .accessibilityIdentifier("HomeView") + } +} + +#Preview("Data") { + HomeView( + viewState: .data(userEmailTitle: "hello@q42.nl"), + refresh: {} + ) +} + +#Preview("Loading") { + HomeView( + viewState: .loading, + refresh: {} + ) +} + +#Preview("Empty") { + HomeView( + viewState: .empty, + refresh: {} + ) +} + +#Preview("Error") { + HomeView( + viewState: .error(TemplateAppError(message: "Example error message")), + refresh: {} + ) +} diff --git a/TemplateApp/Features/Home/HomeViewModel.swift b/TemplateApp/Features/Home/HomeViewModel.swift new file mode 100644 index 0000000..68c9e40 --- /dev/null +++ b/TemplateApp/Features/Home/HomeViewModel.swift @@ -0,0 +1,26 @@ +// +// HomeViewModel.swift +// TemplateApp +// +// Copyright © 2024 Q42. All rights reserved. +// + +import Foundation + +@MainActor class HomeViewModel: ObservableObject { + @Published var uiState: HomeViewState = .empty + + func refresh() async { + do { + try await loadUser() + } catch { + uiState = .error(error) + } + } + + private func loadUser() async throws { + let getUser = GetUserUseCase() + let user = try await getUser.invoke() + uiState = .data(userEmailTitle: user.email) + } +} diff --git a/TemplateApp/Features/Home/HomeViewState.swift b/TemplateApp/Features/Home/HomeViewState.swift new file mode 100644 index 0000000..17cf10f --- /dev/null +++ b/TemplateApp/Features/Home/HomeViewState.swift @@ -0,0 +1,15 @@ +// +// HomeViewState.swift +// TemplateApp +// +// Copyright © 2024 Q42. All rights reserved. +// + +import Foundation + +enum HomeViewState { + case data(userEmailTitle: String?) + case loading + case error(Error) + case empty +} diff --git a/TemplateApp/Localizable.xcstrings b/TemplateApp/Localizable.xcstrings index e023044..acf3a3a 100644 --- a/TemplateApp/Localizable.xcstrings +++ b/TemplateApp/Localizable.xcstrings @@ -1,12 +1,42 @@ { "sourceLanguage" : "en", "strings" : { - "Hello, world!" : { + "An error occurred" : { "localizations" : { "nl" : { "stringUnit" : { "state" : "translated", - "value" : "Hallo, wereld!" + "value" : "Er is een fout opgetreden" + } + } + } + }, + "Home" : { + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Home" + } + } + } + }, + "Ok" : { + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oké" + } + } + } + }, + "Refresh" : { + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Vernieuwen" } } } diff --git a/TemplateApp/RootView.swift b/TemplateApp/RootView.swift index e05e76c..e62fa78 100644 --- a/TemplateApp/RootView.swift +++ b/TemplateApp/RootView.swift @@ -9,15 +9,9 @@ import SwiftUI struct RootView: View { var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") + NavigationView { + HomeScreen() } - .padding() - .accessibilityElement(children: .contain) - .accessibilityIdentifier("RootView") } } diff --git a/TemplateApp/UIComponents/AsyncButton.swift b/TemplateApp/UIComponents/AsyncButton.swift new file mode 100644 index 0000000..f2f0955 --- /dev/null +++ b/TemplateApp/UIComponents/AsyncButton.swift @@ -0,0 +1,121 @@ +// +// AsyncButton.swift +// TemplateApp +// +// Copyright © 2024 Q42. All rights reserved. +// + +import SwiftUI + +// Thanks to Swift by Sundell: https://www.swiftbysundell.com/articles/building-an-async-swiftui-button/ + +struct AsyncButton: View { + var role: ButtonRole? + var action: () async -> Void + var actionOptions = Set(ActionOption.allCases) + @ViewBuilder var label: () -> Label + + @State private var isDisabled = false + @State private var showProgressView = false + + var body: some View { + Button( + role: role, + action: { + if actionOptions.contains(.disableButton) { + isDisabled = true + } + + Task { + var progressViewTask: Task? + + if actionOptions.contains(.showProgressView) { + progressViewTask = Task { + try await Task.sleep(nanoseconds: 150_000_000) + showProgressView = true + } + } + + await action() + progressViewTask?.cancel() + + isDisabled = false + showProgressView = false + } + }, + label: { + ZStack { + label().opacity(showProgressView ? 0 : 1) + + if showProgressView { + ProgressView() + } + } + } + ) + .disabled(isDisabled) + } +} + +extension AsyncButton { + enum ActionOption: CaseIterable { + case disableButton + case showProgressView + } +} + +extension AsyncButton where Label == Text { + init(_ label: LocalizedStringKey, + role: ButtonRole? = nil, + actionOptions: Set = Set(ActionOption.allCases), + action: @escaping () async -> Void + ) { + self.init(role: role, action: action) { + Text(label) + } + } + + init(verbatim label: String, + role: ButtonRole? = nil, + actionOptions: Set = Set(ActionOption.allCases), + action: @escaping () async -> Void + ) { + self.init(role: role, action: action) { + Text(label) + } + } +} + +extension AsyncButton where Label == Image { + init(imageName: String, + role: ButtonRole? = nil, + actionOptions: Set = Set(ActionOption.allCases), + action: @escaping () async -> Void + ) { + self.init(role: role, action: action) { + Image(imageName) + } + } + + init(systemImageName: String, + role: ButtonRole? = nil, + actionOptions: Set = Set(ActionOption.allCases), + action: @escaping () async -> Void + ) { + self.init(role: role, action: action) { + Image(systemName: systemImageName) + } + } +} + +#Preview("Slow action") { + AsyncButton(verbatim: "Async Button") { + try? await Task.sleep(seconds: 3) + } +} + +#Preview("Fast action") { + AsyncButton(verbatim: "Async Button") { + try? await Task.sleep(seconds: 0.1) + } +} diff --git a/TemplateApp/ViewModifiers/ErrorAlert.swift b/TemplateApp/ViewModifiers/ErrorAlert.swift new file mode 100644 index 0000000..33333ff --- /dev/null +++ b/TemplateApp/ViewModifiers/ErrorAlert.swift @@ -0,0 +1,58 @@ +// +// ErrorAlert.swift +// TemplateApp +// +// Copyright © 2024 Q42. All rights reserved. +// + +import Foundation +import SwiftUI + +extension View { + /// Presents an alert when an error is present, with custom actions. + func alert( + _ title: LocalizedStringKey, + error: Binding, + @ViewBuilder actions: @escaping () -> Actions + ) -> some View { + modifier(ErrorAlert(error: error, title: title, actions: actions)) + } + + /// Presents an alert when an error is present. + func alert( + _ title: LocalizedStringKey, + error: Binding, + buttonTitleKey: LocalizedStringKey = "Ok" + ) -> some View { + modifier(ErrorAlert(error: error, title: title, actions: { + Button(buttonTitleKey) { + error.wrappedValue = nil + } + })) + } +} + +/// A modifier that presents an alert when an error is present. +private struct ErrorAlert: ViewModifier { + @Binding var error: E? + let title: LocalizedStringKey + @ViewBuilder let actions: () -> ActionButton + + var isPresented: Bool { + error != nil + } + + func body(content: Content) -> some View { + content.alert(title, isPresented: .constant(isPresented), actions: actions) { + Text(error?.localizedDescription ?? "") + } + } +} + +#Preview("Error alert") { + let error = TemplateAppError(message: "Failed to load all the things") + return Text(verbatim: "Preview text") + .alert("An error occurred", error: .constant(error)) { + Button("Ok") {} + } +} diff --git a/TemplateAppUITests/Behaviours/RefreshBehaviour.swift b/TemplateAppUITests/Behaviours/RefreshBehaviour.swift new file mode 100644 index 0000000..f9f2454 --- /dev/null +++ b/TemplateAppUITests/Behaviours/RefreshBehaviour.swift @@ -0,0 +1,16 @@ +// +// RefreshBehavior.swift +// TemplateAppUITests +// +// Copyright © 2024 Q42. All rights reserved. +// + +import Foundation +import Salad + +struct RefreshHomeView: Behavior { + func perform(from view: HomeView) -> HomeView { + view.refreshButton.tap() + return view + } +} diff --git a/TemplateAppUITests/ExampleUITests.swift b/TemplateAppUITests/ExampleUITests.swift index 09c1540..93750c1 100644 --- a/TemplateAppUITests/ExampleUITests.swift +++ b/TemplateAppUITests/ExampleUITests.swift @@ -1,13 +1,6 @@ -// -// TemplateAppUITests.swift -// TemplateAppUITests -// -// Created by Mathijs Bernson on 17/01/2024. -// - // // ExampleUITests.swift -// TemplateAppTests +// TemplateAppUITests // // Copyright © 2024 Q42. All rights reserved. // @@ -16,7 +9,7 @@ import XCTest import Salad final class ExampleUITests: XCTestCase { - var scenario: Scenario! + var scenario: Scenario! override func setUp() { continueAfterFailure = false @@ -28,9 +21,13 @@ final class ExampleUITests: XCTestCase { func testExample() { scenario - .then { rootView in - XCTAssertTrue(rootView.identifyingElement.staticTexts["Hello, world!"].waitForExist(timeout: .asyncUI), - "Expected to see 'Hello, world!' label") + .then { homeView in + XCTAssertFalse(homeView.userEmailLabel.exists) + } + .when(RefreshHomeView()) + .then { homeView in + XCTAssertTrue(homeView.userEmailLabel.waitForExist(timeout: .fastNetworkCall)) + XCTAssertEqual(homeView.userEmailLabel.label, "example@q42.com") } } } diff --git a/TemplateAppUITests/ViewObjects/HomeView.swift b/TemplateAppUITests/ViewObjects/HomeView.swift new file mode 100644 index 0000000..6822f84 --- /dev/null +++ b/TemplateAppUITests/ViewObjects/HomeView.swift @@ -0,0 +1,21 @@ +// +// HomeView.swift +// TemplateAppUITests +// +// Copyright © 2024 Q42. All rights reserved. +// + +import XCTest +import Salad + +struct HomeView: ViewObject { + let root: XCUIElement + let identifyingElementId: String = "HomeView" + + var userEmailLabel: XCUIElement { + identifyingElement.staticTexts["userEmailLabel"] + } + var refreshButton: XCUIElement { + identifyingElement.buttons["refreshButton"] + } +} diff --git a/TemplateAppUITests/ViewObjects/RootView.swift b/TemplateAppUITests/ViewObjects/RootView.swift deleted file mode 100644 index 8f775d0..0000000 --- a/TemplateAppUITests/ViewObjects/RootView.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// RootView.swift -// TemplateAppUITests -// -// Created by Mathijs Bernson on 10/05/2024. -// Copyright © 2024 Q42. All rights reserved. -// - -import XCTest -import Salad - -struct RootView: ViewObject { - let root: XCUIElement - let identifyingElementId: String = "RootView" -} From e5e89ca0c4b38355b4760559ec3258908fa12d9f Mon Sep 17 00:00:00 2001 From: Mathijs Bernson Date: Mon, 13 May 2024 11:22:34 +0200 Subject: [PATCH 2/7] Split testing up into build and run steps --- .github/workflows/test.yml | 20 ++++++++++++++----- .../xcshareddata/swiftpm/Package.resolved | 2 +- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fce0e58..efd17e2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,13 +38,23 @@ jobs: -resolvePackageDependencies \ -clonedSourcePackagesDirPath "${{ runner.temp }}/SourcePackages" - - name: Run tests - env: - PLATFORM: ${{ 'iOS Simulator' }} + - name: Determine test device run: | + echo "PLATFORM=iOS Simulator" >> "$GITHUB_ENV" # xcrun xctrace returns via stderr, not the expected stdout (see https://developer.apple.com/forums/thread/663959) - DEVICE=`xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}' | sed -e "s/ Simulator$//"` - xcodebuild test -project "${{ env.XCODE_PROJECT }}" \ + echo "DEVICE=$(xcrun xctrace list devices 2>&1 | grep -oE 'iPhone.*?[^\(]+' | head -1 | awk '{$1=$1;print}' | sed -e "s/ Simulator$//")" >> "$GITHUB_ENV" + + - name: Build for testing + run: | + xcodebuild build-for-testing -project "${{ env.XCODE_PROJECT }}" \ + -scheme "${{ env.SCHEME }}" \ + -destination "platform=$PLATFORM,name=$DEVICE" \ + -clonedSourcePackagesDirPath "${{ runner.temp }}/SourcePackages" \ + -disableAutomaticPackageResolution + + - name: Run tests + run: | + xcodebuild test-without-building -project "${{ env.XCODE_PROJECT }}" \ -scheme "${{ env.SCHEME }}" \ -testPlan "${{ env.TEST_PLAN }}" \ -destination "platform=$PLATFORM,name=$DEVICE" \ diff --git a/TemplateApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TemplateApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index bbc553b..1ea8608 100644 --- a/TemplateApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TemplateApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "df61673358a3dd22a0a9edeab8644d322d2a83e7c60f21b1fb5968be13760d2c", + "originHash" : "7f5d67830751eed43216e3b2c4a63415b54e9f0b67f3d3f4d7fb12143114915a", "pins" : [ { "identity" : "factory", From df680ed1324a062025894918afc5ff35b94be2ea Mon Sep 17 00:00:00 2001 From: Mathijs Bernson Date: Mon, 13 May 2024 11:58:44 +0200 Subject: [PATCH 3/7] Fix rename script not renaming files --- scripts/rename-project.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/scripts/rename-project.py b/scripts/rename-project.py index 011fd89..607776e 100644 --- a/scripts/rename-project.py +++ b/scripts/rename-project.py @@ -12,22 +12,28 @@ print("Enter new project name:") newProjectName = input() -# ========= Rename folders: +# ========= Rename folders and files: print( "\nRenaming '%s' to '%s' in folder names.\n" % (oldProjectName, newProjectName) ) +def rename(root, fileName): + oldFileName = os.path.join(root, fileName) + newFileName = os.path.join(root, fileName.replace(oldProjectName, newProjectName)) + if dryRun: + print("Would rename folder: %s to %s" % (oldFileName, newFileName)) + else: + print("Renaming folder: %s to %s" % (oldFileName, newFileName)) + os.rename(oldFileName, newFileName) + for root, dirs, files in os.walk(folder, topdown=False): for subDir in dirs: if oldProjectName in subDir: - oldFolderName = os.path.join(root, subDir) - newFolderName = os.path.join(root, subDir.replace(oldProjectName, newProjectName)) - if dryRun: - print("Would rename folder: %s to %s" % (oldFolderName, newFolderName)) - else: - print("Renaming folder: %s to %s" % (oldFolderName, newFolderName)) - os.rename(oldFolderName, newFolderName) + rename(root, subDir) + for subFile in files: + if oldProjectName in subFile: + rename(root, subFile) # ========= Rename usages in source files: ========= @@ -35,8 +41,7 @@ "\nReplacing all occurrences of %s in source files with: '%s'.\n" % (oldProjectName, newProjectName) ) -def replace_package_name_occurences_in_file(filename): - print("Would update file: " + filename) +def replace_project_name_occurences_in_file(filename): with open(filename, "r") as file: filedata = file.read() @@ -57,7 +62,7 @@ def replace_package_name_occurences_in_file(filename): extension = name.split(".")[-1] if extension in allowed_extensions: file_name = os.path.join(root, name) - replace_package_name_occurences_in_file(file_name) + replace_project_name_occurences_in_file(file_name) print( "\nDone renaming project to: '%s'.\n" % (newProjectName) From 77b901b7c0061011538d6a0d606f42ee97e9c0e0 Mon Sep 17 00:00:00 2001 From: Mathijs Bernson Date: Mon, 13 May 2024 12:11:58 +0200 Subject: [PATCH 4/7] Add comments to UI test --- TemplateAppUITests/ExampleUITests.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/TemplateAppUITests/ExampleUITests.swift b/TemplateAppUITests/ExampleUITests.swift index 93750c1..0c791d6 100644 --- a/TemplateAppUITests/ExampleUITests.swift +++ b/TemplateAppUITests/ExampleUITests.swift @@ -15,11 +15,16 @@ final class ExampleUITests: XCTestCase { continueAfterFailure = false let app = XCUIApplication() + // Custom launch arguments can be set here + // app.launchArguments = ["testing"] app.launch() + // A scenario provides the starting point of your tests ('GIVEN '). In each test case, + // you chain behaviors ('WHEN I do ') and assertions ('THEN I expect ') using the scenario. scenario = Scenario(given: app) } func testExample() { + // Example of a behavior-driven test scenario .then { homeView in XCTAssertFalse(homeView.userEmailLabel.exists) From 888c3f5c7360ac70f8da20fb5a50ec54d87dfcfc Mon Sep 17 00:00:00 2001 From: Mathijs Bernson Date: Wed, 17 Jul 2024 16:11:20 +0200 Subject: [PATCH 5/7] Implement PR feedback regarding Clean Architecture --- README.md | 15 ++--- TemplateApp.xcodeproj/project.pbxproj | 52 +++++++-------- TemplateApp/Container.swift | 4 +- ...ityMapper.swift => UserEntity+Model.swift} | 4 +- ...ositoryImpl.swift => UserRepository.swift} | 8 +-- TemplateApp/Domain/User/GetUserUseCase.swift | 4 +- .../User/{User.swift => UserModel.swift} | 4 +- TemplateApp/Domain/User/UserRepository.swift | 12 ---- .../Domain/User/UserRepositoryProtocol.swift | 12 ++++ ...Extensions.swift => Task+Extensions.swift} | 2 +- TemplateApp/Features/Home/HomeScreen.swift | 62 ++++++++++++++++- TemplateApp/Features/Home/HomeView.swift | 66 ------------------- TemplateApp/Features/Home/HomeViewModel.swift | 1 + 13 files changed, 117 insertions(+), 129 deletions(-) rename TemplateApp/Data/User/{UserEntityMapper.swift => UserEntity+Model.swift} (78%) rename TemplateApp/Data/User/{UserRepositoryImpl.swift => UserRepository.swift} (69%) rename TemplateApp/Domain/User/{User.swift => UserModel.swift} (75%) delete mode 100644 TemplateApp/Domain/User/UserRepository.swift create mode 100644 TemplateApp/Domain/User/UserRepositoryProtocol.swift rename TemplateApp/Extensions/{TaskExtensions.swift => Task+Extensions.swift} (91%) delete mode 100644 TemplateApp/Features/Home/HomeView.swift diff --git a/README.md b/README.md index baeab70..1c38861 100644 --- a/README.md +++ b/README.md @@ -43,16 +43,15 @@ We use the [Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13 #### Clean Architecture layers -- _UI_ with SwiftUI -- _Presentation_ with the ViewModel -- _Domain_ for domain models, UseCases and other domain logic. -- _Data_ for data storage and retrieval. +- *Features* with SwiftUI views and view models +- *Domain* for domain models, UseCases and other domain logic. +- *Data* for persistent data storage and retrieval using Repositories. #### Use cases - Use cases are single-purpose: GetUserUseCase, but also: GetUserWithArticlesUseCase. - Use cases can call other use-cases. -- Use cases do not have state, state preferably lives in the data layer. +- Use cases do not have persistent state. They are instantiated, called to perform their function once or multiple times, and are then discarded. #### Dependency injection @@ -82,7 +81,7 @@ When choosing a third-party library, prefer libraries that: ### Views -* Keep views small and focused. When a view becomes large, split it up into smaller views. +* Keep views focused (single-responsibility principle from SOLID). When a view becomes large a, split it up into smaller views. * Every view gets a UI preview if at all possible. The preview should show the view in different states using dummy data. * We use [custom SF Symbols](https://developer.apple.com/documentation/uikit/uiimage/creating_custom_symbol_images_for_your_app/) whenever a custom icon is needed, so that they render in a consistent manner. @@ -102,8 +101,8 @@ String catalogs are used to localize the project. The default languages supporte ### Async code -* Asynchronous code should be written using async/await whenever possible. -* [Combine](https://developer.apple.com/documentation/combine) should only be used when async/await or `AsyncSequence` fall short, and more complexity is needed to solve the problem at hand. +* `async`/`await` is preferred over Combine/Promises/etc. to leverage the compiler concurrency checking. +* [Combine](https://developer.apple.com/documentation/combine) can be used when `async`/`await` or `AsyncSequence` fall short, and more complexity is needed to solve the problem at hand. ## Continuous integration diff --git a/TemplateApp.xcodeproj/project.pbxproj b/TemplateApp.xcodeproj/project.pbxproj index 0ce746a..679a2c6 100644 --- a/TemplateApp.xcodeproj/project.pbxproj +++ b/TemplateApp.xcodeproj/project.pbxproj @@ -15,22 +15,21 @@ D5284F4B2B57C6B700BB32E7 /* ExampleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5284F4A2B57C6B700BB32E7 /* ExampleUITests.swift */; }; D58BA4CE2BEE6501000D8420 /* Factory in Frameworks */ = {isa = PBXBuildFile; productRef = D58BA4CD2BEE6501000D8420 /* Factory */; }; D58BA4D02BEE696D000D8420 /* Container.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4CF2BEE696D000D8420 /* Container.swift */; }; - D58BA4D42BEE700D000D8420 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4D32BEE700D000D8420 /* HomeView.swift */; }; + D58BA4D42BEE700D000D8420 /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4D32BEE700D000D8420 /* HomeScreen.swift */; }; D58BA4D62BEE7012000D8420 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4D52BEE7012000D8420 /* HomeViewModel.swift */; }; D58BA4D82BEE702A000D8420 /* HomeViewState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4D72BEE702A000D8420 /* HomeViewState.swift */; }; - D58BA4DB2BEE7095000D8420 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4DA2BEE7095000D8420 /* User.swift */; }; - D58BA4DD2BEE70A1000D8420 /* UserRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4DC2BEE70A1000D8420 /* UserRepository.swift */; }; + D58BA4DB2BEE7095000D8420 /* UserModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4DA2BEE7095000D8420 /* UserModel.swift */; }; + D58BA4DD2BEE70A1000D8420 /* UserRepositoryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4DC2BEE70A1000D8420 /* UserRepositoryProtocol.swift */; }; D58BA4DF2BEE70B8000D8420 /* GetUserUseCase.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4DE2BEE70B8000D8420 /* GetUserUseCase.swift */; }; D58BA4E32BEE7101000D8420 /* UserLocalDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4E22BEE7101000D8420 /* UserLocalDataSource.swift */; }; - D58BA4E72BEE711B000D8420 /* UserRepositoryImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4E62BEE711B000D8420 /* UserRepositoryImpl.swift */; }; + D58BA4E72BEE711B000D8420 /* UserRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4E62BEE711B000D8420 /* UserRepository.swift */; }; D58BA4EF2BEE7239000D8420 /* UserEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4EE2BEE7239000D8420 /* UserEntity.swift */; }; D58BA4F12BEE725D000D8420 /* UserRemoteDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4F02BEE725D000D8420 /* UserRemoteDataSource.swift */; }; - D58BA4F42BEE7389000D8420 /* UserEntityMapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4F32BEE7389000D8420 /* UserEntityMapper.swift */; }; + D58BA4F42BEE7389000D8420 /* UserEntity+Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4F32BEE7389000D8420 /* UserEntity+Model.swift */; }; D58BA4F72BEE73A5000D8420 /* AsyncButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4F62BEE73A5000D8420 /* AsyncButton.swift */; }; D58BA4FA2BEE73CB000D8420 /* ErrorAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4F92BEE73CB000D8420 /* ErrorAlert.swift */; }; D58BA4FC2BEE73E4000D8420 /* TemplateAppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4FB2BEE73E4000D8420 /* TemplateAppError.swift */; }; - D58BA4FE2BEE74BF000D8420 /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA4FD2BEE74BF000D8420 /* HomeScreen.swift */; }; - D58BA5012BEE75B2000D8420 /* TaskExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA5002BEE75B2000D8420 /* TaskExtensions.swift */; }; + D58BA5012BEE75B2000D8420 /* Task+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA5002BEE75B2000D8420 /* Task+Extensions.swift */; }; D58BA5042BEE761D000D8420 /* RefreshBehaviour.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58BA5032BEE761D000D8420 /* RefreshBehaviour.swift */; }; D5F745E82BEE14870064F06A /* TemplateAppAppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F745E72BEE14870064F06A /* TemplateAppAppDelegate.swift */; }; D5F745EA2BEE14DD0064F06A /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D5F745E92BEE14DD0064F06A /* Localizable.xcstrings */; }; @@ -67,22 +66,21 @@ D5284F462B57C6B700BB32E7 /* TemplateAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TemplateAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D5284F4A2B57C6B700BB32E7 /* ExampleUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleUITests.swift; sourceTree = ""; }; D58BA4CF2BEE696D000D8420 /* Container.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Container.swift; sourceTree = ""; }; - D58BA4D32BEE700D000D8420 /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + D58BA4D32BEE700D000D8420 /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = ""; }; D58BA4D52BEE7012000D8420 /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; D58BA4D72BEE702A000D8420 /* HomeViewState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewState.swift; sourceTree = ""; }; - D58BA4DA2BEE7095000D8420 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; - D58BA4DC2BEE70A1000D8420 /* UserRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRepository.swift; sourceTree = ""; }; + D58BA4DA2BEE7095000D8420 /* UserModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserModel.swift; sourceTree = ""; }; + D58BA4DC2BEE70A1000D8420 /* UserRepositoryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRepositoryProtocol.swift; sourceTree = ""; }; D58BA4DE2BEE70B8000D8420 /* GetUserUseCase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetUserUseCase.swift; sourceTree = ""; }; D58BA4E22BEE7101000D8420 /* UserLocalDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserLocalDataSource.swift; sourceTree = ""; }; - D58BA4E62BEE711B000D8420 /* UserRepositoryImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRepositoryImpl.swift; sourceTree = ""; }; + D58BA4E62BEE711B000D8420 /* UserRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRepository.swift; sourceTree = ""; }; D58BA4EE2BEE7239000D8420 /* UserEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserEntity.swift; sourceTree = ""; }; D58BA4F02BEE725D000D8420 /* UserRemoteDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRemoteDataSource.swift; sourceTree = ""; }; - D58BA4F32BEE7389000D8420 /* UserEntityMapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserEntityMapper.swift; sourceTree = ""; }; + D58BA4F32BEE7389000D8420 /* UserEntity+Model.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserEntity+Model.swift"; sourceTree = ""; }; D58BA4F62BEE73A5000D8420 /* AsyncButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncButton.swift; sourceTree = ""; }; D58BA4F92BEE73CB000D8420 /* ErrorAlert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorAlert.swift; sourceTree = ""; }; D58BA4FB2BEE73E4000D8420 /* TemplateAppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateAppError.swift; sourceTree = ""; }; - D58BA4FD2BEE74BF000D8420 /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = ""; }; - D58BA5002BEE75B2000D8420 /* TaskExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskExtensions.swift; sourceTree = ""; }; + D58BA5002BEE75B2000D8420 /* Task+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Task+Extensions.swift"; sourceTree = ""; }; D58BA5032BEE761D000D8420 /* RefreshBehaviour.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshBehaviour.swift; sourceTree = ""; }; D5F745E72BEE14870064F06A /* TemplateAppAppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateAppAppDelegate.swift; sourceTree = ""; }; D5F745E92BEE14DD0064F06A /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; @@ -202,8 +200,7 @@ D58BA4D22BEE7008000D8420 /* Home */ = { isa = PBXGroup; children = ( - D58BA4FD2BEE74BF000D8420 /* HomeScreen.swift */, - D58BA4D32BEE700D000D8420 /* HomeView.swift */, + D58BA4D32BEE700D000D8420 /* HomeScreen.swift */, D58BA4D52BEE7012000D8420 /* HomeViewModel.swift */, D58BA4D72BEE702A000D8420 /* HomeViewState.swift */, ); @@ -222,8 +219,8 @@ D58BA4E02BEE70EB000D8420 /* User */ = { isa = PBXGroup; children = ( - D58BA4DA2BEE7095000D8420 /* User.swift */, - D58BA4DC2BEE70A1000D8420 /* UserRepository.swift */, + D58BA4DA2BEE7095000D8420 /* UserModel.swift */, + D58BA4DC2BEE70A1000D8420 /* UserRepositoryProtocol.swift */, D58BA4DE2BEE70B8000D8420 /* GetUserUseCase.swift */, ); path = User; @@ -241,8 +238,8 @@ isa = PBXGroup; children = ( D58BA4EE2BEE7239000D8420 /* UserEntity.swift */, - D58BA4E62BEE711B000D8420 /* UserRepositoryImpl.swift */, - D58BA4F32BEE7389000D8420 /* UserEntityMapper.swift */, + D58BA4E62BEE711B000D8420 /* UserRepository.swift */, + D58BA4F32BEE7389000D8420 /* UserEntity+Model.swift */, D58BA4F02BEE725D000D8420 /* UserRemoteDataSource.swift */, D58BA4E22BEE7101000D8420 /* UserLocalDataSource.swift */, ); @@ -268,7 +265,7 @@ D58BA4FF2BEE75AD000D8420 /* Extensions */ = { isa = PBXGroup; children = ( - D58BA5002BEE75B2000D8420 /* TaskExtensions.swift */, + D58BA5002BEE75B2000D8420 /* Task+Extensions.swift */, ); path = Extensions; sourceTree = ""; @@ -444,24 +441,23 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - D58BA4E72BEE711B000D8420 /* UserRepositoryImpl.swift in Sources */, + D58BA4E72BEE711B000D8420 /* UserRepository.swift in Sources */, D5F745E82BEE14870064F06A /* TemplateAppAppDelegate.swift in Sources */, D58BA4DF2BEE70B8000D8420 /* GetUserUseCase.swift in Sources */, D58BA4FA2BEE73CB000D8420 /* ErrorAlert.swift in Sources */, D58BA4E32BEE7101000D8420 /* UserLocalDataSource.swift in Sources */, D58BA4D62BEE7012000D8420 /* HomeViewModel.swift in Sources */, - D58BA5012BEE75B2000D8420 /* TaskExtensions.swift in Sources */, - D58BA4DD2BEE70A1000D8420 /* UserRepository.swift in Sources */, + D58BA5012BEE75B2000D8420 /* Task+Extensions.swift in Sources */, + D58BA4DD2BEE70A1000D8420 /* UserRepositoryProtocol.swift in Sources */, D58BA4F72BEE73A5000D8420 /* AsyncButton.swift in Sources */, D58BA4EF2BEE7239000D8420 /* UserEntity.swift in Sources */, - D58BA4FE2BEE74BF000D8420 /* HomeScreen.swift in Sources */, D58BA4D02BEE696D000D8420 /* Container.swift in Sources */, D58BA4D82BEE702A000D8420 /* HomeViewState.swift in Sources */, - D58BA4DB2BEE7095000D8420 /* User.swift in Sources */, - D58BA4F42BEE7389000D8420 /* UserEntityMapper.swift in Sources */, + D58BA4DB2BEE7095000D8420 /* UserModel.swift in Sources */, + D58BA4F42BEE7389000D8420 /* UserEntity+Model.swift in Sources */, D5284F322B57C6B600BB32E7 /* RootView.swift in Sources */, D58BA4FC2BEE73E4000D8420 /* TemplateAppError.swift in Sources */, - D58BA4D42BEE700D000D8420 /* HomeView.swift in Sources */, + D58BA4D42BEE700D000D8420 /* HomeScreen.swift in Sources */, D5284F302B57C6B600BB32E7 /* TemplateAppApp.swift in Sources */, D58BA4F12BEE725D000D8420 /* UserRemoteDataSource.swift in Sources */, ); diff --git a/TemplateApp/Container.swift b/TemplateApp/Container.swift index bf4fbbc..c086999 100644 --- a/TemplateApp/Container.swift +++ b/TemplateApp/Container.swift @@ -12,8 +12,8 @@ extension Container { // MARK: User - var userRepository: Factory { - Factory(self) { UserRepositoryImpl() } + var userRepository: Factory { + Factory(self) { UserRepository() } } var userRemoteDataSource: Factory { diff --git a/TemplateApp/Data/User/UserEntityMapper.swift b/TemplateApp/Data/User/UserEntity+Model.swift similarity index 78% rename from TemplateApp/Data/User/UserEntityMapper.swift rename to TemplateApp/Data/User/UserEntity+Model.swift index e8df6ee..ee1f4ff 100644 --- a/TemplateApp/Data/User/UserEntityMapper.swift +++ b/TemplateApp/Data/User/UserEntity+Model.swift @@ -1,5 +1,5 @@ // -// UserEntityMapper.swift +// UserEntity+Model.swift // TemplateApp // // Copyright © 2024 Q42. All rights reserved. @@ -7,7 +7,7 @@ import Foundation -extension User { +extension UserModel { init(userEntity: UserEntity) { self.email = userEntity.email } diff --git a/TemplateApp/Data/User/UserRepositoryImpl.swift b/TemplateApp/Data/User/UserRepository.swift similarity index 69% rename from TemplateApp/Data/User/UserRepositoryImpl.swift rename to TemplateApp/Data/User/UserRepository.swift index 901cd9f..1d021fb 100644 --- a/TemplateApp/Data/User/UserRepositoryImpl.swift +++ b/TemplateApp/Data/User/UserRepository.swift @@ -1,5 +1,5 @@ // -// UserRepositoryImpl.swift +// UserRepository.swift // TemplateApp // // Copyright © 2024 Q42. All rights reserved. @@ -8,14 +8,14 @@ import Foundation import Factory -class UserRepositoryImpl: UserRepository { +class UserRepository: UserRepositoryProtocol { @Injected(\.userLocalDataSource) var userLocalDataSource @Injected(\.userRemoteDataSource) var userRemoteDataSource - func getUser() async throws -> User { + func getUser() async throws -> UserModel { let userEntity = try await userRemoteDataSource.getUser() userLocalDataSource.setUser(userEntity: userEntity) - let user = User(userEntity: userEntity) + let user = UserModel(userEntity: userEntity) return user } } diff --git a/TemplateApp/Domain/User/GetUserUseCase.swift b/TemplateApp/Domain/User/GetUserUseCase.swift index 1a9b66e..1af9650 100644 --- a/TemplateApp/Domain/User/GetUserUseCase.swift +++ b/TemplateApp/Domain/User/GetUserUseCase.swift @@ -9,9 +9,9 @@ import Foundation import Factory class GetUserUseCase { - @Injected(\.userRepository) var userRepository: UserRepository + @Injected(\.userRepository) var userRepository: UserRepositoryProtocol - func invoke() async throws -> User { + func invoke() async throws -> UserModel { try await userRepository.getUser() } } diff --git a/TemplateApp/Domain/User/User.swift b/TemplateApp/Domain/User/UserModel.swift similarity index 75% rename from TemplateApp/Domain/User/User.swift rename to TemplateApp/Domain/User/UserModel.swift index 47aa0d5..5edd9b2 100644 --- a/TemplateApp/Domain/User/User.swift +++ b/TemplateApp/Domain/User/UserModel.swift @@ -1,5 +1,5 @@ // -// User.swift +// UserModel.swift // TemplateApp // // Copyright © 2024 Q42. All rights reserved. @@ -7,6 +7,6 @@ import Foundation -struct User { +struct UserModel { let email: String } diff --git a/TemplateApp/Domain/User/UserRepository.swift b/TemplateApp/Domain/User/UserRepository.swift deleted file mode 100644 index 188ae0e..0000000 --- a/TemplateApp/Domain/User/UserRepository.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// UserRepository.swift -// TemplateApp -// -// Copyright © 2024 Q42. All rights reserved. -// - -import Foundation - -protocol UserRepository { - func getUser() async throws -> User -} diff --git a/TemplateApp/Domain/User/UserRepositoryProtocol.swift b/TemplateApp/Domain/User/UserRepositoryProtocol.swift new file mode 100644 index 0000000..aa70d1e --- /dev/null +++ b/TemplateApp/Domain/User/UserRepositoryProtocol.swift @@ -0,0 +1,12 @@ +// +// UserRepositoryProtocol.swift +// TemplateApp +// +// Copyright © 2024 Q42. All rights reserved. +// + +import Foundation + +protocol UserRepositoryProtocol { + func getUser() async throws -> UserModel +} diff --git a/TemplateApp/Extensions/TaskExtensions.swift b/TemplateApp/Extensions/Task+Extensions.swift similarity index 91% rename from TemplateApp/Extensions/TaskExtensions.swift rename to TemplateApp/Extensions/Task+Extensions.swift index 390bae2..be14f44 100644 --- a/TemplateApp/Extensions/TaskExtensions.swift +++ b/TemplateApp/Extensions/Task+Extensions.swift @@ -1,5 +1,5 @@ // -// TaskExtensions.swift +// Task+Extensions.swift // TemplateApp // // Copyright © 2024 Q42. All rights reserved. diff --git a/TemplateApp/Features/Home/HomeScreen.swift b/TemplateApp/Features/Home/HomeScreen.swift index 2dd6af3..ac4400b 100644 --- a/TemplateApp/Features/Home/HomeScreen.swift +++ b/TemplateApp/Features/Home/HomeScreen.swift @@ -19,6 +19,64 @@ struct HomeScreen: View { } } -#Preview { - HomeScreen() +private struct HomeView: View { + let viewState: HomeViewState + let refresh: () async -> Void + + var body: some View { + VStack { + switch viewState { + case .data(let userEmailTitle): + if let userEmailTitle { + Text(userEmailTitle) + .accessibilityIdentifier("userEmailLabel") + } + case .error(let error): + Text(error.localizedDescription) + .foregroundStyle(.red) + case .loading: + ProgressView() + case .empty: + EmptyView() + } + + Button("Refresh") { + Task { + await refresh() + } + } + .accessibilityIdentifier("refreshButton") + } + .padding() + .accessibilityElement(children: .contain) + .accessibilityIdentifier("HomeView") + } +} + +#Preview("Data") { + HomeView( + viewState: .data(userEmailTitle: "hello@q42.nl"), + refresh: {} + ) +} + +#Preview("Loading") { + HomeView( + viewState: .loading, + refresh: {} + ) +} + +#Preview("Empty") { + HomeView( + viewState: .empty, + refresh: {} + ) +} + +#Preview("Error") { + HomeView( + viewState: .error(TemplateAppError(message: "Example error message")), + refresh: {} + ) } diff --git a/TemplateApp/Features/Home/HomeView.swift b/TemplateApp/Features/Home/HomeView.swift deleted file mode 100644 index 840fdc8..0000000 --- a/TemplateApp/Features/Home/HomeView.swift +++ /dev/null @@ -1,66 +0,0 @@ -// -// HomeView.swift -// TemplateApp -// -// Copyright © 2024 Q42. All rights reserved. -// - -import SwiftUI - -struct HomeView: View { - let viewState: HomeViewState - let refresh: () async -> Void - - var body: some View { - VStack { - switch viewState { - case .data(let userEmailTitle): - if let userEmailTitle { - Text(userEmailTitle) - .accessibilityIdentifier("userEmailLabel") - } - case .error(let error): - Text(error.localizedDescription) - .foregroundStyle(.red) - default: - EmptyView() - } - - AsyncButton("Refresh") { - await refresh() - } - .accessibilityIdentifier("refreshButton") - } - .padding() - .accessibilityElement(children: .contain) - .accessibilityIdentifier("HomeView") - } -} - -#Preview("Data") { - HomeView( - viewState: .data(userEmailTitle: "hello@q42.nl"), - refresh: {} - ) -} - -#Preview("Loading") { - HomeView( - viewState: .loading, - refresh: {} - ) -} - -#Preview("Empty") { - HomeView( - viewState: .empty, - refresh: {} - ) -} - -#Preview("Error") { - HomeView( - viewState: .error(TemplateAppError(message: "Example error message")), - refresh: {} - ) -} diff --git a/TemplateApp/Features/Home/HomeViewModel.swift b/TemplateApp/Features/Home/HomeViewModel.swift index 68c9e40..090ca49 100644 --- a/TemplateApp/Features/Home/HomeViewModel.swift +++ b/TemplateApp/Features/Home/HomeViewModel.swift @@ -12,6 +12,7 @@ import Foundation func refresh() async { do { + uiState = .loading try await loadUser() } catch { uiState = .error(error) From be766a9d76193adc66b800c77d3d78d77e4b6902 Mon Sep 17 00:00:00 2001 From: Mathijs Bernson Date: Thu, 18 Jul 2024 12:35:41 +0200 Subject: [PATCH 6/7] Remove Xcode version selection --- .github/workflows/build.yml | 3 --- .github/workflows/test.yml | 3 --- 2 files changed, 6 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9865183..83313f0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,9 +27,6 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Select Xcode version - run: sudo xcode-select --switch /Applications/Xcode_15.3.app - - name: Cache Swift Package Manager dependencies uses: actions/cache@v4 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 45deac6..12ee207 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,9 +19,6 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Select Xcode version - run: sudo xcode-select --switch /Applications/Xcode_15.3.app - - name: Cache Swift Package Manager dependencies uses: actions/cache@v4 with: From 6e0ed204d3eb73be113952f5abe94bc12a38cf7b Mon Sep 17 00:00:00 2001 From: Mathijs Bernson Date: Mon, 2 Sep 2024 16:41:29 +0200 Subject: [PATCH 7/7] Select latest Xcode version --- .github/workflows/build.yml | 4 ++++ .github/workflows/test.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 83313f0..d086976 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,6 +27,10 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - name: Cache Swift Package Manager dependencies uses: actions/cache@v4 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 12ee207..7d2465a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,6 +19,10 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + - name: Cache Swift Package Manager dependencies uses: actions/cache@v4 with: