├── Gemfile
├── git-images
├── tests.png
└── diagram.png
├── ModularTemplate
├── Assets.xcassets
│ ├── Contents.json
│ ├── AccentColor.colorset
│ │ └── Contents.json
│ └── AppIcon.appiconset
│ │ └── Contents.json
├── Preview Content
│ └── Preview Assets.xcassets
│ │ └── Contents.json
└── ModularTemplateApp.swift
├── fastlane
├── .gitignore
├── Appfile
├── README.md
└── Fastfile
├── Packages
├── CoreLayer
│ ├── Logger
│ │ ├── Sources
│ │ │ └── Logger
│ │ │ │ └── Logger.swift
│ │ ├── .gitignore
│ │ ├── Tests
│ │ │ └── LoggerTests
│ │ │ │ └── LoggerTests.swift
│ │ ├── Package.swift
│ │ └── .swiftpm
│ │ │ └── xcode
│ │ │ └── xcshareddata
│ │ │ └── xcschemes
│ │ │ └── Logger.xcscheme
│ ├── Testing
│ │ ├── Sources
│ │ │ └── Testing
│ │ │ │ └── Testing.swift
│ │ ├── .gitignore
│ │ └── Package.swift
│ ├── Networking
│ │ ├── Sources
│ │ │ └── Networking
│ │ │ │ └── Networking.swift
│ │ ├── .gitignore
│ │ ├── Tests
│ │ │ └── NetworkingTests
│ │ │ │ └── NetworkingTests.swift
│ │ └── Package.swift
│ ├── APIModels
│ │ ├── .gitignore
│ │ ├── Sources
│ │ │ └── APIModels
│ │ │ │ └── APIModel.swift
│ │ ├── Tests
│ │ │ └── APIModelsTests
│ │ │ │ └── APIModelTests.swift
│ │ └── Package.swift
│ ├── DesignSystem
│ │ ├── .gitignore
│ │ ├── Tests
│ │ │ └── DesignSystemTests
│ │ │ │ └── DesignSystemTests.swift
│ │ ├── Package.swift
│ │ ├── Sources
│ │ │ └── DesignSystem
│ │ │ │ └── DesignSystem.swift
│ │ └── .swiftpm
│ │ │ └── xcode
│ │ │ └── xcshareddata
│ │ │ └── xcschemes
│ │ │ └── DesignSystem.xcscheme
│ └── Utilities
│ │ ├── .gitignore
│ │ ├── Tests
│ │ └── UtilitiesTests
│ │ │ └── UtilitiesTests.swift
│ │ ├── Sources
│ │ └── Utilities
│ │ │ └── Utilities.swift
│ │ └── Package.swift
├── DomainLayer
│ └── Domain
│ │ ├── .gitignore
│ │ ├── Tests
│ │ └── DomainTests
│ │ │ └── DomainTests.swift
│ │ ├── Domain.xctestplan
│ │ ├── Package.swift
│ │ └── Sources
│ │ └── Domain
│ │ └── Domain.swift
└── PresentationLayer
│ └── Presentation
│ ├── .gitignore
│ ├── Package.swift
│ ├── Sources
│ └── Presentation
│ │ ├── MainView+ViewModel.swift
│ │ └── MainView.swift
│ └── Tests
│ └── PresentationTests
│ └── MainView+ViewModelTests.swift
├── ModularTemplateTests
└── ModularTemplateTests.swift
├── ModularTemplate.xcodeproj
├── project.xcworkspace
│ └── contents.xcworkspacedata
├── xcshareddata
│ └── xcschemes
│ │ └── ModularTemplate.xcscheme
└── project.pbxproj
├── ModularTemplate.xctestplan
├── .gitignore
├── Readme.md
├── Gemfile.lock
└── .github
└── CONTRIBUTING.md
/Gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org"
2 |
3 | gem "fastlane"
4 | gem "xcpretty"
5 |
--------------------------------------------------------------------------------
/git-images/tests.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mdb1/ModularTemplate/HEAD/git-images/tests.png
--------------------------------------------------------------------------------
/git-images/diagram.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mdb1/ModularTemplate/HEAD/git-images/diagram.png
--------------------------------------------------------------------------------
/ModularTemplate/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/fastlane/.gitignore:
--------------------------------------------------------------------------------
1 | # Fastlane specific
2 | report.xml
3 | Preview.html
4 | screenshots
5 | test_output/*
6 | *.ipa
7 | *.app.dSYM.zip
8 |
--------------------------------------------------------------------------------
/ModularTemplate/Preview Content/Preview Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/ModularTemplate.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
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/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/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/Readme.md:
--------------------------------------------------------------------------------
1 | # Modular Example
2 |
3 | A modular iOS app starting point.
4 |
5 | 
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 | 
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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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.
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------