├── .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 | Project template graph 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 | ![iOS MVVM Project Template](Documentation/Resources/cover-image.png) 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 | --------------------------------------------------------------------------------