diff --git a/README.md b/README.md index e711b29..5973f03 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ Check out the [Definery](https://github.com/anthony1810/Definery) app for a real - [Skeleton Loading (Placeholder)](#skeleton-loading-placeholder) - [Load More Pagination](#load-more-pagination) - [Environment CRUD Callbacks](#environment-crud-callbacks) +- [App Refresh Bus](#app-refresh-bus) - [AsyncAction](#asyncaction) - [Async Streaming](#async-streaming) - [API Reference](#api-reference) @@ -566,6 +567,84 @@ struct EditItemView: View { --- +## App Refresh Bus + +A lightweight, app-wide "refresh signal" bus built on the SwiftUI environment and the `@Observable` macro (no Combine). It lets any screen broadcast *"something changed, reload X"* and lets any other screen react — without the two knowing about each other. This decouples a producer (e.g. you just created an item) from consumers (e.g. a list that needs to reload). + +### How It Works + +- **`AppRefresher`** — `@MainActor @Observable` broadcaster holding a single `action`. Call `refresh(_:)` to publish. +- **`AppRefreshAction.RefreshOption`** — an `OptionSet` describing *what* to refresh. ScreenStateKit ships only `.idle`; **your app defines its own options** by extending it. Because it's an `OptionSet`, multiple signals can be combined in one call. +- **`AppRefreshAction`** — wraps the option **plus a fresh `requestId: UUID`** on every `refresh(_:)`. The UUID makes each signal unique, so consumers still react when the *same* option is fired twice in a row (SwiftUI would otherwise dedupe identical values). + +### 1. Define Your Refresh Options + +```swift +import ScreenStateKit + +extension AppRefreshAction.RefreshOption { + static let globalInbox = AppRefreshAction.RefreshOption(rawValue: 1 << 1) + static let channelState = AppRefreshAction.RefreshOption(rawValue: 1 << 2) +} +``` + +### 2. Host the Bus Once, Near the Root + +```swift +RootView() + .appRefresherHost() // creates and injects an AppRefresher + +// Or inject a shared instance you own (e.g. to send from a store): +RootView() + .appRefresherHost(myRefresher) +``` + +### 3. Broadcast a Signal + +```swift +struct CreateItemScreen: View { + @Environment(\.appRefresher) private var refresher + + var body: some View { + Button("Create") { + Task { + await store.receive(.create) + refresher?.refresh(.globalInbox) // or [.globalInbox, .channelState] + } + } + } +} +``` + +### 4. React to a Signal + +```swift +struct InboxScreen: View { + var body: some View { + List(/* ... */) { /* ... */ } + // .onNextAppear (default): defers until the screen is next visible + .onAppRefresh(.globalInbox) { + Task { await store.receive(.reload) } + } + // .immediate: runs right away, even while off-screen + .onAppRefresh(.channelState, behavior: .immediate) { + Task { await store.receive(.recheckState) } + } + } +} +``` + +### Behavior + +| Behavior | When the action runs | +|----------|----------------------| +| `.onNextAppear` (default) | Stores the request; runs on the view's next `onAppear`. Ideal for hidden screens — no point reloading a list the user can't see. | +| `.immediate` | Runs the instant the signal fires, even if the view is off-screen. | + +> **Using it from a Store:** The bus is read through the SwiftUI environment, so the View is the boundary that listens. Forward into your `ScreenActionStore` from the closure — `.onAppRefresh(.globalInbox) { Task { await store.receive(.reload) } }` — keeping the store free of any SwiftUI/environment coupling. + +--- + ## AsyncAction A generic wrapper for async/await operations with configurable input and output types. @@ -737,6 +816,7 @@ func observe(stream: AnyAsyncStream) async { |-------|---------| | `ScreenState` | `@Observable @MainActor` base class with loading counter, error handling, and parent binding | | `LoadmoreScreenState` | Extends `ScreenState` with pagination state (`canShowLoadmore`, `didLoadAllData`) | +| `AppRefresher` | `@Observable @MainActor` app-wide refresh bus. Call `refresh(_:)` to broadcast a `RefreshOption` | ### Actors @@ -755,6 +835,7 @@ func observe(stream: AnyAsyncStream) async { | `AnyTask` | Public handle to a stored task with `cancel()`, `waitComplete()`, and `isCancelled` | | `AnyAsyncStream` | Type-erased `AsyncSequence` wrapper | | `RMLoadmoreView` | Pre-built `ProgressView` for load-more pagination | +| `AppRefreshAction` | Envelope carrying a `RefreshOption` plus a unique `requestId`; nested `RefreshOption` is a consumer-extensible `OptionSet` | ### View Modifiers @@ -768,6 +849,8 @@ func observe(stream: AnyAsyncStream) async { | `.onDeleted(_:)` | Environment callback for delete actions | | `.onCreated(_:)` | Environment callback for create actions | | `.onCancelled(_:)` | Environment callback for cancel actions | +| `.appRefresherHost()` | Creates and injects an `AppRefresher` into the environment (overload accepts a shared instance) | +| `.onAppRefresh(_:behavior:perform:)` | Reacts to a `RefreshOption`, with `.onNextAppear` (default) or `.immediate` behavior | --- diff --git a/Sources/ScreenStatetKit/Refresh/AppRefresher.swift b/Sources/ScreenStatetKit/Refresh/AppRefresher.swift new file mode 100644 index 0000000..c3347af --- /dev/null +++ b/Sources/ScreenStatetKit/Refresh/AppRefresher.swift @@ -0,0 +1,103 @@ +import SwiftUI + +@MainActor +@Observable +public final class AppRefresher { + public private(set) var action: AppRefreshAction + + public init() { + self.action = AppRefreshAction(option: .idle) + } + + public func refresh(_ option: AppRefreshAction.RefreshOption) { + action = AppRefreshAction(option: option) + } +} + +public extension AppRefresher { + + enum Behavior: Int8, Sendable { + case immediate + case onNextAppear + } +} + +public struct AppRefreshAction: Equatable, Sendable { + + public struct RefreshOption: OptionSet, Sendable { + public let rawValue: Int + + public init(rawValue: Int) { + self.rawValue = rawValue + } + + public static let idle = RefreshOption(rawValue: 1 << 0) + } + + public let requestId: UUID + public let option: RefreshOption + + public init(option: RefreshOption) { + self.requestId = UUID() + self.option = option + } +} + +public extension EnvironmentValues { + @Entry var appRefresher: AppRefresher? = nil +} + +public extension View { + + func appRefresherHost() -> some View { + modifier(AppRefresherHostModifier()) + } + + func appRefresherHost(_ refresher: AppRefresher) -> some View { + environment(\.appRefresher, refresher) + } + + func onAppRefresh( + _ option: AppRefreshAction.RefreshOption, + behavior: AppRefresher.Behavior = .onNextAppear, + perform action: @escaping () -> Void + ) -> some View { + modifier(AppRefreshActionModifier(option: option, behavior: behavior, action: action)) + } +} + +private struct AppRefresherHostModifier: ViewModifier { + @State private var refresher = AppRefresher() + + func body(content: Content) -> some View { + content.environment(\.appRefresher, refresher) + } +} + +private struct AppRefreshActionModifier: ViewModifier { + + @Environment(\.appRefresher) private var refresher + @State private var pendingRefreshId: UUID? + + let option: AppRefreshAction.RefreshOption + let behavior: AppRefresher.Behavior + let action: () -> Void + + func body(content: Content) -> some View { + content + .onAppear { + guard pendingRefreshId != nil, behavior == .onNextAppear else { return } + pendingRefreshId = nil + action() + } + .onChange(of: refresher?.action) { _, newValue in + guard let newValue, newValue.option.contains(option) else { return } + switch behavior { + case .immediate: + action() + case .onNextAppear: + pendingRefreshId = newValue.requestId + } + } + } +} diff --git a/Tests/ScreenStatetKitTests/Refresh/AppRefresherTests.swift b/Tests/ScreenStatetKitTests/Refresh/AppRefresherTests.swift new file mode 100644 index 0000000..3b2d186 --- /dev/null +++ b/Tests/ScreenStatetKitTests/Refresh/AppRefresherTests.swift @@ -0,0 +1,84 @@ +// +// AppRefresherTests.swift +// ScreenStateKit +// + +import Testing +import SwiftUI +@testable import ScreenStateKit + +private extension AppRefreshAction.RefreshOption { + static let inbox = AppRefreshAction.RefreshOption(rawValue: 1 << 1) + static let channelState = AppRefreshAction.RefreshOption(rawValue: 1 << 2) +} + +@Suite("AppRefresher Tests") +@MainActor +struct AppRefresherTests { + + @Test("init starts in idle") + func test_init_startsIdle() { + let sut = AppRefresher() + + #expect(sut.action.option == .idle) + } + + @Test("refresh updates the current option") + func test_refresh_updatesOption() { + let sut = AppRefresher() + + sut.refresh(.inbox) + + #expect(sut.action.option == .inbox) + } + + @Test("refreshing the same option twice produces a new requestId") + func test_refresh_sameOptionTwice_producesUniqueRequestIds() { + let sut = AppRefresher() + + sut.refresh(.inbox) + let first = sut.action.requestId + sut.refresh(.inbox) + let second = sut.action.requestId + + #expect(first != second) + } + + @Test("refresh can carry combined options") + func test_refresh_combinedOptions() { + let sut = AppRefresher() + + sut.refresh([.inbox, .channelState]) + + #expect(sut.action.option.contains(.inbox)) + #expect(sut.action.option.contains(.channelState)) + } + + @Test("idle option does not contain consumer options") + func test_idle_doesNotContainConsumerOptions() { + let sut = AppRefresher() + + #expect(sut.action.option.contains(.inbox) == false) + } + + @Test("environment value can be set and retrieved") + func test_environmentValue_setAndRetrieve() { + var env = EnvironmentValues() + #expect(env.appRefresher == nil) + + env.appRefresher = AppRefresher() + #expect(env.appRefresher != nil) + } + + @Test("view modifiers can be applied to views") + func test_viewModifiers_canBeApplied() { + let _ = Text("Test") + .appRefresherHost() + .onAppRefresh(.inbox) { } + + let refresher = AppRefresher() + let _ = Text("Test") + .appRefresherHost(refresher) + .onAppRefresh(.channelState, behavior: .immediate) { } + } +}