diff --git a/README.md b/README.md
index 5973f03..de877d1 100644
--- a/README.md
+++ b/README.md
@@ -569,32 +569,43 @@ 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).
+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. It is generic over two types you define:
+
+- **`Option`** (an `OptionSet`) — *what* to refresh. Consumers filter on it, and you can combine signals (`[.inbox, .settings]`) in one call.
+- **`Source`** (any `Sendable`, typically an `enum`) — an optional **payload** delivered to observers. Its associated values carry the fresh object, so a consumer can update without touching a local DB (e.g. hand it a brand-new `Session`).
### 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).
+- **`AppRefresher`** — `@MainActor @Observable` broadcaster holding the latest `action`. Call `refresh(_:source:)` to publish.
+- **`AppRefreshAction `** — wraps the `option`, the optional `source` payload, and a fresh `id: UUID` on every call. The UUID makes each signal unique, so consumers still react when the *same* option fires twice in a row (SwiftUI would otherwise dedupe identical values).
-### 1. Define Your Refresh Options
+### 1. Define Your Option and Source
```swift
import ScreenStateKit
-extension AppRefreshAction.RefreshOption {
- static let globalInbox = AppRefreshAction.RefreshOption(rawValue: 1 << 1)
- static let channelState = AppRefreshAction.RefreshOption(rawValue: 1 << 2)
+struct RefreshOption: OptionSet, Sendable {
+ let rawValue: Int
+ static let inboxMessage = RefreshOption(rawValue: 1 << 0)
+ static let teamSettings = RefreshOption(rawValue: 1 << 1)
+}
+
+enum RefreshSource: Sendable {
+ case newSetting(TeamSetting) // associated value = the payload object
+ case newSession(Session)
}
+
+typealias Refresher = AppRefresher
```
### 2. Host the Bus Once, Near the Root
```swift
+// Auto-create and inject:
RootView()
- .appRefresherHost() // creates and injects an AppRefresher
+ .appRefresherHost(option: RefreshOption.self, source: RefreshSource.self)
-// Or inject a shared instance you own (e.g. to send from a store):
+// Or inject a shared instance you own (e.g. to also send from a store):
RootView()
.appRefresherHost(myRefresher)
```
@@ -602,14 +613,14 @@ RootView()
### 3. Broadcast a Signal
```swift
-struct CreateItemScreen: View {
- @Environment(\.appRefresher) private var refresher
+struct EditSettingScreen: View {
+ @Environment(Refresher.self) private var refresher
var body: some View {
- Button("Create") {
+ Button("Save") {
Task {
- await store.receive(.create)
- refresher?.refresh(.globalInbox) // or [.globalInbox, .channelState]
+ let setting = await store.save()
+ refresher?.refresh(.teamSettings, source: .newSetting(setting))
}
}
}
@@ -623,17 +634,23 @@ struct InboxScreen: View {
var body: some View {
List(/* ... */) { /* ... */ }
// .onNextAppear (default): defers until the screen is next visible
- .onAppRefresh(.globalInbox) {
- Task { await store.receive(.reload) }
+ .onAppRefresh(RefreshOption.teamSettings) { (source: RefreshSource?) in
+ if case let .newSetting(setting) = source {
+ store.apply(setting) // use the payload directly
+ } else {
+ Task { await store.receive(.reload) }
+ }
}
// .immediate: runs right away, even while off-screen
- .onAppRefresh(.channelState, behavior: .immediate) {
- Task { await store.receive(.recheckState) }
+ .onAppRefresh(RefreshOption.inboxMessage, behavior: .immediate) { _ in
+ Task { await store.receive(.reload) }
}
}
}
```
+> Spell the option's type at the call site (`RefreshOption.teamSettings`) — chained modifiers give no contextual type for leading-dot syntax to infer the generic `Option` from.
+
### Behavior
| Behavior | When the action runs |
@@ -641,7 +658,7 @@ struct InboxScreen: View {
| `.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.
+> **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(RefreshOption.inboxMessage) { _ in Task { await store.receive(.reload) } }` — keeping the store free of any SwiftUI/environment coupling.
---
@@ -816,7 +833,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` |
+| `AppRefresher` | `@Observable @MainActor` app-wide refresh bus. Call `refresh(_:source:)` to broadcast an `OptionSet` plus an optional payload |
### Actors
@@ -835,7 +852,8 @@ 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` |
+| `AppRefreshAction` | Envelope carrying the `option`, an optional `source` payload, and a unique `id` per emission |
+| `AppRefreshBehavior` | Delivery mode for `onAppRefresh`: `.onNextAppear` (default) or `.immediate` |
### View Modifiers
@@ -849,8 +867,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 |
+| `.appRefresherHost(option:source:)` | Creates and injects an `AppRefresher` into the environment (overload accepts a shared instance) |
+| `.onAppRefresh(_:behavior:perform:)` | Reacts to an option, delivering the `Source?` payload, with `.onNextAppear` (default) or `.immediate` behavior |
---
diff --git a/Sources/ScreenStatetKit/Refresh/AppRefresher.swift b/Sources/ScreenStatetKit/Refresh/AppRefresher.swift
index c3347af..01f48df 100644
--- a/Sources/ScreenStatetKit/Refresh/AppRefresher.swift
+++ b/Sources/ScreenStatetKit/Refresh/AppRefresher.swift
@@ -2,101 +2,88 @@ import SwiftUI
@MainActor
@Observable
-public final class AppRefresher {
- public private(set) var action: AppRefreshAction
+public final class AppRefresher {
+ public private(set) var action: AppRefreshAction?
- public init() {
- self.action = AppRefreshAction(option: .idle)
- }
+ public init() {}
- public func refresh(_ option: AppRefreshAction.RefreshOption) {
- action = AppRefreshAction(option: option)
+ public func refresh(_ option: Option, source: Source? = nil) {
+ action = AppRefreshAction(option: option, source: source)
}
}
-public extension AppRefresher {
-
- enum Behavior: Int8, Sendable {
- case immediate
- case onNextAppear
- }
+public enum AppRefreshBehavior: 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 struct AppRefreshAction: Sendable, Identifiable {
+ public let id: UUID
+ public let option: Option
+ public let source: Source?
- public static let idle = RefreshOption(rawValue: 1 << 0)
- }
-
- public let requestId: UUID
- public let option: RefreshOption
-
- public init(option: RefreshOption) {
- self.requestId = UUID()
+ public init(option: Option, source: Source? = nil) {
+ self.id = UUID()
self.option = option
+ self.source = source
}
}
-public extension EnvironmentValues {
- @Entry var appRefresher: AppRefresher? = nil
-}
-
public extension View {
- func appRefresherHost() -> some View {
- modifier(AppRefresherHostModifier())
+ func appRefresherHost(
+ option: Option.Type,
+ source: Source.Type
+ ) -> some View where Option: OptionSet & Sendable, Source: Sendable {
+ modifier(AppRefresherHostModifier ())
}
- func appRefresherHost(_ refresher: AppRefresher) -> some View {
- environment(\.appRefresher, refresher)
+ func appRefresherHost (_ refresher: AppRefresher ) -> some View
+ where Option: OptionSet & Sendable, Source: Sendable {
+ environment(refresher)
}
- func onAppRefresh(
- _ option: AppRefreshAction.RefreshOption,
- behavior: AppRefresher.Behavior = .onNextAppear,
- perform action: @escaping () -> Void
- ) -> some View {
- modifier(AppRefreshActionModifier(option: option, behavior: behavior, action: action))
+ func onAppRefresh (
+ _ option: Option,
+ behavior: AppRefreshBehavior = .onNextAppear,
+ perform action: @escaping (Source?) -> Void
+ ) -> some View where Option: OptionSet & Sendable, Source: Sendable {
+ modifier(AppRefreshActionModifier (option: option, behavior: behavior, action: action))
}
}
-private struct AppRefresherHostModifier: ViewModifier {
- @State private var refresher = AppRefresher()
+private struct AppRefresherHostModifier: ViewModifier {
+ @State private var refresher = AppRefresher()
func body(content: Content) -> some View {
- content.environment(\.appRefresher, refresher)
+ content.environment(refresher)
}
}
-private struct AppRefreshActionModifier: ViewModifier {
+private struct AppRefreshActionModifier: ViewModifier {
- @Environment(\.appRefresher) private var refresher
- @State private var pendingRefreshId: UUID?
+ @Environment(AppRefresher.self) private var refresher: AppRefresher ?
+ @State private var pending: AppRefreshAction ?
- let option: AppRefreshAction.RefreshOption
- let behavior: AppRefresher.Behavior
- let action: () -> Void
+ let option: Option
+ let behavior: AppRefreshBehavior
+ let action: (Source?) -> Void
func body(content: Content) -> some View {
content
.onAppear {
- guard pendingRefreshId != nil, behavior == .onNextAppear else { return }
- pendingRefreshId = nil
- action()
+ guard behavior == .onNextAppear, let pending else { return }
+ self.pending = nil
+ action(pending.source)
}
- .onChange(of: refresher?.action) { _, newValue in
- guard let newValue, newValue.option.contains(option) else { return }
+ .onChange(of: refresher?.action?.id) { _, newId in
+ guard newId != nil, let current = refresher?.action else { return }
+ guard current.option.isSuperset(of: option) else { return }
switch behavior {
case .immediate:
- action()
+ action(current.source)
case .onNextAppear:
- pendingRefreshId = newValue.requestId
+ pending = current
}
}
}
diff --git a/Tests/ScreenStatetKitTests/Refresh/AppRefresherTests.swift b/Tests/ScreenStatetKitTests/Refresh/AppRefresherTests.swift
index 3b2d186..9e6c15f 100644
--- a/Tests/ScreenStatetKitTests/Refresh/AppRefresherTests.swift
+++ b/Tests/ScreenStatetKitTests/Refresh/AppRefresherTests.swift
@@ -7,78 +7,83 @@ 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)
+private struct TestOption: OptionSet, Sendable {
+ let rawValue: Int
+ static let inbox = TestOption(rawValue: 1 << 0)
+ static let settings = TestOption(rawValue: 1 << 1)
}
+private enum TestSource: Sendable, Equatable {
+ case newSetting(String)
+ case newSession(Int)
+}
+
+private typealias SUT = AppRefresher
+
@Suite("AppRefresher Tests")
@MainActor
struct AppRefresherTests {
- @Test("init starts in idle")
- func test_init_startsIdle() {
- let sut = AppRefresher()
+ @Test("init starts with no action")
+ func test_init_noAction() {
+ let sut = SUT()
- #expect(sut.action.option == .idle)
+ #expect(sut.action == nil)
}
- @Test("refresh updates the current option")
- func test_refresh_updatesOption() {
- let sut = AppRefresher()
+ @Test("refresh records the option and leaves source nil by default")
+ func test_refresh_recordsOption_noSource() {
+ let sut = SUT()
sut.refresh(.inbox)
- #expect(sut.action.option == .inbox)
+ #expect(sut.action?.option == .inbox)
+ #expect(sut.action?.source == nil)
}
- @Test("refreshing the same option twice produces a new requestId")
- func test_refresh_sameOptionTwice_producesUniqueRequestIds() {
- let sut = AppRefresher()
+ @Test("refresh carries the source payload object")
+ func test_refresh_carriesSourcePayload() {
+ let sut = SUT()
- sut.refresh(.inbox)
- let first = sut.action.requestId
- sut.refresh(.inbox)
- let second = sut.action.requestId
+ sut.refresh(.settings, source: .newSetting("dark"))
- #expect(first != second)
+ #expect(sut.action?.option == .settings)
+ #expect(sut.action?.source == .newSetting("dark"))
}
- @Test("refresh can carry combined options")
+ @Test("refresh can combine options in one signal")
func test_refresh_combinedOptions() {
- let sut = AppRefresher()
+ let sut = SUT()
- sut.refresh([.inbox, .channelState])
+ sut.refresh([.inbox, .settings], source: .newSession(7))
- #expect(sut.action.option.contains(.inbox))
- #expect(sut.action.option.contains(.channelState))
+ #expect(sut.action?.option.contains(.inbox) == true)
+ #expect(sut.action?.option.contains(.settings) == true)
+ #expect(sut.action?.source == .newSession(7))
}
- @Test("idle option does not contain consumer options")
- func test_idle_doesNotContainConsumerOptions() {
- let sut = AppRefresher()
+ @Test("refreshing the same option twice produces a new id")
+ func test_refresh_sameOptionTwice_producesUniqueIds() {
+ let sut = SUT()
- #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)
+ sut.refresh(.inbox)
+ let first = sut.action?.id
+ sut.refresh(.inbox)
+ let second = sut.action?.id
- env.appRefresher = AppRefresher()
- #expect(env.appRefresher != nil)
+ #expect(first != nil)
+ #expect(first != second)
}
@Test("view modifiers can be applied to views")
func test_viewModifiers_canBeApplied() {
let _ = Text("Test")
- .appRefresherHost()
- .onAppRefresh(.inbox) { }
+ .appRefresherHost(option: TestOption.self, source: TestSource.self)
+ .onAppRefresh(TestOption.inbox) { (_: TestSource?) in }
- let refresher = AppRefresher()
+ let refresher = SUT()
let _ = Text("Test")
.appRefresherHost(refresher)
- .onAppRefresh(.channelState, behavior: .immediate) { }
+ .onAppRefresh(TestOption.settings, behavior: .immediate) { (_: TestSource?) in }
}
}