Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// ___FILEHEADER___

import SwiftUI

struct ___VARIABLE_sceneIdentifier___Component<Model: ___VARIABLE_sceneIdentifier___ComponentModelProtocol>: 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
Original file line number Diff line number Diff line change
@@ -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<DataCacheModel>

var projection: ___VARIABLE_sceneIdentifier___CacheProjection {
___VARIABLE_sceneIdentifier___CacheProjection.data(from: dataCache.value) ?? .empty(state: .loading)
}

init(
dataCache: DataCache<DataCacheModel>,
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
20 changes: 20 additions & 0 deletions Templates/Scene.xctemplate/TemplateInfo.plist
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
<array>
<string>Scene</string>
<string>Resource Scene</string>
<string>Projection Scene</string>
</array>
</dict>
<dict>
Expand Down Expand Up @@ -104,6 +105,25 @@
<key>Type</key>
<string>static</string>
</dict>
<dict>
<key>RequiredOptions</key>
<dict>
<key>sceneType</key>
<string>Projection Scene</string>
</dict>
<key>Default</key>
<string>___VARIABLE_sceneIdentifier___CacheProjection</string>
<key>Description</key>
<string>Cache Projection name</string>
<key>Identifier</key>
<string>projectionName</string>
<key>Name</key>
<string>Projection name:</string>
<key>NotPersisted</key>
<true/>
<key>Type</key>
<string>static</string>
</dict>
</array>
<key>Platforms</key>
<array>
Expand Down
5 changes: 5 additions & 0 deletions Templates/SwiftUI App.xctemplate/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions Templates/SwiftUI App.xctemplate/CacheProjection.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
31 changes: 31 additions & 0 deletions Templates/SwiftUI App.xctemplate/ComponentState.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
186 changes: 186 additions & 0 deletions Templates/SwiftUI App.xctemplate/ComponentStateView.swift
Original file line number Diff line number Diff line change
@@ -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) }
)
}
}
Loading