├── .swiftlint.yml ├── Tuist ├── Config.swift ├── Templates │ ├── generate │ │ ├── AppTests.stencil │ │ ├── App.stencil │ │ ├── AppStore.stencil │ │ ├── ContentView.stencil │ │ ├── Project.stencil │ │ ├── generate.swift │ │ ├── LaunchScreen.storyboard.stencil │ │ └── Contents.json.stencil │ ├── feature │ │ ├── FeatureTests.stencil │ │ ├── FeatureStore.stencil │ │ ├── FeatureView.stencil │ │ ├── feature.swift │ │ └── Contents.json.stencil │ └── example │ │ ├── Todo.stencil │ │ ├── TodoStore.stencil │ │ ├── TodoTests.stencil │ │ ├── Project.stencil │ │ ├── ContentView.stencil │ │ ├── example.swift │ │ ├── Todos.stencil │ │ ├── Contents.json.stencil │ │ ├── TodosStore.stencil │ │ └── TodosTests.stencil └── ProjectDescriptionHelpers │ ├── Project+App.swift │ ├── Project+Templates.swift │ └── Project+Framework.swift ├── Scripts └── RunSwiftLint.sh ├── .package.resolved ├── LICENSE └── README.md /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | excluded: 2 | - Tuist 3 | - Derived 4 | -------------------------------------------------------------------------------- /Tuist/Config.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | let config = Config( 4 | generationOptions: [] 5 | ) 6 | -------------------------------------------------------------------------------- /Scripts/RunSwiftLint.sh: -------------------------------------------------------------------------------- 1 | if which swiftlint > /dev/null; then 2 | swiftlint 3 | else 4 | echo "SwiftLint not installed?" 5 | fi -------------------------------------------------------------------------------- /Tuist/Templates/generate/AppTests.stencil: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | 4 | final class {{name}}Tests: XCTestCase { 5 | func test_twoPlusTwo_isFour() { 6 | XCTAssertEqual(2+2, 4) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Tuist/Templates/feature/FeatureTests.stencil: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | 4 | final class {{name}}Tests: XCTestCase { 5 | func test_twoPlusTwo_isFour() { 6 | XCTAssertEqual(2+2, 4) 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Tuist/Templates/generate/App.stencil: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import ComposableArchitecture 4 | 5 | struct {{ name }}View: View { 6 | let store: Store<{{ name }}State, {{ name }}Action> 7 | 8 | var body: some View { 9 | WithViewStore(self.store) { _ in 10 | Text("Hello, world!").padding() 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Tuist/Templates/generate/AppStore.stencil: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ComposableArchitecture 3 | import SwiftUI 4 | 5 | struct {{ name }}State: Equatable {} 6 | enum {{ name }}Action: Equatable {} 7 | struct {{ name }}Environment {} 8 | 9 | let {{ name|lowercase }}Reducer = Reducer<{{ name }}State, {{ name }}Action, {{ name }}Environment> { _, _, _ in 10 | return .none 11 | } 12 | -------------------------------------------------------------------------------- /Tuist/Templates/feature/FeatureStore.stencil: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | 3 | public struct {{ name }}State: Equatable {} 4 | public enum {{ name }}Action: Equatable {} 5 | public struct {{ name }}Environment {} 6 | 7 | public let {{ name|lowercase }}Reducer = Reducer<{{ name }}State, {{ name }}Action, {{ name }}Environment> { _, action, _ in 8 | switch action { 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Tuist/Templates/generate/ContentView.stencil: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import ComposableArchitecture 4 | 5 | @main 6 | struct {{ name }}App: App { 7 | var body: some Scene { 8 | WindowGroup { 9 | {{ name }}View(store: Store( 10 | initialState: {{ name }}State(), 11 | reducer: {{ name|lowercase }}Reducer, 12 | environment: {{ name }}Environment() 13 | )) 14 | } 15 | } 16 | } 17 | 18 | struct ContentView_Previews: PreviewProvider { 19 | static var previews: some View { 20 | {{ name }}View(store: Store( 21 | initialState: {{ name }}State(), 22 | reducer: {{ name|lowercase }}Reducer, 23 | environment: {{ name }}Environment() 24 | )) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Tuist/Templates/feature/FeatureView.stencil: -------------------------------------------------------------------------------- 1 | import CasePaths 2 | import Combine 3 | import ComposableArchitecture 4 | import SwiftUI 5 | 6 | public struct {{ name }}View: View { 7 | let store: Store<{{ name }}State, {{ name }}Action> 8 | 9 | public init(store: Store<{{ name }}State, {{ name }}Action>) { 10 | self.store = store 11 | } 12 | 13 | public var body: some View { 14 | WithViewStore(store) { _ in 15 | Text("{{ name }} feature") 16 | } 17 | } 18 | } 19 | 20 | struct {{ name }}_Previews: PreviewProvider { 21 | static var previews: some View { 22 | {{ name }}View( 23 | store: Store( 24 | initialState: {{ name }}State(), 25 | reducer: {{ name|lowercase }}Reducer, 26 | environment: {{ name }}Environment() 27 | ) 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Tuist/Templates/feature/feature.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | let featureName: Template.Attribute = .required("name") 4 | let featurePath = "Targets/" 5 | 6 | let featureTemplate = Template( 7 | description: "Feature template", 8 | attributes: [ 9 | featureName 10 | ], 11 | files: [ 12 | .file(path: featurePath + "\(featureName)/Sources/\(featureName)Store.swift", 13 | templatePath: "FeatureStore.stencil"), 14 | .file(path: featurePath + "\(featureName)/Sources/\(featureName).swift", 15 | templatePath: "FeatureView.stencil"), 16 | .file(path: featurePath + "\(featureName)/Tests/\(featureName)Tests.swift", 17 | templatePath: "FeatureTests.stencil"), 18 | .file(path: featurePath + "\(featureName)/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json", 19 | templatePath: "Contents.json.stencil") 20 | ] 21 | ) 22 | -------------------------------------------------------------------------------- /Tuist/ProjectDescriptionHelpers/Project+App.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | extension Project { 4 | 5 | public struct App { 6 | let name: String 7 | let organisationName: String 8 | let platform: Platform 9 | let packages: [Package] 10 | let infoPlist: [String: InfoPlist.Value] 11 | let actions: [TargetAction] 12 | 13 | public init(name: String, 14 | organisationName: String, 15 | platform: Platform = .iOS, 16 | infoPlist: [String: InfoPlist.Value] = [:], 17 | packages: [Package] = [], 18 | actions: [TargetAction] = []) { 19 | self.name = name 20 | self.organisationName = organisationName 21 | self.packages = packages 22 | self.infoPlist = infoPlist 23 | self.platform = platform 24 | self.actions = actions 25 | } 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /Tuist/Templates/example/Todo.stencil: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import SwiftUI 3 | import ComposableArchitecture 4 | 5 | public struct TodoView: View { 6 | public let store: Store 7 | 8 | public init(store: Store) { 9 | self.store = store 10 | } 11 | 12 | public var body: some View { 13 | WithViewStore(store) { viewStore in 14 | HStack { 15 | Button(action: { viewStore.send(.checkBoxToggled) }, label: { 16 | Image(systemName: viewStore.isComplete ? "checkmark.square" : "square") 17 | }).buttonStyle(PlainButtonStyle()) 18 | 19 | TextField("Untitled", text: viewStore.binding(get: { $0.description }, send: TodoAction.textFieldChanged)) 20 | } 21 | } 22 | } 23 | 24 | } 25 | 26 | struct ContentView_Previews: PreviewProvider { 27 | static var previews: some View { 28 | TodoView(store: Store(initialState: Todo(id: UUID()), reducer: todoReducer, environment: TodoEnvironment())) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "combine-schedulers", 6 | "repositoryURL": "https://github.com/pointfreeco/combine-schedulers", 7 | "state": { 8 | "branch": null, 9 | "revision": "ae2f434e81017bb7de02c168fb0bde83cd8370c1", 10 | "version": "0.3.1" 11 | } 12 | }, 13 | { 14 | "package": "swift-case-paths", 15 | "repositoryURL": "https://github.com/pointfreeco/swift-case-paths", 16 | "state": { 17 | "branch": null, 18 | "revision": "1aa1bf7c4069d9ba2f7edd36dbfc96ff1c58cbff", 19 | "version": "0.1.3" 20 | } 21 | }, 22 | { 23 | "package": "swift-composable-architecture", 24 | "repositoryURL": "https://github.com/pointfreeco/swift-composable-architecture", 25 | "state": { 26 | "branch": null, 27 | "revision": "a116fff6d4dbbad7c17308edf04e40a50b74e088", 28 | "version": "0.16.0" 29 | } 30 | } 31 | ] 32 | }, 33 | "version": 1 34 | } 35 | -------------------------------------------------------------------------------- /Tuist/Templates/example/TodoStore.stencil: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ComposableArchitecture 3 | 4 | public struct Todo: Equatable, Identifiable { 5 | public let id: UUID 6 | public var isComplete = false 7 | public var description = "" 8 | 9 | public init(id: UUID) { 10 | self.id = id 11 | self.description = "" 12 | self.isComplete = false 13 | } 14 | 15 | public init(id: UUID, description: String, isComplete: Bool) { 16 | self.id = id 17 | self.description = description 18 | self.isComplete = isComplete 19 | } 20 | } 21 | 22 | public enum TodoAction: Equatable { 23 | case checkBoxToggled 24 | case textFieldChanged(String) 25 | } 26 | 27 | public struct TodoEnvironment { 28 | public init() {} 29 | } 30 | 31 | public let todoReducer = Reducer { todo, action, _ in 32 | switch action { 33 | case .checkBoxToggled: 34 | todo.isComplete.toggle() 35 | return .none 36 | case let .textFieldChanged(description): 37 | todo.description = description 38 | return .none 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Tyrone Avnit 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Tuist/Templates/example/TodoTests.stencil: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | import ComposableArchitecture 4 | @testable import AddTodo 5 | 6 | public final class AddTodoTests: XCTestCase { 7 | 8 | func testTodoIsNotSetToCompletedWhenInitialised() { 9 | let todo = Todo(id: UUID()) 10 | XCTAssertFalse(todo.isComplete) 11 | } 12 | 13 | func testTodoDescriptionCanBeUpdated() { 14 | let store = TestStore(initialState: Todo(id: UUID()), 15 | reducer: todoReducer, 16 | environment: TodoEnvironment()) 17 | 18 | store.assert( 19 | .send(.textFieldChanged("Get milk")) { todo in 20 | todo.description = "Get milk" 21 | } 22 | ) 23 | } 24 | 25 | func testMarkComplete() { 26 | let store = TestStore(initialState: Todo(id: UUID()), 27 | reducer: todoReducer, 28 | environment: TodoEnvironment()) 29 | 30 | store.assert( 31 | .send(.checkBoxToggled) { todo in 32 | todo.isComplete = true 33 | } 34 | ) 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /Tuist/Templates/example/Project.stencil: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | import ProjectDescriptionHelpers 3 | 4 | let organisationName = "tuist.io" 5 | let platform: Platform = .iOS 6 | 7 | let app = Project.App(name: "Todos", 8 | organisationName: organisationName, 9 | packages: [ 10 | .remote(url: "https://github.com/pointfreeco/swift-composable-architecture", 11 | requirement: .upToNextMajor(from: "0.15.0")) 12 | ]) 13 | 14 | let additionalTargets: [Project.Framework] = [ 15 | .remote(info: .init(name: "ComposableArchitectureFramework", 16 | organisationName: organisationName, 17 | platform: platform, 18 | dependencies: ["ComposableArchitecture"])), 19 | 20 | .feature(info: .init(name: "Todo", 21 | organisationName: organisationName, 22 | platform: platform, 23 | dependencies: ["ComposableArchitectureFramework"], 24 | testdependencies: ["ComposableArchitectureFramework"])), 25 | ] 26 | 27 | let project = Project.app(app: app, additionalTargets: additionalTargets) 28 | -------------------------------------------------------------------------------- /Tuist/Templates/example/ContentView.stencil: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import ComposableArchitecture 3 | import Todo 4 | 5 | @main 6 | struct TodosApp: App { 7 | var body: some Scene { 8 | WindowGroup { 9 | TodosView(store: Store( 10 | initialState: TodosState(todos: .mock), 11 | reducer: todosReducer, 12 | environment: TodosEnvironment( 13 | mainQueue: DispatchQueue.main.eraseToAnyScheduler(), 14 | uuid: UUID.init 15 | ) 16 | )) 17 | } 18 | } 19 | } 20 | 21 | struct ContentView_Previews: PreviewProvider { 22 | static var previews: some View { 23 | TodosView(store: Store( 24 | initialState: TodosState(todos: []), 25 | reducer: todosReducer, 26 | environment: TodosEnvironment( 27 | mainQueue: DispatchQueue.main.eraseToAnyScheduler(), 28 | uuid: UUID.init 29 | ) 30 | )) 31 | } 32 | } 33 | 34 | public extension IdentifiedArray where ID == UUID, Element == Todo { 35 | static let mock: Self = [ 36 | Todo( 37 | id: UUID(uuidString: "DEADBEEF-DEAD-BEEF-DEAD-BEEDDEADBEEF")!, 38 | description: "Check Mail", 39 | isComplete: false 40 | ), 41 | Todo( 42 | id: UUID(uuidString: "CAFEBEEF-CAFE-BEEF-CAFE-BEEFCAFEBEEF")!, 43 | description: "Buy Milk", 44 | isComplete: false 45 | ), 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /Tuist/Templates/generate/Project.stencil: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ProjectDescription 3 | import ProjectDescriptionHelpers 4 | 5 | let organisationName = "tuist.io" 6 | let platform: Platform = .iOS 7 | let infoPlist: [String: InfoPlist.Value] = [ 8 | "CFBundleShortVersionString": "1.0", 9 | "CFBundleVersion": "1", 10 | "UIMainStoryboardFile": "", 11 | "UILaunchStoryboardName": "LaunchScreen" 12 | ] 13 | 14 | let app = Project.App(name: "{{name}}", 15 | organisationName: organisationName, 16 | infoPlist: infoPlist, 17 | packages: [ 18 | .remote(url: "https://github.com/pointfreeco/swift-composable-architecture", 19 | requirement: .upToNextMajor(from: "0.16.0")) 20 | ], 21 | actions: [ 22 | TargetAction.pre(path: "Scripts/RunSwiftLint.sh", 23 | arguments: [], 24 | name: "SwiftLint") 25 | ]) 26 | 27 | let additionalTargets: [Project.Framework] = [ 28 | .remote(info: .init(name: "ComposableArchitectureFramework", 29 | organisationName: organisationName, 30 | platform: platform, 31 | dependencies: ["ComposableArchitecture"])), 32 | ] 33 | 34 | let project = Project.app(app: app, additionalTargets: additionalTargets) 35 | -------------------------------------------------------------------------------- /Tuist/Templates/generate/generate.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | let nameAttribute: Template.Attribute = .required("name") 4 | let platformAttribute: Template.Attribute = .optional("platform", default: "ios") 5 | 6 | let projectPath = "." 7 | let appPath = "Targets/" 8 | 9 | let template = Template( 10 | description: "Project template", 11 | attributes: [ 12 | nameAttribute, 13 | platformAttribute 14 | ], 15 | files: [ 16 | .file(path: projectPath + "/Project.swift", 17 | templatePath: "Project.stencil"), 18 | 19 | .file(path: appPath + "\(nameAttribute)/Sources/\(nameAttribute).swift", 20 | templatePath: "App.stencil"), 21 | 22 | .file(path: appPath + "\(nameAttribute)/Tests/\(nameAttribute)Tests.swift", 23 | templatePath: "AppTests.stencil"), 24 | 25 | .file(path: appPath + "\(nameAttribute)/Sources/\(nameAttribute)Store.swift", 26 | templatePath: "AppStore.stencil"), 27 | 28 | .file(path: appPath + "\(nameAttribute)/Sources/ContentView.swift", 29 | templatePath: "ContentView.stencil"), 30 | 31 | .file(path: appPath + "\(nameAttribute)/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json", 32 | templatePath: "Contents.json.stencil"), 33 | 34 | .file(path: appPath + "\(nameAttribute)/Resources/LaunchScreen.storyboard", 35 | templatePath: "LaunchScreen.storyboard.stencil") 36 | ] 37 | ) 38 | -------------------------------------------------------------------------------- /Tuist/Templates/example/example.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | let examplePath = "Targets/" 4 | let exampleProjectPath = "." 5 | 6 | let exampleTemplate = Template( 7 | description: "Example template", 8 | attributes: [], 9 | files: [ 10 | .file(path: exampleProjectPath + "/Project.swift", 11 | templatePath: "Project.stencil"), 12 | 13 | // Todo Feature 14 | .file(path: examplePath + "Todo/Sources/Todo.swift", 15 | templatePath: "Todo.stencil"), 16 | 17 | .file(path: examplePath + "Todo/Sources/TodoStore.swift", 18 | templatePath: "TodoStore.stencil"), 19 | 20 | .file(path: examplePath + "Todo/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json", 21 | templatePath: "Contents.json.stencil"), 22 | 23 | .file(path: examplePath + "Todo/Tests/TodoTests.swift", 24 | templatePath: "TodoTests.stencil"), 25 | 26 | // Todos Feature 27 | .file(path: examplePath + "Todos/Sources/Todos.swift", 28 | templatePath: "Todos.stencil"), 29 | 30 | .file(path: examplePath + "Todos/Sources/TodosStore.swift", 31 | templatePath: "TodosStore.stencil"), 32 | 33 | .file(path: examplePath + "Todos/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json", 34 | templatePath: "Contents.json.stencil"), 35 | 36 | .file(path: examplePath + "Todos/Tests/TodosTests.swift", 37 | templatePath: "TodosTests.stencil"), 38 | 39 | .file(path: examplePath + "Todos/Sources/ContentView.swift", 40 | templatePath: "ContentView.stencil") 41 | 42 | ] 43 | ) 44 | -------------------------------------------------------------------------------- /Tuist/Templates/generate/LaunchScreen.storyboard.stencil: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Tuist/ProjectDescriptionHelpers/Project+Templates.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | extension Project { 4 | 5 | public static func app(app: App, additionalTargets: [Framework]) -> Project { 6 | return Project(name: app.name, 7 | organizationName: app.organisationName, 8 | packages: app.packages, 9 | targets: makeTargets(app: app, additionalTargets: additionalTargets)) 10 | } 11 | 12 | } 13 | 14 | private extension Project { 15 | 16 | static func makeTargets(app: App, additionalTargets: [Framework]) -> [Target] { 17 | let mainTarget = Target(name: app.name, 18 | platform: app.platform, 19 | product: .app, 20 | bundleId: "\(app.organisationName).\(app.name)", 21 | infoPlist: .extendingDefault(with: app.infoPlist), 22 | sources: ["Targets/\(app.name)/Sources/**"], 23 | resources: [ 24 | "Targets/\(app.name)/Resources/**" 25 | ], 26 | actions: app.actions, 27 | dependencies: 28 | additionalTargets.map(\.targetDependency)) 29 | 30 | let testTarget = Target(name: "\(app.name)Tests", 31 | platform: app.platform, 32 | product: .unitTests, 33 | bundleId: "\(app.organisationName).\(app.name)Tests", 34 | infoPlist: .default, 35 | sources: ["Targets/\(app.name)/Tests/**"], 36 | dependencies: [ 37 | .target(name: "\(app.name)") 38 | ] + additionalTargets.map(\.targetDependency)) 39 | 40 | return [mainTarget, testTarget] 41 | + additionalTargets.map(\.target) 42 | + additionalTargets.compactMap(\.testTarget) 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /Tuist/Templates/example/Todos.stencil: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import ComposableArchitecture 3 | import Todo 4 | 5 | struct TodosView: View { 6 | struct ViewState: Equatable { 7 | var editMode: EditMode 8 | var isClearCompletedButtonDisabled: Bool 9 | } 10 | 11 | let store: Store 12 | 13 | var body: some View { 14 | WithViewStore(self.store.scope(state: { $0.view })) { viewStore in 15 | NavigationView { 16 | VStack(alignment: .leading) { 17 | WithViewStore(self.store.scope(state: { $0.filter }, action: TodosAction.filterPicked)) { 18 | filterViewStore in 19 | Picker( 20 | "Filter", selection: filterViewStore.binding(send: { $0 }) 21 | ) { 22 | ForEach(Filter.allCases, id: \.self) { filter in 23 | Text(filter.rawValue).tag(filter) 24 | } 25 | } 26 | .pickerStyle(SegmentedPickerStyle()) 27 | } 28 | .padding([.leading, .trailing]) 29 | 30 | List { 31 | ForEachStore( 32 | self.store.scope(state: { $0.filteredTodos }, action: TodosAction.todo(id:action:)), 33 | content: TodoView.init(store:) 34 | ) 35 | .onDelete { viewStore.send(.delete($0)) } 36 | .onMove { viewStore.send(.move($0, $1)) } 37 | } 38 | }.navigationBarTitle("Todos") 39 | .navigationBarItems( 40 | trailing: HStack(spacing: 20) { 41 | EditButton() 42 | Button("Clear Completed") { viewStore.send(.clearCompletedButtonTapped) } 43 | .disabled(viewStore.isClearCompletedButtonDisabled) 44 | Button("Add Todo") { viewStore.send(.addTodoButtonTapped) } 45 | } 46 | ) 47 | .environment(\.editMode, viewStore.binding(get: { $0.editMode }, send: TodosAction.editModeChanged)) 48 | }.navigationViewStyle(StackNavigationViewStyle()) 49 | } 50 | } 51 | } 52 | 53 | extension TodosState { 54 | var view: TodosView.ViewState { 55 | .init( 56 | editMode: self.editMode, 57 | isClearCompletedButtonDisabled: !self.todos.contains(where: { $0.isComplete }) 58 | ) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Tuist/Templates/example/Contents.json.stencil: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | }, 93 | { 94 | "idiom" : "mac", 95 | "scale" : "1x", 96 | "size" : "16x16" 97 | }, 98 | { 99 | "idiom" : "mac", 100 | "scale" : "2x", 101 | "size" : "16x16" 102 | }, 103 | { 104 | "idiom" : "mac", 105 | "scale" : "1x", 106 | "size" : "32x32" 107 | }, 108 | { 109 | "idiom" : "mac", 110 | "scale" : "2x", 111 | "size" : "32x32" 112 | }, 113 | { 114 | "idiom" : "mac", 115 | "scale" : "1x", 116 | "size" : "128x128" 117 | }, 118 | { 119 | "idiom" : "mac", 120 | "scale" : "2x", 121 | "size" : "128x128" 122 | }, 123 | { 124 | "idiom" : "mac", 125 | "scale" : "1x", 126 | "size" : "256x256" 127 | }, 128 | { 129 | "idiom" : "mac", 130 | "scale" : "2x", 131 | "size" : "256x256" 132 | }, 133 | { 134 | "idiom" : "mac", 135 | "scale" : "1x", 136 | "size" : "512x512" 137 | }, 138 | { 139 | "idiom" : "mac", 140 | "scale" : "2x", 141 | "size" : "512x512" 142 | } 143 | ], 144 | "info" : { 145 | "author" : "xcode", 146 | "version" : 1 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Tuist/Templates/feature/Contents.json.stencil: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | }, 93 | { 94 | "idiom" : "mac", 95 | "scale" : "1x", 96 | "size" : "16x16" 97 | }, 98 | { 99 | "idiom" : "mac", 100 | "scale" : "2x", 101 | "size" : "16x16" 102 | }, 103 | { 104 | "idiom" : "mac", 105 | "scale" : "1x", 106 | "size" : "32x32" 107 | }, 108 | { 109 | "idiom" : "mac", 110 | "scale" : "2x", 111 | "size" : "32x32" 112 | }, 113 | { 114 | "idiom" : "mac", 115 | "scale" : "1x", 116 | "size" : "128x128" 117 | }, 118 | { 119 | "idiom" : "mac", 120 | "scale" : "2x", 121 | "size" : "128x128" 122 | }, 123 | { 124 | "idiom" : "mac", 125 | "scale" : "1x", 126 | "size" : "256x256" 127 | }, 128 | { 129 | "idiom" : "mac", 130 | "scale" : "2x", 131 | "size" : "256x256" 132 | }, 133 | { 134 | "idiom" : "mac", 135 | "scale" : "1x", 136 | "size" : "512x512" 137 | }, 138 | { 139 | "idiom" : "mac", 140 | "scale" : "2x", 141 | "size" : "512x512" 142 | } 143 | ], 144 | "info" : { 145 | "author" : "xcode", 146 | "version" : 1 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Tuist/Templates/generate/Contents.json.stencil: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "scale" : "2x", 6 | "size" : "20x20" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "scale" : "3x", 11 | "size" : "20x20" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "scale" : "2x", 16 | "size" : "29x29" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "scale" : "3x", 21 | "size" : "29x29" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "scale" : "2x", 26 | "size" : "40x40" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "scale" : "3x", 31 | "size" : "40x40" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "scale" : "2x", 36 | "size" : "60x60" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "scale" : "3x", 41 | "size" : "60x60" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "scale" : "1x", 46 | "size" : "20x20" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "scale" : "2x", 51 | "size" : "20x20" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "scale" : "1x", 56 | "size" : "29x29" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "scale" : "2x", 61 | "size" : "29x29" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "scale" : "1x", 66 | "size" : "40x40" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "scale" : "2x", 71 | "size" : "40x40" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "scale" : "1x", 76 | "size" : "76x76" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "scale" : "2x", 81 | "size" : "76x76" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "scale" : "2x", 86 | "size" : "83.5x83.5" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "scale" : "1x", 91 | "size" : "1024x1024" 92 | }, 93 | { 94 | "idiom" : "mac", 95 | "scale" : "1x", 96 | "size" : "16x16" 97 | }, 98 | { 99 | "idiom" : "mac", 100 | "scale" : "2x", 101 | "size" : "16x16" 102 | }, 103 | { 104 | "idiom" : "mac", 105 | "scale" : "1x", 106 | "size" : "32x32" 107 | }, 108 | { 109 | "idiom" : "mac", 110 | "scale" : "2x", 111 | "size" : "32x32" 112 | }, 113 | { 114 | "idiom" : "mac", 115 | "scale" : "1x", 116 | "size" : "128x128" 117 | }, 118 | { 119 | "idiom" : "mac", 120 | "scale" : "2x", 121 | "size" : "128x128" 122 | }, 123 | { 124 | "idiom" : "mac", 125 | "scale" : "1x", 126 | "size" : "256x256" 127 | }, 128 | { 129 | "idiom" : "mac", 130 | "scale" : "2x", 131 | "size" : "256x256" 132 | }, 133 | { 134 | "idiom" : "mac", 135 | "scale" : "1x", 136 | "size" : "512x512" 137 | }, 138 | { 139 | "idiom" : "mac", 140 | "scale" : "2x", 141 | "size" : "512x512" 142 | } 143 | ], 144 | "info" : { 145 | "author" : "xcode", 146 | "version" : 1 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Tuist/Templates/example/TodosStore.stencil: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ComposableArchitecture 3 | import Todo 4 | import SwiftUI 5 | 6 | enum Filter: String, CaseIterable, Hashable { 7 | case all = "All" 8 | case active = "Active" 9 | case completed = "Completed" 10 | } 11 | 12 | struct TodosState: Equatable { 13 | var editMode: EditMode = .inactive 14 | var filter: Filter = .all 15 | var todos: IdentifiedArrayOf = [] 16 | 17 | var filteredTodos: IdentifiedArrayOf { 18 | switch filter { 19 | case .active: return self.todos.filter { !$0.isComplete } 20 | case .all: return self.todos 21 | case .completed: return self.todos.filter { $0.isComplete } 22 | } 23 | } 24 | } 25 | 26 | enum TodosAction: Equatable { 27 | case addTodoButtonTapped 28 | case clearCompletedButtonTapped 29 | case delete(IndexSet) 30 | case editModeChanged(EditMode) 31 | case filterPicked(Filter) 32 | case move(IndexSet, Int) 33 | case sortCompletedTodos 34 | case todo(id: UUID, action: TodoAction) 35 | } 36 | 37 | struct TodosEnvironment { 38 | var mainQueue: AnySchedulerOf 39 | var uuid: () -> UUID 40 | } 41 | 42 | let todosReducer = Reducer.combine( 43 | todoReducer.forEach( 44 | state: \.todos, 45 | action: /TodosAction.todo(id:action:), 46 | environment: { _ in TodoEnvironment() } 47 | ), 48 | Reducer { state, action, environment in 49 | switch action { 50 | case .addTodoButtonTapped: 51 | state.todos.append(Todo(id: environment.uuid())) 52 | return .none 53 | case .clearCompletedButtonTapped: 54 | state.todos.removeAll(where: { $0.isComplete }) 55 | return .none 56 | case .delete(let indexSet): 57 | state.todos.remove(atOffsets: indexSet) 58 | return .none 59 | case let .editModeChanged(editMode): 60 | state.editMode = editMode 61 | return .none 62 | case let .filterPicked(filter): 63 | state.filter = filter 64 | return .none 65 | case let .move(source, destination): 66 | state.todos.move(fromOffsets: source, toOffset: destination) 67 | return Effect(value: .sortCompletedTodos) 68 | .delay(for: .milliseconds(100), scheduler: environment.mainQueue) 69 | .eraseToEffect() 70 | case .sortCompletedTodos: 71 | state.todos.sortCompleted() 72 | return .none 73 | case .todo(id: _, action: .checkBoxToggled): 74 | struct TodoCompletionId: Hashable {} 75 | return Effect(value: .sortCompletedTodos) 76 | .debounce(id: TodoCompletionId(), for: 1, scheduler: environment.mainQueue) 77 | default: 78 | return .none 79 | } 80 | } 81 | 82 | ) 83 | 84 | extension IdentifiedArray where ID == UUID, Element == Todo { 85 | fileprivate mutating func sortCompleted() { 86 | self = IdentifiedArray( 87 | self.enumerated() 88 | .sorted(by: { lhs, rhs in 89 | (rhs.element.isComplete && !lhs.element.isComplete) || lhs.offset < rhs.offset 90 | }).map { $0.element } 91 | ) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Tuist/ProjectDescriptionHelpers/Project+Framework.swift: -------------------------------------------------------------------------------- 1 | import ProjectDescription 2 | 3 | extension Project { 4 | 5 | public enum Framework { 6 | case feature(info: FeatureInfo) 7 | case remote(info: RemoteInfo) 8 | } 9 | 10 | public struct FeatureInfo { 11 | let name: String 12 | let organisationName: String 13 | let platform: Platform 14 | let dependencies: [String] 15 | let testdependencies: [String] 16 | 17 | public init(name: String, 18 | organisationName: String, 19 | platform: Platform, 20 | dependencies: [String], 21 | testdependencies: [String]) { 22 | self.name = name 23 | self.organisationName = organisationName 24 | self.platform = platform 25 | self.dependencies = dependencies 26 | self.testdependencies = testdependencies 27 | } 28 | 29 | } 30 | 31 | public struct RemoteInfo { 32 | let name: String 33 | let organisationName: String 34 | let platform: Platform 35 | let dependencies: [String] 36 | 37 | public init(name: String, 38 | organisationName: String, 39 | platform: Platform, 40 | dependencies: [String]) { 41 | self.name = name 42 | self.organisationName = organisationName 43 | self.platform = platform 44 | self.dependencies = dependencies 45 | } 46 | } 47 | 48 | } 49 | 50 | extension Project.Framework { 51 | 52 | var name: String { 53 | switch self { 54 | case .feature(let info): 55 | return info.name 56 | case .remote(let info): 57 | return info.name 58 | } 59 | } 60 | 61 | var organisationName: String { 62 | switch self { 63 | case .feature(let info): 64 | return info.organisationName 65 | case .remote(let info): 66 | return info.organisationName 67 | } 68 | } 69 | 70 | var platform: Platform { 71 | switch self { 72 | case .feature(let info): 73 | return info.platform 74 | case .remote(let info): 75 | return info.platform 76 | } 77 | } 78 | 79 | var sources: SourceFilesList { 80 | switch self { 81 | case .feature: 82 | return ["Targets/\(name)/Sources/**"] 83 | case .remote: 84 | return [] 85 | } 86 | } 87 | 88 | var dependencies: [TargetDependency] { 89 | switch self { 90 | case .feature(let info): 91 | return info.dependencies.map(TargetDependency.target(name:)) 92 | case .remote(let info): 93 | return info.dependencies.map(TargetDependency.package(product:)) 94 | } 95 | } 96 | 97 | var resources: [ProjectDescription.FileElement]? { 98 | switch self { 99 | case .feature: 100 | return ["Targets/\(name)/Resources/**"] 101 | default: 102 | return nil 103 | } 104 | } 105 | 106 | var target: Target { 107 | Target(name: name, 108 | platform: .iOS, 109 | product: .framework, 110 | bundleId: "\(organisationName).\(name)", 111 | infoPlist: .default, 112 | sources: sources, 113 | resources: resources, 114 | dependencies: dependencies) 115 | } 116 | 117 | var targetDependency: TargetDependency { 118 | .target(name: name) 119 | } 120 | 121 | var testTarget: Target? { 122 | switch self { 123 | case .feature(let info): 124 | return Target(name: "\(name)Tests", 125 | platform: platform, 126 | product: .unitTests, 127 | bundleId: "\(organisationName).\(name)Tests", 128 | infoPlist: .default, 129 | sources: ["Targets/\(name)/Tests/**"], 130 | resources: [], 131 | dependencies: [.target(name: name)] 132 | + info.testdependencies.map { .target(name: $0) }) 133 | case .remote: 134 | return nil 135 | } 136 | 137 | } 138 | 139 | } 140 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The Composable Microfeature Architecture 2 | 3 | This little project is an opinionated, yet simple tool, that leverages the work of two excellent open-sourced projects namely [Tuist](https://tuist.io) and [TCA](https://github.com/pointfreeco/swift-composable-architecture#what-is-the-composable-architecture) (The Composable Architecture). The aim of this little project is to help you get started building a scalable and modular application immediately without all the boilerplate. 4 | 5 | ## The MicroFeatures Architecture 6 | 7 | > The MicroFeatures Architecture or uFeatures is an architectural approach to structure Apple OS applications to enable scalability, optimize build and test cycles, and ensure good practices in your team. Its core idea is to build your apps by building independent features that are interconnected using clear and concise APIs. 8 | 9 | [Learn more](https://tuist.io/docs/building-at-scale/microfeatures/#µfeatures-architecture) 10 | 11 | ## The Composable Architecture 12 | 13 | > The Composable Architecture (TCA, for short) is a library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind. 14 | 15 | [Learn more](https://github.com/pointfreeco/swift-composable-architecture#what-is-the-composable-architecture) 16 | 17 | By combining the two architectures, we get something that is easy to use, easy to follow, and most importantly modular and thus scalable among teams. 18 | 19 | This little project requires that you are both familiar with [TCA](https://github.com/pointfreeco/swift-composable-architecture#what-is-the-composable-architecture), and [Tuist](https://tuist.io). 20 | 21 | ## How to get started? 22 | 23 | Install Tuist with the following command: 24 | 25 | ```swift 26 | bash <(curl -Ls https://install.tuist.io) 27 | ``` 28 | 29 | Now to get started run the following command in your terminal: 30 | 31 | ```swift 32 | tuist scaffold generate --name YourProjectName 33 | ``` 34 | 35 | This will bootstrap your iOS application. You can now edit your project by running the following command and see all the code provided by this little project: 36 | 37 | ```swift 38 | tuist edit 39 | ``` 40 | 41 | Finally to get started run the following command: 42 | 43 | ```swift 44 | tuist generate && tuist focus YourProjectName 45 | ``` 46 | 47 | ## Creating Microfeatures 48 | 49 | Now we can start creating Microfeatures. Microfeatures allow you to write features in isolation without having to create and write boilerplate code. 50 | 51 | You can create a MicroFeature using the following command: 52 | 53 | ```swift 54 | tuist scaffold feature --name YourFeatureName 55 | ``` 56 | 57 | Now you need to write a little code to add the feature to your project. First type the following command: 58 | 59 | ```swift 60 | tuist edit 61 | ``` 62 | 63 | And then add your feature to the project as follows: 64 | 65 | ```swift 66 | ... 67 | 68 | let additionalTargets: [Project.Framework] = [ 69 | .remote(info: .init(name: "ComposableArchitectureFramework", 70 | organisationName: organisationName, 71 | platform: platform, 72 | dependencies: ["ComposableArchitecture"])), 73 | 74 | .feature(info: Project.FeatureInfo(name: "YourFeatureName", 75 | organisationName: organisationName, 76 | platform: platform, 77 | dependencies: ["ComposableArchitectureFramework"], 78 | testdependencies: ["ComposableArchitectureFramework"])) 79 | ] 80 | 81 | ... 82 | ``` 83 | 84 | Finally run the following command to regenerate your project: 85 | 86 | ```swift 87 | tuist generate 88 | ``` 89 | 90 | Now you can either open the the workspace and plugin your feature, or you can run and write the feature in isolation with the following command: 91 | 92 | ```swift 93 | tuist focus YourFeatureName 94 | ``` 95 | 96 | ## Checking out the example Todo project 97 | 98 | If you are a little unsure how everything fits together, you can checkout the example project, by typing out the following command: 99 | 100 | ```swift 101 | tuist scaffold example 102 | ``` 103 | 104 | This will generate a fully functional Todo example project together with tests. This is the original TCA todo example that is using the Microfeatures architecture 105 | 106 | ## References 107 | 108 | - [ComposableTuistArchitecture](https://github.com/fortmarek/ComposableTuistArchitecture) 109 | - [Tuist init: How to use Tuist templates to bootstrap your project](https://sarunw.com/posts/tuist-init/) 110 | - [Customize Your Xcode Project With Tuist](https://betterprogramming.pub/customize-your-xcodeproject-with-tuist-6fc41fb59262) -------------------------------------------------------------------------------- /Tuist/Templates/example/TodosTests.stencil: -------------------------------------------------------------------------------- 1 | import ComposableArchitecture 2 | import Todo 3 | import Todos 4 | import XCTest 5 | 6 | @testable import Todos 7 | 8 | class TodosTests: XCTestCase { 9 | let scheduler = DispatchQueue.testScheduler 10 | 11 | func testAddTodo() { 12 | let store = TestStore( 13 | initialState: TodosState(), 14 | reducer: todosReducer, 15 | environment: TodosEnvironment( 16 | mainQueue: self.scheduler.eraseToAnyScheduler(), 17 | uuid: UUID.incrementing 18 | )) 19 | 20 | store.assert(.send(.addTodoButtonTapped) { 21 | $0.todos.insert( 22 | Todo(id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, 23 | description: "", 24 | isComplete: false 25 | ),at: 0) 26 | }) 27 | } 28 | 29 | func testEditTodo() { 30 | let state = TodosState( 31 | todos: [ 32 | Todo( 33 | id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, 34 | description: "", 35 | isComplete: false 36 | ) 37 | ]) 38 | 39 | let store = TestStore( 40 | initialState: state, 41 | reducer: todosReducer, 42 | environment: TodosEnvironment( 43 | mainQueue: self.scheduler.eraseToAnyScheduler(), 44 | uuid: UUID.incrementing)) 45 | 46 | store.assert(.send(.todo(id: state.todos[0].id, action: .textFieldChanged("Learn Composable Architecture"))) { 47 | $0.todos[0].description = "Learn Composable Architecture" 48 | }) 49 | } 50 | 51 | func testCompleteTodo() { 52 | let state = TodosState( 53 | todos: [ 54 | Todo( 55 | id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, 56 | description: "", 57 | isComplete: false 58 | ), 59 | Todo( 60 | id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, 61 | description: "", 62 | isComplete: false 63 | ) 64 | ]) 65 | 66 | let store = TestStore( 67 | initialState: state, 68 | reducer: todosReducer, 69 | environment: TodosEnvironment( 70 | mainQueue: self.scheduler.eraseToAnyScheduler(), 71 | uuid: UUID.incrementing)) 72 | 73 | store.assert(.send(.todo(id: state.todos[0].id, action: .checkBoxToggled)) { 74 | $0.todos[0].isComplete = true 75 | }, 76 | .do { self.scheduler.advance(by: 1) }, 77 | .receive(.sortCompletedTodos) { 78 | $0.todos = [ 79 | $0.todos[1], 80 | $0.todos[0], 81 | ] 82 | }) 83 | } 84 | 85 | func testCompleteTodoDebounces() { 86 | let state = TodosState( 87 | todos: [ 88 | Todo( 89 | id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, 90 | description: "", 91 | isComplete: false 92 | ), 93 | Todo( 94 | id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, 95 | description: "", 96 | isComplete: false 97 | ) 98 | ]) 99 | 100 | let store = TestStore( 101 | initialState: state, 102 | reducer: todosReducer, 103 | environment: TodosEnvironment( 104 | mainQueue: self.scheduler.eraseToAnyScheduler(), 105 | uuid: UUID.incrementing)) 106 | 107 | store.assert(.send(.todo(id: state.todos[0].id, action: .checkBoxToggled)) { 108 | $0.todos[0].isComplete = true 109 | }, 110 | .do { self.scheduler.advance(by: 0.5) }, 111 | .send(.todo(id: state.todos[0].id, action: .checkBoxToggled)) { 112 | $0.todos[0].isComplete = false 113 | }, 114 | .do { self.scheduler.advance(by: 1) }, 115 | .receive(.sortCompletedTodos)) 116 | } 117 | 118 | func testClearCompleted() { 119 | let state = TodosState( 120 | todos: [ 121 | Todo( 122 | id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, 123 | description: "", 124 | isComplete: false 125 | ), 126 | Todo( 127 | id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, 128 | description: "", 129 | isComplete: true 130 | ) 131 | ]) 132 | 133 | let store = TestStore( 134 | initialState: state, 135 | reducer: todosReducer, 136 | environment: TodosEnvironment( 137 | mainQueue: self.scheduler.eraseToAnyScheduler(), 138 | uuid: UUID.incrementing)) 139 | 140 | store.assert( 141 | .send(.clearCompletedButtonTapped) { 142 | $0.todos = [ 143 | $0.todos[0] 144 | ] 145 | }) 146 | } 147 | 148 | func testDelete() { 149 | let state = TodosState( 150 | todos: [ 151 | Todo( 152 | id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, 153 | description: "", 154 | isComplete: false 155 | ), 156 | Todo( 157 | id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, 158 | description: "", 159 | isComplete: false 160 | ), 161 | Todo( 162 | id: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, 163 | description: "", 164 | isComplete: false 165 | ) 166 | ]) 167 | 168 | let store = TestStore( 169 | initialState: state, 170 | reducer: todosReducer, 171 | environment: TodosEnvironment( 172 | mainQueue: self.scheduler.eraseToAnyScheduler(), 173 | uuid: UUID.incrementing)) 174 | 175 | store.assert( 176 | .send(.delete([1])) { 177 | $0.todos = [ 178 | $0.todos[0], 179 | $0.todos[2], 180 | ] 181 | } 182 | ) 183 | } 184 | 185 | func testEditModeMoving() { 186 | let state = TodosState( 187 | todos: [ 188 | Todo( 189 | id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, 190 | description: "", 191 | isComplete: false 192 | ), 193 | Todo( 194 | id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, 195 | description: "", 196 | isComplete: false 197 | ), 198 | Todo( 199 | id: UUID(uuidString: "00000000-0000-0000-0000-000000000002")!, 200 | description: "", 201 | isComplete: false 202 | ) 203 | ]) 204 | 205 | let store = TestStore( 206 | initialState: state, 207 | reducer: todosReducer, 208 | environment: TodosEnvironment( 209 | mainQueue: self.scheduler.eraseToAnyScheduler(), 210 | uuid: UUID.incrementing)) 211 | 212 | store.assert( 213 | .send(.editModeChanged(.active)) { 214 | $0.editMode = .active 215 | }, 216 | .send(.move([0], 2)) { 217 | $0.todos = [ 218 | $0.todos[1], 219 | $0.todos[0], 220 | $0.todos[2], 221 | ] 222 | }, 223 | .do { self.scheduler.advance(by: .milliseconds(100)) }, 224 | .receive(.sortCompletedTodos)) 225 | } 226 | 227 | func testFilteredEdit() { 228 | let state = TodosState( 229 | todos: [ 230 | Todo( 231 | id: UUID(uuidString: "00000000-0000-0000-0000-000000000000")!, 232 | description: "", 233 | isComplete: false 234 | ), 235 | Todo( 236 | id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!, 237 | description: "", 238 | isComplete: false 239 | ) 240 | ]) 241 | 242 | let store = TestStore( 243 | initialState: state, 244 | reducer: todosReducer, 245 | environment: TodosEnvironment( 246 | mainQueue: self.scheduler.eraseToAnyScheduler(), 247 | uuid: UUID.incrementing)) 248 | 249 | store.assert( 250 | .send(.filterPicked(.completed)) { 251 | $0.filter = .completed 252 | }, 253 | .send(.todo(id: state.todos[1].id, action: .textFieldChanged("Did this already"))) { 254 | $0.todos[1].description = "Did this already" 255 | }) 256 | } 257 | } 258 | 259 | extension UUID { 260 | static var incrementing: () -> UUID { 261 | var uuid = 0 262 | return { 263 | defer { uuid += 1 } 264 | return UUID(uuidString: "00000000-0000-0000-0000-\(String(format: "%012x", uuid))")! 265 | } 266 | } 267 | } 268 | --------------------------------------------------------------------------------