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..b90cc97 --- /dev/null +++ b/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___CacheProjection.swift @@ -0,0 +1,26 @@ +// ___FILEHEADER___ + +import ProxyMembers + +@dynamicMemberLookup +nonisolated struct ___VARIABLE_sceneIdentifier___CacheProjection: CacheProjection { + var state: ComponentState + @ProxyMembers var data: ___VARIABLE_sceneIdentifier___Data + + 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 + } +} + +nonisolated 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..6d5e360 --- /dev/null +++ b/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___Component.swift @@ -0,0 +1,24 @@ +// ___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@*/) + } + .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..ca567b8 --- /dev/null +++ b/Templates/Scene.xctemplate/Projection Scene/___VARIABLE_sceneIdentifier___/___VARIABLE_sceneIdentifier___ComponentModel.swift @@ -0,0 +1,50 @@ +// ___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/App.swift b/Templates/SwiftUI App.xctemplate/App.swift index 217e1d0..2642b7e 100644 --- a/Templates/SwiftUI App.xctemplate/App.swift +++ b/Templates/SwiftUI App.xctemplate/App.swift @@ -4,6 +4,11 @@ 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.") + +#warning("Move the SwiftFormat and SwiftLint build phases above Compile Sources (templates can only append phases).") + @main struct ___PACKAGENAME:identifier___App: App { @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate diff --git a/Templates/SwiftUI App.xctemplate/CacheProjection.swift b/Templates/SwiftUI App.xctemplate/CacheProjection.swift new file mode 100644 index 0000000..884736f --- /dev/null +++ b/Templates/SwiftUI App.xctemplate/CacheProjection.swift @@ -0,0 +1,26 @@ +// ___FILEHEADER___ + +protocol CacheProjection: Equatable { + associatedtype CacheModel: Equatable + associatedtype Data: Equatable & Mockable + associatedtype ID // swiftlint:disable:this type_name + + 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? +} + +/// 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 new file mode 100644 index 0000000..890be99 --- /dev/null +++ b/Templates/SwiftUI App.xctemplate/ComponentState.swift @@ -0,0 +1,31 @@ +// ___FILEHEADER___ + +/// Screen/component-level lifecycle state. +/// +/// `.empty` and `.error` require a `StateInfoConfig` so that every +/// non-populated state is explicitly designed — no silent placeholders. +nonisolated enum ComponentState: Equatable { + case ready + case loading + case empty(StateInfoConfig) + case error(StateInfoConfig) + + var isLoading: Bool { + if case .loading = self { return true } + return false + } + + var isError: Bool { + if case .error = self { return true } + return false + } + + 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 new file mode 100644 index 0000000..f384fdd --- /dev/null +++ b/Templates/SwiftUI App.xctemplate/ComponentStateView.swift @@ -0,0 +1,186 @@ +// ___FILEHEADER___ + +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, + LoadingView: View, + EmptyView: View, + ErrorView: View +>: View { + 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 loadingView: @escaping () -> LoadingView, + @ViewBuilder emptyView: @escaping (StateInfoConfig) -> EmptyView, + @ViewBuilder errorView: @escaping (StateInfoConfig) -> ErrorView + ) { + self.state = state + self.populatedView = populatedView + self.loadingView = loadingView + self.emptyView = emptyView + self.errorView = errorView + } + + var body: some View { + switch state { + case .ready: + populatedView() + case .loading: + loadingView() + .redacted(reason: LoadingView.self == PopulatedView.self ? [.placeholder] : []) + 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 (StateInfoConfig) -> EmptyView, + @ViewBuilder errorView: @escaping (StateInfoConfig) -> 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/DataCacheModel.swift b/Templates/SwiftUI App.xctemplate/DataCacheModel.swift index 0d2d37a..f30abe1 100644 --- a/Templates/SwiftUI App.xctemplate/DataCacheModel.swift +++ b/Templates/SwiftUI App.xctemplate/DataCacheModel.swift @@ -2,4 +2,10 @@ nonisolated struct DataCacheModel: Equatable, Sendable { // Cached properties + var exampleItem: ExampleItem? +} + +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 new file mode 100644 index 0000000..bb3bdc3 --- /dev/null +++ b/Templates/SwiftUI App.xctemplate/ExampleCacheProjection.swift @@ -0,0 +1,33 @@ +// ___FILEHEADER___ + +import ProxyMembers + +@dynamicMemberLookup +nonisolated struct ExampleCacheProjection: CacheProjection { + typealias ID = Void // swiftlint:disable:this type_name + + var state: ComponentState + @ProxyMembers var data: ExampleData + + 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) + ) + } +} + +nonisolated 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..4e6ac29 100644 --- a/Templates/SwiftUI App.xctemplate/ExampleComponent.swift +++ b/Templates/SwiftUI App.xctemplate/ExampleComponent.swift @@ -6,13 +6,23 @@ struct ExampleComponent: View { @State var model: Model var body: some View { - Button("Hello, World!") { - model.onTouchUpInside() + ComponentStateView(state: model.projection.state) { + 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 50865e7..ed73655 100644 --- a/Templates/SwiftUI App.xctemplate/ExampleComponentModel.swift +++ b/Templates/SwiftUI App.xctemplate/ExampleComponentModel.swift @@ -4,6 +4,8 @@ import FuturedArchitecture import Observation protocol ExampleComponentModelProtocol: ComponentModel { + var projection: ExampleCacheProjection { get } + func onAppear() async func onTouchUpInside() } @@ -15,6 +17,14 @@ final class ExampleComponentModel: ExampleComponentModelProtocol { private let dataCache: DataCache + /// 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) + } + init( dataCache: DataCache, onEvent: @escaping (Event) -> Void @@ -24,7 +34,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 +54,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..7be9501 --- /dev/null +++ b/Templates/SwiftUI App.xctemplate/ItemState.swift @@ -0,0 +1,24 @@ +// ___FILEHEADER___ + +/// State wrapper for a single data item inside a component. +/// +/// There is no `.empty` case — `.populated` implies the data exists. +nonisolated enum ItemState: Equatable { + case populated(Value) + case loading + case error(StateInfoConfig) + + var value: Value? { + if case let .populated(value) = self { + return value + } + return nil + } + + var errorConfig: StateInfoConfig? { + 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..0a974df --- /dev/null +++ b/Templates/SwiftUI App.xctemplate/ItemStateView.swift @@ -0,0 +1,86 @@ +// ___FILEHEADER___ + +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 { + 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 (StateInfoConfig) -> 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 (StateInfoConfig) -> ErrorView + ) { + self.state = state + self.populatedView = populatedView + self.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/Mockable.swift b/Templates/SwiftUI App.xctemplate/Mockable.swift new file mode 100644 index 0000000..5ec6f89 --- /dev/null +++ b/Templates/SwiftUI App.xctemplate/Mockable.swift @@ -0,0 +1,11 @@ +// ___FILEHEADER___ + +protocol Mockable { + nonisolated static var mock: Self { get } +} + +extension Mockable where Self: Equatable { + nonisolated var isMock: Bool { + self == Self.mock + } +} diff --git a/Templates/SwiftUI App.xctemplate/StateInfoConfig.swift b/Templates/SwiftUI App.xctemplate/StateInfoConfig.swift new file mode 100644 index 0000000..f737124 --- /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. +nonisolated struct StateInfoConfig: Equatable { + nonisolated 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 b4a4a22..e5583e0 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 @@ -124,6 +126,62 @@ ___VARIABLE_bundleIdentifierPrefix:bundleIdentifier___.___PACKAGENAMEASRFC1034IDENTIFIER___.beta + 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 + Name + SwiftLint + ShellPath + /bin/sh + ShellScript + 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 "error: SwiftLint not installed, run: brew install swiftlint" +fi + + + @@ -143,6 +201,15 @@ AppDelegate.swift Container.swift Model/DataCacheModel.swift + Model/Mockable.swift + Model/StateInfoConfig.swift + Model/ComponentState.swift + Model/ItemState.swift + Model/CacheProjection.swift + UI/Views/StateInfoView.swift + UI/Views/ComponentStateView.swift + UI/Views/ItemStateView.swift + Scenes/Example/ExampleCacheProjection.swift Scenes/Example/ExampleComponentModel.swift Scenes/Example/ExampleComponent.swift Flow Coordinators/ExampleFlowCoordinator.swift @@ -249,6 +316,99 @@ Group Model + Model/Mockable.swift + + SortOrder + 99 + Path + Mockable.swift + Group + Model + + Model/StateInfoConfig.swift + + SortOrder + 99 + Path + StateInfoConfig.swift + Group + Model + + Model/ComponentState.swift + + SortOrder + 99 + Path + ComponentState.swift + Group + Model + + Model/ItemState.swift + + SortOrder + 99 + Path + ItemState.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/StateInfoView.swift + + SortOrder + 99 + Path + StateInfoView.swift + Group + + UI + Views + + + Scenes/Example/ExampleCacheProjection.swift + + SortOrder + 99 + Path + ExampleCacheProjection.swift + Group + + Scenes + Example + + Flow Coordinators/ExampleFlowCoordinator.swift SortOrder