From 81a791bbc2050d8ebb8f88f9074b288d2b5d391f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Thu, 9 Apr 2026 19:01:07 +0100 Subject: [PATCH 01/11] Add state management patterns to templates Add CacheProjection, ComponentState, ItemState, ArrayState and their corresponding SwiftUI views to the SwiftUI App template. Update the Example scene to demonstrate the full CacheProjection data flow with @Observable computed properties. Add "Projection Scene" variant to the Scene template that scaffolds a Component, ComponentModel and CacheProjection stub. --- ...LE_sceneIdentifier___CacheProjection.swift | 32 +++++ ...VARIABLE_sceneIdentifier___Component.swift | 28 +++++ ...BLE_sceneIdentifier___ComponentModel.swift | 49 ++++++++ Templates/Scene.xctemplate/TemplateInfo.plist | 20 ++++ .../SwiftUI App.xctemplate/ArrayState.swift | 33 ++++++ .../ArrayStateView.swift | 52 ++++++++ .../CacheProjection.swift | 28 +++++ .../ComponentState.swift | 23 ++++ .../ComponentStateView.swift | 59 +++++++++ .../DataCacheModel.swift | 8 ++ .../ErrorViewConfig.swift | 24 ++++ .../ExampleCacheProjection.swift | 37 ++++++ .../ExampleComponent.swift | 24 +++- .../ExampleComponentModel.swift | 10 +- .../SwiftUI App.xctemplate/ItemState.swift | 21 ++++ .../ItemStateView.swift | 52 ++++++++ .../SwiftUI App.xctemplate/Mockable.swift | 11 ++ .../SwiftUI App.xctemplate/TemplateInfo.plist | 112 ++++++++++++++++++ 18 files changed, 620 insertions(+), 3 deletions(-) create mode 100644 Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___CacheProjection.swift create mode 100644 Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___Component.swift create mode 100644 Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___ComponentModel.swift create mode 100644 Templates/SwiftUI App.xctemplate/ArrayState.swift create mode 100644 Templates/SwiftUI App.xctemplate/ArrayStateView.swift create mode 100644 Templates/SwiftUI App.xctemplate/CacheProjection.swift create mode 100644 Templates/SwiftUI App.xctemplate/ComponentState.swift create mode 100644 Templates/SwiftUI App.xctemplate/ComponentStateView.swift create mode 100644 Templates/SwiftUI App.xctemplate/ErrorViewConfig.swift create mode 100644 Templates/SwiftUI App.xctemplate/ExampleCacheProjection.swift create mode 100644 Templates/SwiftUI App.xctemplate/ItemState.swift create mode 100644 Templates/SwiftUI App.xctemplate/ItemStateView.swift create mode 100644 Templates/SwiftUI App.xctemplate/Mockable.swift diff --git a/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___CacheProjection.swift b/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___CacheProjection.swift new file mode 100644 index 0000000..21e6e82 --- /dev/null +++ b/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___CacheProjection.swift @@ -0,0 +1,32 @@ +// ___FILEHEADER___ + +@dynamicMemberLookup +struct ___VARIABLE_sceneIdentifier___CacheProjection: CacheProjection { + typealias CacheModel = DataCacheModel + typealias ID = Void + + var state: ComponentState + var data: ___VARIABLE_sceneIdentifier___Data + + subscript(dynamicMember keyPath: WritableKeyPath<___VARIABLE_sceneIdentifier___Data, T>) -> T { + get { data[keyPath: keyPath] } + set { data[keyPath: keyPath] = newValue } + } + + static func empty(state: ComponentState) -> Self { + Self(state: state, data: .mock) + } + + static func data(from cache: DataCacheModel) -> Self? { + // TODO: Map cache data to projection + nil + } +} + +struct ___VARIABLE_sceneIdentifier___Data: Equatable, Mockable { + // TODO: Add data properties + + static var mock: Self { + Self() + } +} diff --git a/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___Component.swift b/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___Component.swift new file mode 100644 index 0000000..d900a0a --- /dev/null +++ b/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___Component.swift @@ -0,0 +1,28 @@ +// ___FILEHEADER___ + +import SwiftUI + +struct ___VARIABLE_sceneIdentifier___Component: View { + @State var model: Model + + var body: some View { + ComponentStateView(state: model.projection.state) { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } emptyView: { + Text("No data available") + } errorView: { config in + Text(config.title) + } + .task { + await model.onAppear() + } + } +} + +#if DEBUG +#Preview { + ___VARIABLE_sceneIdentifier___Component( + model: ___VARIABLE_sceneIdentifier___ComponentModelMock() + ) +} +#endif diff --git a/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___ComponentModel.swift b/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___ComponentModel.swift new file mode 100644 index 0000000..f50317c --- /dev/null +++ b/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___ComponentModel.swift @@ -0,0 +1,49 @@ +// ___FILEHEADER___ + +import FuturedArchitecture +import Observation + +protocol ___VARIABLE_sceneIdentifier___ComponentModelProtocol: ComponentModel { + var projection: ___VARIABLE_sceneIdentifier___CacheProjection { get } + func onAppear() async +} + +@Observable +final class ___VARIABLE_sceneIdentifier___ComponentModel: ___VARIABLE_sceneIdentifier___ComponentModelProtocol { + + let onEvent: (Event) -> Void + + private let dataCache: DataCache + + var projection: ___VARIABLE_sceneIdentifier___CacheProjection { + ___VARIABLE_sceneIdentifier___CacheProjection.data(from: dataCache.value) ?? .empty(state: .loading) + } + + init( + dataCache: DataCache, + onEvent: @escaping (Event) -> Void + ) { + self.dataCache = dataCache + self.onEvent = onEvent + } + + func onAppear() async { + // Fetch fresh data from network and update dataCache + } +} + +extension ___VARIABLE_sceneIdentifier___ComponentModel { + enum Event {} +} + +#if DEBUG +@Observable +final class ___VARIABLE_sceneIdentifier___ComponentModelMock: ___VARIABLE_sceneIdentifier___ComponentModelProtocol { + typealias Event = ___VARIABLE_sceneIdentifier___ComponentModel.Event + + var onEvent: (Event) -> Void = { _ in } + var projection: ___VARIABLE_sceneIdentifier___CacheProjection = .empty(state: .ready) + + func onAppear() async {} +} +#endif diff --git a/Templates/Scene.xctemplate/TemplateInfo.plist b/Templates/Scene.xctemplate/TemplateInfo.plist index 5d2678b..c552c71 100644 --- a/Templates/Scene.xctemplate/TemplateInfo.plist +++ b/Templates/Scene.xctemplate/TemplateInfo.plist @@ -51,6 +51,7 @@ Scene Resource Scene + Projection Scene @@ -104,6 +105,25 @@ Type static + + RequiredOptions + + sceneType + Projection Scene + + Default + ___VARIABLE_sceneIdentifier___CacheProjection + Description + Cache Projection name + Identifier + projectionName + Name + Projection name: + NotPersisted + + Type + static + Platforms diff --git a/Templates/SwiftUI App.xctemplate/ArrayState.swift b/Templates/SwiftUI App.xctemplate/ArrayState.swift new file mode 100644 index 0000000..653dd66 --- /dev/null +++ b/Templates/SwiftUI App.xctemplate/ArrayState.swift @@ -0,0 +1,33 @@ +// ___FILEHEADER___ + +struct MockableArray: Equatable, Mockable { + let items: [Element] + + static var mock: Self { + .init(items: [Element.mock]) + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.items == rhs.items + } +} + +enum ArrayState: Equatable { + case populated(MockableArray) + case loading + case error(ErrorViewConfig) + + var values: [Value]? { + if case let .populated(array) = self { + return array.items + } + return nil + } + + var errorConfig: ErrorViewConfig? { + if case let .error(config) = self { + return config + } + return nil + } +} diff --git a/Templates/SwiftUI App.xctemplate/ArrayStateView.swift b/Templates/SwiftUI App.xctemplate/ArrayStateView.swift new file mode 100644 index 0000000..65d4b41 --- /dev/null +++ b/Templates/SwiftUI App.xctemplate/ArrayStateView.swift @@ -0,0 +1,52 @@ +// ___FILEHEADER___ + +import SwiftUI + +struct ArrayStateView< + Value: Equatable & Mockable, + PopulatedView: View, + LoadingView: View, + ErrorView: View +>: View { + let state: ArrayState + let populatedView: ([Value]) -> PopulatedView + let loadingView: ([Value]) -> LoadingView + let errorView: (ErrorViewConfig) -> ErrorView + + init( + state: ArrayState, + @ViewBuilder populatedView: @escaping ([Value]) -> PopulatedView, + @ViewBuilder loadingView: @escaping ([Value]) -> LoadingView, + @ViewBuilder errorView: @escaping (ErrorViewConfig) -> ErrorView + ) { + self.state = state + self.populatedView = populatedView + self.loadingView = loadingView + self.errorView = errorView + } + + var body: some View { + switch state { + case let .populated(array): + populatedView(array.items) + case .loading: + loadingView([Value.mock]) + .redacted(reason: LoadingView.self == PopulatedView.self ? [.placeholder] : []) + case let .error(config): + errorView(config) + } + } +} + +extension ArrayStateView where LoadingView == PopulatedView { + init( + state: ArrayState, + @ViewBuilder populatedView: @escaping ([Value]) -> PopulatedView, + @ViewBuilder errorView: @escaping (ErrorViewConfig) -> ErrorView + ) { + self.state = state + self.populatedView = populatedView + self.loadingView = populatedView + self.errorView = errorView + } +} diff --git a/Templates/SwiftUI App.xctemplate/CacheProjection.swift b/Templates/SwiftUI App.xctemplate/CacheProjection.swift new file mode 100644 index 0000000..fb350ac --- /dev/null +++ b/Templates/SwiftUI App.xctemplate/CacheProjection.swift @@ -0,0 +1,28 @@ +// ___FILEHEADER___ + +protocol CacheProjection: Equatable { + associatedtype CacheModel: Equatable + associatedtype Data: Equatable & Mockable + associatedtype ID + + var state: ComponentState { get set } + var data: Data { get set } + + static func empty(state: ComponentState) -> Self + + /// Extract projection from the entire cache (for global/singleton data). + static func data(from cache: CacheModel) -> Self? + + /// Extract projection for a specific ID (for keyed data). + static func data(for id: ID, from cache: CacheModel) -> Self? +} + +extension CacheProjection { + static func data(from cache: CacheModel) -> Self? { + nil + } + + static func data(for id: ID, from cache: CacheModel) -> Self? { + nil + } +} diff --git a/Templates/SwiftUI App.xctemplate/ComponentState.swift b/Templates/SwiftUI App.xctemplate/ComponentState.swift new file mode 100644 index 0000000..742db55 --- /dev/null +++ b/Templates/SwiftUI App.xctemplate/ComponentState.swift @@ -0,0 +1,23 @@ +// ___FILEHEADER___ + +enum ComponentState: Equatable { + case ready + case loading + case empty + case error(ErrorViewConfig) + + var isLoading: Bool { + if case .loading = self { return true } + return false + } + + var isError: Bool { + if case .error = self { return true } + return false + } + + var errorConfig: ErrorViewConfig? { + if case let .error(config) = self { return config } + return nil + } +} diff --git a/Templates/SwiftUI App.xctemplate/ComponentStateView.swift b/Templates/SwiftUI App.xctemplate/ComponentStateView.swift new file mode 100644 index 0000000..e77c08e --- /dev/null +++ b/Templates/SwiftUI App.xctemplate/ComponentStateView.swift @@ -0,0 +1,59 @@ +// ___FILEHEADER___ + +import SwiftUI + +struct ComponentStateView< + PopulatedView: View, + EmptyView: View, + LoadingView: View, + ErrorView: View +>: View { + let state: ComponentState + let populatedView: () -> PopulatedView + let emptyView: () -> EmptyView + let loadingView: () -> LoadingView + let errorView: (ErrorViewConfig) -> ErrorView + + init( + state: ComponentState, + @ViewBuilder populatedView: @escaping () -> PopulatedView, + @ViewBuilder emptyView: @escaping () -> EmptyView, + @ViewBuilder loadingView: @escaping () -> LoadingView, + @ViewBuilder errorView: @escaping (ErrorViewConfig) -> ErrorView + ) { + self.state = state + self.populatedView = populatedView + self.emptyView = emptyView + self.loadingView = loadingView + self.errorView = errorView + } + + var body: some View { + switch state { + case .ready: + populatedView() + case .loading: + loadingView() + .redacted(reason: LoadingView.self == PopulatedView.self ? [.placeholder] : []) + case .empty: + emptyView() + case let .error(config): + errorView(config) + } + } +} + +extension ComponentStateView where LoadingView == PopulatedView { + init( + state: ComponentState, + @ViewBuilder populatedView: @escaping () -> PopulatedView, + @ViewBuilder emptyView: @escaping () -> EmptyView, + @ViewBuilder errorView: @escaping (ErrorViewConfig) -> ErrorView + ) { + self.state = state + self.populatedView = populatedView + self.emptyView = emptyView + self.loadingView = populatedView + self.errorView = errorView + } +} diff --git a/Templates/SwiftUI App.xctemplate/DataCacheModel.swift b/Templates/SwiftUI App.xctemplate/DataCacheModel.swift index 0d2d37a..90e1045 100644 --- a/Templates/SwiftUI App.xctemplate/DataCacheModel.swift +++ b/Templates/SwiftUI App.xctemplate/DataCacheModel.swift @@ -2,4 +2,12 @@ nonisolated struct DataCacheModel: Equatable, Sendable { // Cached properties + var exampleItem: ExampleItem? +} + +extension DataCacheModel { + struct ExampleItem: Equatable, Sendable, Identifiable { + let id: String + var title: String + } } diff --git a/Templates/SwiftUI App.xctemplate/ErrorViewConfig.swift b/Templates/SwiftUI App.xctemplate/ErrorViewConfig.swift new file mode 100644 index 0000000..c2bd8b7 --- /dev/null +++ b/Templates/SwiftUI App.xctemplate/ErrorViewConfig.swift @@ -0,0 +1,24 @@ +// ___FILEHEADER___ + +import Foundation + +struct ErrorViewConfig: Error, Equatable { + struct RetryActionConfig: Equatable { + let title: String + let action: @Sendable () async -> Void + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.title == rhs.title + } + } + + let title: String + let subtitle: String? + let retryConfig: RetryActionConfig? + + init(title: String, subtitle: String? = nil, retryConfig: RetryActionConfig? = nil) { + self.title = title + self.subtitle = subtitle + self.retryConfig = retryConfig + } +} diff --git a/Templates/SwiftUI App.xctemplate/ExampleCacheProjection.swift b/Templates/SwiftUI App.xctemplate/ExampleCacheProjection.swift new file mode 100644 index 0000000..9b7773d --- /dev/null +++ b/Templates/SwiftUI App.xctemplate/ExampleCacheProjection.swift @@ -0,0 +1,37 @@ +// ___FILEHEADER___ + +@dynamicMemberLookup +struct ExampleCacheProjection: CacheProjection { + typealias CacheModel = DataCacheModel + typealias ID = Void + + var state: ComponentState + var data: ExampleData + + subscript(dynamicMember keyPath: WritableKeyPath) -> T { + get { data[keyPath: keyPath] } + set { data[keyPath: keyPath] = newValue } + } + + static func empty(state: ComponentState) -> Self { + Self(state: state, data: .mock) + } + + static func data(from cache: DataCacheModel) -> Self? { + guard let item = cache.exampleItem else { + return nil + } + return Self( + state: .ready, + data: ExampleData(title: item.title) + ) + } +} + +struct ExampleData: Equatable, Mockable { + var title: String + + static var mock: Self { + Self(title: "Loading...") + } +} diff --git a/Templates/SwiftUI App.xctemplate/ExampleComponent.swift b/Templates/SwiftUI App.xctemplate/ExampleComponent.swift index fd760a9..cf73216 100644 --- a/Templates/SwiftUI App.xctemplate/ExampleComponent.swift +++ b/Templates/SwiftUI App.xctemplate/ExampleComponent.swift @@ -6,8 +6,28 @@ struct ExampleComponent: View { @State var model: Model var body: some View { - Button("Hello, World!") { - model.onTouchUpInside() + ComponentStateView(state: model.projection.state) { + VStack { + Text(model.projection.title) + + Button("Hello, World!") { + model.onTouchUpInside() + } + } + } emptyView: { + Text("No data available") + } errorView: { config in + VStack { + Text(config.title) + + if let retryConfig = config.retryConfig { + Button(retryConfig.title) { + Task { + await retryConfig.action() + } + } + } + } } .task { await model.onAppear() diff --git a/Templates/SwiftUI App.xctemplate/ExampleComponentModel.swift b/Templates/SwiftUI App.xctemplate/ExampleComponentModel.swift index 50865e7..005ea7b 100644 --- a/Templates/SwiftUI App.xctemplate/ExampleComponentModel.swift +++ b/Templates/SwiftUI App.xctemplate/ExampleComponentModel.swift @@ -4,6 +4,7 @@ import FuturedArchitecture import Observation protocol ExampleComponentModelProtocol: ComponentModel { + var projection: ExampleCacheProjection { get } func onAppear() async func onTouchUpInside() } @@ -15,6 +16,12 @@ final class ExampleComponentModel: ExampleComponentModelProtocol { private let dataCache: DataCache + /// Computed projection: automatically observes dataCache.value changes + /// via @Observable tracking. No Combine subscription needed. + var projection: ExampleCacheProjection { + ExampleCacheProjection.data(from: dataCache.value) ?? .empty(state: .loading) + } + init( dataCache: DataCache, onEvent: @escaping (Event) -> Void @@ -24,7 +31,7 @@ final class ExampleComponentModel: ExampleComponentModelProtocol { } func onAppear() async { - // Fetch fresh data from network or subscribe to cache changes + // Fetch fresh data from network and update dataCache } func onTouchUpInside() { @@ -44,6 +51,7 @@ final class ExampleComponentModelMock: ExampleComponentModelProtocol { typealias Event = ExampleComponentModel.Event var onEvent: (Event) -> Void = { _ in } + var projection: ExampleCacheProjection = .empty(state: .ready) func onAppear() async { } diff --git a/Templates/SwiftUI App.xctemplate/ItemState.swift b/Templates/SwiftUI App.xctemplate/ItemState.swift new file mode 100644 index 0000000..74beca1 --- /dev/null +++ b/Templates/SwiftUI App.xctemplate/ItemState.swift @@ -0,0 +1,21 @@ +// ___FILEHEADER___ + +enum ItemState: Equatable { + case populated(Value) + case loading + case error(ErrorViewConfig) + + var value: Value? { + if case let .populated(value) = self { + return value + } + return nil + } + + var errorConfig: ErrorViewConfig? { + if case let .error(config) = self { + return config + } + return nil + } +} diff --git a/Templates/SwiftUI App.xctemplate/ItemStateView.swift b/Templates/SwiftUI App.xctemplate/ItemStateView.swift new file mode 100644 index 0000000..8584a7e --- /dev/null +++ b/Templates/SwiftUI App.xctemplate/ItemStateView.swift @@ -0,0 +1,52 @@ +// ___FILEHEADER___ + +import SwiftUI + +struct ItemStateView< + Value: Mockable & Equatable, + PopulatedView: View, + LoadingView: View, + ErrorView: View +>: View { + let state: ItemState + let populatedView: (Value) -> PopulatedView + let loadingView: (Value) -> LoadingView + let errorView: (ErrorViewConfig) -> ErrorView + + init( + state: ItemState, + @ViewBuilder populatedView: @escaping (Value) -> PopulatedView, + @ViewBuilder loadingView: @escaping (Value) -> LoadingView, + @ViewBuilder errorView: @escaping (ErrorViewConfig) -> ErrorView + ) { + self.state = state + self.populatedView = populatedView + self.loadingView = loadingView + self.errorView = errorView + } + + var body: some View { + switch state { + case let .populated(value): + populatedView(value) + case .loading: + loadingView(Value.mock) + .redacted(reason: LoadingView.self == PopulatedView.self ? [.placeholder] : []) + case let .error(config): + errorView(config) + } + } +} + +extension ItemStateView where LoadingView == PopulatedView { + init( + state: ItemState, + @ViewBuilder populatedView: @escaping (Value) -> PopulatedView, + @ViewBuilder errorView: @escaping (ErrorViewConfig) -> ErrorView + ) { + self.state = state + self.populatedView = populatedView + self.loadingView = populatedView + self.errorView = errorView + } +} diff --git a/Templates/SwiftUI App.xctemplate/Mockable.swift b/Templates/SwiftUI App.xctemplate/Mockable.swift new file mode 100644 index 0000000..f3684e8 --- /dev/null +++ b/Templates/SwiftUI App.xctemplate/Mockable.swift @@ -0,0 +1,11 @@ +// ___FILEHEADER___ + +protocol Mockable { + static var mock: Self { get } +} + +extension Mockable where Self: Equatable { + var isMock: Bool { + self == Self.mock + } +} diff --git a/Templates/SwiftUI App.xctemplate/TemplateInfo.plist b/Templates/SwiftUI App.xctemplate/TemplateInfo.plist index b4a4a22..dc2c0fd 100644 --- a/Templates/SwiftUI App.xctemplate/TemplateInfo.plist +++ b/Templates/SwiftUI App.xctemplate/TemplateInfo.plist @@ -143,6 +143,16 @@ AppDelegate.swift Container.swift Model/DataCacheModel.swift + Model/Mockable.swift + Model/ErrorViewConfig.swift + Model/ComponentState.swift + Model/ItemState.swift + Model/ArrayState.swift + Model/CacheProjection.swift + UI/Views/ComponentStateView.swift + UI/Views/ItemStateView.swift + UI/Views/ArrayStateView.swift + Scenes/Example/ExampleCacheProjection.swift Scenes/Example/ExampleComponentModel.swift Scenes/Example/ExampleComponent.swift Flow Coordinators/ExampleFlowCoordinator.swift @@ -249,6 +259,108 @@ Group Model + Model/Mockable.swift + + SortOrder + 99 + Path + Mockable.swift + Group + Model + + Model/ErrorViewConfig.swift + + SortOrder + 99 + Path + ErrorViewConfig.swift + Group + Model + + Model/ComponentState.swift + + SortOrder + 99 + Path + ComponentState.swift + Group + Model + + Model/ItemState.swift + + SortOrder + 99 + Path + ItemState.swift + Group + Model + + Model/ArrayState.swift + + SortOrder + 99 + Path + ArrayState.swift + Group + Model + + Model/CacheProjection.swift + + SortOrder + 99 + Path + CacheProjection.swift + Group + Model + + UI/Views/ComponentStateView.swift + + SortOrder + 99 + Path + ComponentStateView.swift + Group + + UI + Views + + + UI/Views/ItemStateView.swift + + SortOrder + 99 + Path + ItemStateView.swift + Group + + UI + Views + + + UI/Views/ArrayStateView.swift + + SortOrder + 99 + Path + ArrayStateView.swift + Group + + UI + Views + + + Scenes/Example/ExampleCacheProjection.swift + + SortOrder + 99 + Path + ExampleCacheProjection.swift + Group + + Scenes + Example + + Flow Coordinators/ExampleFlowCoordinator.swift SortOrder From a2d198a4a6622b9ed037f55bfbd0360fe3090d49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Mon, 13 Apr 2026 12:37:51 +0100 Subject: [PATCH 02/11] Refine state templates: unified StateInfoView and cleaner APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge ErrorViewConfig and the missing EmptyViewConfig into a single StateInfoConfig carried inside ComponentState's empty/error cases. The config requires a config wherever the state is non-populated, which prevents the EmptyView() placeholder anti-pattern observed in production. StateInfoView is the baseline renderer for empty and error; a TODO asks each project to swap it for its design system. ComponentStateView keeps four generics but adds seven extension inits so callers can default empty/error to StateInfoView and only customize what they need. ItemStateView gains equivalent convenience inits. Drop ArrayStateView — production evidence (jt-assets) shows ArrayState is always converted to ItemState via a computed property on the Data struct, so a dedicated array view is dead weight. Use @ProxyMembers from futured-macros instead of the hand-rolled subscript(dynamicMember:) on cache projections. CacheModel is now inferred from the data(from:) signature; the default nil data(for:from:) is constrained to where ID == Void so projections with a real ID are forced to implement it. Extract the Example component's content into a private computed view and clarify the observation comment on the computed projection. --- ...LE_sceneIdentifier___CacheProjection.swift | 13 +- ...VARIABLE_sceneIdentifier___Component.swift | 4 - .../SwiftUI App.xctemplate/ArrayState.swift | 34 ++-- .../ArrayStateView.swift | 52 ------ .../CacheProjection.swift | 10 +- .../ComponentState.swift | 18 +- .../ComponentStateView.swift | 163 ++++++++++++++++-- .../ErrorViewConfig.swift | 24 --- .../ExampleCacheProjection.swift | 13 +- .../ExampleComponent.swift | 32 ++-- .../ExampleComponentModel.swift | 7 +- .../SwiftUI App.xctemplate/ItemState.swift | 7 +- .../ItemStateView.swift | 46 ++++- .../StateInfoConfig.swift | 34 ++++ .../StateInfoView.swift | 86 +++++++++ .../SwiftUI App.xctemplate/TemplateInfo.plist | 12 +- 16 files changed, 377 insertions(+), 178 deletions(-) delete mode 100644 Templates/SwiftUI App.xctemplate/ArrayStateView.swift delete mode 100644 Templates/SwiftUI App.xctemplate/ErrorViewConfig.swift create mode 100644 Templates/SwiftUI App.xctemplate/StateInfoConfig.swift create mode 100644 Templates/SwiftUI App.xctemplate/StateInfoView.swift diff --git a/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___CacheProjection.swift b/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___CacheProjection.swift index 21e6e82..792f256 100644 --- a/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___CacheProjection.swift +++ b/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___CacheProjection.swift @@ -1,17 +1,12 @@ // ___FILEHEADER___ -@dynamicMemberLookup +import ProxyMembers + struct ___VARIABLE_sceneIdentifier___CacheProjection: CacheProjection { - typealias CacheModel = DataCacheModel - typealias ID = Void + typealias ID = Void // swiftlint:disable:this type_name var state: ComponentState - var data: ___VARIABLE_sceneIdentifier___Data - - subscript(dynamicMember keyPath: WritableKeyPath<___VARIABLE_sceneIdentifier___Data, T>) -> T { - get { data[keyPath: keyPath] } - set { data[keyPath: keyPath] = newValue } - } + @ProxyMembers var data: ___VARIABLE_sceneIdentifier___Data static func empty(state: ComponentState) -> Self { Self(state: state, data: .mock) diff --git a/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___Component.swift b/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___Component.swift index d900a0a..6d5e360 100644 --- a/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___Component.swift +++ b/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___Component.swift @@ -8,10 +8,6 @@ struct ___VARIABLE_sceneIdentifier___Component: Equatable, Mockable { - let items: [Element] - - static var mock: Self { - .init(items: [Element.mock]) - } - - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.items == rhs.items - } -} - +/// State wrapper for a collection of items. +/// +/// **Rendering:** prefer converting `ArrayState` to `ItemState` via a +/// computed property on your `CacheProjection` data struct, then render +/// with `ItemStateView`. Direct array rendering tends to leak filtering +/// and sorting logic into views. enum ArrayState: Equatable { case populated(MockableArray) case loading - case error(ErrorViewConfig) + case error(StateInfoConfig) var values: [Value]? { if case let .populated(array) = self { @@ -24,10 +18,22 @@ enum ArrayState: Equatable { return nil } - var errorConfig: ErrorViewConfig? { + var errorConfig: StateInfoConfig? { if case let .error(config) = self { return config } return nil } } + +struct MockableArray: Equatable, Mockable { + let items: [Element] + + static var mock: Self { + .init(items: [Element.mock]) + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.items == rhs.items + } +} diff --git a/Templates/SwiftUI App.xctemplate/ArrayStateView.swift b/Templates/SwiftUI App.xctemplate/ArrayStateView.swift deleted file mode 100644 index 65d4b41..0000000 --- a/Templates/SwiftUI App.xctemplate/ArrayStateView.swift +++ /dev/null @@ -1,52 +0,0 @@ -// ___FILEHEADER___ - -import SwiftUI - -struct ArrayStateView< - Value: Equatable & Mockable, - PopulatedView: View, - LoadingView: View, - ErrorView: View ->: View { - let state: ArrayState - let populatedView: ([Value]) -> PopulatedView - let loadingView: ([Value]) -> LoadingView - let errorView: (ErrorViewConfig) -> ErrorView - - init( - state: ArrayState, - @ViewBuilder populatedView: @escaping ([Value]) -> PopulatedView, - @ViewBuilder loadingView: @escaping ([Value]) -> LoadingView, - @ViewBuilder errorView: @escaping (ErrorViewConfig) -> ErrorView - ) { - self.state = state - self.populatedView = populatedView - self.loadingView = loadingView - self.errorView = errorView - } - - var body: some View { - switch state { - case let .populated(array): - populatedView(array.items) - case .loading: - loadingView([Value.mock]) - .redacted(reason: LoadingView.self == PopulatedView.self ? [.placeholder] : []) - case let .error(config): - errorView(config) - } - } -} - -extension ArrayStateView where LoadingView == PopulatedView { - init( - state: ArrayState, - @ViewBuilder populatedView: @escaping ([Value]) -> PopulatedView, - @ViewBuilder errorView: @escaping (ErrorViewConfig) -> ErrorView - ) { - self.state = state - self.populatedView = populatedView - self.loadingView = populatedView - self.errorView = errorView - } -} diff --git a/Templates/SwiftUI App.xctemplate/CacheProjection.swift b/Templates/SwiftUI App.xctemplate/CacheProjection.swift index fb350ac..884736f 100644 --- a/Templates/SwiftUI App.xctemplate/CacheProjection.swift +++ b/Templates/SwiftUI App.xctemplate/CacheProjection.swift @@ -3,7 +3,7 @@ protocol CacheProjection: Equatable { associatedtype CacheModel: Equatable associatedtype Data: Equatable & Mockable - associatedtype ID + associatedtype ID // swiftlint:disable:this type_name var state: ComponentState { get set } var data: Data { get set } @@ -17,11 +17,9 @@ protocol CacheProjection: Equatable { static func data(for id: ID, from cache: CacheModel) -> Self? } -extension CacheProjection { - static func data(from cache: CacheModel) -> Self? { - nil - } - +/// Default `data(for:from:)` for projections that don't use an ID. +/// Projections with a real `ID` type are forced to implement it. +extension CacheProjection where ID == Void { static func data(for id: ID, from cache: CacheModel) -> Self? { nil } diff --git a/Templates/SwiftUI App.xctemplate/ComponentState.swift b/Templates/SwiftUI App.xctemplate/ComponentState.swift index 742db55..1b4f075 100644 --- a/Templates/SwiftUI App.xctemplate/ComponentState.swift +++ b/Templates/SwiftUI App.xctemplate/ComponentState.swift @@ -1,10 +1,14 @@ // ___FILEHEADER___ +/// Screen/component-level lifecycle state. +/// +/// `.empty` and `.error` require a `StateInfoConfig` so that every +/// non-populated state is explicitly designed — no silent placeholders. enum ComponentState: Equatable { case ready case loading - case empty - case error(ErrorViewConfig) + case empty(StateInfoConfig) + case error(StateInfoConfig) var isLoading: Bool { if case .loading = self { return true } @@ -16,8 +20,12 @@ enum ComponentState: Equatable { return false } - var errorConfig: ErrorViewConfig? { - if case let .error(config) = self { return config } - return nil + var infoConfig: StateInfoConfig? { + switch self { + case let .empty(config), let .error(config): + return config + case .ready, .loading: + return nil + } } } diff --git a/Templates/SwiftUI App.xctemplate/ComponentStateView.swift b/Templates/SwiftUI App.xctemplate/ComponentStateView.swift index e77c08e..f384fdd 100644 --- a/Templates/SwiftUI App.xctemplate/ComponentStateView.swift +++ b/Templates/SwiftUI App.xctemplate/ComponentStateView.swift @@ -2,29 +2,37 @@ import SwiftUI +/// Renders a `ComponentState` lifecycle. +/// +/// The `.empty` and `.error` cases carry a `StateInfoConfig`, which by +/// default is rendered via `StateInfoView`. Callers can provide custom +/// empty/error view builders when a screen needs a bespoke layout. +/// +/// When `LoadingView == PopulatedView`, the loading state reuses the +/// populated view with `.redacted(reason: .placeholder)` applied. struct ComponentStateView< PopulatedView: View, - EmptyView: View, LoadingView: View, + EmptyView: View, ErrorView: View >: View { - let state: ComponentState - let populatedView: () -> PopulatedView - let emptyView: () -> EmptyView - let loadingView: () -> LoadingView - let errorView: (ErrorViewConfig) -> ErrorView + private let state: ComponentState + private let populatedView: () -> PopulatedView + private let loadingView: () -> LoadingView + private let emptyView: (StateInfoConfig) -> EmptyView + private let errorView: (StateInfoConfig) -> ErrorView init( state: ComponentState, @ViewBuilder populatedView: @escaping () -> PopulatedView, - @ViewBuilder emptyView: @escaping () -> EmptyView, @ViewBuilder loadingView: @escaping () -> LoadingView, - @ViewBuilder errorView: @escaping (ErrorViewConfig) -> ErrorView + @ViewBuilder emptyView: @escaping (StateInfoConfig) -> EmptyView, + @ViewBuilder errorView: @escaping (StateInfoConfig) -> ErrorView ) { self.state = state self.populatedView = populatedView - self.emptyView = emptyView self.loadingView = loadingView + self.emptyView = emptyView self.errorView = errorView } @@ -35,25 +43,144 @@ struct ComponentStateView< case .loading: loadingView() .redacted(reason: LoadingView.self == PopulatedView.self ? [.placeholder] : []) - case .empty: - emptyView() + case let .empty(config): + emptyView(config) case let .error(config): errorView(config) } } } +// MARK: - Default empty/error via StateInfoView + +extension ComponentStateView where EmptyView == StateInfoView, ErrorView == StateInfoView { + /// Uses `StateInfoView` as the default renderer for both empty and + /// error states. Most common call shape. + init( + state: ComponentState, + @ViewBuilder populatedView: @escaping () -> PopulatedView, + @ViewBuilder loadingView: @escaping () -> LoadingView + ) { + self.init( + state: state, + populatedView: populatedView, + loadingView: loadingView, + emptyView: { StateInfoView(config: $0) }, + errorView: { StateInfoView(config: $0) } + ) + } +} + +extension ComponentStateView where + LoadingView == PopulatedView, + EmptyView == StateInfoView, + ErrorView == StateInfoView { + /// Minimal call site. Loading reuses the populated view (with + /// `.redacted(.placeholder)`); empty and error use `StateInfoView`. + init( + state: ComponentState, + @ViewBuilder populatedView: @escaping () -> PopulatedView + ) { + self.init( + state: state, + populatedView: populatedView, + loadingView: populatedView, + emptyView: { StateInfoView(config: $0) }, + errorView: { StateInfoView(config: $0) } + ) + } +} + +// MARK: - Custom loading, default empty/error + extension ComponentStateView where LoadingView == PopulatedView { + /// Loading reuses populated; empty and error fully custom. init( state: ComponentState, @ViewBuilder populatedView: @escaping () -> PopulatedView, - @ViewBuilder emptyView: @escaping () -> EmptyView, - @ViewBuilder errorView: @escaping (ErrorViewConfig) -> ErrorView + @ViewBuilder emptyView: @escaping (StateInfoConfig) -> EmptyView, + @ViewBuilder errorView: @escaping (StateInfoConfig) -> ErrorView ) { - self.state = state - self.populatedView = populatedView - self.emptyView = emptyView - self.loadingView = populatedView - self.errorView = errorView + self.init( + state: state, + populatedView: populatedView, + loadingView: populatedView, + emptyView: emptyView, + errorView: errorView + ) + } +} + +// MARK: - Customize only error (empty defaults to StateInfoView) + +extension ComponentStateView where EmptyView == StateInfoView { + /// Custom error view; empty stays as the default `StateInfoView`. + init( + state: ComponentState, + @ViewBuilder populatedView: @escaping () -> PopulatedView, + @ViewBuilder loadingView: @escaping () -> LoadingView, + @ViewBuilder errorView: @escaping (StateInfoConfig) -> ErrorView + ) { + self.init( + state: state, + populatedView: populatedView, + loadingView: loadingView, + emptyView: { StateInfoView(config: $0) }, + errorView: errorView + ) + } +} + +extension ComponentStateView where LoadingView == PopulatedView, EmptyView == StateInfoView { + /// Custom error view; loading reuses populated, empty defaults. + init( + state: ComponentState, + @ViewBuilder populatedView: @escaping () -> PopulatedView, + @ViewBuilder errorView: @escaping (StateInfoConfig) -> ErrorView + ) { + self.init( + state: state, + populatedView: populatedView, + loadingView: populatedView, + emptyView: { StateInfoView(config: $0) }, + errorView: errorView + ) + } +} + +// MARK: - Customize only empty (error defaults to StateInfoView) + +extension ComponentStateView where ErrorView == StateInfoView { + /// Custom empty view; error stays as the default `StateInfoView`. + init( + state: ComponentState, + @ViewBuilder populatedView: @escaping () -> PopulatedView, + @ViewBuilder loadingView: @escaping () -> LoadingView, + @ViewBuilder emptyView: @escaping (StateInfoConfig) -> EmptyView + ) { + self.init( + state: state, + populatedView: populatedView, + loadingView: loadingView, + emptyView: emptyView, + errorView: { StateInfoView(config: $0) } + ) + } +} + +extension ComponentStateView where LoadingView == PopulatedView, ErrorView == StateInfoView { + /// Custom empty view; loading reuses populated, error defaults. + init( + state: ComponentState, + @ViewBuilder populatedView: @escaping () -> PopulatedView, + @ViewBuilder emptyView: @escaping (StateInfoConfig) -> EmptyView + ) { + self.init( + state: state, + populatedView: populatedView, + loadingView: populatedView, + emptyView: emptyView, + errorView: { StateInfoView(config: $0) } + ) } } diff --git a/Templates/SwiftUI App.xctemplate/ErrorViewConfig.swift b/Templates/SwiftUI App.xctemplate/ErrorViewConfig.swift deleted file mode 100644 index c2bd8b7..0000000 --- a/Templates/SwiftUI App.xctemplate/ErrorViewConfig.swift +++ /dev/null @@ -1,24 +0,0 @@ -// ___FILEHEADER___ - -import Foundation - -struct ErrorViewConfig: Error, Equatable { - struct RetryActionConfig: Equatable { - let title: String - let action: @Sendable () async -> Void - - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.title == rhs.title - } - } - - let title: String - let subtitle: String? - let retryConfig: RetryActionConfig? - - init(title: String, subtitle: String? = nil, retryConfig: RetryActionConfig? = nil) { - self.title = title - self.subtitle = subtitle - self.retryConfig = retryConfig - } -} diff --git a/Templates/SwiftUI App.xctemplate/ExampleCacheProjection.swift b/Templates/SwiftUI App.xctemplate/ExampleCacheProjection.swift index 9b7773d..6ddee30 100644 --- a/Templates/SwiftUI App.xctemplate/ExampleCacheProjection.swift +++ b/Templates/SwiftUI App.xctemplate/ExampleCacheProjection.swift @@ -1,17 +1,12 @@ // ___FILEHEADER___ -@dynamicMemberLookup +import ProxyMembers + struct ExampleCacheProjection: CacheProjection { - typealias CacheModel = DataCacheModel - typealias ID = Void + typealias ID = Void // swiftlint:disable:this type_name var state: ComponentState - var data: ExampleData - - subscript(dynamicMember keyPath: WritableKeyPath) -> T { - get { data[keyPath: keyPath] } - set { data[keyPath: keyPath] = newValue } - } + @ProxyMembers var data: ExampleData static func empty(state: ComponentState) -> Self { Self(state: state, data: .mock) diff --git a/Templates/SwiftUI App.xctemplate/ExampleComponent.swift b/Templates/SwiftUI App.xctemplate/ExampleComponent.swift index cf73216..4e6ac29 100644 --- a/Templates/SwiftUI App.xctemplate/ExampleComponent.swift +++ b/Templates/SwiftUI App.xctemplate/ExampleComponent.swift @@ -7,32 +7,22 @@ struct ExampleComponent: View { var body: some View { ComponentStateView(state: model.projection.state) { - VStack { - Text(model.projection.title) - - Button("Hello, World!") { - model.onTouchUpInside() - } - } - } emptyView: { - Text("No data available") - } errorView: { config in - VStack { - Text(config.title) - - if let retryConfig = config.retryConfig { - Button(retryConfig.title) { - Task { - await retryConfig.action() - } - } - } - } + content } .task { await model.onAppear() } } + + private var content: some View { + VStack { + Text(model.projection.title) + + Button("Hello, World!") { + model.onTouchUpInside() + } + } + } } #if DEBUG diff --git a/Templates/SwiftUI App.xctemplate/ExampleComponentModel.swift b/Templates/SwiftUI App.xctemplate/ExampleComponentModel.swift index 005ea7b..ed73655 100644 --- a/Templates/SwiftUI App.xctemplate/ExampleComponentModel.swift +++ b/Templates/SwiftUI App.xctemplate/ExampleComponentModel.swift @@ -5,6 +5,7 @@ import Observation protocol ExampleComponentModelProtocol: ComponentModel { var projection: ExampleCacheProjection { get } + func onAppear() async func onTouchUpInside() } @@ -16,8 +17,10 @@ final class ExampleComponentModel: ExampleComponentModelProtocol { private let dataCache: DataCache - /// Computed projection: automatically observes dataCache.value changes - /// via @Observable tracking. No Combine subscription needed. + /// Computed projection. Reading `dataCache.value` inside this getter + /// registers the view as an observer of the cache via `@Observable`, + /// so the view re-renders automatically when the cache changes. + /// No Combine subscription needed. var projection: ExampleCacheProjection { ExampleCacheProjection.data(from: dataCache.value) ?? .empty(state: .loading) } diff --git a/Templates/SwiftUI App.xctemplate/ItemState.swift b/Templates/SwiftUI App.xctemplate/ItemState.swift index 74beca1..2e3f83a 100644 --- a/Templates/SwiftUI App.xctemplate/ItemState.swift +++ b/Templates/SwiftUI App.xctemplate/ItemState.swift @@ -1,9 +1,12 @@ // ___FILEHEADER___ +/// State wrapper for a single data item inside a component. +/// +/// There is no `.empty` case — `.populated` implies the data exists. enum ItemState: Equatable { case populated(Value) case loading - case error(ErrorViewConfig) + case error(StateInfoConfig) var value: Value? { if case let .populated(value) = self { @@ -12,7 +15,7 @@ enum ItemState: Equatable { return nil } - var errorConfig: ErrorViewConfig? { + var errorConfig: StateInfoConfig? { if case let .error(config) = self { return config } diff --git a/Templates/SwiftUI App.xctemplate/ItemStateView.swift b/Templates/SwiftUI App.xctemplate/ItemStateView.swift index 8584a7e..0a974df 100644 --- a/Templates/SwiftUI App.xctemplate/ItemStateView.swift +++ b/Templates/SwiftUI App.xctemplate/ItemStateView.swift @@ -2,22 +2,27 @@ import SwiftUI +/// Renders an `ItemState`. Used inline (inside a populated +/// component) for a single piece of data with its own loading/error state. +/// +/// The error view receives a `StateInfoConfig` so inline errors can +/// render differently from full-screen component errors. struct ItemStateView< Value: Mockable & Equatable, PopulatedView: View, LoadingView: View, ErrorView: View >: View { - let state: ItemState - let populatedView: (Value) -> PopulatedView - let loadingView: (Value) -> LoadingView - let errorView: (ErrorViewConfig) -> ErrorView + private let state: ItemState + private let populatedView: (Value) -> PopulatedView + private let loadingView: (Value) -> LoadingView + private let errorView: (StateInfoConfig) -> ErrorView init( state: ItemState, @ViewBuilder populatedView: @escaping (Value) -> PopulatedView, @ViewBuilder loadingView: @escaping (Value) -> LoadingView, - @ViewBuilder errorView: @escaping (ErrorViewConfig) -> ErrorView + @ViewBuilder errorView: @escaping (StateInfoConfig) -> ErrorView ) { self.state = state self.populatedView = populatedView @@ -42,7 +47,7 @@ extension ItemStateView where LoadingView == PopulatedView { init( state: ItemState, @ViewBuilder populatedView: @escaping (Value) -> PopulatedView, - @ViewBuilder errorView: @escaping (ErrorViewConfig) -> ErrorView + @ViewBuilder errorView: @escaping (StateInfoConfig) -> ErrorView ) { self.state = state self.populatedView = populatedView @@ -50,3 +55,32 @@ extension ItemStateView where LoadingView == PopulatedView { self.errorView = errorView } } + +extension ItemStateView where ErrorView == StateInfoView { + /// Defaults the error view to a full `StateInfoView`. Use the other + /// initializers when you need a custom inline error layout. + init( + state: ItemState, + @ViewBuilder populatedView: @escaping (Value) -> PopulatedView, + @ViewBuilder loadingView: @escaping (Value) -> LoadingView + ) { + self.state = state + self.populatedView = populatedView + self.loadingView = loadingView + self.errorView = { StateInfoView(config: $0) } + } +} + +extension ItemStateView where LoadingView == PopulatedView, ErrorView == StateInfoView { + /// Defaults both the loading view (reuses populated with `.redacted`) + /// and the error view (`StateInfoView`). Minimal call site. + init( + state: ItemState, + @ViewBuilder populatedView: @escaping (Value) -> PopulatedView + ) { + self.state = state + self.populatedView = populatedView + self.loadingView = populatedView + self.errorView = { StateInfoView(config: $0) } + } +} diff --git a/Templates/SwiftUI App.xctemplate/StateInfoConfig.swift b/Templates/SwiftUI App.xctemplate/StateInfoConfig.swift new file mode 100644 index 0000000..2e54031 --- /dev/null +++ b/Templates/SwiftUI App.xctemplate/StateInfoConfig.swift @@ -0,0 +1,34 @@ +// ___FILEHEADER___ + +import SwiftUI + +/// Configuration used by `StateInfoView` to render non-populated component +/// states (`.empty` and `.error`). The same shape is reused so empty and +/// error views share one visual language. +struct StateInfoConfig: Equatable { + struct Action: Equatable { + let title: String + let action: @Sendable () async -> Void + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.title == rhs.title + } + } + + let icon: Image? + let title: String + let message: String? + let actions: [Action] + + init( + icon: Image? = nil, + title: String, + message: String? = nil, + actions: [Action] = [] + ) { + self.icon = icon + self.title = title + self.message = message + self.actions = actions + } +} diff --git a/Templates/SwiftUI App.xctemplate/StateInfoView.swift b/Templates/SwiftUI App.xctemplate/StateInfoView.swift new file mode 100644 index 0000000..1038893 --- /dev/null +++ b/Templates/SwiftUI App.xctemplate/StateInfoView.swift @@ -0,0 +1,86 @@ +// ___FILEHEADER___ + +import SwiftUI + +/// Reusable view that renders a `StateInfoConfig`. Used as the default +/// renderer for `.empty` and `.error` states in `ComponentStateView`. +/// +/// TODO: Replace this baseline layout with the project's design system — +/// typography, spacing, icon treatment, button styles, and tokens should +/// match the app. This template provides a minimal SwiftUI implementation +/// only so the architecture compiles out of the box. +struct StateInfoView: View { + let config: StateInfoConfig + + var body: some View { + VStack(spacing: 16) { + if let icon = config.icon { + icon + .resizable() + .scaledToFit() + .frame(width: 64, height: 64) + .foregroundStyle(.secondary) + } + + Text(config.title) + .font(.headline) + .multilineTextAlignment(.center) + + if let message = config.message { + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + if !config.actions.isEmpty { + VStack(spacing: 8) { + ForEach(Array(config.actions.enumerated()), id: \.offset) { index, action in + actionButton(for: action, isPrimary: index == 0) + } + } + .padding(.top, 8) + } + } + .padding() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + @ViewBuilder + private func actionButton(for action: StateInfoConfig.Action, isPrimary: Bool) -> some View { + if isPrimary { + Button(action.title) { + Task { await action.action() } + } + .buttonStyle(.borderedProminent) + } else { + Button(action.title) { + Task { await action.action() } + } + .buttonStyle(.bordered) + } + } +} + +#if DEBUG +#Preview("Empty") { + StateInfoView(config: .init( + icon: Image(systemName: "tray"), + title: "No items yet", + message: "Pull to refresh or tap the button below.", + actions: [.init(title: "Refresh", action: {})] + )) +} + +#Preview("Error") { + StateInfoView(config: .init( + icon: Image(systemName: "exclamationmark.triangle"), + title: "Something went wrong", + message: "We couldn't load this screen. Please try again.", + actions: [ + .init(title: "Retry", action: {}), + .init(title: "Cancel", action: {}) + ] + )) +} +#endif diff --git a/Templates/SwiftUI App.xctemplate/TemplateInfo.plist b/Templates/SwiftUI App.xctemplate/TemplateInfo.plist index dc2c0fd..799827e 100644 --- a/Templates/SwiftUI App.xctemplate/TemplateInfo.plist +++ b/Templates/SwiftUI App.xctemplate/TemplateInfo.plist @@ -144,14 +144,14 @@ Container.swift Model/DataCacheModel.swift Model/Mockable.swift - Model/ErrorViewConfig.swift + Model/StateInfoConfig.swift Model/ComponentState.swift Model/ItemState.swift Model/ArrayState.swift Model/CacheProjection.swift + UI/Views/StateInfoView.swift UI/Views/ComponentStateView.swift UI/Views/ItemStateView.swift - UI/Views/ArrayStateView.swift Scenes/Example/ExampleCacheProjection.swift Scenes/Example/ExampleComponentModel.swift Scenes/Example/ExampleComponent.swift @@ -268,12 +268,12 @@ Group Model - Model/ErrorViewConfig.swift + Model/StateInfoConfig.swift SortOrder 99 Path - ErrorViewConfig.swift + StateInfoConfig.swift Group Model @@ -337,12 +337,12 @@ Views - UI/Views/ArrayStateView.swift + UI/Views/StateInfoView.swift SortOrder 99 Path - ArrayStateView.swift + StateInfoView.swift Group UI From 945ab4cb1c601e7d4cd9fa2b8c97f7c8fb905326 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Mon, 13 Apr 2026 12:57:40 +0100 Subject: [PATCH 03/11] Mark state and data types nonisolated for Swift 6 MainActor mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Swift 6 default MainActor isolation, value types default to MainActor unless annotated otherwise. DataCacheModel must stay nonisolated (it crosses the DataCache boundary), so every type it transitively equates against also needs to be nonisolated — otherwise the synthesized Equatable conformance fails with "Main actor-isolated conformance cannot be used in nonisolated context". ExampleItem moves out of the extension so it sits alongside DataCacheModel at file scope with explicit nonisolated. Restore @dynamicMemberLookup on cache projections so @ProxyMembers can generate its subscript. --- .../___VARIABLE_sceneIdentifier___CacheProjection.swift | 5 +++-- Templates/SwiftUI App.xctemplate/ArrayState.swift | 4 ++-- Templates/SwiftUI App.xctemplate/ComponentState.swift | 2 +- Templates/SwiftUI App.xctemplate/DataCacheModel.swift | 8 +++----- .../SwiftUI App.xctemplate/ExampleCacheProjection.swift | 5 +++-- Templates/SwiftUI App.xctemplate/ItemState.swift | 2 +- Templates/SwiftUI App.xctemplate/Mockable.swift | 4 ++-- Templates/SwiftUI App.xctemplate/StateInfoConfig.swift | 4 ++-- 8 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___CacheProjection.swift b/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___CacheProjection.swift index 792f256..a305f28 100644 --- a/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___CacheProjection.swift +++ b/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___CacheProjection.swift @@ -2,7 +2,8 @@ import ProxyMembers -struct ___VARIABLE_sceneIdentifier___CacheProjection: CacheProjection { +@dynamicMemberLookup +nonisolated struct ___VARIABLE_sceneIdentifier___CacheProjection: CacheProjection { typealias ID = Void // swiftlint:disable:this type_name var state: ComponentState @@ -18,7 +19,7 @@ struct ___VARIABLE_sceneIdentifier___CacheProjection: CacheProjection { } } -struct ___VARIABLE_sceneIdentifier___Data: Equatable, Mockable { +nonisolated struct ___VARIABLE_sceneIdentifier___Data: Equatable, Mockable { // TODO: Add data properties static var mock: Self { diff --git a/Templates/SwiftUI App.xctemplate/ArrayState.swift b/Templates/SwiftUI App.xctemplate/ArrayState.swift index 0ae76a7..521dc1b 100644 --- a/Templates/SwiftUI App.xctemplate/ArrayState.swift +++ b/Templates/SwiftUI App.xctemplate/ArrayState.swift @@ -6,7 +6,7 @@ /// computed property on your `CacheProjection` data struct, then render /// with `ItemStateView`. Direct array rendering tends to leak filtering /// and sorting logic into views. -enum ArrayState: Equatable { +nonisolated enum ArrayState: Equatable { case populated(MockableArray) case loading case error(StateInfoConfig) @@ -26,7 +26,7 @@ enum ArrayState: Equatable { } } -struct MockableArray: Equatable, Mockable { +nonisolated struct MockableArray: Equatable, Mockable { let items: [Element] static var mock: Self { diff --git a/Templates/SwiftUI App.xctemplate/ComponentState.swift b/Templates/SwiftUI App.xctemplate/ComponentState.swift index 1b4f075..890be99 100644 --- a/Templates/SwiftUI App.xctemplate/ComponentState.swift +++ b/Templates/SwiftUI App.xctemplate/ComponentState.swift @@ -4,7 +4,7 @@ /// /// `.empty` and `.error` require a `StateInfoConfig` so that every /// non-populated state is explicitly designed — no silent placeholders. -enum ComponentState: Equatable { +nonisolated enum ComponentState: Equatable { case ready case loading case empty(StateInfoConfig) diff --git a/Templates/SwiftUI App.xctemplate/DataCacheModel.swift b/Templates/SwiftUI App.xctemplate/DataCacheModel.swift index 90e1045..f30abe1 100644 --- a/Templates/SwiftUI App.xctemplate/DataCacheModel.swift +++ b/Templates/SwiftUI App.xctemplate/DataCacheModel.swift @@ -5,9 +5,7 @@ nonisolated struct DataCacheModel: Equatable, Sendable { var exampleItem: ExampleItem? } -extension DataCacheModel { - struct ExampleItem: Equatable, Sendable, Identifiable { - let id: String - var title: String - } +nonisolated struct ExampleItem: Equatable, Sendable, Identifiable { + let id: String + var title: String } diff --git a/Templates/SwiftUI App.xctemplate/ExampleCacheProjection.swift b/Templates/SwiftUI App.xctemplate/ExampleCacheProjection.swift index 6ddee30..bb3bdc3 100644 --- a/Templates/SwiftUI App.xctemplate/ExampleCacheProjection.swift +++ b/Templates/SwiftUI App.xctemplate/ExampleCacheProjection.swift @@ -2,7 +2,8 @@ import ProxyMembers -struct ExampleCacheProjection: CacheProjection { +@dynamicMemberLookup +nonisolated struct ExampleCacheProjection: CacheProjection { typealias ID = Void // swiftlint:disable:this type_name var state: ComponentState @@ -23,7 +24,7 @@ struct ExampleCacheProjection: CacheProjection { } } -struct ExampleData: Equatable, Mockable { +nonisolated struct ExampleData: Equatable, Mockable { var title: String static var mock: Self { diff --git a/Templates/SwiftUI App.xctemplate/ItemState.swift b/Templates/SwiftUI App.xctemplate/ItemState.swift index 2e3f83a..7be9501 100644 --- a/Templates/SwiftUI App.xctemplate/ItemState.swift +++ b/Templates/SwiftUI App.xctemplate/ItemState.swift @@ -3,7 +3,7 @@ /// State wrapper for a single data item inside a component. /// /// There is no `.empty` case — `.populated` implies the data exists. -enum ItemState: Equatable { +nonisolated enum ItemState: Equatable { case populated(Value) case loading case error(StateInfoConfig) diff --git a/Templates/SwiftUI App.xctemplate/Mockable.swift b/Templates/SwiftUI App.xctemplate/Mockable.swift index f3684e8..5ec6f89 100644 --- a/Templates/SwiftUI App.xctemplate/Mockable.swift +++ b/Templates/SwiftUI App.xctemplate/Mockable.swift @@ -1,11 +1,11 @@ // ___FILEHEADER___ protocol Mockable { - static var mock: Self { get } + nonisolated static var mock: Self { get } } extension Mockable where Self: Equatable { - var isMock: Bool { + nonisolated var isMock: Bool { self == Self.mock } } diff --git a/Templates/SwiftUI App.xctemplate/StateInfoConfig.swift b/Templates/SwiftUI App.xctemplate/StateInfoConfig.swift index 2e54031..f737124 100644 --- a/Templates/SwiftUI App.xctemplate/StateInfoConfig.swift +++ b/Templates/SwiftUI App.xctemplate/StateInfoConfig.swift @@ -5,8 +5,8 @@ import SwiftUI /// Configuration used by `StateInfoView` to render non-populated component /// states (`.empty` and `.error`). The same shape is reused so empty and /// error views share one visual language. -struct StateInfoConfig: Equatable { - struct Action: Equatable { +nonisolated struct StateInfoConfig: Equatable { + nonisolated struct Action: Equatable { let title: String let action: @Sendable () async -> Void From f3a6f75dbcd548cc3d0bf69334d0ef402bfeb4da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Mon, 13 Apr 2026 14:19:39 +0100 Subject: [PATCH 04/11] Warn developer to set UILaunchScreen in Info.plist Without UILaunchScreen (or UILaunchStoryboardName) iOS runs the app in legacy compatibility mode and the UI is letterboxed instead of filling the screen on modern devices. A compile-time #warning in App.swift surfaces the requirement in the build log. --- Templates/SwiftUI App.xctemplate/App.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Templates/SwiftUI App.xctemplate/App.swift b/Templates/SwiftUI App.xctemplate/App.swift index 217e1d0..1cede84 100644 --- a/Templates/SwiftUI App.xctemplate/App.swift +++ b/Templates/SwiftUI App.xctemplate/App.swift @@ -4,6 +4,9 @@ import SwiftUI #error("Add https://github.com/futuredapp/FuturedKit.git to the project!") +// swiftlint:disable:next line_length +#warning("Add UILaunchScreen (dictionary) to Info.plist. Without it, iOS runs the app in legacy compatibility mode and the UI does not fill the full screen on modern devices.") + @main struct ___PACKAGENAME:identifier___App: App { @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate From ffa141518ffb1d39c9ae7ae1801db936a940b192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 12 May 2026 13:34:23 +0100 Subject: [PATCH 05/11] Clean up Projection Scene template formatting --- .../___VARIABLE_sceneIdentifier___CacheProjection.swift | 2 -- .../___VARIABLE_sceneIdentifier___ComponentModel.swift | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___CacheProjection.swift b/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___CacheProjection.swift index a305f28..b90cc97 100644 --- a/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___CacheProjection.swift +++ b/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___CacheProjection.swift @@ -4,8 +4,6 @@ import ProxyMembers @dynamicMemberLookup nonisolated struct ___VARIABLE_sceneIdentifier___CacheProjection: CacheProjection { - typealias ID = Void // swiftlint:disable:this type_name - var state: ComponentState @ProxyMembers var data: ___VARIABLE_sceneIdentifier___Data diff --git a/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___ComponentModel.swift b/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___ComponentModel.swift index f50317c..ca567b8 100644 --- a/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___ComponentModel.swift +++ b/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___ComponentModel.swift @@ -5,6 +5,7 @@ import Observation protocol ___VARIABLE_sceneIdentifier___ComponentModelProtocol: ComponentModel { var projection: ___VARIABLE_sceneIdentifier___CacheProjection { get } + func onAppear() async } From e6f90fd90448e7df56ef94322f0dfe598bdfaf85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 12 May 2026 14:08:20 +0100 Subject: [PATCH 06/11] Remove ArrayState from SwiftUI App template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ArrayState was an unused state wrapper — render arrays via the CacheProjection -> ItemState -> ItemStateView pipeline instead, which keeps filtering and sorting out of views. --- .../SwiftUI App.xctemplate/ArrayState.swift | 39 ------------------- .../SwiftUI App.xctemplate/TemplateInfo.plist | 10 ----- 2 files changed, 49 deletions(-) delete mode 100644 Templates/SwiftUI App.xctemplate/ArrayState.swift diff --git a/Templates/SwiftUI App.xctemplate/ArrayState.swift b/Templates/SwiftUI App.xctemplate/ArrayState.swift deleted file mode 100644 index 521dc1b..0000000 --- a/Templates/SwiftUI App.xctemplate/ArrayState.swift +++ /dev/null @@ -1,39 +0,0 @@ -// ___FILEHEADER___ - -/// State wrapper for a collection of items. -/// -/// **Rendering:** prefer converting `ArrayState` to `ItemState` via a -/// computed property on your `CacheProjection` data struct, then render -/// with `ItemStateView`. Direct array rendering tends to leak filtering -/// and sorting logic into views. -nonisolated enum ArrayState: Equatable { - case populated(MockableArray) - case loading - case error(StateInfoConfig) - - var values: [Value]? { - if case let .populated(array) = self { - return array.items - } - return nil - } - - var errorConfig: StateInfoConfig? { - if case let .error(config) = self { - return config - } - return nil - } -} - -nonisolated struct MockableArray: Equatable, Mockable { - let items: [Element] - - static var mock: Self { - .init(items: [Element.mock]) - } - - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.items == rhs.items - } -} diff --git a/Templates/SwiftUI App.xctemplate/TemplateInfo.plist b/Templates/SwiftUI App.xctemplate/TemplateInfo.plist index 799827e..660b9a9 100644 --- a/Templates/SwiftUI App.xctemplate/TemplateInfo.plist +++ b/Templates/SwiftUI App.xctemplate/TemplateInfo.plist @@ -147,7 +147,6 @@ Model/StateInfoConfig.swift Model/ComponentState.swift Model/ItemState.swift - Model/ArrayState.swift Model/CacheProjection.swift UI/Views/StateInfoView.swift UI/Views/ComponentStateView.swift @@ -295,15 +294,6 @@ Group Model - Model/ArrayState.swift - - SortOrder - 99 - Path - ArrayState.swift - Group - Model - Model/CacheProjection.swift SortOrder From e9bac3bc23f621a8e2a9639005b2ba055b3e471d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 12 May 2026 13:40:58 +0100 Subject: [PATCH 07/11] Add SwiftLint build phase to SwiftUI App template Adds a Run Script build phase that runs swiftlint --fix followed by swiftlint, so generated projects autocorrect and surface remaining warnings in Xcode's issue navigator. Falls back to a friendly warning when SwiftLint is not installed. --- .../SwiftUI App.xctemplate/TemplateInfo.plist | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Templates/SwiftUI App.xctemplate/TemplateInfo.plist b/Templates/SwiftUI App.xctemplate/TemplateInfo.plist index 660b9a9..322c12d 100644 --- a/Templates/SwiftUI App.xctemplate/TemplateInfo.plist +++ b/Templates/SwiftUI App.xctemplate/TemplateInfo.plist @@ -124,6 +124,26 @@ ___VARIABLE_bundleIdentifierPrefix:bundleIdentifier___.___PACKAGENAMEASRFC1034IDENTIFIER___.beta + BuildPhases + + + Class + ShellScript + Name + SwiftLint + ShellPath + /bin/sh + ShellScript + export PATH="$PATH:/opt/homebrew/bin" +if which swiftlint > /dev/null; then + swiftlint --fix --quiet + swiftlint +else + echo "warning: SwiftLint not installed — install with: brew install swiftlint" +fi + + + From 0df307e747104a5a76b011d98cd3a16cc8f72da2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 12 May 2026 13:50:44 +0100 Subject: [PATCH 08/11] Run SwiftLint before Compile Sources and disable script sandboxing Declare the full BuildPhases array so SwiftLint runs first, ensuring --fix corrects sources before they are compiled. Also disable ENABLE_USER_SCRIPT_SANDBOXING so SwiftLint can read project files under Xcode 15+'s default sandbox. --- .../SwiftUI App.xctemplate/TemplateInfo.plist | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Templates/SwiftUI App.xctemplate/TemplateInfo.plist b/Templates/SwiftUI App.xctemplate/TemplateInfo.plist index 322c12d..4020959 100644 --- a/Templates/SwiftUI App.xctemplate/TemplateInfo.plist +++ b/Templates/SwiftUI App.xctemplate/TemplateInfo.plist @@ -111,6 +111,8 @@ Manual SWIFT_VERSION 6 + ENABLE_USER_SCRIPT_SANDBOXING + NO Configurations @@ -143,6 +145,18 @@ else fi + + Class + Sources + + + Class + Frameworks + + + Class + Resources + From cc2e5253b3b4cd7e9b3ae93761ae13b417ad3e1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=CC=8Cimon=20S=CC=8Cesta=CC=81k?= Date: Tue, 12 May 2026 13:59:37 +0100 Subject: [PATCH 09/11] Remove duplicate inherited build phases, warn to reorder SwiftLint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Xcode template inheritance only appends BuildPhases — declaring Sources/Frameworks/Resources in the child template creates empty duplicates instead of replacing the inherited ones. Keep only the SwiftLint phase and surface a #warning telling the developer to drag it above Compile Sources so --fix corrections apply to the current build instead of the next one. --- Templates/SwiftUI App.xctemplate/App.swift | 3 +++ Templates/SwiftUI App.xctemplate/TemplateInfo.plist | 12 ------------ 2 files changed, 3 insertions(+), 12 deletions(-) diff --git a/Templates/SwiftUI App.xctemplate/App.swift b/Templates/SwiftUI App.xctemplate/App.swift index 1cede84..45257cd 100644 --- a/Templates/SwiftUI App.xctemplate/App.swift +++ b/Templates/SwiftUI App.xctemplate/App.swift @@ -7,6 +7,9 @@ import SwiftUI // swiftlint:disable:next line_length #warning("Add UILaunchScreen (dictionary) to Info.plist. Without it, iOS runs the app in legacy compatibility mode and the UI does not fill the full screen on modern devices.") +// swiftlint:disable:next line_length +#warning("Drag the 'SwiftLint' build phase above 'Compile Sources' in the target's Build Phases. Xcode templates can only append phases, so SwiftLint --fix corrections currently apply only to the next build instead of the current one.") + @main struct ___PACKAGENAME:identifier___App: App { @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate diff --git a/Templates/SwiftUI App.xctemplate/TemplateInfo.plist b/Templates/SwiftUI App.xctemplate/TemplateInfo.plist index 4020959..bf4efc3 100644 --- a/Templates/SwiftUI App.xctemplate/TemplateInfo.plist +++ b/Templates/SwiftUI App.xctemplate/TemplateInfo.plist @@ -145,18 +145,6 @@ else fi - - Class - Sources - - - Class - Frameworks - - - Class - Resources - From 47ababb5acb6e68a4a186fa474470370bfa43949 Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Fri, 4 Sep 2026 08:59:24 +0200 Subject: [PATCH 10/11] Add SwiftFormat phase and align lint scripts with Futrumi/iRozhlas Both scripts now skip Xcode preview builds, prepend /opt/homebrew/bin only when present, and report a missing SwiftLint as an error and a missing SwiftFormat as a warning, matching the production projects. SwiftFormat is appended before SwiftLint so formatting runs first. Co-Authored-By: Claude Fable 5.1 --- Templates/SwiftUI App.xctemplate/App.swift | 2 +- .../SwiftUI App.xctemplate/TemplateInfo.plist | 42 +++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/Templates/SwiftUI App.xctemplate/App.swift b/Templates/SwiftUI App.xctemplate/App.swift index 45257cd..7659e2e 100644 --- a/Templates/SwiftUI App.xctemplate/App.swift +++ b/Templates/SwiftUI App.xctemplate/App.swift @@ -8,7 +8,7 @@ import SwiftUI #warning("Add UILaunchScreen (dictionary) to Info.plist. Without it, iOS runs the app in legacy compatibility mode and the UI does not fill the full screen on modern devices.") // swiftlint:disable:next line_length -#warning("Drag the 'SwiftLint' build phase above 'Compile Sources' in the target's Build Phases. Xcode templates can only append phases, so SwiftLint --fix corrections currently apply only to the next build instead of the current one.") +#warning("Drag the 'SwiftFormat' and 'SwiftLint' build phases above 'Compile Sources' in the target's Build Phases, in that order. Xcode templates can only append phases, so formatting and SwiftLint --fix corrections currently apply only to the next build instead of the current one.") @main struct ___PACKAGENAME:identifier___App: App { diff --git a/Templates/SwiftUI App.xctemplate/TemplateInfo.plist b/Templates/SwiftUI App.xctemplate/TemplateInfo.plist index bf4efc3..e5583e0 100644 --- a/Templates/SwiftUI App.xctemplate/TemplateInfo.plist +++ b/Templates/SwiftUI App.xctemplate/TemplateInfo.plist @@ -128,6 +128,32 @@ BuildPhases + + Class + ShellScript + Name + SwiftFormat + ShellPath + /bin/sh + ShellScript + if [[ "${BUILD_DIR}" =~ "Previews" ]]; then + echo "SwiftFormat not enabled when compiling for previews" + exit +fi + +if test -d "/opt/homebrew/bin/"; then + PATH="/opt/homebrew/bin/:${PATH}" +fi + +export PATH + +if which swiftformat >/dev/null; then + swiftformat "$SRCROOT" +else + echo "warning: SwiftFormat not installed, run: brew install swiftformat" +fi + + Class ShellScript @@ -136,12 +162,22 @@ ShellPath /bin/sh ShellScript - export PATH="$PATH:/opt/homebrew/bin" -if which swiftlint > /dev/null; then + if [[ "${BUILD_DIR}" =~ "Previews" ]]; then + echo "SwiftLint not enabled when compiling for previews" + exit +fi + +if test -d "/opt/homebrew/bin/"; then + PATH="/opt/homebrew/bin/:${PATH}" +fi + +export PATH + +if which swiftlint >/dev/null; then swiftlint --fix --quiet swiftlint else - echo "warning: SwiftLint not installed — install with: brew install swiftlint" + echo "error: SwiftLint not installed, run: brew install swiftlint" fi From a93bd8bf50c4f5571f328f0e1256db3a02b2ecbd Mon Sep 17 00:00:00 2001 From: Jakub Marek Date: Fri, 4 Sep 2026 09:03:59 +0200 Subject: [PATCH 11/11] Shorten build phase reorder warning to one line Co-Authored-By: Claude Fable 5.1 --- Templates/SwiftUI App.xctemplate/App.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Templates/SwiftUI App.xctemplate/App.swift b/Templates/SwiftUI App.xctemplate/App.swift index 7659e2e..2642b7e 100644 --- a/Templates/SwiftUI App.xctemplate/App.swift +++ b/Templates/SwiftUI App.xctemplate/App.swift @@ -7,8 +7,7 @@ import SwiftUI // swiftlint:disable:next line_length #warning("Add UILaunchScreen (dictionary) to Info.plist. Without it, iOS runs the app in legacy compatibility mode and the UI does not fill the full screen on modern devices.") -// swiftlint:disable:next line_length -#warning("Drag the 'SwiftFormat' and 'SwiftLint' build phases above 'Compile Sources' in the target's Build Phases, in that order. Xcode templates can only append phases, so formatting and SwiftLint --fix corrections currently apply only to the next build instead of the current one.") +#warning("Move the SwiftFormat and SwiftLint build phases above Compile Sources (templates can only append phases).") @main struct ___PACKAGENAME:identifier___App: App {