├── .github └── CONTRIBUTING.md ├── .gitignore ├── Gemfile ├── Gemfile.lock ├── ModularTemplate.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ └── contents.xcworkspacedata └── xcshareddata │ └── xcschemes │ └── ModularTemplate.xcscheme ├── ModularTemplate.xctestplan ├── ModularTemplate ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── Contents.json ├── ModularTemplateApp.swift └── Preview Content │ └── Preview Assets.xcassets │ └── Contents.json ├── ModularTemplateTests └── ModularTemplateTests.swift ├── Packages ├── CoreLayer │ ├── APIModels │ │ ├── .gitignore │ │ ├── Package.swift │ │ ├── Sources │ │ │ └── APIModels │ │ │ │ └── APIModel.swift │ │ └── Tests │ │ │ └── APIModelsTests │ │ │ └── APIModelTests.swift │ ├── DesignSystem │ │ ├── .gitignore │ │ ├── .swiftpm │ │ │ └── xcode │ │ │ │ └── xcshareddata │ │ │ │ └── xcschemes │ │ │ │ └── DesignSystem.xcscheme │ │ ├── Package.swift │ │ ├── Sources │ │ │ └── DesignSystem │ │ │ │ └── DesignSystem.swift │ │ └── Tests │ │ │ └── DesignSystemTests │ │ │ └── DesignSystemTests.swift │ ├── Logger │ │ ├── .gitignore │ │ ├── .swiftpm │ │ │ └── xcode │ │ │ │ └── xcshareddata │ │ │ │ └── xcschemes │ │ │ │ └── Logger.xcscheme │ │ ├── Package.swift │ │ ├── Sources │ │ │ └── Logger │ │ │ │ └── Logger.swift │ │ └── Tests │ │ │ └── LoggerTests │ │ │ └── LoggerTests.swift │ ├── Networking │ │ ├── .gitignore │ │ ├── Package.swift │ │ ├── Sources │ │ │ └── Networking │ │ │ │ └── Networking.swift │ │ └── Tests │ │ │ └── NetworkingTests │ │ │ └── NetworkingTests.swift │ ├── Testing │ │ ├── .gitignore │ │ ├── Package.swift │ │ └── Sources │ │ │ └── Testing │ │ │ └── Testing.swift │ └── Utilities │ │ ├── .gitignore │ │ ├── Package.swift │ │ ├── Sources │ │ └── Utilities │ │ │ └── Utilities.swift │ │ └── Tests │ │ └── UtilitiesTests │ │ └── UtilitiesTests.swift ├── DomainLayer │ └── Domain │ │ ├── .gitignore │ │ ├── Domain.xctestplan │ │ ├── Package.swift │ │ ├── Sources │ │ └── Domain │ │ │ └── Domain.swift │ │ └── Tests │ │ └── DomainTests │ │ └── DomainTests.swift └── PresentationLayer │ └── Presentation │ ├── .gitignore │ ├── Package.swift │ ├── Sources │ └── Presentation │ │ ├── MainView+ViewModel.swift │ │ └── MainView.swift │ └── Tests │ └── PresentationTests │ └── MainView+ViewModelTests.swift ├── Readme.md ├── fastlane ├── .gitignore ├── Appfile ├── Fastfile └── README.md └── git-images ├── diagram.png └── tests.png /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This is a living document representing some guidelines that will help our team do great work, and keep consistency in the codebase and the systems over time. 4 | 5 | ## Code Quality Principles 6 | 7 | - `Naming` → Explicitness, brevity, and consistency. 8 | - `Commenting` → Exist to help future readers (including yourself). 9 | - `Testing`: 10 | - Code isn’t high quality without tests. 11 | - Unit testing is a baseline tool for sustainable engineering. 12 | - Tests should be fast. Inject dependencies to favor decoupling. 13 | - Run tests often. Main should always be green. 14 | - `Cleverness` → Favor explicitness and simplicity over cleverness. Make things easier to maintain. 15 | - `Code Reviews are mandatory`: 16 | - Is it correct? 17 | - Is it clear? 18 | - Is it consistent with the codebase? 19 | - Is it tested? 20 | - Be polite. We are humans and we all play for the same team. 21 | 22 | ### Swift Style Guide 23 | Follow Swift's [API Design Guidelines](https://www.swift.org/documentation/api-design-guidelines/) whenever possible. 24 | 25 | - Types start with uppercased letters. Functions, variables, and enums start with lowercased letters. 26 | - Default to `struct` unless you need a class-only feature. 27 | - Mark classes as `final` unless you want inheritance. 28 | - Use `guard` to exit functions early. 29 | - Avoid `self.` whenever possible. 30 | 31 | ## Git Strategy 32 | 33 | We follow the [Github flow](https://githubflow.github.io/) strategy based on it's simplicity. The only difference is that our principal branch is called `main` instead of `master`. 34 | 35 | All the changes to the codebase must be reviewed by our peers before merging to the `main` branch. 36 | 37 | We keep the `main` branch always `green` by ensuring the new changes don't break previous functionality. 38 | 39 | ## Testing Strategy 40 | 41 | For now, we will value unit-testing over every other form of tests. We should strive to always test the business logic of the new changes while not breaking the previously coded unit tests. 42 | 43 | In the future, we will introduce other forms of testing (UI, Snapshot, Integration testing, etc). 44 | 45 | ## Coding Conventions 46 | 47 | We have a formatter and a linter set up in the repository that will enforce must of the conventions. Checkout the rules we have by looking at the `.swiftformat` and/or the `.swiftlint.yml` files. 48 | 49 | ### SwiftUI's View/ViewModel 50 | 51 | To provide a consistent feeling across the app, we will use namespaces for the view models. 52 | 53 | Example: 54 | 55 | ```swift 56 | // In HomeView+ViewModel.swift 57 | extension HomeView { 58 | @MainActor 59 | final class ViewModel: ObservableObject { 60 | private let dependencies: Dependencies 61 | 62 | init(dependencies: Dependencies = .default) { 63 | self.dependencies = dependencies 64 | } 65 | } 66 | } 67 | 68 | extension HomeView.ViewModel { 69 | // Functionality 70 | } 71 | 72 | extension HomeView.ViewModel { 73 | struct Dependencies { 74 | // Dependencies as functions 75 | 76 | static let `default`: Dependencies = Dependencies() 77 | } 78 | } 79 | 80 | ... 81 | 82 | // In HomeView.swift 83 | public struct MainView: View { 84 | @StateObject private var viewModel = ViewModel() 85 | 86 | public init() {} 87 | 88 | /// Only used for the Previews 89 | init(dependencies: ViewModel.Dependencies) { 90 | _viewModel = StateObject(wrappedValue: ViewModel(dependencies: dependencies)) 91 | } 92 | 93 | public var body: some View { 94 | content 95 | } 96 | } 97 | 98 | private extension HomeView { 99 | var content: some View { 100 | Text("Something") 101 | } 102 | } 103 | ``` 104 | 105 | Be mindful of dependencies between modules. A good rule of thumb is: 106 | 107 | - Core modules should have minimal or no dependencies on other modules 108 | - Domain modules can depend on Core modules. Domain cannot depend on DesignSystem. 109 | - UI/Presentation modules can depend on Domain modules and Core Modules. 110 | 111 | This creates a clean dependency graph that avoids circular dependencies. 112 | 113 | ### CoreLayer 114 | 115 | This layer is for the foundational packages. 116 | 117 | Every other package can import a package from this layer (including other Core Packages). 118 | 119 | Think of this layer as the fundamentals for your app. 120 | 121 | Examples include: 122 | 123 | - API Models: The decodable object representation of the backend data. More info in [UI vs API Models](/2023-08-25-ui-vs-api-models-different-layers/) 124 | - DesignSystem: All the tokens (colors, fonts, sizes, images), and the reusable components of the app (buttons, inputs, toggles, etc). This layer is imported directly from the `Presentation` layer. 125 | - [Components](/2023-01-04-new-app-components/) 126 | - [Toasts](/2023-03-08-new-app-toasts/) 127 | - [Constants](/2022-12-24-new-app-constants/) 128 | - [ViewModifiers](/2023-01-03-new-app-view-modifiers/) 129 | - Logger: A logging mechanism. I find [this one](/2024-03-19-new-app-os-log/) really useful. 130 | - Networking: Here you could either import a third party library, or create your own implementation. For new projects, I usually start with something [like this](https://github.com/mdb1/CoreNetworking) and only evolve as necessary. 131 | - Storage: Something simple as a [UserDefaults wrapper](/2023-04-18-user-preferences/) to begin with, it can evolve to support caching mechanism when needed. 132 | - Utilities: Extensions useful across the app. Examples: 133 | - Strings+Extension 134 | - [DateFormatters](/2023-01-10-new-app-date-formatters/) 135 | - [JSON+Extension](/2023-01-10-new-app-json-encoder-decoder/) 136 | - [NumberFormatters](/2023-06-12-new-app-number-formatters/) 137 | - [NotificationCenter](/2023-08-12-new-app-notification-center-protocols/) 138 | - Testing: Useful extensions to enhance XCTest. More info in: [Unit tests helpers](/2023-02-02-new-app-testing-helpers/) 139 | 140 | ### Domain Layer 141 | 142 | This is where the business logic lives. Domain should only depend on Core packages. 143 | 144 | If in need in the future, you can split up the Domain modules into multiple ones. Maybe one per feature. 145 | 146 | This is the layer that it's most important to cover with unit tests. 147 | 148 | Some advice on how to achieve that in: 149 | - [Enhancing Testability with Protocols](/2023-02-13-enhancing-testability-with-protocols/) 150 | - [Enhancing Testability without Protocols](/2023-02-03-enhancing-testability-without-protocols/) 151 | 152 | This layer is also where the `World` object lives from: [Centralized Dependencies](/2024-02-29-centralized-dependencies/) 153 | 154 | In this layer, you will also have the: 155 | - Services (import Networking to talk with the backend) 156 | - Repositories (import Storage to persist data) 157 | - Real app models (with their mappers from the API models) 158 | - Extensions on the models to represent their capabilities 159 | 160 | From Domain Driven Design: 161 | ```yml 162 | A model is a simplification. 163 | 164 | 1. The model and the heart of the design shape each other. 165 | 2. The model is the backbone of a language used by all team members. 166 | 3. The model is distilled knowledge. 167 | 168 | Developers have to steep themselves in the domain to build up 169 | knowledge of the business. 170 | ``` 171 | 172 | ### Presentation Layer 173 | 174 | This is where the Screens live. Presentation depends on Domain, and on DesignSystem. It can also depend on CorePackages directly if needed. 175 | 176 | Each Screen will be composed of many DesignSystem components. 177 | 178 | The development team can decide which UI pattern (MVVM, MVP, VIP, VIPER, TCA, etc) to use. 179 | 180 | It's important to cover the state changes with unit tests. 181 | 182 | In this layer, we could also include: 183 | 184 | - [ViewState](2023-01-08-new-app-view-state/) 185 | - [ViewStateController](/2023-03-04-view-state-controller/) 186 | 187 | ## Third Party dependencies 188 | 189 | Third party SDKs should be in the Foundation layer, however, we need to create a wrapper SDK (following the adapter and factory patterns) for each library. So, for example, the Analytics package would import FirebaseAnalytics, and only expose the necessary methods, without any hint to the use of Firebase under the hood. -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## Obj-C/Swift specific 9 | *.hmap 10 | 11 | ## App packaging 12 | *.ipa 13 | *.dSYM.zip 14 | *.dSYM 15 | 16 | ## Playgrounds 17 | timeline.xctimeline 18 | playground.xcworkspace 19 | 20 | # Swift Package Manager 21 | # 22 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 23 | # Packages/ 24 | # Package.pins 25 | # Package.resolved 26 | # *.xcodeproj 27 | # 28 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 29 | # hence it is not needed unless you have added a package configuration file to your project 30 | # .swiftpm 31 | 32 | build/ 33 | 34 | # CocoaPods 35 | # 36 | # We recommend against adding the Pods directory to your .gitignore. However 37 | # you should judge for yourself, the pros and cons are mentioned at: 38 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 39 | # 40 | # Pods/ 41 | # 42 | # Add this line if you want to avoid checking in source code from the Xcode workspace 43 | # *.xcworkspace 44 | 45 | # Carthage 46 | # 47 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 48 | # Carthage/Checkouts 49 | 50 | Carthage/Build/ 51 | 52 | # fastlane 53 | # 54 | # It is recommended to not store the screenshots in the git repo. 55 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 56 | # For more information about the recommended setup visit: 57 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 58 | 59 | fastlane/report.xml 60 | fastlane/Preview.html 61 | fastlane/screenshots/**/*.png 62 | fastlane/test_output/* 63 | test_output/ 64 | 65 | # Test output 66 | test_output/ 67 | 68 | # Swift Package Manager build folders in packages 69 | Packages/*/build/ 70 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "fastlane" 4 | gem "xcpretty" 5 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | CFPropertyList (3.0.7) 5 | base64 6 | nkf 7 | rexml 8 | addressable (2.8.7) 9 | public_suffix (>= 2.0.2, < 7.0) 10 | artifactory (3.0.17) 11 | atomos (0.1.3) 12 | aws-eventstream (1.3.1) 13 | aws-partitions (1.1057.0) 14 | aws-sdk-core (3.219.0) 15 | aws-eventstream (~> 1, >= 1.3.0) 16 | aws-partitions (~> 1, >= 1.992.0) 17 | aws-sigv4 (~> 1.9) 18 | base64 19 | jmespath (~> 1, >= 1.6.1) 20 | aws-sdk-kms (1.99.0) 21 | aws-sdk-core (~> 3, >= 3.216.0) 22 | aws-sigv4 (~> 1.5) 23 | aws-sdk-s3 (1.182.0) 24 | aws-sdk-core (~> 3, >= 3.216.0) 25 | aws-sdk-kms (~> 1) 26 | aws-sigv4 (~> 1.5) 27 | aws-sigv4 (1.11.0) 28 | aws-eventstream (~> 1, >= 1.0.2) 29 | babosa (1.0.4) 30 | base64 (0.2.0) 31 | claide (1.1.0) 32 | colored (1.2) 33 | colored2 (3.1.2) 34 | commander (4.6.0) 35 | highline (~> 2.0.0) 36 | declarative (0.0.20) 37 | digest-crc (0.7.0) 38 | rake (>= 12.0.0, < 14.0.0) 39 | domain_name (0.6.20240107) 40 | dotenv (2.8.1) 41 | emoji_regex (3.2.3) 42 | excon (0.112.0) 43 | faraday (1.10.4) 44 | faraday-em_http (~> 1.0) 45 | faraday-em_synchrony (~> 1.0) 46 | faraday-excon (~> 1.1) 47 | faraday-httpclient (~> 1.0) 48 | faraday-multipart (~> 1.0) 49 | faraday-net_http (~> 1.0) 50 | faraday-net_http_persistent (~> 1.0) 51 | faraday-patron (~> 1.0) 52 | faraday-rack (~> 1.0) 53 | faraday-retry (~> 1.0) 54 | ruby2_keywords (>= 0.0.4) 55 | faraday-cookie_jar (0.0.7) 56 | faraday (>= 0.8.0) 57 | http-cookie (~> 1.0.0) 58 | faraday-em_http (1.0.0) 59 | faraday-em_synchrony (1.0.0) 60 | faraday-excon (1.1.0) 61 | faraday-httpclient (1.0.1) 62 | faraday-multipart (1.1.0) 63 | multipart-post (~> 2.0) 64 | faraday-net_http (1.0.2) 65 | faraday-net_http_persistent (1.2.0) 66 | faraday-patron (1.0.0) 67 | faraday-rack (1.0.0) 68 | faraday-retry (1.0.3) 69 | faraday_middleware (1.2.1) 70 | faraday (~> 1.0) 71 | fastimage (2.4.0) 72 | fastlane (2.226.0) 73 | CFPropertyList (>= 2.3, < 4.0.0) 74 | addressable (>= 2.8, < 3.0.0) 75 | artifactory (~> 3.0) 76 | aws-sdk-s3 (~> 1.0) 77 | babosa (>= 1.0.3, < 2.0.0) 78 | bundler (>= 1.12.0, < 3.0.0) 79 | colored (~> 1.2) 80 | commander (~> 4.6) 81 | dotenv (>= 2.1.1, < 3.0.0) 82 | emoji_regex (>= 0.1, < 4.0) 83 | excon (>= 0.71.0, < 1.0.0) 84 | faraday (~> 1.0) 85 | faraday-cookie_jar (~> 0.0.6) 86 | faraday_middleware (~> 1.0) 87 | fastimage (>= 2.1.0, < 3.0.0) 88 | fastlane-sirp (>= 1.0.0) 89 | gh_inspector (>= 1.1.2, < 2.0.0) 90 | google-apis-androidpublisher_v3 (~> 0.3) 91 | google-apis-playcustomapp_v1 (~> 0.1) 92 | google-cloud-env (>= 1.6.0, < 2.0.0) 93 | google-cloud-storage (~> 1.31) 94 | highline (~> 2.0) 95 | http-cookie (~> 1.0.5) 96 | json (< 3.0.0) 97 | jwt (>= 2.1.0, < 3) 98 | mini_magick (>= 4.9.4, < 5.0.0) 99 | multipart-post (>= 2.0.0, < 3.0.0) 100 | naturally (~> 2.2) 101 | optparse (>= 0.1.1, < 1.0.0) 102 | plist (>= 3.1.0, < 4.0.0) 103 | rubyzip (>= 2.0.0, < 3.0.0) 104 | security (= 0.1.5) 105 | simctl (~> 1.6.3) 106 | terminal-notifier (>= 2.0.0, < 3.0.0) 107 | terminal-table (~> 3) 108 | tty-screen (>= 0.6.3, < 1.0.0) 109 | tty-spinner (>= 0.8.0, < 1.0.0) 110 | word_wrap (~> 1.0.0) 111 | xcodeproj (>= 1.13.0, < 2.0.0) 112 | xcpretty (~> 0.4.0) 113 | xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) 114 | fastlane-sirp (1.0.0) 115 | sysrandom (~> 1.0) 116 | gh_inspector (1.1.3) 117 | google-apis-androidpublisher_v3 (0.54.0) 118 | google-apis-core (>= 0.11.0, < 2.a) 119 | google-apis-core (0.11.3) 120 | addressable (~> 2.5, >= 2.5.1) 121 | googleauth (>= 0.16.2, < 2.a) 122 | httpclient (>= 2.8.1, < 3.a) 123 | mini_mime (~> 1.0) 124 | representable (~> 3.0) 125 | retriable (>= 2.0, < 4.a) 126 | rexml 127 | google-apis-iamcredentials_v1 (0.17.0) 128 | google-apis-core (>= 0.11.0, < 2.a) 129 | google-apis-playcustomapp_v1 (0.13.0) 130 | google-apis-core (>= 0.11.0, < 2.a) 131 | google-apis-storage_v1 (0.31.0) 132 | google-apis-core (>= 0.11.0, < 2.a) 133 | google-cloud-core (1.7.1) 134 | google-cloud-env (>= 1.0, < 3.a) 135 | google-cloud-errors (~> 1.0) 136 | google-cloud-env (1.6.0) 137 | faraday (>= 0.17.3, < 3.0) 138 | google-cloud-errors (1.4.0) 139 | google-cloud-storage (1.47.0) 140 | addressable (~> 2.8) 141 | digest-crc (~> 0.4) 142 | google-apis-iamcredentials_v1 (~> 0.1) 143 | google-apis-storage_v1 (~> 0.31.0) 144 | google-cloud-core (~> 1.6) 145 | googleauth (>= 0.16.2, < 2.a) 146 | mini_mime (~> 1.0) 147 | googleauth (1.8.1) 148 | faraday (>= 0.17.3, < 3.a) 149 | jwt (>= 1.4, < 3.0) 150 | multi_json (~> 1.11) 151 | os (>= 0.9, < 2.0) 152 | signet (>= 0.16, < 2.a) 153 | highline (2.0.3) 154 | http-cookie (1.0.8) 155 | domain_name (~> 0.5) 156 | httpclient (2.9.0) 157 | mutex_m 158 | jmespath (1.6.2) 159 | json (2.10.1) 160 | jwt (2.10.1) 161 | base64 162 | mini_magick (4.13.2) 163 | mini_mime (1.1.5) 164 | multi_json (1.15.0) 165 | multipart-post (2.4.1) 166 | mutex_m (0.3.0) 167 | nanaimo (0.4.0) 168 | naturally (2.2.1) 169 | nkf (0.2.0) 170 | optparse (0.6.0) 171 | os (1.1.4) 172 | plist (3.7.2) 173 | public_suffix (6.0.1) 174 | rake (13.2.1) 175 | representable (3.2.0) 176 | declarative (< 0.1.0) 177 | trailblazer-option (>= 0.1.1, < 0.2.0) 178 | uber (< 0.2.0) 179 | retriable (3.1.2) 180 | rexml (3.4.1) 181 | rouge (3.28.0) 182 | ruby2_keywords (0.0.5) 183 | rubyzip (2.4.1) 184 | security (0.1.5) 185 | signet (0.19.0) 186 | addressable (~> 2.8) 187 | faraday (>= 0.17.5, < 3.a) 188 | jwt (>= 1.5, < 3.0) 189 | multi_json (~> 1.10) 190 | simctl (1.6.10) 191 | CFPropertyList 192 | naturally 193 | sysrandom (1.0.5) 194 | terminal-notifier (2.0.0) 195 | terminal-table (3.0.2) 196 | unicode-display_width (>= 1.1.1, < 3) 197 | trailblazer-option (0.1.2) 198 | tty-cursor (0.7.1) 199 | tty-screen (0.8.2) 200 | tty-spinner (0.9.3) 201 | tty-cursor (~> 0.7) 202 | uber (0.1.0) 203 | unicode-display_width (2.6.0) 204 | word_wrap (1.0.0) 205 | xcodeproj (1.27.0) 206 | CFPropertyList (>= 2.3.3, < 4.0) 207 | atomos (~> 0.1.3) 208 | claide (>= 1.0.2, < 2.0) 209 | colored2 (~> 3.1) 210 | nanaimo (~> 0.4.0) 211 | rexml (>= 3.3.6, < 4.0) 212 | xcpretty (0.4.0) 213 | rouge (~> 3.28.0) 214 | xcpretty-travis-formatter (1.0.1) 215 | xcpretty (~> 0.2, >= 0.0.7) 216 | 217 | PLATFORMS 218 | arm64-darwin-22 219 | 220 | DEPENDENCIES 221 | fastlane 222 | xcpretty 223 | 224 | BUNDLED WITH 225 | 2.4.19 226 | -------------------------------------------------------------------------------- /ModularTemplate.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 77; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 3216D5EC2D71F40300F299BF /* Presentation in Frameworks */ = {isa = PBXBuildFile; productRef = 3278C7D42D6FD3F20056B4D8 /* Presentation */; }; 11 | 3216D5ED2D71F40300F299BF /* Presentation in Frameworks */ = {isa = PBXBuildFile; productRef = 320211ED2D70019C00DD030A /* Presentation */; }; 12 | 3216D5EE2D71F40300F299BF /* Presentation in Frameworks */ = {isa = PBXBuildFile; productRef = 32CB60192D70081F00643C92 /* Presentation */; }; 13 | 32CB600D2D70081000643C92 /* Presentation in Frameworks */ = {isa = PBXBuildFile; productRef = 32350C4A2D70046200367609 /* Presentation */; }; 14 | /* End PBXBuildFile section */ 15 | 16 | /* Begin PBXContainerItemProxy section */ 17 | 3278C7B62D6FD32A0056B4D8 /* PBXContainerItemProxy */ = { 18 | isa = PBXContainerItemProxy; 19 | containerPortal = 3278C79D2D6FD3290056B4D8 /* Project object */; 20 | proxyType = 1; 21 | remoteGlobalIDString = 3278C7A42D6FD3290056B4D8; 22 | remoteInfo = ModularTemplate; 23 | }; 24 | /* End PBXContainerItemProxy section */ 25 | 26 | /* Begin PBXFileReference section */ 27 | 3216D5E22D71EDB800F299BF /* ModularTemplate.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = ModularTemplate.xctestplan; sourceTree = ""; }; 28 | 3278C7A52D6FD3290056B4D8 /* ModularTemplate.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ModularTemplate.app; sourceTree = BUILT_PRODUCTS_DIR; }; 29 | 3278C7B52D6FD32A0056B4D8 /* ModularTemplateTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ModularTemplateTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 30 | /* End PBXFileReference section */ 31 | 32 | /* Begin PBXFileSystemSynchronizedRootGroup section */ 33 | 3278C7A72D6FD3290056B4D8 /* ModularTemplate */ = { 34 | isa = PBXFileSystemSynchronizedRootGroup; 35 | path = ModularTemplate; 36 | sourceTree = ""; 37 | }; 38 | 3278C7B82D6FD32A0056B4D8 /* ModularTemplateTests */ = { 39 | isa = PBXFileSystemSynchronizedRootGroup; 40 | path = ModularTemplateTests; 41 | sourceTree = ""; 42 | }; 43 | /* End PBXFileSystemSynchronizedRootGroup section */ 44 | 45 | /* Begin PBXFrameworksBuildPhase section */ 46 | 3278C7A22D6FD3290056B4D8 /* Frameworks */ = { 47 | isa = PBXFrameworksBuildPhase; 48 | buildActionMask = 2147483647; 49 | files = ( 50 | 3216D5EE2D71F40300F299BF /* Presentation in Frameworks */, 51 | 3216D5ED2D71F40300F299BF /* Presentation in Frameworks */, 52 | 3216D5EC2D71F40300F299BF /* Presentation in Frameworks */, 53 | 32CB600D2D70081000643C92 /* Presentation in Frameworks */, 54 | ); 55 | runOnlyForDeploymentPostprocessing = 0; 56 | }; 57 | 3278C7B22D6FD32A0056B4D8 /* Frameworks */ = { 58 | isa = PBXFrameworksBuildPhase; 59 | buildActionMask = 2147483647; 60 | files = ( 61 | ); 62 | runOnlyForDeploymentPostprocessing = 0; 63 | }; 64 | /* End PBXFrameworksBuildPhase section */ 65 | 66 | /* Begin PBXGroup section */ 67 | 3278C79C2D6FD3290056B4D8 = { 68 | isa = PBXGroup; 69 | children = ( 70 | 3216D5E22D71EDB800F299BF /* ModularTemplate.xctestplan */, 71 | 3278C7A72D6FD3290056B4D8 /* ModularTemplate */, 72 | 3278C7B82D6FD32A0056B4D8 /* ModularTemplateTests */, 73 | 3278C7A62D6FD3290056B4D8 /* Products */, 74 | ); 75 | sourceTree = ""; 76 | }; 77 | 3278C7A62D6FD3290056B4D8 /* Products */ = { 78 | isa = PBXGroup; 79 | children = ( 80 | 3278C7A52D6FD3290056B4D8 /* ModularTemplate.app */, 81 | 3278C7B52D6FD32A0056B4D8 /* ModularTemplateTests.xctest */, 82 | ); 83 | name = Products; 84 | sourceTree = ""; 85 | }; 86 | /* End PBXGroup section */ 87 | 88 | /* Begin PBXNativeTarget section */ 89 | 3278C7A42D6FD3290056B4D8 /* ModularTemplate */ = { 90 | isa = PBXNativeTarget; 91 | buildConfigurationList = 3278C7C92D6FD32A0056B4D8 /* Build configuration list for PBXNativeTarget "ModularTemplate" */; 92 | buildPhases = ( 93 | 3278C7A12D6FD3290056B4D8 /* Sources */, 94 | 3278C7A22D6FD3290056B4D8 /* Frameworks */, 95 | 3278C7A32D6FD3290056B4D8 /* Resources */, 96 | ); 97 | buildRules = ( 98 | ); 99 | dependencies = ( 100 | ); 101 | fileSystemSynchronizedGroups = ( 102 | 3278C7A72D6FD3290056B4D8 /* ModularTemplate */, 103 | ); 104 | name = ModularTemplate; 105 | packageProductDependencies = ( 106 | 3278C7D42D6FD3F20056B4D8 /* Presentation */, 107 | 320211ED2D70019C00DD030A /* Presentation */, 108 | 32350C4A2D70046200367609 /* Presentation */, 109 | 32CB60192D70081F00643C92 /* Presentation */, 110 | ); 111 | productName = ModularTemplate; 112 | productReference = 3278C7A52D6FD3290056B4D8 /* ModularTemplate.app */; 113 | productType = "com.apple.product-type.application"; 114 | }; 115 | 3278C7B42D6FD32A0056B4D8 /* ModularTemplateTests */ = { 116 | isa = PBXNativeTarget; 117 | buildConfigurationList = 3278C7CC2D6FD32A0056B4D8 /* Build configuration list for PBXNativeTarget "ModularTemplateTests" */; 118 | buildPhases = ( 119 | 3278C7B12D6FD32A0056B4D8 /* Sources */, 120 | 3278C7B22D6FD32A0056B4D8 /* Frameworks */, 121 | 3278C7B32D6FD32A0056B4D8 /* Resources */, 122 | ); 123 | buildRules = ( 124 | ); 125 | dependencies = ( 126 | 3278C7B72D6FD32A0056B4D8 /* PBXTargetDependency */, 127 | ); 128 | fileSystemSynchronizedGroups = ( 129 | 3278C7B82D6FD32A0056B4D8 /* ModularTemplateTests */, 130 | ); 131 | name = ModularTemplateTests; 132 | packageProductDependencies = ( 133 | ); 134 | productName = ModularTemplateTests; 135 | productReference = 3278C7B52D6FD32A0056B4D8 /* ModularTemplateTests.xctest */; 136 | productType = "com.apple.product-type.bundle.unit-test"; 137 | }; 138 | /* End PBXNativeTarget section */ 139 | 140 | /* Begin PBXProject section */ 141 | 3278C79D2D6FD3290056B4D8 /* Project object */ = { 142 | isa = PBXProject; 143 | attributes = { 144 | BuildIndependentTargetsInParallel = 1; 145 | LastSwiftUpdateCheck = 1620; 146 | LastUpgradeCheck = 1620; 147 | TargetAttributes = { 148 | 3278C7A42D6FD3290056B4D8 = { 149 | CreatedOnToolsVersion = 16.2; 150 | }; 151 | 3278C7B42D6FD32A0056B4D8 = { 152 | CreatedOnToolsVersion = 16.2; 153 | TestTargetID = 3278C7A42D6FD3290056B4D8; 154 | }; 155 | }; 156 | }; 157 | buildConfigurationList = 3278C7A02D6FD3290056B4D8 /* Build configuration list for PBXProject "ModularTemplate" */; 158 | developmentRegion = en; 159 | hasScannedForEncodings = 0; 160 | knownRegions = ( 161 | en, 162 | Base, 163 | ); 164 | mainGroup = 3278C79C2D6FD3290056B4D8; 165 | minimizedProjectReferenceProxies = 1; 166 | packageReferences = ( 167 | 328E1CC92D709FC400F9D2CB /* XCLocalSwiftPackageReference "Packages/PresentationLayer/Presentation" */, 168 | ); 169 | preferredProjectObjectVersion = 77; 170 | productRefGroup = 3278C7A62D6FD3290056B4D8 /* Products */; 171 | projectDirPath = ""; 172 | projectRoot = ""; 173 | targets = ( 174 | 3278C7A42D6FD3290056B4D8 /* ModularTemplate */, 175 | 3278C7B42D6FD32A0056B4D8 /* ModularTemplateTests */, 176 | ); 177 | }; 178 | /* End PBXProject section */ 179 | 180 | /* Begin PBXResourcesBuildPhase section */ 181 | 3278C7A32D6FD3290056B4D8 /* Resources */ = { 182 | isa = PBXResourcesBuildPhase; 183 | buildActionMask = 2147483647; 184 | files = ( 185 | ); 186 | runOnlyForDeploymentPostprocessing = 0; 187 | }; 188 | 3278C7B32D6FD32A0056B4D8 /* Resources */ = { 189 | isa = PBXResourcesBuildPhase; 190 | buildActionMask = 2147483647; 191 | files = ( 192 | ); 193 | runOnlyForDeploymentPostprocessing = 0; 194 | }; 195 | /* End PBXResourcesBuildPhase section */ 196 | 197 | /* Begin PBXSourcesBuildPhase section */ 198 | 3278C7A12D6FD3290056B4D8 /* Sources */ = { 199 | isa = PBXSourcesBuildPhase; 200 | buildActionMask = 2147483647; 201 | files = ( 202 | ); 203 | runOnlyForDeploymentPostprocessing = 0; 204 | }; 205 | 3278C7B12D6FD32A0056B4D8 /* Sources */ = { 206 | isa = PBXSourcesBuildPhase; 207 | buildActionMask = 2147483647; 208 | files = ( 209 | ); 210 | runOnlyForDeploymentPostprocessing = 0; 211 | }; 212 | /* End PBXSourcesBuildPhase section */ 213 | 214 | /* Begin PBXTargetDependency section */ 215 | 3278C7B72D6FD32A0056B4D8 /* PBXTargetDependency */ = { 216 | isa = PBXTargetDependency; 217 | target = 3278C7A42D6FD3290056B4D8 /* ModularTemplate */; 218 | targetProxy = 3278C7B62D6FD32A0056B4D8 /* PBXContainerItemProxy */; 219 | }; 220 | /* End PBXTargetDependency section */ 221 | 222 | /* Begin XCBuildConfiguration section */ 223 | 3278C7C72D6FD32A0056B4D8 /* Debug */ = { 224 | isa = XCBuildConfiguration; 225 | buildSettings = { 226 | ALWAYS_SEARCH_USER_PATHS = NO; 227 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 228 | CLANG_ANALYZER_NONNULL = YES; 229 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 230 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 231 | CLANG_ENABLE_MODULES = YES; 232 | CLANG_ENABLE_OBJC_ARC = YES; 233 | CLANG_ENABLE_OBJC_WEAK = YES; 234 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 235 | CLANG_WARN_BOOL_CONVERSION = YES; 236 | CLANG_WARN_COMMA = YES; 237 | CLANG_WARN_CONSTANT_CONVERSION = YES; 238 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 239 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 240 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 241 | CLANG_WARN_EMPTY_BODY = YES; 242 | CLANG_WARN_ENUM_CONVERSION = YES; 243 | CLANG_WARN_INFINITE_RECURSION = YES; 244 | CLANG_WARN_INT_CONVERSION = YES; 245 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 246 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 247 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 248 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 249 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 250 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 251 | CLANG_WARN_STRICT_PROTOTYPES = YES; 252 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 253 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 254 | CLANG_WARN_UNREACHABLE_CODE = YES; 255 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 256 | COPY_PHASE_STRIP = NO; 257 | DEBUG_INFORMATION_FORMAT = dwarf; 258 | ENABLE_STRICT_OBJC_MSGSEND = YES; 259 | ENABLE_TESTABILITY = YES; 260 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 261 | GCC_C_LANGUAGE_STANDARD = gnu17; 262 | GCC_DYNAMIC_NO_PIC = NO; 263 | GCC_NO_COMMON_BLOCKS = YES; 264 | GCC_OPTIMIZATION_LEVEL = 0; 265 | GCC_PREPROCESSOR_DEFINITIONS = ( 266 | "DEBUG=1", 267 | "$(inherited)", 268 | ); 269 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 270 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 271 | GCC_WARN_UNDECLARED_SELECTOR = YES; 272 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 273 | GCC_WARN_UNUSED_FUNCTION = YES; 274 | GCC_WARN_UNUSED_VARIABLE = YES; 275 | IPHONEOS_DEPLOYMENT_TARGET = 18.2; 276 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 277 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 278 | MTL_FAST_MATH = YES; 279 | ONLY_ACTIVE_ARCH = YES; 280 | SDKROOT = iphoneos; 281 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 282 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 283 | }; 284 | name = Debug; 285 | }; 286 | 3278C7C82D6FD32A0056B4D8 /* Release */ = { 287 | isa = XCBuildConfiguration; 288 | buildSettings = { 289 | ALWAYS_SEARCH_USER_PATHS = NO; 290 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 291 | CLANG_ANALYZER_NONNULL = YES; 292 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 293 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 294 | CLANG_ENABLE_MODULES = YES; 295 | CLANG_ENABLE_OBJC_ARC = YES; 296 | CLANG_ENABLE_OBJC_WEAK = YES; 297 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 298 | CLANG_WARN_BOOL_CONVERSION = YES; 299 | CLANG_WARN_COMMA = YES; 300 | CLANG_WARN_CONSTANT_CONVERSION = YES; 301 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 302 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 303 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 304 | CLANG_WARN_EMPTY_BODY = YES; 305 | CLANG_WARN_ENUM_CONVERSION = YES; 306 | CLANG_WARN_INFINITE_RECURSION = YES; 307 | CLANG_WARN_INT_CONVERSION = YES; 308 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 309 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 310 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 311 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 312 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 313 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 314 | CLANG_WARN_STRICT_PROTOTYPES = YES; 315 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 316 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 317 | CLANG_WARN_UNREACHABLE_CODE = YES; 318 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 319 | COPY_PHASE_STRIP = NO; 320 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 321 | ENABLE_NS_ASSERTIONS = NO; 322 | ENABLE_STRICT_OBJC_MSGSEND = YES; 323 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 324 | GCC_C_LANGUAGE_STANDARD = gnu17; 325 | GCC_NO_COMMON_BLOCKS = YES; 326 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 327 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 328 | GCC_WARN_UNDECLARED_SELECTOR = YES; 329 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 330 | GCC_WARN_UNUSED_FUNCTION = YES; 331 | GCC_WARN_UNUSED_VARIABLE = YES; 332 | IPHONEOS_DEPLOYMENT_TARGET = 18.2; 333 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 334 | MTL_ENABLE_DEBUG_INFO = NO; 335 | MTL_FAST_MATH = YES; 336 | SDKROOT = iphoneos; 337 | SWIFT_COMPILATION_MODE = wholemodule; 338 | VALIDATE_PRODUCT = YES; 339 | }; 340 | name = Release; 341 | }; 342 | 3278C7CA2D6FD32A0056B4D8 /* Debug */ = { 343 | isa = XCBuildConfiguration; 344 | buildSettings = { 345 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 346 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 347 | CODE_SIGN_STYLE = Automatic; 348 | CURRENT_PROJECT_VERSION = 1; 349 | DEVELOPMENT_ASSET_PATHS = "\"ModularTemplate/Preview Content\""; 350 | ENABLE_PREVIEWS = YES; 351 | GENERATE_INFOPLIST_FILE = YES; 352 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 353 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 354 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 355 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 356 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 357 | LD_RUNPATH_SEARCH_PATHS = ( 358 | "$(inherited)", 359 | "@executable_path/Frameworks", 360 | ); 361 | MARKETING_VERSION = 1.0; 362 | PRODUCT_BUNDLE_IDENTIFIER = mdb.ModularTemplate; 363 | PRODUCT_NAME = "$(TARGET_NAME)"; 364 | SWIFT_EMIT_LOC_STRINGS = YES; 365 | SWIFT_VERSION = 5.0; 366 | TARGETED_DEVICE_FAMILY = "1,2"; 367 | }; 368 | name = Debug; 369 | }; 370 | 3278C7CB2D6FD32A0056B4D8 /* Release */ = { 371 | isa = XCBuildConfiguration; 372 | buildSettings = { 373 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 374 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 375 | CODE_SIGN_STYLE = Automatic; 376 | CURRENT_PROJECT_VERSION = 1; 377 | DEVELOPMENT_ASSET_PATHS = "\"ModularTemplate/Preview Content\""; 378 | ENABLE_PREVIEWS = YES; 379 | GENERATE_INFOPLIST_FILE = YES; 380 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 381 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 382 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 383 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 384 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 385 | LD_RUNPATH_SEARCH_PATHS = ( 386 | "$(inherited)", 387 | "@executable_path/Frameworks", 388 | ); 389 | MARKETING_VERSION = 1.0; 390 | PRODUCT_BUNDLE_IDENTIFIER = mdb.ModularTemplate; 391 | PRODUCT_NAME = "$(TARGET_NAME)"; 392 | SWIFT_EMIT_LOC_STRINGS = YES; 393 | SWIFT_VERSION = 5.0; 394 | TARGETED_DEVICE_FAMILY = "1,2"; 395 | }; 396 | name = Release; 397 | }; 398 | 3278C7CD2D6FD32A0056B4D8 /* Debug */ = { 399 | isa = XCBuildConfiguration; 400 | buildSettings = { 401 | BUNDLE_LOADER = "$(TEST_HOST)"; 402 | CODE_SIGN_STYLE = Automatic; 403 | CURRENT_PROJECT_VERSION = 1; 404 | GENERATE_INFOPLIST_FILE = YES; 405 | IPHONEOS_DEPLOYMENT_TARGET = 18.2; 406 | MARKETING_VERSION = 1.0; 407 | PRODUCT_BUNDLE_IDENTIFIER = mdb.ModularTemplateTests; 408 | PRODUCT_NAME = "$(TARGET_NAME)"; 409 | SWIFT_EMIT_LOC_STRINGS = NO; 410 | SWIFT_VERSION = 5.0; 411 | TARGETED_DEVICE_FAMILY = "1,2"; 412 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ModularTemplate.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ModularTemplate"; 413 | }; 414 | name = Debug; 415 | }; 416 | 3278C7CE2D6FD32A0056B4D8 /* Release */ = { 417 | isa = XCBuildConfiguration; 418 | buildSettings = { 419 | BUNDLE_LOADER = "$(TEST_HOST)"; 420 | CODE_SIGN_STYLE = Automatic; 421 | CURRENT_PROJECT_VERSION = 1; 422 | GENERATE_INFOPLIST_FILE = YES; 423 | IPHONEOS_DEPLOYMENT_TARGET = 18.2; 424 | MARKETING_VERSION = 1.0; 425 | PRODUCT_BUNDLE_IDENTIFIER = mdb.ModularTemplateTests; 426 | PRODUCT_NAME = "$(TARGET_NAME)"; 427 | SWIFT_EMIT_LOC_STRINGS = NO; 428 | SWIFT_VERSION = 5.0; 429 | TARGETED_DEVICE_FAMILY = "1,2"; 430 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ModularTemplate.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ModularTemplate"; 431 | }; 432 | name = Release; 433 | }; 434 | /* End XCBuildConfiguration section */ 435 | 436 | /* Begin XCConfigurationList section */ 437 | 3278C7A02D6FD3290056B4D8 /* Build configuration list for PBXProject "ModularTemplate" */ = { 438 | isa = XCConfigurationList; 439 | buildConfigurations = ( 440 | 3278C7C72D6FD32A0056B4D8 /* Debug */, 441 | 3278C7C82D6FD32A0056B4D8 /* Release */, 442 | ); 443 | defaultConfigurationIsVisible = 0; 444 | defaultConfigurationName = Release; 445 | }; 446 | 3278C7C92D6FD32A0056B4D8 /* Build configuration list for PBXNativeTarget "ModularTemplate" */ = { 447 | isa = XCConfigurationList; 448 | buildConfigurations = ( 449 | 3278C7CA2D6FD32A0056B4D8 /* Debug */, 450 | 3278C7CB2D6FD32A0056B4D8 /* Release */, 451 | ); 452 | defaultConfigurationIsVisible = 0; 453 | defaultConfigurationName = Release; 454 | }; 455 | 3278C7CC2D6FD32A0056B4D8 /* Build configuration list for PBXNativeTarget "ModularTemplateTests" */ = { 456 | isa = XCConfigurationList; 457 | buildConfigurations = ( 458 | 3278C7CD2D6FD32A0056B4D8 /* Debug */, 459 | 3278C7CE2D6FD32A0056B4D8 /* Release */, 460 | ); 461 | defaultConfigurationIsVisible = 0; 462 | defaultConfigurationName = Release; 463 | }; 464 | /* End XCConfigurationList section */ 465 | 466 | /* Begin XCLocalSwiftPackageReference section */ 467 | 328E1CC92D709FC400F9D2CB /* XCLocalSwiftPackageReference "Packages/PresentationLayer/Presentation" */ = { 468 | isa = XCLocalSwiftPackageReference; 469 | relativePath = Packages/PresentationLayer/Presentation; 470 | }; 471 | /* End XCLocalSwiftPackageReference section */ 472 | 473 | /* Begin XCSwiftPackageProductDependency section */ 474 | 320211ED2D70019C00DD030A /* Presentation */ = { 475 | isa = XCSwiftPackageProductDependency; 476 | productName = Presentation; 477 | }; 478 | 32350C4A2D70046200367609 /* Presentation */ = { 479 | isa = XCSwiftPackageProductDependency; 480 | productName = Presentation; 481 | }; 482 | 3278C7D42D6FD3F20056B4D8 /* Presentation */ = { 483 | isa = XCSwiftPackageProductDependency; 484 | productName = Presentation; 485 | }; 486 | 32CB60192D70081F00643C92 /* Presentation */ = { 487 | isa = XCSwiftPackageProductDependency; 488 | productName = Presentation; 489 | }; 490 | /* End XCSwiftPackageProductDependency section */ 491 | }; 492 | rootObject = 3278C79D2D6FD3290056B4D8 /* Project object */; 493 | } 494 | -------------------------------------------------------------------------------- /ModularTemplate.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ModularTemplate.xcodeproj/xcshareddata/xcschemes/ModularTemplate.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 31 | 32 | 35 | 36 | 37 | 38 | 41 | 47 | 48 | 49 | 50 | 51 | 61 | 63 | 69 | 70 | 71 | 72 | 78 | 80 | 86 | 87 | 88 | 89 | 91 | 92 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /ModularTemplate.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "CF75D2C6-FAF0-4295-80F9-676C90281FD6", 5 | "name" : "Test Scheme Action", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "targetForVariableExpansion" : { 13 | "containerPath" : "container:ModularTemplate.xcodeproj", 14 | "identifier" : "3278C7A42D6FD3290056B4D8", 15 | "name" : "ModularTemplate" 16 | } 17 | }, 18 | "testTargets" : [ 19 | { 20 | "parallelizable" : true, 21 | "target" : { 22 | "containerPath" : "container:ModularTemplate.xcodeproj", 23 | "identifier" : "3278C7B42D6FD32A0056B4D8", 24 | "name" : "ModularTemplateTests" 25 | } 26 | } 27 | ], 28 | "version" : 1 29 | } 30 | -------------------------------------------------------------------------------- /ModularTemplate/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ModularTemplate/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | } 30 | ], 31 | "info" : { 32 | "author" : "xcode", 33 | "version" : 1 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /ModularTemplate/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ModularTemplate/ModularTemplateApp.swift: -------------------------------------------------------------------------------- 1 | import Presentation 2 | import SwiftUI 3 | 4 | @main 5 | struct ModularTemplateApp: App { 6 | var body: some Scene { 7 | WindowGroup { 8 | MainView() 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /ModularTemplate/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /ModularTemplateTests/ModularTemplateTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import ModularTemplate 3 | 4 | final class ModularTemplateTests: XCTestCase { 5 | func testExample() throws { 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Packages/CoreLayer/APIModels/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Packages/CoreLayer/APIModels/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "APIModels", 8 | platforms: [.iOS(.v17)], 9 | products: [ 10 | .library( 11 | name: "APIModels", 12 | targets: ["APIModels"] 13 | ), 14 | ], 15 | dependencies: [ 16 | 17 | ], 18 | targets: [ 19 | .target( 20 | name: "APIModels", 21 | dependencies: []), 22 | .testTarget( 23 | name: "APIModelsTests", 24 | dependencies: ["APIModels"] 25 | ), 26 | ] 27 | ) 28 | -------------------------------------------------------------------------------- /Packages/CoreLayer/APIModels/Sources/APIModels/APIModel.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum APIModel { 4 | // NameSpace 5 | // More info in: https://www.manu.show/2023-08-25-ui-vs-api-models-different-layers/ 6 | } 7 | -------------------------------------------------------------------------------- /Packages/CoreLayer/APIModels/Tests/APIModelsTests/APIModelTests.swift: -------------------------------------------------------------------------------- 1 | @testable import APIModels 2 | import XCTest 3 | 4 | final class APIModels: XCTestCase { 5 | func testSomething() { 6 | XCTAssertTrue(true) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Packages/CoreLayer/DesignSystem/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Packages/CoreLayer/DesignSystem/.swiftpm/xcode/xcshareddata/xcschemes/DesignSystem.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /Packages/CoreLayer/DesignSystem/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "DesignSystem", 8 | platforms: [.iOS(.v17)], 9 | products: [ 10 | .library( 11 | name: "DesignSystem", 12 | targets: ["DesignSystem"] 13 | ), 14 | ], 15 | dependencies: [ 16 | 17 | ], 18 | targets: [ 19 | .target( 20 | name: "DesignSystem", 21 | dependencies: []), 22 | .testTarget( 23 | name: "DesignSystemTests", 24 | dependencies: ["DesignSystem"] 25 | ), 26 | ] 27 | ) 28 | -------------------------------------------------------------------------------- /Packages/CoreLayer/DesignSystem/Sources/DesignSystem/DesignSystem.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | // More info in: 4 | // https://manu.show/2023-01-04-new-app-components/ 5 | // https://manu.show/2023-03-08-new-app-toasts/ 6 | // https://manu.show/2022-12-24-new-app-constants/ 7 | // https://www.manu.show/2023-01-03-new-app-view-modifiers/ 8 | // https://www.manu.show/2023-01-20-new-app-fonts/ 9 | 10 | public struct PrimaryButtonStyle: ButtonStyle { 11 | public init() {} 12 | 13 | public func makeBody(configuration: Configuration) -> some View { 14 | configuration.label 15 | .padding() 16 | .background(Color.blue) 17 | .foregroundColor(.white) 18 | .cornerRadius(8) 19 | .scaleEffect(configuration.isPressed ? 0.95 : 1) 20 | .opacity(configuration.isPressed ? 0.9 : 1) 21 | } 22 | } 23 | 24 | public enum Colors { 25 | public static let primary = Color.blue 26 | public static let secondary = Color.gray 27 | public static let accent = Color.orange 28 | } 29 | 30 | public enum Typography { 31 | public static let title = Font.title 32 | public static let body = Font.body 33 | public static let caption = Font.caption 34 | } 35 | 36 | #Preview { 37 | Button("Hey") {} 38 | } 39 | -------------------------------------------------------------------------------- /Packages/CoreLayer/DesignSystem/Tests/DesignSystemTests/DesignSystemTests.swift: -------------------------------------------------------------------------------- 1 | @testable import DesignSystem 2 | import XCTest 3 | 4 | final class DesignSystemTests: XCTestCase { 5 | func testSomething() { 6 | XCTAssertTrue(true) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Packages/CoreLayer/Logger/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Packages/CoreLayer/Logger/.swiftpm/xcode/xcshareddata/xcschemes/Logger.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 44 | 50 | 51 | 57 | 58 | 59 | 60 | 62 | 63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /Packages/CoreLayer/Logger/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Logger", 8 | platforms: [.iOS(.v17)], 9 | products: [ 10 | .library( 11 | name: "Logger", 12 | targets: ["Logger"]), 13 | ], 14 | dependencies: [ 15 | ], 16 | targets: [ 17 | .target( 18 | name: "Logger", 19 | dependencies: []), 20 | .testTarget( 21 | name: "LoggerTests", 22 | dependencies: ["Logger"]), 23 | ] 24 | ) 25 | -------------------------------------------------------------------------------- /Packages/CoreLayer/Logger/Sources/Logger/Logger.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | public enum LoggerService { 4 | // More info in: https://www.manu.show/2024-03-19-new-app-os-log/ 5 | } 6 | -------------------------------------------------------------------------------- /Packages/CoreLayer/Logger/Tests/LoggerTests/LoggerTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Logger 2 | import XCTest 3 | 4 | final class LoggerTests: XCTestCase { 5 | func testSomething() { 6 | XCTAssertTrue(true) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Packages/CoreLayer/Networking/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Packages/CoreLayer/Networking/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Networking", 8 | platforms: [.iOS(.v17)], 9 | products: [ 10 | .library( 11 | name: "Networking", 12 | targets: ["Networking"]), 13 | ], 14 | dependencies: [ 15 | .package(path: "../Testing"), 16 | ], 17 | targets: [ 18 | .target( 19 | name: "Networking", 20 | dependencies: []), 21 | .testTarget( 22 | name: "NetworkingTests", 23 | dependencies: ["Networking", "Testing"]), 24 | ] 25 | ) 26 | -------------------------------------------------------------------------------- /Packages/CoreLayer/Networking/Sources/Networking/Networking.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // More info in: https://github.com/mdb1/CoreNetworking 4 | 5 | public struct NetworkService { 6 | public init() {} 7 | } 8 | -------------------------------------------------------------------------------- /Packages/CoreLayer/Networking/Tests/NetworkingTests/NetworkingTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Networking 2 | import XCTest 3 | 4 | final class NetworkingTests: XCTestCase { 5 | func testSomething() { 6 | XCTAssertTrue(true) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Packages/CoreLayer/Testing/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Packages/CoreLayer/Testing/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Testing", 8 | platforms: [.iOS(.v17)], 9 | products: [ 10 | .library( 11 | name: "Testing", 12 | targets: ["Testing"] 13 | ), 14 | ], 15 | dependencies: [ 16 | ], 17 | targets: [ 18 | .target( 19 | name: "Testing", 20 | dependencies: [] 21 | ) 22 | ] 23 | ) 24 | -------------------------------------------------------------------------------- /Packages/CoreLayer/Testing/Sources/Testing/Testing.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | // More info in: https://www.manu.show/2023-02-02-new-app-testing-helpers/ 4 | 5 | public protocol Mockable {} 6 | -------------------------------------------------------------------------------- /Packages/CoreLayer/Utilities/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Packages/CoreLayer/Utilities/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Utilities", 8 | platforms: [.iOS(.v17)], 9 | products: [ 10 | .library( 11 | name: "Utilities", 12 | targets: ["Utilities"]), 13 | ], 14 | dependencies: [ 15 | 16 | ], 17 | targets: [ 18 | .target( 19 | name: "Utilities", 20 | dependencies: []), 21 | .testTarget( 22 | name: "UtilitiesTests", 23 | dependencies: ["Utilities"]), 24 | ] 25 | ) 26 | -------------------------------------------------------------------------------- /Packages/CoreLayer/Utilities/Sources/Utilities/Utilities.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // More info in: 4 | // https://www.manu.show/2023-01-10-new-app-json-encoder-decoder/ 5 | // https://www.manu.show/2023-01-10-new-app-date-formatters/ 6 | // https://www.manu.show/2023-06-12-new-app-number-formatters/ 7 | // https://www.manu.show/2023-08-12-new-app-notification-center-protocols/ 8 | -------------------------------------------------------------------------------- /Packages/CoreLayer/Utilities/Tests/UtilitiesTests/UtilitiesTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Utilities 2 | import XCTest 3 | 4 | final class UtilitiesTests: XCTestCase { 5 | func testSomething() { 6 | XCTAssertTrue(true) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Packages/DomainLayer/Domain/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Packages/DomainLayer/Domain/Domain.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "868CB4B7-9386-4FAA-BF41-57E275C6188F", 5 | "name" : "Configuration 1", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | 13 | }, 14 | "testTargets" : [ 15 | { 16 | "target" : { 17 | "containerPath" : "container:", 18 | "identifier" : "DomainTests", 19 | "name" : "DomainTests" 20 | } 21 | } 22 | ], 23 | "version" : 1 24 | } 25 | -------------------------------------------------------------------------------- /Packages/DomainLayer/Domain/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Domain", 8 | platforms: [.iOS(.v17)], 9 | products: [ 10 | .library( 11 | name: "Domain", 12 | targets: ["Domain"]), 13 | ], 14 | dependencies: [ 15 | .package(path: "../../CoreLayer/APIModels"), 16 | .package(path: "../../CoreLayer/Logger"), 17 | .package(path: "../../CoreLayer/Networking"), 18 | .package(path: "../../CoreLayer/Testing"), 19 | .package(path: "../../CoreLayer/Utilities"), 20 | ], 21 | targets: [ 22 | .target( 23 | name: "Domain", 24 | dependencies: [ 25 | "APIModels", 26 | "Logger", 27 | "Networking", 28 | "Utilities", 29 | ] 30 | ), 31 | .testTarget( 32 | name: "DomainTests", 33 | dependencies: [ 34 | "Domain", 35 | "Testing" 36 | ] 37 | ), 38 | ] 39 | ) 40 | -------------------------------------------------------------------------------- /Packages/DomainLayer/Domain/Sources/Domain/Domain.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Networking 3 | import Utilities 4 | import Logger 5 | 6 | /* 7 | This is where the business logic lives. Domain should only depend on Core packages. 8 | 9 | If in need in the future, you can split up the Domain modules into multiple ones. Maybe one per feature. 10 | 11 | This is the layer that it's most important to cover with unit tests. 12 | 13 | Some advice on how to achieve that in: 14 | - [Enhancing Testability with Protocols](https://manu.show/2023-02-13-enhancing-testability-with-protocols/) 15 | - [Enhancing Testability without Protocols](https://manu.show/2023-02-03-enhancing-testability-without-protocols/) 16 | 17 | This layer is also where the `World` object lives from: [Centralized Dependencies](https://manu.show/2024-02-29-centralized-dependencies/) 18 | 19 | In this layer, you will also have the: 20 | - Services (import Networking to talk with the backend) 21 | - Repositories (import Storage to persist data) 22 | - Real app models (with their mappers from the API models) 23 | - Extensions on the models to represent their capabilities 24 | */ 25 | 26 | public struct DomainService { 27 | public init() { 28 | 29 | } 30 | 31 | // This should use Networking 32 | public func getData() async throws -> String { 33 | "Hola" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Packages/DomainLayer/Domain/Tests/DomainTests/DomainTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Domain 2 | import Testing 3 | import XCTest 4 | 5 | final class DomainTests: XCTestCase { 6 | func testSomething() { 7 | XCTAssertTrue(true) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Packages/PresentationLayer/Presentation/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /Packages/PresentationLayer/Presentation/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Presentation", 8 | platforms: [.iOS(.v17)], 9 | products: [ 10 | .library( 11 | name: "Presentation", 12 | targets: ["Presentation"]), 13 | ], 14 | dependencies: [ 15 | .package(path: "../../DomainLayer/Domain"), 16 | .package(path: "../../CoreLayer/DesignSystem"), 17 | .package(path: "../../CoreLayer/Testing"), 18 | ], 19 | targets: [ 20 | .target( 21 | name: "Presentation", 22 | dependencies: ["Domain", "DesignSystem"]), 23 | .testTarget( 24 | name: "PresentationTests", 25 | dependencies: ["Presentation", "Testing"]), 26 | ] 27 | ) 28 | -------------------------------------------------------------------------------- /Packages/PresentationLayer/Presentation/Sources/Presentation/MainView+ViewModel.swift: -------------------------------------------------------------------------------- 1 | import Domain 2 | import SwiftUI 3 | 4 | extension MainView { 5 | @MainActor 6 | final class ViewModel: ObservableObject { 7 | @Published var message: String? 8 | @Published var error: String? 9 | private let dependencies: Dependencies 10 | 11 | init(dependencies: Dependencies = .default) { 12 | self.dependencies = dependencies 13 | } 14 | } 15 | } 16 | 17 | extension MainView.ViewModel { 18 | func fetchData() async throws { 19 | do { 20 | message = try await dependencies.fetchData() 21 | } catch { 22 | self.error = error.localizedDescription 23 | } 24 | } 25 | } 26 | 27 | extension MainView.ViewModel { 28 | struct Dependencies { 29 | var fetchData: () async throws -> String 30 | 31 | static let `default`: Dependencies = Dependencies( 32 | fetchData: { 33 | try await DomainService().getData() 34 | } 35 | ) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Packages/PresentationLayer/Presentation/Sources/Presentation/MainView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Domain 3 | import DesignSystem 4 | 5 | // This is where the Screens live. Presentation depends on Domain, and on DesignSystem. It can also depend on CorePackages directly if needed. 6 | // Each Screen will be composed of many DesignSystem components. 7 | // The development team can decide which UI pattern (MVVM, MVP, VIP, VIPER, TCA, etc) to use. 8 | // It's important to cover the state changes with unit tests. 9 | // In this layer, we could also include: 10 | // https://manu.show/2023-01-08-new-app-view-state/ 11 | // https://manu.showtroller/2023-03-04-view-state-controller/ 12 | 13 | public struct MainView: View { 14 | @StateObject private var viewModel = ViewModel() 15 | 16 | public init() {} 17 | 18 | /// Only used for the Previews 19 | init(dependencies: ViewModel.Dependencies) { 20 | _viewModel = StateObject(wrappedValue: ViewModel(dependencies: dependencies)) 21 | } 22 | 23 | public var body: some View { 24 | VStack(spacing: 20) { 25 | Text("Modular iOS App") 26 | .font(.title) 27 | .bold() 28 | 29 | Text("Architecture Demo") 30 | .font(.subheadline) 31 | .foregroundColor(.secondary) 32 | 33 | Button("Fetch Data") { 34 | Task { 35 | try await viewModel.fetchData() 36 | } 37 | } 38 | .buttonStyle(PrimaryButtonStyle()) 39 | 40 | if let message = viewModel.message { 41 | Text(message) 42 | .padding() 43 | .background(Color.gray.opacity(0.1)) 44 | .cornerRadius(8) 45 | } else if let error = viewModel.error { 46 | Text(error) 47 | .padding() 48 | .background(Color.red.opacity(0.1)) 49 | .cornerRadius(8) 50 | } 51 | } 52 | .padding() 53 | } 54 | } 55 | 56 | #Preview("Success") { 57 | // By using the dependencies approach, we can use in-line mocks for previews 58 | MainView(dependencies: .init(fetchData: { 59 | "Something Mocked" 60 | })) 61 | } 62 | 63 | #Preview("Error") { 64 | // By using the dependencies approach, we can use in-line mocks for previews 65 | MainView(dependencies: .init(fetchData: { 66 | throw NSError(domain: "", code: 1) 67 | })) 68 | } 69 | -------------------------------------------------------------------------------- /Packages/PresentationLayer/Presentation/Tests/PresentationTests/MainView+ViewModelTests.swift: -------------------------------------------------------------------------------- 1 | @testable import Presentation 2 | import XCTest 3 | 4 | final class MainViewViewModelTests: XCTestCase { 5 | @MainActor 6 | func testMessageIsSetWhenFetchDataSucceeds() async throws { 7 | // Given 8 | let expectedMessage = "Mock" 9 | let sut = MainView.ViewModel(dependencies: .init(fetchData: { 10 | expectedMessage 11 | })) 12 | XCTAssertNil(sut.message) 13 | 14 | // When 15 | try await sut.fetchData() 16 | 17 | // Then 18 | XCTAssertEqual(sut.message, expectedMessage) 19 | XCTAssertNil(sut.error) 20 | } 21 | 22 | @MainActor 23 | func testErrorIsSetWhenFetchDataThrows() async throws { 24 | // Given 25 | let error = NSError(domain: "", code: 1) 26 | let sut = MainView.ViewModel(dependencies: .init(fetchData: { 27 | throw error 28 | })) 29 | XCTAssertNil(sut.error) 30 | 31 | // When 32 | try await sut.fetchData() 33 | 34 | // Then 35 | XCTAssertEqual(sut.error, error.localizedDescription) 36 | XCTAssertNil(sut.message) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # Modular Example 2 | 3 | A modular iOS app starting point. 4 | 5 | ![diagram](git-images/diagram.png) 6 | 7 | A deeper explanation can be found in the [blog post](https://manu.show/2025-02-27-simple-modularization-setup/). 8 | 9 | ## How to start using this? 10 | 11 | As packages are really easy to move around, importing this structure into your app is as easy as: 12 | 13 | 1. Clone the repository 14 | 2. Copy the Pakcages folder into your project 15 | 3. Add the `Presentation` local dependency in your Xcode Project or SPM Package. 16 | 17 | In your repository, you could also add [Contributing Guidelines](/2023-01-02-new-app-contributing-guidelines/) and [The Definition of Done](/2023-05-13-the-definition-of-done/). 18 | 19 | There is also a [contributing guidelines](.github/CONTRIBUTING.md) document in this repository that can be used as a starting point. 20 | 21 | ## Testing 22 | 23 | This project uses Fastlane to automate testing across all packages. To run tests for all packages on iOS simulators: 24 | 25 | ```bash 26 | bundle exec fastlane test_all_packages [verbose:minimal|simple|full] 27 | ``` 28 | 29 | ![tests](git-images/tests.png) 30 | 31 | To run tests for a specific package: 32 | 33 | ```bash 34 | bundle exec fastlane test_scheme scheme:PackageName [verbose:minimal|simple|full] 35 | ``` 36 | 37 | For example, to test the Logger package: 38 | 39 | ```bash 40 | bundle exec fastlane test_scheme scheme:Logger [verbose:minimal|simple|full] 41 | ``` 42 | 43 | To run tests only for packages with changes compared to a base branch: 44 | 45 | ```bash 46 | bundle exec fastlane test_changed_packages [base_branch:main] [verbose:minimal|simple|full] 47 | ``` 48 | 49 | This is particularly useful during development or in CI/CD pipelines to validate only the code that has changed. 50 | 51 | Test results are stored in the `test_output` directory at the project root level. 52 | 53 | ### Verbosity Levels 54 | 55 | - `minimal` (default): Only shows when tests start for each package and the final results. Shows a comprehensive test summary at the end. 56 | - `simple`: Shows simplified test output with xcpretty 57 | - `full`: Shows full test output with detailed xcpretty formatting 58 | 59 | The test output includes: 60 | - Number of tests passed/failed for each package 61 | - A summary of all packages tested, skipped, and their results 62 | - Overall statistics including total tests run, passed, and failed 63 | 64 | Tests will exit with a non-zero status code if any tests fail, making it suitable for CI/CD pipelines. 65 | 66 | See the [Fastlane README](fastlane/README.md) for more details. 67 | -------------------------------------------------------------------------------- /fastlane/.gitignore: -------------------------------------------------------------------------------- 1 | # Fastlane specific 2 | report.xml 3 | Preview.html 4 | screenshots 5 | test_output/* 6 | *.ipa 7 | *.app.dSYM.zip 8 | -------------------------------------------------------------------------------- /fastlane/Appfile: -------------------------------------------------------------------------------- 1 | app_identifier("com.example.ModularTemplate") # The bundle identifier of your app 2 | apple_id("your_apple_id@example.com") # Your Apple email address 3 | 4 | # For more information about the Appfile, see: 5 | # https://docs.fastlane.tools/advanced/#appfile 6 | -------------------------------------------------------------------------------- /fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | default_platform(:ios) 2 | 3 | # Custom method to print minimal output 4 | def minimal_output(message) 5 | puts message 6 | end 7 | 8 | # Custom method to suppress fastlane output for minimal verbosity 9 | def suppress_output 10 | original_stdout = $stdout.clone 11 | original_stderr = $stderr.clone 12 | 13 | $stdout.reopen(File.new('/dev/null', 'w')) 14 | $stderr.reopen(File.new('/dev/null', 'w')) 15 | 16 | yield 17 | 18 | $stdout.reopen(original_stdout) 19 | $stderr.reopen(original_stderr) 20 | end 21 | 22 | # Custom method to extract code coverage from xcresult bundle 23 | def extract_coverage_from_xcresult(result_bundle_path) 24 | return nil unless File.exist?(result_bundle_path) 25 | 26 | begin 27 | # Get the coverage report 28 | cmd = "xcrun xccov view --report --json #{result_bundle_path}" 29 | json_output = `#{cmd}` 30 | 31 | # Check if we have output 32 | return nil if json_output.empty? 33 | 34 | # Extract just the JSON part (sometimes there might be other output before/after) 35 | json_match = json_output.match(/\{.*\}/m) 36 | return nil unless json_match 37 | 38 | json_data = json_match[0] 39 | report_json = JSON.parse(json_data) 40 | 41 | # Initialize counters 42 | total_source_coverable_lines = 0 43 | total_source_covered_lines = 0 44 | 45 | # Process all targets in the report 46 | targets = report_json["targets"] || [] 47 | targets.each do |target| 48 | # Skip test targets or targets with no executable lines 49 | next if target["name"].include?("Tests") || target["executableLines"].to_i == 0 50 | 51 | # Process files 52 | files = target["files"] || [] 53 | files.each do |file| 54 | # Skip test files and mocks 55 | file_path = file["path"] || "" 56 | next if file_path.include?("Tests/") || file_path.include?("XCTest") || 57 | file_path.include?("Mock") || file_path.include?("Stub") 58 | 59 | # Count lines for source files 60 | coverable_lines = file["executableLines"] || 0 61 | covered_lines = file["coveredLines"] || 0 62 | 63 | total_source_coverable_lines += coverable_lines 64 | total_source_covered_lines += covered_lines 65 | end 66 | end 67 | 68 | # Calculate the coverage percentage for source files only 69 | if total_source_coverable_lines > 0 70 | return { 71 | percentage: ((total_source_covered_lines.to_f / total_source_coverable_lines) * 100).round(2), 72 | covered_lines: total_source_covered_lines, 73 | coverable_lines: total_source_coverable_lines 74 | } 75 | end 76 | rescue => e 77 | puts "Error extracting coverage: #{e.message}" 78 | end 79 | 80 | return nil 81 | end 82 | 83 | # Custom method to print coverage badge 84 | def coverage_badge(percentage) 85 | if percentage.nil? 86 | return "⚠️ No coverage data" 87 | end 88 | 89 | # Determine color based on percentage 90 | if percentage >= 75 91 | return "🟢 #{percentage}%" 92 | elsif percentage >= 50 93 | return "🟡 #{percentage}%" 94 | else 95 | return "🔴 #{percentage}%" 96 | end 97 | end 98 | 99 | # Custom method to get changed files between branches 100 | def get_changed_files(base_branch) 101 | # Get the list of changed files 102 | changed_files = sh("git diff --name-only origin/#{base_branch}", log: false).split("\n") 103 | # Filter only files in Packages directory 104 | changed_files.select { |file| file.start_with?("Packages/") } 105 | end 106 | 107 | # Custom method to get package name from file path 108 | def get_package_from_path(file_path) 109 | # Extract the package path after 'Packages/' 110 | match = file_path.match(/^Packages\/(.+?)\/(?:Sources|Tests)\//) 111 | return nil unless match 112 | match[1] # This will return the full package path (e.g. 'CoreLayer/APIModels') 113 | end 114 | 115 | # Custom method to check if a package has tests 116 | def has_tests?(package_name) 117 | # Make path relative to the root directory, not the fastlane directory 118 | root_dir = File.expand_path('..', Dir.pwd) 119 | base_path = File.join(root_dir, "Packages/#{package_name}") 120 | minimal_output("Checking for tests in: #{base_path}") 121 | minimal_output("Tests directory exists? #{Dir.exist?("#{base_path}/Tests")}") 122 | minimal_output("Current directory: #{Dir.pwd}") 123 | 124 | return true if Dir.exist?("#{base_path}/Tests") 125 | 126 | # Check for tests in subpackages 127 | subpackage_tests = Dir.glob("#{base_path}/*/Tests") 128 | minimal_output("Subpackage tests found: #{subpackage_tests}") 129 | subpackage_tests.any? 130 | end 131 | 132 | platform :ios do 133 | desc "Run tests only for changed packages" 134 | lane :test_changed_packages do |options| 135 | # Get base branch (default to main) 136 | base_branch = options[:base_branch] || "main" 137 | 138 | # Get verbosity level (default: minimal) 139 | verbose = options[:verbose] || "minimal" 140 | 141 | # Configure output based on verbosity 142 | if verbose == "minimal" 143 | FastlaneCore::Globals.verbose = false 144 | ENV["FASTLANE_SKIP_ACTION_SUMMARY"] = "true" 145 | FastlaneCore::UI.disable_colors = true if FastlaneCore::Helper.ci? 146 | ENV["FASTLANE_HIDE_TIMESTAMP"] = "true" 147 | ENV["FASTLANE_HIDE_DEVICE_TIMESTAMP"] = "true" 148 | end 149 | 150 | # Get changed files 151 | changed_files = get_changed_files(base_branch) 152 | 153 | # Extract unique package names 154 | changed_packages = changed_files.map { |file| get_package_from_path(file) }.compact.uniq 155 | 156 | minimal_output("🔍 Found #{changed_packages.length} changed packages: #{changed_packages.join(", ")}") 157 | 158 | if !changed_packages.empty? 159 | # Run tests for changed packages 160 | test_all_packages( 161 | packages: changed_packages, 162 | verbose: verbose 163 | ) 164 | end 165 | end 166 | 167 | desc "Run tests for all packages or specific packages" 168 | lane :test_all_packages do |options| 169 | # Get the packages to test (if specified) 170 | packages_to_test = options[:packages] 171 | 172 | # Get verbosity level (default: minimal) 173 | verbose = options[:verbose] || "minimal" 174 | 175 | # Configure output based on verbosity 176 | if verbose == "minimal" 177 | # Disable fastlane verbosity 178 | FastlaneCore::Globals.verbose = false 179 | 180 | # Disable summary 181 | ENV["FASTLANE_SKIP_ACTION_SUMMARY"] = "true" 182 | 183 | # Disable other fastlane output 184 | FastlaneCore::UI.disable_colors = true if FastlaneCore::Helper.ci? 185 | 186 | # Disable step output 187 | ENV["FASTLANE_HIDE_TIMESTAMP"] = "true" 188 | ENV["FASTLANE_HIDE_DEVICE_TIMESTAMP"] = "true" 189 | 190 | # Suppress initial fastlane output 191 | suppress_output do 192 | UI.message("Starting test process...") 193 | end 194 | 195 | if packages_to_test && !packages_to_test.empty? 196 | minimal_output("🚀 Running tests for specified packages: #{packages_to_test.join(", ")}") 197 | else 198 | minimal_output("🚀 Running tests for all packages...") 199 | end 200 | else 201 | FastlaneCore::Globals.verbose = true 202 | ENV["FASTLANE_SKIP_ACTION_SUMMARY"] = "false" 203 | end 204 | 205 | # Find all package directories 206 | core_packages = Dir.glob("../Packages/CoreLayer/*").select { |f| File.directory?(f) } 207 | domain_packages = Dir.glob("../Packages/DomainLayer/*").select { |f| File.directory?(f) } 208 | presentation_packages = Dir.glob("../Packages/PresentationLayer/*").select { |f| File.directory?(f) } 209 | 210 | all_packages = core_packages + domain_packages + presentation_packages 211 | 212 | # Filter packages if a specific list was provided 213 | if packages_to_test && !packages_to_test.empty? 214 | # Convert package paths to match format in packages_to_test (e.g. 'CoreLayer/APIModels') 215 | filtered_packages = all_packages.select do |package_path| 216 | # Extract the package hierarchy (e.g. from '/path/to/Packages/CoreLayer/APIModels' to 'CoreLayer/APIModels') 217 | parts = package_path.split('Packages/').last.split('/') 218 | if parts.length >= 2 219 | # For nested packages like CoreLayer/APIModels 220 | package_id = [parts[0], parts[1]].join('/') 221 | packages_to_test.include?(package_id) 222 | else 223 | # For top-level packages 224 | packages_to_test.include?(parts[0]) 225 | end 226 | end 227 | all_packages = filtered_packages 228 | end 229 | 230 | # Track test results 231 | results = { 232 | passed: [], 233 | failed: [], 234 | skipped: [] 235 | } 236 | 237 | # Create the output directory at the project root level 238 | project_root = File.expand_path("../..", __FILE__) 239 | FileUtils.mkdir_p(File.join(project_root, "test_output")) 240 | 241 | # Track total test counts 242 | total_tests_run = 0 243 | total_tests_passed = 0 244 | total_tests_failed = 0 245 | 246 | # Run tests for each package 247 | all_packages.each do |package_dir| 248 | package_name = File.basename(package_dir) 249 | 250 | begin 251 | # Test the package directly with xcodebuild 252 | Dir.chdir(package_dir) do 253 | # Check if Package.swift exists 254 | unless File.exist?("Package.swift") 255 | UI.message("Skipping #{package_name} - no Package.swift found") if verbose != "minimal" 256 | results[:skipped] << { name: package_name, reason: "No Package.swift found" } 257 | next 258 | end 259 | 260 | # Check if the package has tests 261 | has_tests = Dir.exist?("Tests") || Dir.glob("Sources/*/Tests").any? || Dir.glob("*/Tests").any? 262 | unless has_tests 263 | UI.message("Skipping #{package_name} - no tests found") if verbose != "minimal" 264 | results[:skipped] << { name: package_name, reason: "No tests found" } 265 | next 266 | end 267 | 268 | # Announce test start 269 | if verbose == "minimal" 270 | minimal_output("▶️ Testing #{package_name}...") 271 | else 272 | UI.message("Running tests for package: #{package_name}") 273 | end 274 | 275 | # Define result bundle path at the project root level 276 | result_bundle_path = File.join(project_root, "test_output", "#{package_name}.xcresult") 277 | 278 | # Remove any existing result bundle 279 | FileUtils.rm_rf(result_bundle_path) if File.exist?(result_bundle_path) 280 | 281 | # Create a temporary file to capture the output 282 | output_file = Tempfile.new(["#{package_name}-test", ".log"]) 283 | 284 | # Run tests using xcodebuild with SPM integration and pipe through xcpretty 285 | destination = "platform=iOS Simulator,name=iPhone 16 Pro,OS=latest" 286 | 287 | # Adjust xcpretty output based on verbosity 288 | xcpretty_format = verbose == "full" ? "" : "--simple" 289 | 290 | # Add code coverage option 291 | test_command = "set -o pipefail && xcodebuild test -scheme #{package_name} -destination '#{destination}' -resultBundlePath '#{result_bundle_path}' -enableCodeCoverage YES" 292 | 293 | # Add output redirection based on verbosity 294 | if verbose == "minimal" 295 | test_command += " > #{output_file.path} 2>&1" 296 | 297 | # Execute command with suppressed output 298 | suppress_output do 299 | begin 300 | sh(test_command) 301 | test_success = true 302 | rescue => e 303 | test_success = false 304 | end 305 | end 306 | else 307 | test_command += " | tee #{output_file.path} | xcpretty --color #{xcpretty_format} --report junit" 308 | begin 309 | sh(test_command) 310 | test_success = true 311 | rescue => e 312 | test_success = false 313 | end 314 | end 315 | 316 | # Read the output file to estimate test counts 317 | output_content = File.read(output_file.path) 318 | 319 | # Parse the output to get test counts 320 | # Look for patterns like "Executed 5 tests, with 0 failures" 321 | test_count_match = output_content.match(/Executed (\d+) tests?, with (\d+) failures/) 322 | 323 | tests_count = 0 324 | tests_failed = 0 325 | 326 | if test_count_match 327 | tests_count = test_count_match[1].to_i 328 | tests_failed = test_count_match[2].to_i 329 | else 330 | # If we can't find the pattern, check if test failed 331 | if !test_success || output_content.include?("** TEST FAILED **") 332 | tests_count = 1 333 | tests_failed = 1 334 | else 335 | # If we can't find the pattern, assume at least 1 test passed 336 | tests_count = 1 337 | tests_failed = 0 338 | end 339 | end 340 | 341 | tests_passed = tests_count - tests_failed 342 | 343 | # Update total counts 344 | total_tests_run += tests_count 345 | total_tests_passed += tests_passed 346 | total_tests_failed += tests_failed 347 | 348 | # Clean up the temporary file 349 | output_file.close 350 | output_file.unlink 351 | 352 | # Clean up build folder 353 | FileUtils.rm_rf("build") if Dir.exist?("build") 354 | 355 | # Extract coverage data 356 | coverage_data = extract_coverage_from_xcresult(result_bundle_path) 357 | 358 | # Add coverage data to results 359 | if tests_failed > 0 360 | results[:failed] << { 361 | name: package_name, 362 | tests_count: tests_count, 363 | tests_failed: tests_failed, 364 | tests_passed: tests_passed, 365 | coverage: coverage_data 366 | } 367 | else 368 | results[:passed] << { 369 | name: package_name, 370 | tests_count: tests_count, 371 | tests_failed: tests_failed, 372 | tests_passed: tests_passed, 373 | coverage: coverage_data 374 | } 375 | end 376 | 377 | if tests_failed > 0 378 | if verbose == "minimal" 379 | # Check for test failure 380 | if output_content.include?("** TEST FAILED **") 381 | minimal_output("❌ Test Failed") 382 | end 383 | minimal_output("❌ #{package_name}: #{tests_passed}/#{tests_count} tests passed (#{tests_failed} failed)") 384 | minimal_output(" Coverage: #{coverage_badge(coverage_data ? coverage_data[:percentage] : nil)}") 385 | # Return non-zero exit code for CI systems 386 | UI.user_error!("Tests failed for #{package_name}") 387 | else 388 | UI.error("❌ Tests for #{package_name} failed!") 389 | UI.error(" ❌ #{tests_passed}/#{tests_count} tests passed (#{tests_failed} failed)") 390 | UI.error(" Coverage: #{coverage_badge(coverage_data ? coverage_data[:percentage] : nil)}") 391 | # Return non-zero exit code for CI systems 392 | UI.user_error!("Tests failed for #{package_name}") 393 | end 394 | else 395 | if verbose == "minimal" 396 | # Check if test succeeded 397 | if output_content.include?("TEST SUCCEEDED") 398 | minimal_output("▸ Test Succeeded") 399 | end 400 | minimal_output("✅ #{package_name}: #{tests_passed}/#{tests_count} tests passed") 401 | minimal_output(" Coverage: #{coverage_badge(coverage_data ? coverage_data[:percentage] : nil)}") 402 | else 403 | UI.success("🎉 Tests for #{package_name} completed successfully!") 404 | UI.success(" ✅ #{tests_passed}/#{tests_count} tests passed") 405 | UI.success(" Coverage: #{coverage_badge(coverage_data ? coverage_data[:percentage] : nil)}") 406 | end 407 | end 408 | end 409 | rescue => e 410 | UI.error("Error testing package #{package_name}: #{e.message}") 411 | results[:failed] << { 412 | name: package_name, 413 | tests_count: 0, 414 | tests_failed: 0, 415 | tests_passed: 0, 416 | error: e.message 417 | } 418 | total_tests_failed += 1 419 | end 420 | end 421 | 422 | # Print a pretty summary 423 | if verbose == "minimal" 424 | # Display a simplified summary for minimal verbosity 425 | minimal_output("\n📊 Test Results Summary") 426 | 427 | if !results[:passed].empty? 428 | minimal_output("✅ Passed: #{results[:passed].count} packages, #{total_tests_passed} tests") 429 | results[:passed].each do |package| 430 | minimal_output(" • #{package[:name]} - #{package[:tests_passed]}/#{package[:tests_count]} tests") 431 | minimal_output(" Coverage: #{coverage_badge(package[:coverage] ? package[:coverage][:percentage] : nil)}") 432 | end 433 | end 434 | 435 | if !results[:failed].empty? 436 | minimal_output("❌ Failed: #{results[:failed].count} packages, #{total_tests_failed} tests") 437 | results[:failed].each do |package| 438 | minimal_output(" • #{package[:name]} - #{package[:tests_passed]}/#{package[:tests_count]} tests passed (#{package[:tests_failed]} failed)") 439 | minimal_output(" Coverage: #{coverage_badge(package[:coverage] ? package[:coverage][:percentage] : nil)}") 440 | end 441 | end 442 | 443 | if !results[:skipped].empty? 444 | minimal_output("⏭️ Skipped: #{results[:skipped].count} packages") 445 | results[:skipped].each do |package| 446 | minimal_output(" • #{package[:name]} - #{package[:reason]}") 447 | end 448 | end 449 | 450 | minimal_output("\n📈 Overall Statistics") 451 | minimal_output("Total tests: #{total_tests_run}") 452 | minimal_output("Passed: #{total_tests_passed}") 453 | minimal_output("Failed: #{total_tests_failed}") 454 | 455 | if !results[:failed].empty? 456 | minimal_output("❌ Some tests failed. Please check the logs for details.") 457 | UI.user_error!("Tests failed for #{results[:failed].map { |p| p[:name] }.join(', ')}") 458 | else 459 | minimal_output("🎉 All tests passed successfully!") 460 | end 461 | else 462 | UI.header("📊 Test Results Summary") 463 | 464 | # Only show passed section if there are passed tests 465 | if !results[:passed].empty? 466 | UI.success("✅ Passed (#{results[:passed].count} packages, #{total_tests_passed} tests):") 467 | results[:passed].each do |package| 468 | tests_info = "#{package[:tests_passed]}/#{package[:tests_count]} tests" 469 | coverage_info = package[:coverage] ? "#{coverage_badge(package[:coverage][:percentage])}" : "⚠️ No coverage data" 470 | UI.success(" • #{package[:name]} - #{tests_info}") 471 | UI.success(" Coverage: #{coverage_info}") 472 | end 473 | end 474 | 475 | # Only show failed section if there are failed tests 476 | if !results[:failed].empty? 477 | UI.error("❌ Failed (#{results[:failed].count} packages, #{total_tests_failed} tests):") 478 | results[:failed].each do |package| 479 | tests_info = "#{package[:tests_passed]}/#{package[:tests_count]} tests passed (#{package[:tests_failed]} failed)" 480 | coverage_info = package[:coverage] ? "#{coverage_badge(package[:coverage][:percentage])}" : "⚠️ No coverage data" 481 | UI.error(" • #{package[:name]} - #{tests_info}") 482 | UI.error(" Coverage: #{coverage_info}") 483 | end 484 | end 485 | 486 | # Only show skipped section if there are skipped packages 487 | if !results[:skipped].empty? 488 | UI.important("⏭️ Skipped (#{results[:skipped].count}):") 489 | results[:skipped].each do |package| 490 | UI.important(" • #{package[:name]} - #{package[:reason]}") 491 | end 492 | end 493 | 494 | # Final summary 495 | UI.header("📈 Overall Statistics") 496 | UI.message("Total tests: #{total_tests_run}") 497 | UI.message("Passed: #{total_tests_passed}") 498 | UI.message("Failed: #{total_tests_failed}") 499 | 500 | if results[:failed].empty? 501 | UI.success("🎉 All tests passed successfully!") 502 | else 503 | UI.error("❌ Some tests failed. Please check the logs for details.") 504 | UI.user_error!("Tests failed for #{results[:failed].map { |p| p[:name] }.join(', ')}") 505 | end 506 | end 507 | end 508 | 509 | desc "Run tests for a specific scheme" 510 | lane :test_scheme do |options| 511 | scheme_name = options[:scheme] 512 | 513 | unless scheme_name 514 | UI.user_error!("Please provide a scheme name using the 'scheme' parameter") 515 | end 516 | 517 | # Get verbosity level (default: minimal) 518 | verbose = options[:verbose] || "minimal" 519 | 520 | # Configure output based on verbosity 521 | if verbose == "minimal" 522 | # Disable fastlane verbosity 523 | FastlaneCore::Globals.verbose = false 524 | 525 | # Disable summary 526 | ENV["FASTLANE_SKIP_ACTION_SUMMARY"] = "true" 527 | 528 | # Disable other fastlane output 529 | FastlaneCore::UI.disable_colors = true if FastlaneCore::Helper.ci? 530 | 531 | # Disable step output 532 | ENV["FASTLANE_HIDE_TIMESTAMP"] = "true" 533 | ENV["FASTLANE_HIDE_DEVICE_TIMESTAMP"] = "true" 534 | 535 | # Suppress initial fastlane output 536 | suppress_output do 537 | UI.message("Starting test process...") 538 | end 539 | 540 | minimal_output("🚀 Testing #{scheme_name}...") 541 | else 542 | FastlaneCore::Globals.verbose = true 543 | ENV["FASTLANE_SKIP_ACTION_SUMMARY"] = "false" 544 | end 545 | 546 | # Find the package directory 547 | package_dir = nil 548 | 549 | # Search in all layer directories 550 | ["CoreLayer", "DomainLayer", "PresentationLayer"].each do |layer| 551 | potential_dir = "../Packages/#{layer}/#{scheme_name}" 552 | if Dir.exist?(potential_dir) 553 | package_dir = potential_dir 554 | break 555 | end 556 | end 557 | 558 | unless package_dir 559 | UI.user_error!("Package '#{scheme_name}' not found in any layer") 560 | end 561 | 562 | # Create the output directory at the project root level 563 | project_root = File.expand_path("../..", __FILE__) 564 | FileUtils.mkdir_p(File.join(project_root, "test_output")) 565 | 566 | # Test the package directly with xcodebuild 567 | Dir.chdir(package_dir) do 568 | # Check if Package.swift exists 569 | unless File.exist?("Package.swift") 570 | UI.user_error!("No Package.swift found in #{scheme_name}") 571 | end 572 | 573 | # Check if the package has tests 574 | has_tests = Dir.exist?("Tests") || Dir.glob("Sources/*/Tests").any? || Dir.glob("*/Tests").any? 575 | unless has_tests 576 | UI.user_error!("No tests found for package #{scheme_name}") 577 | end 578 | 579 | # Define result bundle path at the project root level 580 | result_bundle_path = File.join(project_root, "test_output", "#{scheme_name}.xcresult") 581 | 582 | # Remove any existing result bundle 583 | FileUtils.rm_rf(result_bundle_path) if File.exist?(result_bundle_path) 584 | 585 | # Create a temporary file to capture the output 586 | output_file = Tempfile.new(["#{scheme_name}-test", ".log"]) 587 | 588 | # Run tests using xcodebuild with SPM integration and pipe through xcpretty 589 | destination = "platform=iOS Simulator,name=iPhone 16 Pro,OS=latest" 590 | 591 | # Adjust xcpretty output based on verbosity 592 | xcpretty_format = verbose == "full" ? "" : "--simple" 593 | 594 | # Add code coverage option 595 | test_command = "set -o pipefail && xcodebuild test -scheme #{scheme_name} -destination '#{destination}' -resultBundlePath '#{result_bundle_path}' -enableCodeCoverage YES" 596 | 597 | # Add output redirection based on verbosity 598 | if verbose == "minimal" 599 | test_command += " > #{output_file.path} 2>&1" 600 | 601 | # Execute command with suppressed output 602 | suppress_output do 603 | begin 604 | sh(test_command) 605 | test_success = true 606 | rescue => e 607 | test_success = false 608 | end 609 | end 610 | else 611 | test_command += " | tee #{output_file.path} | xcpretty --color #{xcpretty_format} --report junit" 612 | begin 613 | sh(test_command) 614 | test_success = true 615 | rescue => e 616 | test_success = false 617 | end 618 | end 619 | 620 | # Read the output file to estimate test counts 621 | output_content = File.read(output_file.path) 622 | 623 | # Parse the output to get test counts 624 | # Look for patterns like "Executed 5 tests, with 0 failures" 625 | test_count_match = output_content.match(/Executed (\d+) tests?, with (\d+) failures/) 626 | 627 | tests_count = 0 628 | tests_failed = 0 629 | 630 | if test_count_match 631 | tests_count = test_count_match[1].to_i 632 | tests_failed = test_count_match[2].to_i 633 | else 634 | # If we can't find the pattern, check if test failed 635 | if !test_success || output_content.include?("** TEST FAILED **") 636 | tests_count = 1 637 | tests_failed = 1 638 | else 639 | # If we can't find the pattern, assume at least 1 test passed 640 | tests_count = 1 641 | tests_failed = 0 642 | end 643 | end 644 | 645 | tests_passed = tests_count - tests_failed 646 | 647 | # Clean up the temporary file 648 | output_file.close 649 | output_file.unlink 650 | 651 | # Clean up build folder 652 | FileUtils.rm_rf("build") if Dir.exist?("build") 653 | 654 | # Extract coverage data 655 | coverage_data = extract_coverage_from_xcresult(result_bundle_path) 656 | 657 | if tests_failed > 0 658 | if verbose == "minimal" 659 | # Check for test failure 660 | if output_content.include?("** TEST FAILED **") 661 | minimal_output("❌ Test Failed") 662 | end 663 | minimal_output("❌ #{scheme_name}: #{tests_passed}/#{tests_count} tests passed (#{tests_failed} failed)") 664 | minimal_output(" Coverage: #{coverage_badge(coverage_data ? coverage_data[:percentage] : nil)}") 665 | # Return non-zero exit code for CI systems 666 | UI.user_error!("Tests failed for #{scheme_name}") 667 | else 668 | UI.error("❌ Tests for #{scheme_name} failed!") 669 | UI.error(" ❌ #{tests_passed}/#{tests_count} tests passed (#{tests_failed} failed)") 670 | UI.error(" Coverage: #{coverage_badge(coverage_data ? coverage_data[:percentage] : nil)}") 671 | # Return non-zero exit code for CI systems 672 | UI.user_error!("Tests failed for #{scheme_name}") 673 | end 674 | else 675 | if verbose == "minimal" 676 | # Check if test succeeded 677 | if output_content.include?("TEST SUCCEEDED") 678 | minimal_output("▸ Test Succeeded") 679 | end 680 | minimal_output("✅ #{scheme_name}: #{tests_passed}/#{tests_count} tests passed") 681 | minimal_output(" Coverage: #{coverage_badge(coverage_data ? coverage_data[:percentage] : nil)}") 682 | else 683 | UI.success("🎉 Tests for #{scheme_name} completed successfully!") 684 | UI.success(" ✅ #{tests_passed}/#{tests_count} tests passed") 685 | UI.success(" Coverage: #{coverage_badge(coverage_data ? coverage_data[:percentage] : nil)}") 686 | end 687 | end 688 | end 689 | end 690 | end 691 | -------------------------------------------------------------------------------- /fastlane/README.md: -------------------------------------------------------------------------------- 1 | fastlane documentation 2 | ---- 3 | 4 | # Installation 5 | 6 | Make sure you have the latest version of the Xcode command line tools installed: 7 | 8 | ```sh 9 | xcode-select --install 10 | ``` 11 | 12 | For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) 13 | 14 | # Available Actions 15 | 16 | ## iOS 17 | 18 | ### ios test_changed_packages 19 | 20 | ```sh 21 | [bundle exec] fastlane ios test_changed_packages 22 | ``` 23 | 24 | Run tests only for changed packages 25 | 26 | ### ios test_all_packages 27 | 28 | ```sh 29 | [bundle exec] fastlane ios test_all_packages 30 | ``` 31 | 32 | Run tests for all packages or specific packages 33 | 34 | ### ios test_scheme 35 | 36 | ```sh 37 | [bundle exec] fastlane ios test_scheme 38 | ``` 39 | 40 | Run tests for a specific scheme 41 | 42 | ---- 43 | 44 | This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. 45 | 46 | More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). 47 | 48 | The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). 49 | -------------------------------------------------------------------------------- /git-images/diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdb1/ModularTemplate/b2fa5110e9b961bd387cca2aae21938ca5fdd660/git-images/diagram.png -------------------------------------------------------------------------------- /git-images/tests.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mdb1/ModularTemplate/b2fa5110e9b961bd387cca2aae21938ca5fdd660/git-images/tests.png --------------------------------------------------------------------------------