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
83 changes: 83 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -737,6 +816,7 @@ func observe<T>(stream: AnyAsyncStream<T>) 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

Expand All @@ -755,6 +835,7 @@ func observe<T>(stream: AnyAsyncStream<T>) async {
| `AnyTask` | Public handle to a stored task with `cancel()`, `waitComplete()`, and `isCancelled` |
| `AnyAsyncStream<Element>` | 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

Expand All @@ -768,6 +849,8 @@ func observe<T>(stream: AnyAsyncStream<T>) 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 |

---

Expand Down
103 changes: 103 additions & 0 deletions Sources/ScreenStatetKit/Refresh/AppRefresher.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
84 changes: 84 additions & 0 deletions Tests/ScreenStatetKitTests/Refresh/AppRefresherTests.swift
Original file line number Diff line number Diff line change
@@ -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) { }
}
}
Loading