├── .github
├── workflows
│ ├── build.yml
│ └── stale.yml
└── xcode-version
├── .gitignore
├── .tuist-version
├── App
├── GoogleService
│ └── cz.ackee.ProjectTemplate.test
│ │ └── GoogleService-Info.plist
├── Resources
│ ├── Assets.xcassets
│ │ ├── AppIcon.appiconset
│ │ │ ├── Ackee.png
│ │ │ └── Contents.json
│ │ └── Contents.json
│ ├── LaunchScreen.storyboard
│ └── Localizable.strings
└── Sources
│ ├── AppDelegate.swift
│ ├── AppDependency.swift
│ └── FirebaseFetcher.swift
├── Cartfile
├── Cartfile.resolved
├── Documentation
├── ProjectArchitecture.md
├── ProjectGeneration.md
└── Resources
│ ├── cover-image.png
│ └── project_template_graph.png
├── LICENSE
├── Modules
├── AppCore
│ ├── Sources
│ │ └── Protocols
│ │ │ └── UserManager.swift
│ ├── Testing
│ │ └── UserManager_Mock.swift
│ └── Tests
│ │ └── Empty.swift
├── AppUI
│ ├── Sources
│ │ └── Theme+Colors.swift
│ └── Tests
│ │ └── Empty.swift
├── Assets
│ └── Resources
│ │ ├── Assets.xcassets
│ │ ├── Contents.json
│ │ └── image.imageset
│ │ │ └── Contents.json
│ │ └── cs.lproj
│ │ └── Localizable.strings
├── Login
│ ├── Sources
│ │ └── LoginViewModel.swift
│ └── Tests
│ │ └── LoginViewModel_Tests.swift
├── Profile
│ ├── Sources
│ │ └── ProfileViewModel.swift
│ └── Tests
│ │ └── ProfileViewModel_Tests.swift
└── UserManager
│ ├── Sources
│ └── UserManager.swift
│ └── Tests
│ └── UserManager_Tests.swift
├── Project.swift
├── README.md
├── Tuist
├── Config.swift
└── ProjectDescriptionHelpers
│ ├── Constants.swift
│ ├── Extensions
│ ├── CodeSigningExtensions.swift
│ ├── DestinationsExtensions.swift
│ ├── InfoPlistExtensions.swift
│ └── TargetDependencyExtensions.swift
│ └── Targets
│ ├── App.swift
│ ├── AppCore.swift
│ ├── AppUI.swift
│ ├── Assets.swift
│ ├── Login.swift
│ ├── Profile.swift
│ └── UserManager.swift
└── Workspace.swift
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build
2 |
3 | on: [pull_request, push]
4 |
5 | jobs:
6 | build:
7 | name: Build
8 | runs-on: macos-14
9 | steps:
10 | - uses: actions/checkout@v4
11 | - uses: AckeeCZ/load-xcode-version@1.1.0
12 | - uses: actions/cache@v3
13 | with:
14 | path: Carthage
15 | key: ${{ runner.os }}-carthage-${{ hashFiles('**/Cartfile.resolved') }}
16 | restore-keys: |
17 | ${{ runner.os }}-carthage-
18 | - name: Build Carthage dependencies
19 | run: carthage bootstrap --cache-builds --use-xcframeworks
20 | - name: Build project
21 | run: |
22 | bash <(curl -Ls https://install.tuist.io)
23 | tuist fetch
24 | tuist generate
25 | tuist build
26 |
--------------------------------------------------------------------------------
/.github/workflows/stale.yml:
--------------------------------------------------------------------------------
1 | name: "Close stale issues"
2 | on:
3 | schedule:
4 | - cron: "0 0 * * *"
5 |
6 | jobs:
7 | stale:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/stale@v9
11 | with:
12 | repo-token: ${{ secrets.GITHUB_TOKEN }}
13 | stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.'
14 | stale-pr-message: 'This PR has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.'
15 | stale-issue-label: 'stale'
16 | exempt-issue-labels: 'enhancement,update'
17 | stale-pr-label: 'stale'
18 | exempt-pr-labels: 'enhancement,update'
19 | days-before-stale: 30
20 | days-before-close: 5
--------------------------------------------------------------------------------
/.github/xcode-version:
--------------------------------------------------------------------------------
1 | 15.2
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | ### OSX ###
2 | .DS_Store
3 | .AppleDouble
4 | .LSOverride
5 |
6 | # Thumbnails
7 | ._*
8 |
9 | # Files that might appear on external disk
10 | .Spotlight-V100
11 | .Trashes
12 |
13 | # Directories potentially created on remote AFP share
14 | .AppleDB
15 | .AppleDesktop
16 | Network Trash Folder
17 | Temporary Items
18 | .apdisk
19 |
20 | ### Objective-C ###
21 | # Xcode
22 | #
23 | build/
24 | .build/
25 | *.pbxuser
26 | !default.pbxuser
27 | *.mode1v3
28 | !default.mode1v3
29 | *.mode2v3
30 | !default.mode2v3
31 | *.perspectivev3
32 | !default.perspectivev3
33 | xcuserdata
34 | *.xccheckout
35 | *.moved-aside
36 | DerivedData
37 | *.hmap
38 | *.ipa
39 | *.xcuserstate
40 | *.xcodeproj
41 | *.xcworkspace
42 |
43 | # CocoaPods
44 | Pods/
45 |
46 | # Carthage
47 | Carthage/Build
48 | Carthage/Checkouts
49 |
50 | #build output
51 | /outputs
52 |
53 | # tuist
54 | Derived
55 | Tuist/Dependencies/*
56 | !Tuist/Dependencies/Lockfiles
57 |
58 | # SPM
59 | .swiftpm
--------------------------------------------------------------------------------
/.tuist-version:
--------------------------------------------------------------------------------
1 | 3.37.0
2 |
--------------------------------------------------------------------------------
/App/GoogleService/cz.ackee.ProjectTemplate.test/GoogleService-Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CLIENT_ID
6 | <id>.apps.googleusercontent.com
7 | REVERSED_CLIENT_ID
8 | com.googleusercontent.apps.<id>
9 | ANDROID_CLIENT_ID
10 | <id>.apps.googleusercontent.com
11 | API_KEY
12 | <key>
13 | GCM_SENDER_ID
14 | <id>
15 | PLIST_VERSION
16 | 1
17 | BUNDLE_ID
18 | cz.ackee.ProjectTemplate.test
19 | PROJECT_ID
20 | <id>
21 | STORAGE_BUCKET
22 | <id>.appspot.com
23 | IS_ADS_ENABLED
24 |
25 | IS_ANALYTICS_ENABLED
26 |
27 | IS_APPINVITE_ENABLED
28 |
29 | IS_GCM_ENABLED
30 |
31 | IS_SIGNIN_ENABLED
32 |
33 | GOOGLE_APP_ID
34 | <id>
35 | DATABASE_URL
36 | <id>
37 |
38 |
39 |
--------------------------------------------------------------------------------
/App/Resources/Assets.xcassets/AppIcon.appiconset/Ackee.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AckeeCZ/iOS-MVVM-ProjectTemplate/df39b593c05de912b5b3b0f1a769953ac71a51ff/App/Resources/Assets.xcassets/AppIcon.appiconset/Ackee.png
--------------------------------------------------------------------------------
/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "Ackee.png",
5 | "idiom" : "universal",
6 | "platform" : "ios",
7 | "size" : "1024x1024"
8 | }
9 | ],
10 | "info" : {
11 | "author" : "xcode",
12 | "version" : 1
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/App/Resources/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/App/Resources/LaunchScreen.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
24 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/App/Resources/Localizable.strings:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/App/Sources/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | import FirebaseCore
2 | import FirebaseCrashlytics
3 | import UIKit
4 |
5 | @main
6 | final class AppDelegate: UIResponder, UIApplicationDelegate {
7 | var window: UIWindow?
8 |
9 | func application(
10 | _ application: UIApplication,
11 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
12 | ) -> Bool {
13 | // This is gonna crash as plist file is just a placeholder
14 | FirebaseApp.configure()
15 |
16 | window = .init(frame: UIScreen.main.bounds)
17 | window?.rootViewController = UIViewController()
18 | window?.rootViewController?.view.backgroundColor = .red
19 | window?.makeKeyAndVisible()
20 |
21 | appDependencies.userManager.currentUserChanged = {
22 | Crashlytics.crashlytics().setUserID($0?.id)
23 | Crashlytics.crashlytics().setCustomValue($0?.username, forKey: "username")
24 | }
25 |
26 | return true
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/App/Sources/AppDependency.swift:
--------------------------------------------------------------------------------
1 | import ACKategories
2 | import AppCore
3 | import Foundation
4 | import UserManager
5 |
6 | final class AppDependency: HasUserManager {
7 | let userManager = createUserManager()
8 | let versionUpdateManager = VersionUpdateManager(fetcher: FirebaseFetcher(key: "min_build_number"))
9 | }
10 |
11 | let appDependencies = AppDependency()
12 |
--------------------------------------------------------------------------------
/App/Sources/FirebaseFetcher.swift:
--------------------------------------------------------------------------------
1 | import ACKategories
2 | import FirebaseRemoteConfig
3 |
4 | public final class FirebaseFetcher: MinBuildNumberFetcher {
5 | public var minBuildNumber: Int {
6 | get async throws {
7 | try await remoteConfig.fetchAndActivate()
8 | return remoteConfig[decodedValue: key] ?? Int.max
9 | }
10 | }
11 |
12 | private let key: String
13 | private let remoteConfig: RemoteConfig
14 |
15 | public init(
16 | key: String,
17 | remoteConfig: RemoteConfig = RemoteConfig.remoteConfig()
18 | ) {
19 | self.key = key
20 | self.remoteConfig = remoteConfig
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/Cartfile:
--------------------------------------------------------------------------------
1 | github "AckeeCZ/ACKategories" ~> 6.13
2 |
3 | binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseAnalyticsBinary.json" ~> 10.19
4 | binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseCrashlyticsBinary.json" ~> 10.19
5 | binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseRemoteConfigBinary.json" ~> 10.19
6 |
--------------------------------------------------------------------------------
/Cartfile.resolved:
--------------------------------------------------------------------------------
1 | binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseAnalyticsBinary.json" "10.21.0"
2 | binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseCrashlyticsBinary.json" "10.21.0"
3 | binary "https://dl.google.com/dl/firebase/ios/carthage/FirebaseRemoteConfigBinary.json" "10.21.0"
4 | github "AckeeCZ/ACKategories" "6.13.0"
5 |
--------------------------------------------------------------------------------
/Documentation/ProjectArchitecture.md:
--------------------------------------------------------------------------------
1 | # Project architecture
2 |
3 | Our single app projects use the same basic module architecture and consist from the following base targets.
4 |
5 | Basically any of the following modules can contain tests and "testing" code. For us "testing" code means any useful extensions and mock implementations that could be used for tests. To simplify usage of this testing code we have optional "Testing" folder under module's folder (that usually contains Sources and Tests folder) which is imported for debug configurations. This way we make sure this testing code never leaves dev machines, but it means that ocassionally project generation could fail if you make changes on disk and not in Tuist manifest, to fix that you should try running `tuist clean`.
6 |
7 | ## AppCore
8 | Contains stuff that is shared basically throughout the whole project - models, extensions. It is designed to be named this way without any project prefix.
9 |
10 | Most importantly it contains protocol interfaces **without any implementations**. Here you can see [`UserManager`](/Modules/Core/Sources/Protocols/UserManager.swift), that defines how user manager looks and that's all of it. If we were really correct, we should rather have a module for that but sometimes it seems like too much effort that would significantly increase number of dynamic frameworks so this is our little compromise as basically all modules depend on Core. Of course you can do it your way 😎
11 |
12 | ## AppUI
13 | Contains UI components that are shared throughout the app - button styles, text styles, colors, reused list cells, etc. By design this module does not depend on [Core](#appcore) as we prefer these view components to be just simple UI views.
14 |
15 | > 💡 It is a good idea to do some [snapshot tests](https://github.com/pointfreeco/swift-snapshot-testing) for your UI components
16 |
17 | ## Assets
18 | As mentioned in [AppUI](#appui), we prefer our views to be as simple as possible, therefore there isn't any relationship between [Core](#appcore) and [AppUI](#appui) modules. But still there might be some stuff you wanna share - icons, localizations.
19 |
20 | We have pretty good experience with having a single localization strings file (string catalog soon) and single asset catalog for the whole project - this way we can make sure that e.g. icons are not duplicated unless it is so by design. If you have a single strings file and wanna share it between [AppCore](#appcore) and [AppUI](#appui) modules, you need some module that will be linked to both and that's our Assets module. The use case for sharing icons and localizations between both modules is that you might wanna have some localized error messages in the [Core](#appcore) module, so it can be reused throughout the [App](#app) and you also might have a view with localized message in the [AppUI](#appui) module. This way you can do that.
21 |
22 | ## App
23 | Contains the final app, might be named _App_ or renamed based on project name, depends on preferences of devs working on it. Always contains our dependency injection container (just a struct with some protocol conformances) with implementations of required dependency protocols.
24 |
25 | ## Feature & Service modules
26 | Then project contains various number of feature and service modules - in our example feature modules would be [Login][login] and [Profile][profile], service module would be [UserManager][usermanager module].
27 |
28 | In general such modules are static frameworks, service frameworks should always be static as no one except the [App](#app) should depend directly on them (you should depend on its interface protocol that is in our case part of the [AppCore](#appcore) module). This also improves compile time as to build [Profile][profile] module, I don't need to compile [UserManager][usermanager module] module and any of its dependencies, [AppCore](#appcore) is enough for it.
29 |
30 | This is module graph of project where you can see how [Login][login] and [Profile][profile] modules have no dependency to [UserManager][usermanager module], they just depend on [Core](/TemplateProject/Modules/Core) module as it contains the interface for it. This allows [UserManager][usermanager module] module to stay static.
31 |
32 |
33 |
34 |
35 |
36 | Feature frameworks might be dynamic that depends on your case, but mostly you should be good with static frameworks.
37 |
38 | [usermanager module]: /Modules/UserManager
39 | [profile]: /Modules/Profile/
40 | [login]: /Modules/Login
--------------------------------------------------------------------------------
/Documentation/ProjectGeneration.md:
--------------------------------------------------------------------------------
1 | # Project generation
2 | To be able to run the project you need to have a Xcodeproj file. As mentioned above we use [Tuist][tuist] for that. This way we can get rid of dealing with merge conflicts in project file and use other features [Tuist][tuist] offers.
3 |
4 | Our projects usually have 3 build configurations and 3 environments, but to keep things simple our [Tuist][tuist] setup always generates a project with a single configuration and a single environment.
5 |
6 | Our standard configurations are _Debug_ for Xcode development, _Beta_ for testing and _Release_ for App Store builds. Usually _Debug_ and _Beta_ configurations share its bundle identifier and are connected to the same testing app in TestFlight. Which configuration is generated to Xcode project file is determined by using `TUIST_CONFIGURATION` environment variable that should contain the configuration that should be generated - if it is missing, empty or its value doesn't match any of known values - _Debug_ is used.
7 |
8 | Environments usually are _Development_, _Stage_ and _Production_. Which environment is generated is determined by using `TUIST_ENVIRONMENT` environment variable - if it is missing, empty or its value doesn't match any of known values - _Development_ is used.
9 |
10 | As our project contains our [Tuist plugin][tuist plugin], or you might use [Tuist Dependencies](https://docs.tuist.io/guides/third-party-dependencies) you might also need to run `tuist fetch`, it is a good idea to run that command with the same environment variables as they might be used also in dependencies definition.
11 |
12 | So to generate production app for App Store, you will need to run these commands:
13 | ```
14 | TUIST_ENVIRONMENT=Production TUIST_CONFIGURATION=Release tuist fetch
15 | TUIST_ENVIRONMENT=Production TUIST_CONFIGURATION=Release tuist generate
16 | ```
17 |
18 | [tuist]: https://tuist.io
19 |
--------------------------------------------------------------------------------
/Documentation/Resources/cover-image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AckeeCZ/iOS-MVVM-ProjectTemplate/df39b593c05de912b5b3b0f1a769953ac71a51ff/Documentation/Resources/cover-image.png
--------------------------------------------------------------------------------
/Documentation/Resources/project_template_graph.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AckeeCZ/iOS-MVVM-ProjectTemplate/df39b593c05de912b5b3b0f1a769953ac71a51ff/Documentation/Resources/project_template_graph.png
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2016 Jan Mísař
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in
11 | all copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19 | THE SOFTWARE.
20 |
--------------------------------------------------------------------------------
/Modules/AppCore/Sources/Protocols/UserManager.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | public struct User: Identifiable {
4 | public let id: String
5 | public let username: String
6 |
7 | public init(id: String, username: String) {
8 | self.id = id
9 | self.username = username
10 | }
11 | }
12 |
13 | public protocol UserManaging: AnyObject {
14 | var actions: UserManagingActions { get }
15 |
16 | var currentUser: User? { get }
17 | var isLoggedIn: Bool { get }
18 |
19 | var currentUserChanged: (User?) -> () { get set }
20 | }
21 |
22 | public protocol UserManagingActions {
23 | func login(username: String, password: String) async throws
24 | func logout() async
25 | }
26 |
27 | public extension UserManaging where Self: UserManagingActions {
28 | var actions: UserManagingActions { self }
29 | }
30 |
31 | public protocol HasUserManager {
32 | var userManager: UserManaging { get }
33 | }
34 |
--------------------------------------------------------------------------------
/Modules/AppCore/Testing/UserManager_Mock.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 |
3 | final class UserManager_Mock: UserManaging, UserManagingActions {
4 | var currentUser: User?
5 | var currentUserChanged: (User?) -> () = { _ in }
6 | var isLoggedIn: Bool = false
7 |
8 | var loginBody: (String, String) async throws -> Void = { _, _ in }
9 |
10 | func login(username: String, password: String) async throws {
11 | try await loginBody(username, password)
12 | }
13 |
14 | var logoutBody: () async -> Void = { }
15 |
16 | func logout() async {
17 | await logoutBody()
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Modules/AppCore/Tests/Empty.swift:
--------------------------------------------------------------------------------
1 | // For now empty as we are currently making Tuist happy so it doesn't contain an empty glob,
2 | // but you should definitely test your code 😎
3 |
4 |
--------------------------------------------------------------------------------
/Modules/AppUI/Sources/Theme+Colors.swift:
--------------------------------------------------------------------------------
1 | import ACKategories
2 | import UIKit
3 |
4 | public extension Theme where Base: UIColor {
5 | static var primary: UIColor { .green }
6 | }
7 |
--------------------------------------------------------------------------------
/Modules/AppUI/Tests/Empty.swift:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/Modules/Assets/Resources/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/Modules/Assets/Resources/Assets.xcassets/image.imageset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | },
11 | "properties" : {
12 | "preserves-vector-representation" : true,
13 | "template-rendering-intent" : "template"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/Modules/Assets/Resources/cs.lproj/Localizable.strings:
--------------------------------------------------------------------------------
1 | "ok" = "OK";
2 |
--------------------------------------------------------------------------------
/Modules/Login/Sources/LoginViewModel.swift:
--------------------------------------------------------------------------------
1 | import AppCore
2 | import Foundation
3 |
4 | public protocol LoginViewModeling {
5 | var actions: LoginViewModelingActions { get }
6 |
7 | var username: String { get set }
8 | var password: String { get set }
9 | }
10 |
11 | public protocol LoginViewModelingActions {
12 | func login() async throws
13 | }
14 |
15 | public extension LoginViewModeling where Self: LoginViewModelingActions {
16 | var actions: LoginViewModelingActions { self }
17 | }
18 |
19 | public func createLoginVM(
20 | dependencies: HasUserManager
21 | ) -> LoginViewModeling {
22 | LoginViewModel(dependencies: dependencies)
23 | }
24 |
25 | final class LoginViewModel: LoginViewModeling, LoginViewModelingActions {
26 | var username = ""
27 | var password = ""
28 |
29 | private let userManager: UserManaging
30 |
31 | // MARK: - Initializers
32 |
33 | init(dependencies: HasUserManager) {
34 | userManager = dependencies.userManager
35 | }
36 |
37 | func login() async throws {
38 | try await userManager.actions.login(username: username, password: password)
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Modules/Login/Tests/LoginViewModel_Tests.swift:
--------------------------------------------------------------------------------
1 | // For now empty as we are currently making Tuist happy so it doesn't contain an empty glob,
2 | // but you should definitely test your code 😎
3 |
--------------------------------------------------------------------------------
/Modules/Profile/Sources/ProfileViewModel.swift:
--------------------------------------------------------------------------------
1 | import AppCore
2 | import Foundation
3 |
4 | public protocol ProfileViewModeling {
5 | var actions: ProfileViewModelingActions { get }
6 |
7 | var username: String { get }
8 | }
9 |
10 | public protocol ProfileViewModelingActions {
11 | func logout() async
12 | }
13 |
14 | public extension ProfileViewModeling where Self: ProfileViewModelingActions {
15 | var actions: ProfileViewModelingActions { self }
16 | }
17 |
18 | public func createProfileVM(
19 | dependencies: HasUserManager
20 | ) -> ProfileViewModeling {
21 | ProfileViewModel(dependencies: dependencies)
22 | }
23 |
24 | final class ProfileViewModel: ProfileViewModeling, ProfileViewModelingActions {
25 | let username: String
26 |
27 | private let userManager: UserManaging
28 |
29 | // MARK: - Initializers
30 |
31 | init(dependencies: HasUserManager) {
32 | userManager = dependencies.userManager
33 | username = userManager.currentUser?.username ?? ""
34 | }
35 |
36 | func logout() async {
37 | await userManager.actions.logout()
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/Modules/Profile/Tests/ProfileViewModel_Tests.swift:
--------------------------------------------------------------------------------
1 | // For now empty as we are currently making Tuist happy so it doesn't contain an empty glob,
2 | // but you should definitely test your code 😎
3 |
--------------------------------------------------------------------------------
/Modules/UserManager/Sources/UserManager.swift:
--------------------------------------------------------------------------------
1 | import AppCore
2 | import Foundation
3 |
4 | public func createUserManager() -> UserManaging {
5 | UserManager()
6 | }
7 |
8 | final class UserManager: UserManaging, UserManagingActions {
9 | private(set) var currentUser: User?
10 | var currentUserChanged: (User?) -> () = { _ in }
11 |
12 | var isLoggedIn: Bool { currentUser != nil }
13 |
14 | func login(username: String, password: String) async throws {
15 | try await Task.sleep(nanoseconds: 1_000_000_000) // simulate network request
16 | currentUser = .init(id: UUID().uuidString, username: username)
17 | currentUserChanged(currentUser)
18 | }
19 |
20 | func logout() async {
21 | currentUser = nil
22 | currentUserChanged(currentUser)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/Modules/UserManager/Tests/UserManager_Tests.swift:
--------------------------------------------------------------------------------
1 | @testable import UserManager
2 | import XCTest
3 |
4 | @MainActor
5 | final class UserManager_Tests: XCTestCase {
6 | func test_login() async throws {
7 | let subject = UserManager()
8 |
9 | try await subject.login(username: "user", password: "password")
10 |
11 | XCTAssertEqual(subject.currentUser?.username, "user")
12 | }
13 |
14 | func test_loggedIn() async throws {
15 | let subject = UserManager()
16 |
17 | try await subject.login(username: "user", password: "password")
18 |
19 | XCTAssertTrue(subject.isLoggedIn)
20 |
21 | await subject.logout()
22 |
23 | XCTAssertFalse(subject.isLoggedIn)
24 | }
25 |
26 | func test_logout() async throws {
27 | let subject = UserManager()
28 |
29 | try await subject.login(username: "user", password: "password")
30 | await subject.logout()
31 |
32 | XCTAssertNil(subject.currentUser)
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/Project.swift:
--------------------------------------------------------------------------------
1 | import ProjectDescription
2 | import ProjectDescriptionHelpers
3 |
4 | let project = Project(
5 | name: projectName,
6 | options: .options(
7 | automaticSchemesOptions: .enabled(
8 | targetSchemesGrouping: .byNameSuffix(
9 | build: ["_Testing", "_Interface"],
10 | test: ["_Tests"],
11 | run: []
12 | ),
13 | codeCoverageEnabled: true,
14 | testLanguage: "cs",
15 | testRegion: "CZ"
16 | ),
17 | developmentRegion: "cs",
18 | textSettings: .textSettings(
19 | usesTabs: false,
20 | indentWidth: 4,
21 | tabWidth: 4,
22 | wrapsLines: true
23 | )
24 | ),
25 | settings: .settings(
26 | base: [
27 | "DEBUG_INFORMATION_FORMAT": "dwarf-with-dsym",
28 | "EAGER_LINKING": true,
29 | "ENABLE_MODULE_VERIFIER": true,
30 | "ENABLE_USER_SCRIPT_SANDBOXING": true,
31 | "IPHONEOS_DEPLOYMENT_TARGET": "15.0",
32 | "MARKETING_VERSION": .string(version.description),
33 | "OTHER_LDFLAGS": "-ObjC",
34 | ],
35 | configurations: [.current]
36 | ),
37 | targets: projectTargets
38 | )
39 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | # iOS MVVM Project Template
4 |
5 | This repository contains one of our project template components, this document will guide you through the setup and will suggest some good practices on how to use it.
6 |
7 | Our template consists of two components, well three if you may:
8 | - library with reused components like API service for network calls ([SPM](spm) and [Carthage][carthage] compatible)
9 | - [Tuist plugin][tuist plugin] that we use to share components among our projects
10 | - [example project][example project] that consumes both of the above
11 |
12 | ## Requirements
13 | Template itself basically has no requirements, it just relies heavily on [Tuist][tuist]. AckeeTemplate library that is part of this repository is [SPM][spm] and [Carthage](carthage) compatible so you can choose your integration style according to your preferences. The [example project][example project] uses [Carthage][carthage] as it is preferred for us.
14 |
15 | ## Documentation
16 | - [Project generation](Documentation/ProjectGeneration.md)
17 | - [Project architecture](Documentation/ProjectArchitecture.md)
18 |
19 | ## Usage
20 | The easiest usage would be to copy the [example project][example project] and update it according to your needs. It would be good idea to update the AckeeTemplate lib to latest version and to also update our [Tuist plugin][tuist plugin].
21 |
22 | ## Future steps
23 | In future we would love to move the whole template to Tuist plugin which will allow us to use this template in true template manner.
24 |
25 | In the meantime next minor step would be to create a true release process that would use GitHub releases where it would be possible to download archive with all the necessary stuff - for now we need to be good with this guide.
26 |
27 | [example project]: ProjectTemplate
28 | [carthage]: https://github.com/Carthage/Carthage
29 | [spm]: http://github.com/apple/swift-package-manager
30 | [tuist]: https://tuist.io
31 | [tuist plugin]: https://github.com/AckeeCZ/AckeeTemplate-TuistPlugin
--------------------------------------------------------------------------------
/Tuist/Config.swift:
--------------------------------------------------------------------------------
1 | import ProjectDescription
2 |
3 | let config = Config(
4 | plugins: [
5 | .git(
6 | url: "https://github.com/AckeeCZ/AckeeTemplate-TuistPlugin",
7 | tag: "0.2.0"
8 | )
9 | ]
10 | )
11 |
--------------------------------------------------------------------------------
/Tuist/ProjectDescriptionHelpers/Constants.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import ProjectDescription
3 |
4 | public let projectName = "ProjectTemplate"
5 | public let version: Version = "0.1.0"
6 |
7 | public let codeCoverageTargets = projectTargets
8 | public let projectTargets = [
9 | app,
10 | assets,
11 | core, coreTests,
12 | appUI, appUITests,
13 | login, loginTests,
14 | profile, profileTests,
15 | userManager, userManagerTests
16 | ]
17 |
--------------------------------------------------------------------------------
/Tuist/ProjectDescriptionHelpers/Extensions/CodeSigningExtensions.swift:
--------------------------------------------------------------------------------
1 | import AckeeTemplate
2 | import ProjectDescription
3 |
4 | extension CodeSigning {
5 | static func current(
6 | bundleID: String,
7 | teamID: TeamID,
8 | configuration: Configuration = .current
9 | ) -> CodeSigning {
10 | if configuration.isDebug {
11 | return .automatic(teamID)
12 | }
13 | return .manual(
14 | team: teamID,
15 | identity: .appleDistribution,
16 | provisioningSpecifier: "jarvis AppStore " + bundleID
17 | )
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/Tuist/ProjectDescriptionHelpers/Extensions/DestinationsExtensions.swift:
--------------------------------------------------------------------------------
1 | import ProjectDescription
2 |
3 | public extension Destinations {
4 | static let app = Destinations.iOS
5 | static let tests = app
6 | }
7 |
--------------------------------------------------------------------------------
/Tuist/ProjectDescriptionHelpers/Extensions/InfoPlistExtensions.swift:
--------------------------------------------------------------------------------
1 | import AckeeTemplate
2 | import ProjectDescription
3 |
4 | extension InfoPlist {
5 | private static var _sharedDefault: [String: Plist.Value] {
6 | [
7 | "CFBundleVersion": .string(String(Shell.numberOfCommits() ?? 1)),
8 | "CFBundleShortVersionString": "$(MARKETING_VERSION)",
9 | ]
10 | }
11 |
12 | static var sharedDefault: InfoPlist {
13 | .extendingDefault(with: _sharedDefault)
14 | }
15 |
16 | static func extendingSharedDefault(
17 | with dictionary: [String: Plist.Value]
18 | ) -> InfoPlist {
19 | .extendingDefault(with: _sharedDefault.merging(dictionary) { $1 })
20 | }
21 | }
22 |
23 | import ProjectDescription
24 |
--------------------------------------------------------------------------------
/Tuist/ProjectDescriptionHelpers/Extensions/TargetDependencyExtensions.swift:
--------------------------------------------------------------------------------
1 | import AckeeTemplate
2 | import ProjectDescription
3 |
4 | public extension TargetDependency {
5 | static let ackategories = TargetDependency.carthage("ACKategories")
6 | }
7 |
8 | public extension [TargetDependency] {
9 | static let firebase: Self = [
10 | .carthage("FBLPromises"),
11 | .carthage("FirebaseABTesting"),
12 | .carthage("FirebaseAnalytics"),
13 | .carthage("FirebaseCore"),
14 | .carthage("FirebaseCoreExtension"),
15 | .carthage("FirebaseCoreInternal"),
16 | .carthage("FirebaseCrashlytics"),
17 | .carthage("FirebaseInstallations"),
18 | .carthage("FirebaseRemoteConfig"),
19 | .carthage("FirebaseSessions"),
20 | .carthage("FirebaseSharedSwift"),
21 | .carthage("GoogleAppMeasurement"),
22 | .carthage("GoogleAppMeasurementIdentitySupport"),
23 | .carthage("GoogleDataTransport"),
24 | .carthage("GoogleUtilities"),
25 | .carthage("Networking"),
26 | .carthage("PromisesSwift"),
27 | .carthage("PushNotifications"),
28 | .carthage("nanopb"),
29 | ]
30 | }
31 |
--------------------------------------------------------------------------------
/Tuist/ProjectDescriptionHelpers/Targets/App.swift:
--------------------------------------------------------------------------------
1 | import AckeeTemplate
2 | import Foundation
3 | import ProjectDescription
4 |
5 | private let targetName = "App"
6 | private let bundleID = {
7 | if Configuration.current.isRelease {
8 | fatalError("TODO: Release bundleID not configured")
9 | }
10 | return "cz.ackee.ProjectTemplate.test"
11 | }()
12 | private let codeSigning = CodeSigning.current(
13 | bundleID: bundleID,
14 | teamID: .ackeeProduction
15 | )
16 |
17 | let app = Target(
18 | name: targetName,
19 | destinations: .app,
20 | product: .app,
21 | bundleId: bundleID,
22 | infoPlist: .extendingSharedDefault(with: [
23 | "ITSAppUsesNonExemptEncryption": false,
24 | "UILaunchStoryboardName": "LaunchScreen.storyboard",
25 | ]),
26 | sources: "\(targetName)/Sources/**",
27 | resources: [
28 | "\(targetName)/Resources/**",
29 | "\(targetName)/GoogleService/\(bundleID)/GoogleService-Info.plist",
30 | ],
31 | scripts: .crashlytics(),
32 | dependencies: [
33 | .core,
34 | .target(login),
35 | .target(profile),
36 | .target(userManager),
37 | ] + .firebase,
38 | settings: .settings(
39 | base: codeSigning.settings,
40 | configurations: [.current]
41 | )
42 | )
43 |
--------------------------------------------------------------------------------
/Tuist/ProjectDescriptionHelpers/Targets/AppCore.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import ProjectDescription
3 |
4 | private let targetName = "AppCore"
5 | private let basePath = "Modules/" + targetName
6 |
7 | let core = Target(
8 | name: targetName,
9 | destinations: .app,
10 | product: .framework,
11 | bundleId: "cz.ackee.\(projectName).\(targetName.toBundleID())",
12 | sources: .init(globs: [
13 | "\(basePath)/Sources/**",
14 | .testing(at: basePath, isDebug: true)
15 | ].compactMap { $0 }),
16 | dependencies: [
17 | .assets,
18 | .ackategories,
19 | ]
20 | )
21 |
22 | let coreTests = Target(
23 | name: core.name + "Tests",
24 | destinations: .tests,
25 | product: .unitTests,
26 | bundleId: core.bundleId + ".tests",
27 | sources: "\(basePath)/Tests/**",
28 | dependencies: [
29 | .xctest,
30 | .core
31 | ]
32 | )
33 |
34 | public extension TargetDependency {
35 | static let core = TargetDependency.target(ProjectDescriptionHelpers.core)
36 | }
37 |
--------------------------------------------------------------------------------
/Tuist/ProjectDescriptionHelpers/Targets/AppUI.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import ProjectDescription
3 |
4 | private let targetName = "AppUI"
5 | private let basePath = "Modules/" + targetName
6 |
7 | let appUI = Target(
8 | name: targetName,
9 | destinations: .app,
10 | product: .framework,
11 | bundleId: "cz.ackee.\(projectName).\(targetName.toBundleID())",
12 | sources: .init(globs: [
13 | "\(basePath)/Sources/**",
14 | .testing(at: basePath)
15 | ].compactMap { $0 }),
16 | dependencies: [
17 | .assets,
18 | .ackategories,
19 | ]
20 | )
21 |
22 | let appUITests = Target(
23 | name: appUI.name + "Tests",
24 | destinations: .tests,
25 | product: .unitTests,
26 | bundleId: appUI.bundleId + ".tests",
27 | sources: "\(basePath)/Tests/**",
28 | dependencies: [
29 | .xctest,
30 | .appUI
31 | ]
32 | )
33 |
34 | public extension TargetDependency {
35 | static let appUI = TargetDependency.target(ProjectDescriptionHelpers.appUI)
36 | }
37 |
--------------------------------------------------------------------------------
/Tuist/ProjectDescriptionHelpers/Targets/Assets.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import ProjectDescription
3 |
4 | private let targetName = "Assets"
5 | private let basePath = "Modules/" + targetName
6 |
7 | let assets = Target(
8 | name: targetName,
9 | destinations: .app,
10 | product: .framework,
11 | bundleId: "cz.ackee.\(projectName).\(targetName.toBundleID())",
12 | resources: "\(basePath)/Resources/**"
13 | )
14 |
15 | public extension TargetDependency {
16 | static let assets = TargetDependency.target(ProjectDescriptionHelpers.assets)
17 | }
18 |
--------------------------------------------------------------------------------
/Tuist/ProjectDescriptionHelpers/Targets/Login.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import ProjectDescription
3 |
4 | private let targetName = "Login"
5 | private let basePath = "Modules/" + targetName
6 |
7 | let login = Target(
8 | name: targetName,
9 | destinations: .app,
10 | product: .staticFramework,
11 | bundleId: "cz.ackee.\(projectName).\(targetName.toBundleID())",
12 | sources: .init(globs: [
13 | "\(basePath)/Sources/**",
14 | .testing(at: basePath)
15 | ].compactMap { $0 }),
16 | dependencies: [
17 | .core,
18 | .ackategories,
19 | .appUI
20 | ]
21 | )
22 |
23 | let loginTests = Target(
24 | name: login.name + "Tests",
25 | destinations: .tests,
26 | product: .unitTests,
27 | bundleId: login.bundleId + ".tests",
28 | sources: "\(basePath)/Tests/**",
29 | dependencies: [
30 | .xctest,
31 | .target(login)
32 | ]
33 | )
34 |
--------------------------------------------------------------------------------
/Tuist/ProjectDescriptionHelpers/Targets/Profile.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import ProjectDescription
3 |
4 | private let targetName = "Profile"
5 | private let basePath = "Modules/" + targetName
6 |
7 | let profile = Target(
8 | name: targetName,
9 | destinations: .app,
10 | product: .staticFramework,
11 | bundleId: "cz.ackee.\(projectName).\(targetName.toBundleID())",
12 | sources: .init(globs: [
13 | "\(basePath)/Sources/**",
14 | .testing(at: basePath)
15 | ].compactMap { $0 }),
16 | dependencies: [
17 | .core,
18 | .ackategories,
19 | .appUI
20 | ]
21 | )
22 |
23 | let profileTests = Target(
24 | name: profile.name + "Tests",
25 | destinations: .tests,
26 | product: .unitTests,
27 | bundleId: profile.bundleId + ".tests",
28 | sources: "\(basePath)/Tests/**",
29 | dependencies: [
30 | .xctest,
31 | .target(profile)
32 | ]
33 | )
34 |
--------------------------------------------------------------------------------
/Tuist/ProjectDescriptionHelpers/Targets/UserManager.swift:
--------------------------------------------------------------------------------
1 | import Foundation
2 | import ProjectDescription
3 |
4 | private let targetName = "UserManager"
5 | private let basePath = "Modules/" + targetName
6 |
7 | let userManager = Target(
8 | name: targetName,
9 | destinations: .app,
10 | product: .staticFramework,
11 | bundleId: "cz.ackee.\(projectName).\(targetName.toBundleID())",
12 | sources: .init(globs: [
13 | "\(basePath)/Sources/**",
14 | .testing(at: basePath)
15 | ].compactMap { $0 }),
16 | dependencies: [
17 | .core,
18 | .ackategories,
19 | ]
20 | )
21 |
22 | let userManagerTests = Target(
23 | name: userManager.name + "Tests",
24 | destinations: .tests,
25 | product: .unitTests,
26 | bundleId: userManager.bundleId + ".tests",
27 | sources: "\(basePath)/Tests/**",
28 | dependencies: [
29 | .xctest,
30 | .target(userManager)
31 | ]
32 | )
33 |
--------------------------------------------------------------------------------
/Workspace.swift:
--------------------------------------------------------------------------------
1 | import ProjectDescription
2 | import ProjectDescriptionHelpers
3 |
4 | let workspace = Workspace(
5 | name: projectName,
6 | projects: ["."],
7 | generationOptions: .options(autogeneratedWorkspaceSchemes: .enabled(
8 | codeCoverageMode: .targets(projectTargets.map { .init(projectPath: nil, target: $0.name) }),
9 | testLanguage: "cs",
10 | testRegion: "CZ"
11 | ))
12 | )
13 |
--------------------------------------------------------------------------------