From d4e90737e95de5e30d9e3b1be67f64c524e123dd Mon Sep 17 00:00:00 2001 From: Anthony Tran Date: Mon, 2 Feb 2026 08:38:11 +0700 Subject: [PATCH 1/8] Add placeholder view modifier for skeleton loading Provides .placeholder(_:) view modifier that applies .redacted() when the value conforms to PlaceholderRepresentable and isPlaceholder returns true. --- .../ViewModifiers/PlaceholderModifier.swift | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 Sources/ScreenStatetKit/ViewModifiers/PlaceholderModifier.swift diff --git a/Sources/ScreenStatetKit/ViewModifiers/PlaceholderModifier.swift b/Sources/ScreenStatetKit/ViewModifiers/PlaceholderModifier.swift new file mode 100644 index 0000000..7e9bd26 --- /dev/null +++ b/Sources/ScreenStatetKit/ViewModifiers/PlaceholderModifier.swift @@ -0,0 +1,34 @@ +// +// PlaceholderModifier.swift +// ScreenStateKit +// + +import SwiftUI + +/// A view modifier that applies `.redacted(reason: .placeholder)` when the value is a placeholder. +struct PlaceholderModifier: ViewModifier { + let value: T + + func body(content: Content) -> some View { + content + .redacted(reason: value.isPlaceholder ? .placeholder : []) + } +} + +// MARK: - View Extension + +public extension View { + /// Applies `.redacted(reason: .placeholder)` when the value is a placeholder. + /// + /// Usage: + /// ```swift + /// ForEach(viewState.snapshot.words) { word in + /// WordCardView(word: word) + /// } + /// .placeholder(viewState.snapshot) + /// .shimmering(active: viewState.snapshot.isPlaceholder) + /// ``` + func placeholder(_ value: T) -> some View { + modifier(PlaceholderModifier(value: value)) + } +} From 63965ea7ef3de580a24ef6d59e166aa44729c01a Mon Sep 17 00:00:00 2001 From: Anthony Tran Date: Sun, 8 Feb 2026 09:42:43 +0700 Subject: [PATCH 2/8] Update README with complete API documentation - Fix Swift badge to 5.9+ (matches Package.swift) - Fix package URL from anthropics to anthony1810 - Add macOS 14.0+ to requirements - Add StateUpdatable protocol documentation - Add Parent State Binding (BindingParentStateOption) section - Add Skeleton Loading section (PlaceholderRepresentable, .placeholder()) - Add Load More Pagination section (LoadmoreScreenState, RMLoadmoreView) - Add comprehensive API Reference table (protocols, classes, actors, structs, view modifiers) - Document StreamProducer withLatest option and non-isolated methods - Document CancelBag Task extension methods - Update feature example to use RMLoadmoreView instead of inline ProgressView --- README.md | 279 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 270 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 1128b5e..371fb38 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ScreenStateKit -[![Swift](https://img.shields.io/badge/Swift-6.0-orange.svg)](https://swift.org) +[![Swift](https://img.shields.io/badge/Swift-5.9+-orange.svg)](https://swift.org) [![iOS](https://img.shields.io/badge/iOS-17+-blue.svg)](https://developer.apple.com/ios/) [![macOS](https://img.shields.io/badge/macOS-14+-blue.svg)](https://developer.apple.com/macos/) [![Tests](https://github.com/anthony1810/ScreenStateKit/actions/workflows/tests.yml/badge.svg)](https://github.com/anthony1810/ScreenStateKit/actions/workflows/tests.yml) @@ -20,17 +20,22 @@ Check out the [Definery](https://github.com/anthony1810/Definery) app for a real - [Installation](#installation) - [Architecture Overview](#architecture-overview) - [Complete Feature Example](#complete-feature-example) +- [StateUpdatable](#stateupdatable) +- [Parent State Binding](#parent-state-binding) - [View Modifiers](#view-modifiers) +- [Skeleton Loading (Placeholder)](#skeleton-loading-placeholder) +- [Load More Pagination](#load-more-pagination) - [Environment CRUD Callbacks](#environment-crud-callbacks) - [AsyncAction](#asyncaction) - [Async Streaming](#async-streaming) +- [API Reference](#api-reference) - [License](#license) --- ## Requirements -- iOS 17.0+ +- iOS 17.0+ / macOS 14.0+ - Swift 5.9+ - Xcode 15.0+ @@ -42,10 +47,16 @@ Add the following to your `Package.swift`: ```swift dependencies: [ - .package(url: "https://github.com/anthropics/ScreenStateKit.git", from: "1.0.0") + .package(url: "https://github.com/anthony1810/ScreenStateKit.git", from: "1.0.0") ] ``` +Or in Xcode: **File > Add Package Dependencies** and enter: + +``` +https://github.com/anthony1810/ScreenStateKit.git +``` + --- ## Architecture Overview @@ -249,11 +260,7 @@ struct FeatureView: View { // Load more indicator if !viewState.items.isEmpty && viewState.canShowLoadmore { - ProgressView() - .frame(maxWidth: .infinity) - .onAppear { - viewStore.receive(action: .loadMore) - } + RMLoadmoreView(states: viewState) } } .refreshable { @@ -280,6 +287,91 @@ struct FeatureView: View { --- +## StateUpdatable + +The `StateUpdatable` protocol provides a safe way to batch state updates with optional animation and transaction control. + +```swift +@MainActor +public protocol StateUpdatable { + func updateState( + _ updateBlock: @MainActor (_ state: Self) -> Void, + withAnimation animation: Animation?, + disablesAnimations: Bool + ) +} +``` + +Conform your state class to `StateUpdatable` to gain the `updateState` method: + +```swift +@Observable @MainActor +final class MyViewState: ScreenState, StateUpdatable { + var items: [Item] = [] + var title: String = "" +} +``` + +### Usage + +```swift +// Basic state update (no animation) +await viewState?.updateState { state in + state.items = newItems + state.title = "Updated" +} + +// Update with animation +await viewState?.updateState({ state in + state.items = newItems +}, withAnimation: .easeInOut) + +// Update with animations disabled +await viewState?.updateState({ state in + state.items = newItems +}, disablesAnimations: true) +``` + +--- + +## Parent State Binding + +`ScreenState` supports parent-child relationships, where loading and error states propagate upward from a child state to a parent. + +```swift +public struct BindingParentStateOption: OptionSet, Sendable { + public static let loading // Propagate loading state + public static let error // Propagate error state + public static let all // Propagate both (default) +} +``` + +### Usage + +```swift +@Observable @MainActor +final class ParentViewState: ScreenState { } + +@Observable @MainActor +final class ChildViewState: ScreenState { + init(parent: ParentViewState) { + // Propagate both loading and error to parent + super.init(states: parent) + } +} + +// Or selectively propagate only loading: +final class ChildViewState: ScreenState { + init(parent: ParentViewState) { + super.init(states: parent, options: .loading) + } +} +``` + +When the child's `isLoading` changes or `displayError` is set, the parent state is automatically updated. + +--- + ## View Modifiers ### .onShowError @@ -308,6 +400,98 @@ Shows full-screen semi-transparent loading overlay that blocks interaction. --- +## Skeleton Loading (Placeholder) + +ScreenStateKit provides a `PlaceholderRepresentable` protocol and `.placeholder()` view modifier for skeleton loading effects using SwiftUI's built-in `.redacted(reason: .placeholder)`. + +### 1. Conform Your Data to PlaceholderRepresentable + +```swift +struct HomeSnapshot: Equatable, PlaceholderRepresentable { + let items: [Item] + + static var placeholder: HomeSnapshot { + HomeSnapshot(items: Item.mocks) + } + + var isPlaceholder: Bool { self == .placeholder } +} +``` + +### 2. Use .placeholder() in Your View + +The `.placeholder()` modifier applies `.redacted(reason: .placeholder)` automatically when the value is a placeholder instance: + +```swift +ForEach(viewState.snapshot.items) { item in + ItemCardView(item: item) +} +.placeholder(viewState.snapshot) +``` + +Pair it with a shimmer library for a polished skeleton loading effect: + +```swift +ForEach(viewState.snapshot.items) { item in + ItemCardView(item: item) +} +.placeholder(viewState.snapshot) +.shimmering(active: viewState.snapshot.isPlaceholder) +``` + +### 3. Initialize State with Placeholder + +```swift +@Observable @MainActor +final class HomeViewState: ScreenState, StateUpdatable { + var snapshot: HomeSnapshot = .placeholder // Start with skeleton +} +``` + +Once real data loads, update the snapshot and the redaction is automatically removed. + +--- + +## Load More Pagination + +### LoadmoreScreenState + +Extend `LoadmoreScreenState` instead of `ScreenState` to get built-in pagination support: + +```swift +@Observable @MainActor +final class ListViewState: LoadmoreScreenState, StateUpdatable { + var items: [Item] = [] +} +``` + +**Properties:** +- `canShowLoadmore: Bool` (read-only) - Whether the load more indicator should be visible +- `didLoadAllData: Bool` (read-only) - Whether all data has been loaded + +**Methods:** +- `canExecuteLoadmore()` - Enables the load more indicator (no-op if `didLoadAllData` is true) +- `updateDidLoadAllData(_ didLoadAllData: Bool)` - Updates the `didLoadAllData` flag and toggles `canShowLoadmore` +- `ternimateLoadmoreView()` - Hides the load more indicator + +### RMLoadmoreView + +A pre-built `ProgressView` that automatically calls `canExecuteLoadmore()` when it disappears (scrolled past): + +```swift +List { + ForEach(viewState.items) { item in + ItemRow(item: item) + } + + if viewState.canShowLoadmore { + RMLoadmoreView(states: viewState) + } +} +``` + +--- + ## Environment CRUD Callbacks Environment-based action callbacks for passing actions down the view hierarchy. Perfect for CRUD operations where child views need to notify parents of changes. @@ -455,7 +639,7 @@ let user = try await fetchUser.asyncExecute("user-123") ### StreamProducer -A multi-consumer async event emitter (actor-based) that allows multiple subscribers to receive events. +A multi-consumer async event emitter (actor-based) that allows multiple subscribers to receive events. Conforms to the `StreamProducerType` protocol. ```swift // Create a stream producer @@ -481,6 +665,21 @@ Task { await eventProducer.finish() ``` +**Options:** +- `withLatest: Bool` (default `true`) - When `true`, new subscribers immediately receive the most recently emitted element. + +```swift +// New subscribers get the latest element immediately +let producer = StreamProducer(element: 0, withLatest: true) + +// New subscribers only get future elements +let producer = StreamProducer(withLatest: false) +``` + +**Non-isolated methods** for use from `nonisolated` or `deinit` contexts: +- `nonIsolatedEmit(_ element:)` - Emits from a non-isolated context +- `nonIsolatedFinish()` - Finishes the stream from a non-isolated context + ### CancelBag Manages and cancels multiple async tasks. Essential for cleanup in actors and view models. @@ -510,6 +709,15 @@ actor MyViewModel { } ``` +**Methods:** +- `cancelAll()` - Cancels all stored tasks +- `cancel(forIdentifier:)` - Cancels a specific task by its identifier +- `cancelAllInTask()` - Non-isolated version for use in `deinit` + +**Task extension:** +- `task.store(in: cancelBag)` - Store with auto-generated identifier +- `task.store(in: cancelBag, withIdentifier: "id")` - Store with a specific identifier (cancels any existing task with the same identifier) + ### AnyAsyncStream Type-erased wrapper for any `AsyncSequence`, useful for abstracting different stream types. @@ -528,6 +736,59 @@ func observe(stream: AnyAsyncStream) async { --- +## API Reference + +### Protocols + +| Protocol | Purpose | +|----------|---------| +| `ScreenActionStore` | Actor-based protocol for ViewModels. Requires `binding(state:)` and `receive(action:)` | +| `ActionLockable` | Provides a `lockKey` for action deduplication. Auto-conforms for `Hashable` types | +| `LoadingTrackable` | Declares whether an action should track loading state via `canTrackLoading` | +| `StateUpdatable` | Provides `updateState(_:withAnimation:disablesAnimations:)` for batched state updates | +| `PlaceholderRepresentable` | Declares `placeholder` and `isPlaceholder` for skeleton loading | +| `StreamProducerType` | Actor protocol for multi-subscriber async stream producers | +| `TypeNamed` | Provides `declaredName` and `typeNamed` for type name reflection | + +### Classes + +| Class | Purpose | +|-------|---------| +| `ScreenState` | `@Observable @MainActor` base class with loading counter, error handling, and parent binding | +| `LoadmoreScreenState` | Extends `ScreenState` with pagination state (`canShowLoadmore`, `didLoadAllData`) | + +### Actors + +| Actor | Purpose | +|-------|---------| +| `ActionLocker` | Prevents duplicate action execution with `lock`, `unlock`, `canExecute`, `free` | +| `CancelBag` | Task lifecycle management with identifier-based cancellation | +| `StreamProducer` | Multi-subscriber async stream with optional latest-value replay | + +### Structs + +| Struct | Purpose | +|--------|---------| +| `AsyncAction` | Generic async action wrapper with `execute` and `asyncExecute` | +| `RMDisplayableError` | `LocalizedError` wrapper for displaying error alerts | +| `AnyAsyncStream` | Type-erased `AsyncSequence` wrapper | +| `RMLoadmoreView` | Pre-built `ProgressView` for load-more pagination | + +### View Modifiers + +| Modifier | Purpose | +|----------|---------| +| `.onShowError(_:)` | Displays error alert from `RMDisplayableError?` binding | +| `.onShowLoading(_:)` | Shows centered progress indicator | +| `.onShowBlockLoading(_:subtitles:)` | Shows full-screen blocking loading overlay | +| `.placeholder(_:)` | Applies `.redacted(reason: .placeholder)` for skeleton loading | +| `.onEdited(_:)` | Environment callback for edit actions | +| `.onDeleted(_:)` | Environment callback for delete actions | +| `.onCreated(_:)` | Environment callback for create actions | +| `.onCancelled(_:)` | Environment callback for cancel actions | + +--- + ## License MIT License From 427944bf4f1e679562efb0fd4caa25773a999855 Mon Sep 17 00:00:00 2001 From: Anthony Tran Date: Sun, 8 Feb 2026 09:52:03 +0700 Subject: [PATCH 3/8] Fix ViewModel example to match correct do/catch placement Move error handling from nonisolated receive into isolatedReceive so unlock and loadingFinished always run regardless of errors. --- README.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 371fb38..72bd57f 100644 --- a/README.md +++ b/README.md @@ -162,26 +162,26 @@ actor FeatureViewStore: ScreenActionStore { nonisolated func receive(action: Action) { Task { - do { - try await isolatedReceive(action: action) - } catch { - await viewState?.showError( - RMDisplayableError(message: error.localizedDescription) - ) - } + await isolatedReceive(action: action) } } // MARK: - Action Processing - func isolatedReceive(action: Action) async throws { + func isolatedReceive(action: Action) async { guard await actionLocker.canExecute(action) else { return } await viewState?.loadingStarted(action: action) - switch action { - case .fetchItems: - try await fetchItems() - case .loadMore: - try await loadMoreItems() + do { + switch action { + case .fetchItems: + try await fetchItems() + case .loadMore: + try await loadMoreItems() + } + } catch { + await viewState?.showError( + RMDisplayableError(message: error.localizedDescription) + ) } await actionLocker.unlock(action) From 87b9c44c202c45ef171e6f2181a5fbde4b9cecad Mon Sep 17 00:00:00 2001 From: Anthony Tran Date: Sun, 8 Feb 2026 10:00:14 +0700 Subject: [PATCH 4/8] Rename RMDisplayableError to DisplayableError Rename struct, file, and all usages across sources, tests, and README. Add deprecated typealias for backwards compatibility. --- README.md | 6 +++--- .../{RMDisplayableError.swift => DisplayableError.swift} | 9 ++++++--- Sources/ScreenStatetKit/States/ScreenState.swift | 2 +- .../ViewModifiers/OnShowErrorModifier.swift | 4 ++-- Tests/ScreenStatetKitTests/States/ScreenStateTests.swift | 6 +++--- .../StoreStateIntegrationTests/Store/TestStore.swift | 2 +- 6 files changed, 16 insertions(+), 13 deletions(-) rename Sources/ScreenStatetKit/Helpers/{RMDisplayableError.swift => DisplayableError.swift} (64%) diff --git a/README.md b/README.md index 72bd57f..dbf80c2 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ actor FeatureViewStore: ScreenActionStore { } } catch { await viewState?.showError( - RMDisplayableError(message: error.localizedDescription) + DisplayableError(message: error.localizedDescription) ) } @@ -770,7 +770,7 @@ func observe(stream: AnyAsyncStream) async { | Struct | Purpose | |--------|---------| | `AsyncAction` | Generic async action wrapper with `execute` and `asyncExecute` | -| `RMDisplayableError` | `LocalizedError` wrapper for displaying error alerts | +| `DisplayableError` | `LocalizedError` wrapper for displaying error alerts | | `AnyAsyncStream` | Type-erased `AsyncSequence` wrapper | | `RMLoadmoreView` | Pre-built `ProgressView` for load-more pagination | @@ -778,7 +778,7 @@ func observe(stream: AnyAsyncStream) async { | Modifier | Purpose | |----------|---------| -| `.onShowError(_:)` | Displays error alert from `RMDisplayableError?` binding | +| `.onShowError(_:)` | Displays error alert from `DisplayableError?` binding | | `.onShowLoading(_:)` | Shows centered progress indicator | | `.onShowBlockLoading(_:subtitles:)` | Shows full-screen blocking loading overlay | | `.placeholder(_:)` | Applies `.redacted(reason: .placeholder)` for skeleton loading | diff --git a/Sources/ScreenStatetKit/Helpers/RMDisplayableError.swift b/Sources/ScreenStatetKit/Helpers/DisplayableError.swift similarity index 64% rename from Sources/ScreenStatetKit/Helpers/RMDisplayableError.swift rename to Sources/ScreenStatetKit/Helpers/DisplayableError.swift index 12fdbbb..9bd1109 100644 --- a/Sources/ScreenStatetKit/Helpers/RMDisplayableError.swift +++ b/Sources/ScreenStatetKit/Helpers/DisplayableError.swift @@ -8,17 +8,20 @@ import SwiftUI -public struct RMDisplayableError: LocalizedError, Identifiable, Hashable { - +public struct DisplayableError: LocalizedError, Identifiable, Hashable { + public let id: String public var errorDescription: String? { message } let message: String - + public init(message: String) { self.message = message self.id = UUID().uuidString } } +@available(*, deprecated, renamed: "DisplayableError") +public typealias RMDisplayableError = DisplayableError + diff --git a/Sources/ScreenStatetKit/States/ScreenState.swift b/Sources/ScreenStatetKit/States/ScreenState.swift index e85e5c2..adb395f 100644 --- a/Sources/ScreenStatetKit/States/ScreenState.swift +++ b/Sources/ScreenStatetKit/States/ScreenState.swift @@ -18,7 +18,7 @@ open class ScreenState: Sendable { } } - public var displayError: RMDisplayableError? { + public var displayError: DisplayableError? { didSet { if let displayError { isLoading = false diff --git a/Sources/ScreenStatetKit/ViewModifiers/OnShowErrorModifier.swift b/Sources/ScreenStatetKit/ViewModifiers/OnShowErrorModifier.swift index 2fe6fa6..5f0e50e 100644 --- a/Sources/ScreenStatetKit/ViewModifiers/OnShowErrorModifier.swift +++ b/Sources/ScreenStatetKit/ViewModifiers/OnShowErrorModifier.swift @@ -8,7 +8,7 @@ import SwiftUI struct OnShowErrorModifier: ViewModifier { @State private var isPresentAlert: Bool = false - @Binding var error: RMDisplayableError? + @Binding var error: DisplayableError? private var errorMessage: String { error?.message ?? "Something went wrong." @@ -40,7 +40,7 @@ struct OnShowErrorModifier: ViewModifier { extension View { - public func onShowError(_ error: Binding) -> some View { + public func onShowError(_ error: Binding) -> some View { modifier(OnShowErrorModifier(error: error)) } } diff --git a/Tests/ScreenStatetKitTests/States/ScreenStateTests.swift b/Tests/ScreenStatetKitTests/States/ScreenStateTests.swift index a725d59..a0d620b 100644 --- a/Tests/ScreenStatetKitTests/States/ScreenStateTests.swift +++ b/Tests/ScreenStatetKitTests/States/ScreenStateTests.swift @@ -100,7 +100,7 @@ struct ScreenStateTests { sut.loadingStarted() #expect(sut.isLoading == true) - sut.displayError = RMDisplayableError(message: "Error") + sut.displayError = DisplayableError(message: "Error") #expect(sut.isLoading == false) } @@ -124,7 +124,7 @@ struct ScreenStateTests { let parent = ScreenState() let child = ScreenState(states: parent, options: .error) - child.displayError = RMDisplayableError(message: "Child error") + child.displayError = DisplayableError(message: "Child error") #expect(parent.displayError?.message == "Child error") } @@ -134,7 +134,7 @@ struct ScreenStateTests { let parent = ScreenState() let child = ScreenState(states: parent, options: .loading) - child.displayError = RMDisplayableError(message: "Child error") + child.displayError = DisplayableError(message: "Child error") #expect(parent.displayError == nil) } diff --git a/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestStore.swift b/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestStore.swift index d8334fd..f271b67 100644 --- a/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestStore.swift +++ b/Tests/ScreenStatetKitTests/StoreStateIntegrationTests/Store/TestStore.swift @@ -49,7 +49,7 @@ extension StoreStateIntegrationTests { throw TestError.somethingWentWrong } } catch { - await state?.showError(RMDisplayableError(message: error.localizedDescription)) + await state?.showError(DisplayableError(message: error.localizedDescription)) } await actionLocker.unlock(action) From a86e25a75834a05a6484dcbb350879cbd8e9cffe Mon Sep 17 00:00:00 2001 From: Anthony Tran Date: Sun, 8 Feb 2026 10:01:25 +0700 Subject: [PATCH 5/8] Remove deprecated RMDisplayableError typealias No consumers exist yet, so backwards compatibility is unnecessary. Also fix file header comment to match new filename. --- Sources/ScreenStatetKit/Helpers/DisplayableError.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Sources/ScreenStatetKit/Helpers/DisplayableError.swift b/Sources/ScreenStatetKit/Helpers/DisplayableError.swift index 9bd1109..77a5729 100644 --- a/Sources/ScreenStatetKit/Helpers/DisplayableError.swift +++ b/Sources/ScreenStatetKit/Helpers/DisplayableError.swift @@ -1,5 +1,5 @@ // -// RMDisplayableError.swift +// DisplayableError.swift // ScreenStatetKit // // Created by Anthony on 4/12/25. @@ -22,6 +22,3 @@ public struct DisplayableError: LocalizedError, Identifiable, Hashable { } } -@available(*, deprecated, renamed: "DisplayableError") -public typealias RMDisplayableError = DisplayableError - From 5bb838589c1da89607fc4940e2e803c88cf9c034 Mon Sep 17 00:00:00 2001 From: Anthony Tran Date: Sun, 8 Feb 2026 11:21:56 +0700 Subject: [PATCH 6/8] Add multi-subscriber, withLatest, and finish tests for StreamProducer --- .../AsyncStream/StreamProducerTests.swift | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/Tests/ScreenStatetKitTests/AsyncStream/StreamProducerTests.swift b/Tests/ScreenStatetKitTests/AsyncStream/StreamProducerTests.swift index f74eed7..7a8a199 100644 --- a/Tests/ScreenStatetKitTests/AsyncStream/StreamProducerTests.swift +++ b/Tests/ScreenStatetKitTests/AsyncStream/StreamProducerTests.swift @@ -4,6 +4,7 @@ // import Testing +import ConcurrencyExtras @testable import ScreenStateKit @Suite("StreamProducer Tests") @@ -29,6 +30,42 @@ struct StreamProducerTests { #expect(received == 42) } + @Test("emit delivers element to multiple subscribers") + func test_emit_deliversToMultipleSubscribers() async { + await withMainSerialExecutor { + let sut = StreamProducer(withLatest: false) + + let received1 = LockIsolated<[Int]>([]) + let received2 = LockIsolated<[Int]>([]) + + let task1 = Task { + for await element in await sut.stream { + received1.withValue { $0.append(element) } + } + } + + let task2 = Task { + for await element in await sut.stream { + received2.withValue { $0.append(element) } + } + } + + await Task.yield() + + await sut.emit(element: 10) + await sut.emit(element: 20) + await sut.finish() + + await task1.value + await task2.value + + #expect(received1.value == [10, 20]) + #expect(received2.value == [10, 20]) + } + } + + // MARK: - withLatest Tests + @Test("withLatest true emits latest element to new subscriber") func test_withLatestTrue_emitsLatestToNewSubscriber() async { let sut = StreamProducer(withLatest: true) @@ -47,4 +84,35 @@ struct StreamProducerTests { #expect(received == 99) } + + // MARK: - finish() Tests + + @Test("finish terminates all streams") + func test_finish_terminatesAllStreams() async { + await withMainSerialExecutor { + let sut = StreamProducer(withLatest: false) + + let task1Finished = LockIsolated(false) + let task2Finished = LockIsolated(false) + + let task1 = Task { + for await _ in await sut.stream { } + task1Finished.setValue(true) + } + + let task2 = Task { + for await _ in await sut.stream { } + task2Finished.setValue(true) + } + + await Task.yield() + await sut.finish() + + await task1.value + await task2.value + + #expect(task1Finished.value == true) + #expect(task2Finished.value == true) + } + } } From 632078b9559a7fa9fba603848f30d38898c47be1 Mon Sep 17 00:00:00 2001 From: Anthony Tran Date: Sun, 8 Feb 2026 11:32:16 +0700 Subject: [PATCH 7/8] Add tests for OnShowErrorModifier and Environment extensions --- .../EnvironmentExtensionsTests.swift | 99 +++++++++++++++++++ .../OnShowErrorModifierTests.swift | 37 +++++++ 2 files changed, 136 insertions(+) create mode 100644 Tests/ScreenStatetKitTests/ViewModifiers/EnvironmentExtensionsTests.swift create mode 100644 Tests/ScreenStatetKitTests/ViewModifiers/OnShowErrorModifierTests.swift diff --git a/Tests/ScreenStatetKitTests/ViewModifiers/EnvironmentExtensionsTests.swift b/Tests/ScreenStatetKitTests/ViewModifiers/EnvironmentExtensionsTests.swift new file mode 100644 index 0000000..4d8f94f --- /dev/null +++ b/Tests/ScreenStatetKitTests/ViewModifiers/EnvironmentExtensionsTests.swift @@ -0,0 +1,99 @@ +// +// EnvironmentExtensionsTests.swift +// ScreenStateKit +// + +import Testing +import SwiftUI +import ConcurrencyExtras +@testable import ScreenStateKit + +@Suite("Environment Extensions Tests") +@MainActor +struct EnvironmentExtensionsTests { + + // MARK: - onEdited + + @Test("onEdited executes provided closure") + func test_onEdited_executesClosure() async throws { + let executed = LockIsolated(false) + let action = AsyncActionVoid { + executed.setValue(true) + } + + try await action.asyncExecute() + + #expect(executed.value == true) + } + + // MARK: - onDeleted + + @Test("onDeleted executes provided closure") + func test_onDeleted_executesClosure() async throws { + let executed = LockIsolated(false) + let action = AsyncActionVoid { + executed.setValue(true) + } + + try await action.asyncExecute() + + #expect(executed.value == true) + } + + // MARK: - onCreated + + @Test("onCreated executes provided closure") + func test_onCreated_executesClosure() async throws { + let executed = LockIsolated(false) + let action = AsyncActionVoid { + executed.setValue(true) + } + + try await action.asyncExecute() + + #expect(executed.value == true) + } + + // MARK: - onCancelled + + @Test("onCancelled executes provided closure") + func test_onCancelled_executesClosure() async throws { + let executed = LockIsolated(false) + let action = AsyncActionVoid { + executed.setValue(true) + } + + try await action.asyncExecute() + + #expect(executed.value == true) + } + + // MARK: - View Extension Integration + + @Test("environment modifiers can be applied to views") + func test_environmentModifiers_canBeAppliedToViews() { + let _ = Text("Test") + .onEdited { } + .onDeleted { } + .onCreated { } + .onCancelled { } + } + + @Test("environment values can be set and retrieved") + func test_environmentValues_canBeSetAndRetrieved() { + var env = EnvironmentValues() + let action = AsyncActionVoid { } + + env.onEditedAction = action + #expect(env.onEditedAction != nil) + + env.onDeletedAction = action + #expect(env.onDeletedAction != nil) + + env.onCreatedAction = action + #expect(env.onCreatedAction != nil) + + env.onCancelledAction = action + #expect(env.onCancelledAction != nil) + } +} diff --git a/Tests/ScreenStatetKitTests/ViewModifiers/OnShowErrorModifierTests.swift b/Tests/ScreenStatetKitTests/ViewModifiers/OnShowErrorModifierTests.swift new file mode 100644 index 0000000..a5fd9ff --- /dev/null +++ b/Tests/ScreenStatetKitTests/ViewModifiers/OnShowErrorModifierTests.swift @@ -0,0 +1,37 @@ +// +// OnShowErrorModifierTests.swift +// ScreenStateKit +// + +import Testing +import SwiftUI +@testable import ScreenStateKit + +@Suite("OnShowErrorModifier Tests") +@MainActor +struct OnShowErrorModifierTests { + + // MARK: - DisplayableError Integration + + @Test("error message returns custom message") + func test_errorMessage_returnsCustomMessage() { + let error = DisplayableError(message: "Network timeout") + #expect(error.message == "Network timeout") + #expect(error.errorDescription == "Network timeout") + } + + @Test("error conforms to Identifiable with unique id") + func test_error_hasUniqueId() { + let error1 = DisplayableError(message: "Error 1") + let error2 = DisplayableError(message: "Error 2") + #expect(error1.id != error2.id) + } + + @Test("error conforms to Hashable") + func test_error_isHashable() { + let error = DisplayableError(message: "Test") + var set = Set() + set.insert(error) + #expect(set.contains(error)) + } +} From ba35a882e3d2e772aef236929301b7b98b192ab7 Mon Sep 17 00:00:00 2001 From: Anthony Tran Date: Mon, 9 Feb 2026 08:33:32 +0700 Subject: [PATCH 8/8] Add RMLoadmoreView tests and update local settings --- .claude/settings.local.json | 6 ++- .../Views/RMLoadmoreViewTests.swift | 43 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 Tests/ScreenStatetKitTests/Views/RMLoadmoreViewTests.swift diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 339f094..5f0848a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -14,7 +14,11 @@ "Bash(xcodebuild test:*)", "Bash(git -C /Users/anthony/Documents/personal-projects/ScreenStatetKit add README.md)", "Bash(git -C /Users/anthony/Documents/personal-projects/ScreenStatetKit commit --author=\"Anthony Tran \" -m \"Update sample project to Definery in README\")", - "Bash(ls:*)" + "Bash(ls:*)", + "Bash(git mv:*)", + "Skill(pfw-testing)", + "Skill(pfw-testing:*)", + "Bash(git status:*)" ] } } diff --git a/Tests/ScreenStatetKitTests/Views/RMLoadmoreViewTests.swift b/Tests/ScreenStatetKitTests/Views/RMLoadmoreViewTests.swift new file mode 100644 index 0000000..fa76187 --- /dev/null +++ b/Tests/ScreenStatetKitTests/Views/RMLoadmoreViewTests.swift @@ -0,0 +1,43 @@ +// +// RMLoadmoreViewTests.swift +// ScreenStateKit +// + +import Testing +import SwiftUI +@testable import ScreenStateKit + +@Suite("RMLoadmoreView Tests") +@MainActor +struct RMLoadmoreViewTests { + + // MARK: - Initialization + + @Test("init accepts LoadmoreScreenState") + func test_init_acceptsLoadmoreScreenState() { + let states = LoadmoreScreenState() + let _ = RMLoadmoreView(states: states) + } + + // MARK: - canExecuteLoadmore Integration + + @Test("canExecuteLoadmore sets canShowLoadmore when data not exhausted") + func test_canExecuteLoadmore_setsCanShowLoadmore() { + let states = LoadmoreScreenState() + #expect(states.canShowLoadmore == false) + + states.canExecuteLoadmore() + + #expect(states.canShowLoadmore == true) + } + + @Test("canExecuteLoadmore does not set canShowLoadmore when all data loaded") + func test_canExecuteLoadmore_doesNotSetWhenAllDataLoaded() { + let states = LoadmoreScreenState() + states.updateDidLoadAllData(true) + + states.canExecuteLoadmore() + + #expect(states.canShowLoadmore == false) + } +}