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
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ target that only needs "is setup done" can depend on just this.
is the same as tapping Skip), `true` for a non-skippable,
acknowledge-only step (there's only one way to leave it). Never
overwrites a real answer.
- `reconcile(isSatisfied:)`: records `true` for every unanswered step the
closure reports as already satisfied outside the flow (a permission the OS
already granted). Leaves answered steps alone and only notifies
observers when it records something.
- **`OnboardingStepConfig`**: a step's `id` plus two independent flags.
`isRequired`: the flow isn't satisfied until the step is recorded `true`.
`isSkippable` (default `true`): the step can be left unanswered, which
Expand All @@ -71,23 +75,31 @@ layer never compiles SwiftUI it doesn't use.
which `OnboardingAction` to pass on each render, the view itself just
renders whatever it's given right now.
- **`OnboardingStepView`**: header + arbitrary `@ViewBuilder` content +
primary/optional-secondary actions, themed. Renders one step.
primary/optional-secondary actions, themed. Renders one step. Inside a flow
it publishes its actions to `OnboardingFlowView`'s shared footer; on its own
it draws the footer itself.
- **`OnboardingWelcomeView`**: the first-run screen. No header/content split
forced on it (a hero layout usually wants more room than that), no skip,
a single action that records `true` for the flow's `welcomeID` itself.
Still themed and still the same `OnboardingFlow` API as every other step,
just its own config so the layout can be as custom as CrumbDB's brand intro or Marie's
plain "Welcome to Marie" needs.
plain "Welcome to Marie" needs. Publishes its action to the flow's shared
footer the same way a step does.
- **`OnboardingFlowView`**: shows the welcome first when the flow still needs
it, then sequences the steps a flow still needs into a paged container.
The pager is a paging `ScrollView` with the kit's own page dots, and one footer under it for
whichever page is showing.
Recording the welcome moves it on to the steps on its own, so an app
doesn't branch on `needsWelcome` itself. An app hands it per-step content (keyed by
`OnboardingStepConfig`) and an `advance` closure to call once that step's
own action has recorded an answer; the container handles paging,
swipe-past backfill (`flow.recordIfNeeded(leaving: step)`, driven by
`step.isSkippable`; swiping past a required step is allowed and records
`false`), and calls `onFinished` once
the last step advances. This is the turnkey piece: CrumbDB and Marie get
the last step advances. Pass `isAlreadySatisfied:` (for example, "is the
OS permission already granted") and steps it accepts are recorded `true`
through `flow.reconcile` before paging, and again when the app becomes
active, so they never get a page. This is the turnkey piece: CrumbDB and Marie get
the same flow mechanics, not just the same per-step look.

All three are genuinely turnkey. An app can still build its own container
Expand Down
24 changes: 24 additions & 0 deletions Sources/HowdyKit/OnboardingFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,30 @@ public final class OnboardingFlow {
changeToken += 1
}

/// Records `true` for every unanswered step that `isSatisfied` reports
/// as already satisfied outside the flow (a system permission the OS
/// already granted, say). Steps with an answer are left alone. Call it
/// before presenting the flow, and again when the app returns to the
/// foreground, since Settings may have changed a permission meanwhile.
public func reconcile(isSatisfied: (OnboardingStepConfig) -> Bool) {
var recorded = false
for step in steps where storage.value(for: step.id) == nil && isSatisfied(step) {
storage.setValue(true, for: step.id)
recorded = true
}
// Only bump when something changed, so a no-op reconcile re-renders nothing.
if recorded {
changeToken += 1
}
}

/// Clears one record back to unrecorded. Development/testing, like
/// `reset`: lets a tool set up "this step was never seen" by itself.
public func clear(_ id: OnboardingStepID) {
storage.setValue(nil, for: id)
changeToken += 1
}

/// Clears every step's record back to unrecorded, plus the welcome's when
/// the flow has one, and any IDs in `additionalIDs` that an app stores
/// alongside the flow without them being steps. For development/testing - a shipped
Expand Down
39 changes: 39 additions & 0 deletions Sources/HowdyKitUI/OnboardingActions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import HowdyKit
import SwiftUI

/// What a page wants in the shared footer. Published upward by
/// `OnboardingStepView`/`OnboardingWelcomeView`; rendered once, below the
/// TabView, by `OnboardingFlowView`.
public struct OnboardingActions: Equatable {
public var primary: OnboardingAction
public var secondary: OnboardingAction?

public init(primary: OnboardingAction, secondary: OnboardingAction? = nil) {
self.primary = primary
self.secondary = secondary
}

// Equality ignores handlers: titles and prominence are what the footer
// renders, and a handler built in the same render captures the same
// state, so a stale one still reads current values.
public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.primary.title == rhs.primary.title
&& lhs.primary.isProminent == rhs.primary.isProminent
&& lhs.secondary?.title == rhs.secondary?.title
&& lhs.secondary?.isProminent == rhs.secondary?.isProminent
}
}

struct OnboardingActionsKey: PreferenceKey {
static let defaultValue: [OnboardingStepID: OnboardingActions] = [:]

static func reduce(value: inout Value, nextValue: () -> Value) {
value.merge(nextValue()) { $1 }
}
}

extension EnvironmentValues {
/// The step a page is showing, so it knows which key to publish under.
/// `nil` outside an `OnboardingFlowView`.
@Entry var onboardingStepID: OnboardingStepID? = nil
}
32 changes: 32 additions & 0 deletions Sources/HowdyKitUI/OnboardingFlowPage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import HowdyKit

/// One page of an `OnboardingFlowView`: the welcome (no step config, keyed
/// by the flow's `welcomeID`) or a step.
struct OnboardingFlowPage: Equatable {
let id: OnboardingStepID
let step: OnboardingStepConfig?

var isWelcome: Bool { step == nil }

/// The indices a move from `old` to `new` leaves behind (all of them,
/// for a jump of more than one page); empty when moving backward.
static func indicesPassed(from old: Int, to new: Int) -> Range<Int> {
new > old ? old..<new : 0..<0
}

/// The pages a flow still needs, welcome first when it's wanted and
/// unrecorded, then every unanswered step in declaration order. Reads
/// `storage` directly rather than the flow's tracked queries: this runs
/// inside a parent view's body, and tracking there would re-render the
/// parent on every answer for no reason.
static func pending(in flow: OnboardingFlow, includeWelcome: Bool) -> [OnboardingFlowPage] {
var pages: [OnboardingFlowPage] = []
if includeWelcome, let welcomeID = flow.welcomeID, flow.storage.value(for: welcomeID) == nil {
pages.append(OnboardingFlowPage(id: welcomeID, step: nil))
}
for step in flow.steps where flow.storage.value(for: step.id) == nil {
pages.append(OnboardingFlowPage(id: step.id, step: step))
}
return pages
}
}
Loading
Loading