diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b8a157e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,173 @@ +# Integrating HowdyKit (for agents) + +HowdyKit is the shared onboarding for dryan-org's apps (CrumbDB, Marie, and +whatever comes next). Integrate it; do not fork its views or reimplement the +flow in the app. `README.md` is the API reference; this file is the order of +operations and the rules that are easy to get wrong. + +## 1. Dependency + +Swift package, two products. Depend on a tag, not `main`. + +XcodeGen (`project.yml`): + +```yaml +packages: + HowdyKit: + url: https://github.com/dryan-org/howdykit + from: "1.0.0" +targets: + : + dependencies: + - package: HowdyKit + product: HowdyKit # storage + flow model, Foundation and Observation only + - package: HowdyKit + product: HowdyKitUI # SwiftUI views; iOS/macOS/tvOS targets only +``` + +A watchOS or widget target that only needs "is onboarding done" links +`HowdyKit` alone and asks `flow.needsOnboarding`. + +Platform floor is 27 across the board. + +## 2. Define the flow once, app-wide + +One `OnboardingFlow` per app, created once and shared (a plain `final class` +holding it is fine; the flow itself is `@Observable`). + +```swift +import HowdyKit + +enum Onboarding { + static let welcome = OnboardingStepID("com.dryan..welcome") + static let location = OnboardingStepID("com.dryan..reqs.location") + static let photos = OnboardingStepID("com.dryan..reqs.photos") + static let tips = OnboardingStepID("com.dryan..info.tips") +} + +let flow = OnboardingFlow( + welcome: Onboarding.welcome, + steps: [ + OnboardingStepConfig(id: Onboarding.location, isRequired: true, isSkippable: true), + OnboardingStepConfig(id: Onboarding.photos, isRequired: true, isSkippable: true), + OnboardingStepConfig(id: Onboarding.tips, isRequired: false, isSkippable: false), + ], + storage: UserDefaultsOnboardingStorage(keyPrefix: ".") +) +``` + +Rules: +- IDs are reverse-DNS and permanent. Never rename one; records are keyed by it. + A new step is a new ID appended to `steps`; returning users see only it. +- `isRequired` and `isSkippable` are independent. Required means the flow is + not satisfied until the step records `true`. Skippable means the step has a + Skip path (button or swipe past), and leaving it unanswered records `false`. + A step with no Skip (an info screen) records `true` when left. A required + permission step is normally skippable too: the user may decline and the app + stays unsatisfied; that is correct, do not record `true` for a denial. +- `keyPrefix` keeps one `UserDefaults` suite safe for more than one flow. Use + an App Group suite only when an extension must read the records on the same + device; it does not reach a paired Watch (use `NSUbiquitousKeyValueStore` + or `WatchConnectivity` for that). + +## 3. Decide "onboarding or app" from the flow, nowhere else + +```swift +if flow.needsOnboarding { OnboardingRoot() } else { MainApp() } +``` + +Full-screen root swap, not a sheet. Do not keep a separate +`hasCompletedOnboarding` flag: `needsOnboarding` is the one source of truth +and it is observable, so the swap happens on its own when the last step is +recorded. If the app already has such a flag, migrate it once at launch +(flag set and every step unrecorded: record each step `true`) and delete it. + +## 4. Build the screens with the kit's views + +```swift +import HowdyKitUI + +OnboardingFlowView( + flow: flow, + onFinished: { /* analytics, then nothing: needsOnboarding already flipped */ }, + isAlreadySatisfied: alreadyGranted, + welcome: { + OnboardingWelcomeView(flow: flow) { /* hero content */ } + }, + stepContent: { step, advance in + switch step.id { + case Onboarding.location: LocationStep(flow: flow, stepID: step.id, advance: advance) + case Onboarding.photos: PhotosStep(flow: flow, stepID: step.id, advance: advance) + case Onboarding.tips: InfoStep(flow: flow, stepID: step.id, advance: advance) + default: EmptyView() + } + } +) +.onboardingTheme(OnboardingTheme(primaryColor: brandColor, titleFont: brandFont, background: { Brand() })) +``` + +Each step is an `OnboardingStepView(header:primaryAction:secondaryAction:content:)`. +The step owns its state (permission status) and its actions; the kit renders +the buttons once, below the pages, with the dots above them, and sequences +the pages. The step's primary action must `flow.record(...)` then call +`advance()`; Skip records `false` then `advance()`. + +Theme comes from the environment (`.onboardingTheme(_:)`), set once at the +root. Never pass a theme into a view; never hard-code brand colors in a step. + +## 5. Reconcile system permissions + +A permission the OS has already granted must not get a page. Pass +`isAlreadySatisfied:` and the kit records such steps `true` before paging and +again when the scene becomes active (Settings may have changed something): + +```swift +func alreadyGranted(_ step: OnboardingStepConfig) -> Bool { + switch step.id { + case Onboarding.location: + let s = CLLocationManager().authorizationStatus + return s == .authorizedAlways || s == .authorizedWhenInUse + case Onboarding.photos: + let s = PHPhotoLibrary.authorizationStatus(for: .readWrite) + return s == .authorized || s == .limited + default: return false + } +} +``` + +Permission steps that can be granted asynchronously (Core Location's +delegate) should also auto-advance on grant: watch the status and call +`flow.record(true, ...)` then `advance()` from `onChange`. + +iOS never re-prompts for a permission once answered, and it shows the +While Using to Always upgrade prompt once per install. After that the only +path is Settings; make the button say "Open Settings" in that state rather +than calling the request again. + +## 6. Analytics and replay are the app's + +The kit fires no analytics. Fire the app's own events from the step actions +and from `onFinished`. For a "replay onboarding" entry in Settings, call +`flow.reset()`; for a dev screen that sets up one scenario, `flow.clear(id)` +and `flow.record(_:for:)` per step. + +## Do not + +- Snapshot `flow` queries into local `@State` to drive navigation. The flow + is `@Observable`; read it in `body`. +- Put the welcome in `steps`. It is `welcome:` on the flow; the kit pages it + first and records it. +- Build a parallel `TabView` or `NavigationStack` around `OnboardingStepView` + for a linear flow. `OnboardingFlowView` is the pager; only a genuinely + branching flow gets its own container. +- Use `#if DEBUG` for a reset button. Put it behind the app's beta flag. + +## Verifying on a simulator without tapping + +A `-autoAdvance` launch-argument hook (see Dolly's `AutoAdvance.swift` in the +dryan-org demo app) lets each step fire its primary path when it becomes the +flow's next unanswered step, so `xcrun simctl io recordVideo` can +capture the whole flow. The recorder only writes frames while the screen +changes, so a short video means the flow finished early or got stuck. Reset +the app's records with uninstall + install; `simctl spawn ... defaults delete` +does not clear what the running app has cached. diff --git a/README.md b/README.md index c06d1ca..1ad6eed 100644 --- a/README.md +++ b/README.md @@ -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 @@ -58,8 +62,8 @@ target that only needs "is setup done" can depend on just this. Separate from `HowdyKit` so a watchOS target depending only on the storage layer never compiles SwiftUI it doesn't use. -- **`OnboardingTheme`**: primary color, card background (`ShapeStyle`, any - material or color), fonts, and a custom background view, all defaulted to +- **`OnboardingTheme`**: primary color (text) and accent color (header icon, buttons, Skip, page dot; defaults to primary), optional card background (`AnyShapeStyle`, any + material or color; nil means no card), fonts, and a custom background view, all defaulted to plain system styling. Provided through the environment with `.onboardingTheme(_:)` at the root. CrumbDB's brand (custom font, topo background) and Marie's plain look are the same view with different themes, @@ -71,15 +75,20 @@ 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, drawn inside a rounded card when the theme sets `cardBackground`. 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 @@ -87,7 +96,10 @@ layer never compiles SwiftUI it doesn't use. 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 and `onPageShown` with the step ID each time a page is shown (for analytics). 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 diff --git a/Sources/HowdyKit/OnboardingFlow.swift b/Sources/HowdyKit/OnboardingFlow.swift index 549af5b..66dae65 100644 --- a/Sources/HowdyKit/OnboardingFlow.swift +++ b/Sources/HowdyKit/OnboardingFlow.swift @@ -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 diff --git a/Sources/HowdyKitUI/OnboardingActions.swift b/Sources/HowdyKitUI/OnboardingActions.swift new file mode 100644 index 0000000..19f50b1 --- /dev/null +++ b/Sources/HowdyKitUI/OnboardingActions.swift @@ -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 +} diff --git a/Sources/HowdyKitUI/OnboardingFlowPage.swift b/Sources/HowdyKitUI/OnboardingFlowPage.swift new file mode 100644 index 0000000..9a9bf09 --- /dev/null +++ b/Sources/HowdyKitUI/OnboardingFlowPage.swift @@ -0,0 +1,39 @@ +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 { + new > old ? old.. OnboardingStepID? { + guard let id, id != last else { return nil } + return id + } + + /// 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 + } +} diff --git a/Sources/HowdyKitUI/OnboardingFlowView.swift b/Sources/HowdyKitUI/OnboardingFlowView.swift index fe4b957..708fc0c 100644 --- a/Sources/HowdyKitUI/OnboardingFlowView.swift +++ b/Sources/HowdyKitUI/OnboardingFlowView.swift @@ -1,29 +1,61 @@ import HowdyKit import SwiftUI -/// Shows the flow's welcome first when it still needs one, then sequences -/// the steps `flow` still needs, in order, as a paged container. This is the turnkey piece: an app hands it a step's content and gets -/// paging, swipe-past backfill, and completion for free, so CrumbDB, Marie, -/// and anything after them share the same flow mechanics, not just the same -/// per-step look. +/// Sequences everything `flow` still needs as one paged container: the +/// welcome first (when the flow has one and it's unrecorded), then each +/// unanswered step in order. This is the turnkey piece: an app hands it a +/// step's content and gets paging, swipe-past backfill, and completion for +/// free, so CrumbDB, Marie, and anything after them share the same flow +/// mechanics, not just the same per-step look. /// -/// The pending step list is captured once, at init. Steps already answered -/// when the flow starts never appear; a step answered *during* the flow -/// (via its own `OnboardingAction`) doesn't resize the page count out from -/// under an in-progress swipe, it's just skipped over on `advance()`. +/// The welcome is a real page, not a separate screen swapped out before the +/// pager, so Continue slides to the first step exactly like a swipe and the +/// user can swipe back to it. +/// +/// The pager is a paging `ScrollView` (not `TabView(.page)`, whose +/// programmatic changes don't slide and whose dots can go missing). One +/// shared footer sits below the pages, with the kit's own page dots above +/// the buttons. Each page keeps owning its actions and state and publishes them +/// upward (`OnboardingActions`); this view draws the footer for the page +/// currently shown. +/// +/// The page list is captured once, when the view first appears, and held in +/// `@State`. Pages already answered when the flow starts never appear; one +/// answered *during* the flow does not resize the page count out from under +/// the user (which would shift every index, drop pages, and hide the dots +/// once one page is left). public struct OnboardingFlowView: View { private let flow: OnboardingFlow private let welcome: (() -> Welcome)? - private let pendingSteps: [OnboardingStepConfig] private let onFinished: () -> Void + private let onPageShown: ((OnboardingStepID) -> Void)? + private let isAlreadySatisfied: (OnboardingStepConfig) -> Bool private let stepContent: (OnboardingStepConfig, @escaping () -> Void) -> StepContent - @State private var currentIndex = 0 + @Environment(\.scenePhase) private var scenePhase + @Environment(\.onboardingTheme) private var theme + @State private var actions: [OnboardingStepID: OnboardingActions] = [:] + @State private var currentPageID: OnboardingStepID? @State private var highestVisitedIndex = 0 + @State private var lastShownPageID: OnboardingStepID? + // `init` runs on every parent re-render (and every recorded answer + // re-renders the parent, the flow being observable); `@State` keeps the + // first snapshot and ignores the later `initialValue`s. + @State private var pages: [OnboardingFlowPage] - /// - Parameter welcome: builds the welcome screen, shown while - /// `flow.needsWelcome`. Typically an `OnboardingWelcomeView`, which - /// records the welcome itself. + /// - Parameter welcome: builds the welcome page, shown first while the + /// flow's welcome is unrecorded. Typically an `OnboardingWelcomeView`, + /// which records the welcome itself; this view then slides on. + /// - Parameter onPageShown: called with the page's ID each time a page + /// comes on screen (the first page on appear, then every page change), + /// never twice in a row for the same page. For analytics: the IDs are + /// the flow's step IDs (the welcome reports `welcomeID`), and the app + /// maps them to whatever names its funnel uses. + /// - Parameter isAlreadySatisfied: reports whether a step is already + /// satisfied outside the flow (a permission the OS already granted). + /// Such unanswered steps are recorded `true` via + /// `flow.reconcile(isSatisfied:)` and never get a page. Runs on every + /// init, so keep it cheap. /// - Parameter stepContent: builds one step's view, given the step and /// an `advance` closure the step's own primary action calls after /// recording its answer. The container doesn't record answers itself, @@ -31,77 +63,160 @@ public struct OnboardingFlowView: View { public init( flow: OnboardingFlow, onFinished: @escaping () -> Void = {}, + onPageShown: ((OnboardingStepID) -> Void)? = nil, + isAlreadySatisfied: @escaping (OnboardingStepConfig) -> Bool = { _ in false }, @ViewBuilder welcome: @escaping () -> Welcome, @ViewBuilder stepContent: @escaping (OnboardingStepConfig, @escaping () -> Void) -> StepContent ) { self.flow = flow self.welcome = welcome self.onFinished = onFinished + self.onPageShown = onPageShown + self.isAlreadySatisfied = isAlreadySatisfied self.stepContent = stepContent - self.pendingSteps = flow.steps.filter { flow.value(for: $0.id) == nil } + flow.reconcile(isSatisfied: isAlreadySatisfied) + let pages = OnboardingFlowPage.pending(in: flow, includeWelcome: true) + _pages = State(initialValue: pages) + _currentPageID = State(initialValue: pages.first?.id) } public var body: some View { - // No state of its own: recording the welcome bumps the flow's change - // token, which `needsWelcome` reads, so this re-renders by itself. - if flow.needsWelcome, let welcome { - welcome() - } else if pendingSteps.isEmpty { + VStack(spacing: 0) { + content + OnboardingPageIndicator(count: pages.count, current: currentIndex) + .transaction { $0.animation = nil } + footer + } + // The pages draw their own background; this covers the footer strip. + .background(theme.background.ignoresSafeArea()) + .onPreferenceChange(OnboardingActionsKey.self) { actions = $0 } + .onChange(of: scenePhase) { _, phase in + // `pages` stays as captured: this only affects what shows next + // time, and `advance()` already handles pages answered meanwhile. + if phase == .active { + flow.reconcile(isSatisfied: isAlreadySatisfied) + } + } + } + + @ViewBuilder + private var content: some View { + if pages.isEmpty { Color.clear.onAppear(perform: onFinished) } else { - TabView(selection: $currentIndex) { - ForEach(Array(pendingSteps.enumerated()), id: \.element.id) { index, step in - stepContent(step, advance) - // Explicit identity per step. Every page shares one - // opaque `StepContent` type (a single closure builds - // them all), so without this SwiftUI may diff - // adjacent pages as one conditional view changing - // content. Added while chasing a crossed-over page - // slide; not yet confirmed as the cause. - .id(step.id.rawValue) - .tag(index) + // A horizontal ScrollView takes its height from its content, so + // the pages are sized from the slot the pager actually gets (the + // GeometryReader fills whatever the VStack leaves above the dots + // and footer). `containerRelativeFrame` measured against the + // whole screen here and pushed the footer below the bottom edge. + GeometryReader { geometry in + ScrollView(.horizontal) { + // Not lazy: a handful of pages, created up front so none + // is created mid-slide. + HStack(spacing: 0) { + ForEach(pages, id: \.id) { page in + Group { + if let step = page.step { + stepContent(step, advance) + } else if let welcome { + welcome() + } + } + .frame(width: geometry.size.width, height: geometry.size.height) + .environment(\.onboardingStepID, page.id) + .id(page.id) + } + } + .scrollTargetLayout() } + .scrollTargetBehavior(.paging) + .scrollPosition(id: $currentPageID) + .scrollIndicators(.hidden) } - #if os(iOS) - // `.automatic` shows the page dots CrumbDB's own onboarding - // already has (and hides them for a single pending step), not - // suppress them - `.never` also leaves the space iOS reserves - // for the control empty instead of actually removing it. - .tabViewStyle(.page(indexDisplayMode: .automatic)) - #endif - .onChange(of: currentIndex) { _, newIndex in + .onChange(of: currentPageID) { _, _ in // A swipe can only move one page, but handle a programmatic - // jump of more than one too: backfill every step passed over. - guard newIndex > highestVisitedIndex else { return } - for passedIndex in highestVisitedIndex.. Void = {}, + onPageShown: ((OnboardingStepID) -> Void)? = nil, + isAlreadySatisfied: @escaping (OnboardingStepConfig) -> Bool = { _ in false }, @ViewBuilder stepContent: @escaping (OnboardingStepConfig, @escaping () -> Void) -> StepContent ) { self.flow = flow self.welcome = nil self.onFinished = onFinished + self.onPageShown = onPageShown + self.isAlreadySatisfied = isAlreadySatisfied self.stepContent = stepContent - self.pendingSteps = flow.steps.filter { flow.value(for: $0.id) == nil } + flow.reconcile(isSatisfied: isAlreadySatisfied) + let pages = OnboardingFlowPage.pending(in: flow, includeWelcome: false) + _pages = State(initialValue: pages) + _currentPageID = State(initialValue: pages.first?.id) } } diff --git a/Sources/HowdyKitUI/OnboardingFooter.swift b/Sources/HowdyKitUI/OnboardingFooter.swift new file mode 100644 index 0000000..416fabc --- /dev/null +++ b/Sources/HowdyKitUI/OnboardingFooter.swift @@ -0,0 +1,69 @@ +import SwiftUI + +/// The button area every screen shares, rendered once by +/// `OnboardingFlowView` below the pages. Its height never changes: both +/// slots always take their space, and `actions == nil` (a page whose actions +/// haven't arrived yet) hides the buttons rather than removing them, so the +/// main button and the Skip line sit at the same spot on every screen and +/// nothing above the footer moves between pages. +struct OnboardingFooter: View { + let actions: OnboardingActions? + + @Environment(\.onboardingTheme) private var theme + + private var primary: OnboardingAction { + actions?.primary ?? OnboardingAction(title: "", handler: {}) + } + + private var secondary: OnboardingAction? { + actions?.secondary + } + + var body: some View { + VStack(spacing: 4) { + Group { + if primary.isProminent { + primaryButton.buttonStyle(.borderedProminent) + } else { + primaryButton.buttonStyle(.bordered) + } + } + .controlSize(.large) + .opacity(actions == nil ? 0 : 1) + .disabled(actions == nil) + // No insertion/removal animation when prominence flips between + // pages: an outgoing and incoming button overlapping for a frame + // makes the footer momentarily taller. + .transition(.identity) + + // One stable button, hidden rather than swapped for a placeholder, + // for the same reason: a `Button`/`Color.clear` swap inside an + // animated page change briefly stacks both. + Button(secondary?.title ?? "", action: secondary?.handler ?? {}) + .buttonStyle(.plain) + .font(.subheadline) + .foregroundStyle(theme.accentColor.opacity(0.85)) + .opacity(secondary == nil ? 0 : 1) + .disabled(secondary == nil) + .accessibilityHidden(secondary == nil) + .frame(height: 44) + } + // `.borderedProminent`/`.bordered` otherwise fall back to the + // system accent color, not the theme. + .tint(theme.accentColor) + .padding(.horizontal, 28) + .padding(.top, 8) + // The page control no longer overlaps this: the footer sits below + // the TabView, so the dots are above it. + .padding(.bottom, 12) + .frame(maxWidth: 560) + .frame(maxWidth: .infinity) + } + + private var primaryButton: some View { + Button(action: primary.handler) { + Text(primary.title) + .frame(maxWidth: .infinity) + } + } +} diff --git a/Sources/HowdyKitUI/OnboardingPageIndicator.swift b/Sources/HowdyKitUI/OnboardingPageIndicator.swift new file mode 100644 index 0000000..c26bd4c --- /dev/null +++ b/Sources/HowdyKitUI/OnboardingPageIndicator.swift @@ -0,0 +1,24 @@ +import SwiftUI + +/// The page dots under an `OnboardingFlowView`'s pager. Always takes the +/// same height and hides (rather than leaves) for a single page, so nothing +/// above or below it shifts. +struct OnboardingPageIndicator: View { + let count: Int + let current: Int + + @Environment(\.onboardingTheme) private var theme + + var body: some View { + HStack(spacing: 8) { + ForEach(0.. 1 ? 1 : 0) + .accessibilityLabel("Page \(current + 1) of \(count)") + } +} diff --git a/Sources/HowdyKitUI/OnboardingStepView.swift b/Sources/HowdyKitUI/OnboardingStepView.swift index 8846d23..828d2b8 100644 --- a/Sources/HowdyKitUI/OnboardingStepView.swift +++ b/Sources/HowdyKitUI/OnboardingStepView.swift @@ -1,11 +1,17 @@ +import HowdyKit import SwiftUI /// One step: an optional header, arbitrary content, and up to two actions, /// themed from the environment (`.onboardingTheme(_:)`, set once at the app's /// root). This renders a single step only; /// `OnboardingFlowView` sequences a list of these into an actual flow. +/// +/// Inside an `OnboardingFlowView` the view draws header and content only and +/// publishes its actions upward; the flow renders them once, below the +/// pages. Used on its own (no flow around it), it draws its own footer. public struct OnboardingStepView: View { @Environment(\.onboardingTheme) private var theme + @Environment(\.onboardingStepID) private var stepID private let header: OnboardingHeader? private let primaryAction: OnboardingAction private let secondaryAction: OnboardingAction? @@ -27,20 +33,38 @@ public struct OnboardingStepView: View { VStack(spacing: 0) { GeometryReader { geometry in ScrollView { - VStack(spacing: 20) { - if let header { - headerView(header) - } - content - } + card .padding(24) .frame(maxWidth: 560) .frame(maxWidth: .infinity, minHeight: geometry.size.height) } } - footer + if stepID == nil { + OnboardingFooter(actions: OnboardingActions(primary: primaryAction, secondary: secondaryAction)) + } } .background(theme.background.ignoresSafeArea()) + .preference(key: OnboardingActionsKey.self, value: publishedActions) + } + + @ViewBuilder + private var card: some View { + if let cardBackground = theme.cardBackground { + stack + .padding(24) + .background(RoundedRectangle(cornerRadius: 20).fill(cardBackground)) + } else { + stack + } + } + + private var stack: some View { + VStack(spacing: 20) { + if let header { + headerView(header) + } + content + } } @ViewBuilder @@ -49,7 +73,7 @@ public struct OnboardingStepView: View { if let icon = header.icon { Image(systemName: icon) .font(.system(size: 64)) - .foregroundStyle(theme.primaryColor) + .foregroundStyle(theme.accentColor) } Text(header.title) .font(theme.titleFont) @@ -63,41 +87,8 @@ public struct OnboardingStepView: View { } } - @ViewBuilder - private var footer: some View { - VStack(spacing: 4) { - Group { - if primaryAction.isProminent { - primaryButton.buttonStyle(.borderedProminent) - } else { - primaryButton.buttonStyle(.bordered) - } - } - .controlSize(.large) - - if let secondaryAction { - Button(secondaryAction.title, action: secondaryAction.handler) - .buttonStyle(.plain) - .font(.subheadline) - .foregroundStyle(theme.primaryColor.opacity(0.85)) - .frame(minHeight: 44) - } - } - // `.borderedProminent`/`.bordered` otherwise fall back to the - // system accent color, not the theme - without this the header text - // re-themes live but the button never does. - .tint(theme.primaryColor) - .padding(.horizontal, 28) - .padding(.top, 8) - .padding(.bottom, 32) - .frame(maxWidth: 560) - .frame(maxWidth: .infinity) - } - - private var primaryButton: some View { - Button(action: primaryAction.handler) { - Text(primaryAction.title) - .frame(maxWidth: .infinity) - } + private var publishedActions: [OnboardingStepID: OnboardingActions] { + guard let stepID else { return [:] } + return [stepID: OnboardingActions(primary: primaryAction, secondary: secondaryAction)] } } diff --git a/Sources/HowdyKitUI/OnboardingTheme.swift b/Sources/HowdyKitUI/OnboardingTheme.swift index feab99b..3985cf9 100644 --- a/Sources/HowdyKitUI/OnboardingTheme.swift +++ b/Sources/HowdyKitUI/OnboardingTheme.swift @@ -13,8 +13,16 @@ import SwiftUI /// Not `Sendable`: this is SwiftUI configuration data, read on the main /// actor as part of rendering, the same as the views it configures. public struct OnboardingTheme { + /// Text color (titles). See `accentColor` for the interactive highlights. public var primaryColor: Color - public var cardBackground: AnyShapeStyle + /// Interactive and brand highlights: the header icon, the primary button, + /// the Skip text, the current page dot. Primary is for text. Defaults to + /// `primaryColor` when not given. + public var accentColor: Color + /// When set, each step's header and content are drawn inside a rounded + /// card filled with it; `nil` means no card. Pass one as + /// `AnyShapeStyle(.regularMaterial)` or `AnyShapeStyle(Color.x.opacity(0.85))`. + public var cardBackground: AnyShapeStyle? public var titleFont: Font public var headlineFont: Font public var bodyFont: Font @@ -22,22 +30,24 @@ public struct OnboardingTheme { public init( primaryColor: Color = .primary, - cardBackground: some ShapeStyle = .regularMaterial, + accentColor: Color? = nil, + cardBackground: AnyShapeStyle? = nil, titleFont: Font = .title2.bold(), headlineFont: Font = .headline, bodyFont: Font = .subheadline, @ViewBuilder background: () -> some View = { Color.clear } ) { self.primaryColor = primaryColor - self.cardBackground = AnyShapeStyle(cardBackground) + self.accentColor = accentColor ?? primaryColor + self.cardBackground = cardBackground self.titleFont = titleFont self.headlineFont = headlineFont self.bodyFont = bodyFont self.background = AnyView(background()) } - /// Plain system styling: no custom font, no custom background, a - /// materials-based card. What an app reaches for until it wants to + /// Plain system styling: no custom font, no custom background, no + /// card. What an app reaches for until it wants to /// brand the flow. public static let `default` = OnboardingTheme() } diff --git a/Sources/HowdyKitUI/OnboardingWelcomeView.swift b/Sources/HowdyKitUI/OnboardingWelcomeView.swift index 7df5a72..cc8f229 100644 --- a/Sources/HowdyKitUI/OnboardingWelcomeView.swift +++ b/Sources/HowdyKitUI/OnboardingWelcomeView.swift @@ -12,6 +12,10 @@ import SwiftUI /// Records `true` for the flow's `welcomeID` when the action fires, there's /// no second storage mechanism and no way to leave this screen unanswered. /// The flow must have been created with a welcome ID. +/// +/// Draws the hero content only and publishes its action under the +/// `welcomeID`; `OnboardingFlowView` renders the footer, so Continue sits +/// where Next will on the next screen. public struct OnboardingWelcomeView: View { private let flow: OnboardingFlow private let welcomeID: OnboardingStepID @@ -44,19 +48,18 @@ public struct OnboardingWelcomeView: View { .frame(maxWidth: .infinity, minHeight: geometry.size.height) } } - Button(actionTitle) { - flow.record(true, for: welcomeID) - onContinue() - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .tint(theme.primaryColor) - .frame(maxWidth: 560) - .frame(maxWidth: .infinity) - .padding(.horizontal, 28) - .padding(.top, 8) - .padding(.bottom, 12) } .background(theme.background.ignoresSafeArea()) + .preference( + key: OnboardingActionsKey.self, + value: [ + welcomeID: OnboardingActions( + primary: OnboardingAction(title: actionTitle) { + flow.record(true, for: welcomeID) + onContinue() + } + ) + ] + ) } } diff --git a/Tests/HowdyKitTests/OnboardingFlowTests.swift b/Tests/HowdyKitTests/OnboardingFlowTests.swift index 41064f8..c1c47f7 100644 --- a/Tests/HowdyKitTests/OnboardingFlowTests.swift +++ b/Tests/HowdyKitTests/OnboardingFlowTests.swift @@ -128,6 +128,17 @@ struct OnboardingFlowTests { #expect(flow.value(for: health) == false) } + @Test("Clear puts one step back to unrecorded and leaves the rest") + func clearOneStep() { + let storage = makeStorage() + let flow = threeStepFlow(storage: storage) + flow.record(true, for: health) + flow.record(false, for: location) + flow.clear(health) + #expect(flow.value(for: health) == nil) + #expect(flow.value(for: location) == false) + } + @Test("Reset clears every step, the welcome, and extra IDs") func resetClearsEverything() { let storage = makeStorage() @@ -202,6 +213,39 @@ struct OnboardingFlowTests { flow.record(true, for: health) #expect(flag.value) } + + @Test("Reconcile records true for satisfied unanswered steps and leaves the rest nil") + func reconcileRecordsSatisfiedSteps() { + let flow = threeStepFlow(storage: makeStorage()) + flow.reconcile { $0.id == location } + #expect(flow.value(for: location) == true) + #expect(flow.value(for: health) == nil) + #expect(flow.value(for: photos) == nil) + } + + @Test("Reconcile never changes an existing answer") + func reconcileKeepsExistingAnswers() { + let flow = threeStepFlow(storage: makeStorage()) + flow.record(false, for: photos) + flow.reconcile { _ in true } + #expect(flow.value(for: photos) == false) + #expect(flow.value(for: health) == true) + #expect(flow.value(for: location) == true) + } + + @Test("Reconcile notifies Observation only when it records something") + func reconcileNotifiesOnlyOnChange() { + let flow = threeStepFlow(storage: makeStorage()) + let quiet = Flag() + withObservationTracking({ _ = flow.needsOnboarding }, onChange: { quiet.value = true }) + flow.reconcile { _ in false } + #expect(!quiet.value) + + let loud = Flag() + withObservationTracking({ _ = flow.needsOnboarding }, onChange: { loud.value = true }) + flow.reconcile { $0.id == health } + #expect(loud.value) + } } @Suite("UserDefaults storage") diff --git a/Tests/HowdyKitUITests/OnboardingActionsTests.swift b/Tests/HowdyKitUITests/OnboardingActionsTests.swift new file mode 100644 index 0000000..04accbc --- /dev/null +++ b/Tests/HowdyKitUITests/OnboardingActionsTests.swift @@ -0,0 +1,69 @@ +import HowdyKit +import SwiftUI +import Testing + +@testable import HowdyKitUI + +@MainActor +@Suite("OnboardingActions") +struct OnboardingActionsTests { + private func actions( + _ primary: LocalizedStringKey = "Next", + prominent: Bool = true, + secondary: LocalizedStringKey? = nil, + handler: @escaping () -> Void = {} + ) -> OnboardingActions { + OnboardingActions( + primary: OnboardingAction(title: primary, isProminent: prominent, handler: handler), + secondary: secondary.map { OnboardingAction(title: $0, handler: handler) } + ) + } + + @Test func equalityIgnoresHandlers() { + var calls = 0 + let a = actions(secondary: "Skip") {} + let b = actions(secondary: "Skip") { calls += 1 } + #expect(a == b) + #expect(calls == 0) + } + + @Test func differentTitleIsNotEqual() { + #expect(actions("Next") != actions("Done")) + #expect(actions(secondary: "Skip") != actions(secondary: "Later")) + } + + @Test func differentProminenceIsNotEqual() { + #expect(actions(prominent: true) != actions(prominent: false)) + } + + @Test func nilVersusNonNilSecondaryIsNotEqual() { + #expect(actions(secondary: nil) != actions(secondary: "Skip")) + } + + @Test func reduceMergesWithLaterValueWinning() { + let first = OnboardingStepID("a") + let second = OnboardingStepID("b") + var value: [OnboardingStepID: OnboardingActions] = [ + first: actions("Old"), + second: actions("Kept"), + ] + OnboardingActionsKey.reduce(value: &value) { + [first: actions("New"), OnboardingStepID("c"): actions("Added")] + } + #expect(value.count == 3) + #expect(value[first] == actions("New")) + #expect(value[second] == actions("Kept")) + #expect(value[OnboardingStepID("c")] == actions("Added")) + } + + @Test func defaultPreferenceValueIsEmpty() { + #expect(OnboardingActionsKey.defaultValue.isEmpty) + } + + @Test func stepIDEnvironmentDefaultsToNilAndRoundTrips() { + var env = EnvironmentValues() + #expect(env.onboardingStepID == nil) + env.onboardingStepID = OnboardingStepID("welcome") + #expect(env.onboardingStepID == OnboardingStepID("welcome")) + } +} diff --git a/Tests/HowdyKitUITests/OnboardingFlowPageTests.swift b/Tests/HowdyKitUITests/OnboardingFlowPageTests.swift new file mode 100644 index 0000000..02334db --- /dev/null +++ b/Tests/HowdyKitUITests/OnboardingFlowPageTests.swift @@ -0,0 +1,69 @@ +import Foundation +import HowdyKit +import Testing +@testable import HowdyKitUI + +@Suite("OnboardingFlowPage.pending") +struct OnboardingFlowPageTests { + private let welcome = OnboardingStepID("com.dryan.test.welcome") + private let first = OnboardingStepID("com.dryan.test.first") + private let middle = OnboardingStepID("com.dryan.test.middle") + private let last = OnboardingStepID("com.dryan.test.last") + + private func makeFlow(welcome: OnboardingStepID?) -> OnboardingFlow { + let defaults = UserDefaults(suiteName: "howdykit-ui-tests-\(UUID().uuidString)")! + return OnboardingFlow( + welcome: welcome, + steps: [ + OnboardingStepConfig(id: first, isRequired: false, isSkippable: false), + OnboardingStepConfig(id: middle, isRequired: true), + OnboardingStepConfig(id: last, isRequired: false), + ], + storage: UserDefaultsOnboardingStorage(defaults: defaults) + ) + } + + @Test("A fresh flow pages the welcome first, then every step in order") + func freshFlowStartsWithWelcome() { + let pages = OnboardingFlowPage.pending(in: makeFlow(welcome: welcome), includeWelcome: true) + #expect(pages.map(\.id) == [welcome, first, middle, last]) + #expect(pages.first?.isWelcome == true) + #expect(pages.dropFirst().allSatisfy { !$0.isWelcome }) + } + + @Test("A recorded welcome, or a view without one, leaves the welcome out") + func welcomeOmittedWhenRecordedOrNotWanted() { + let flow = makeFlow(welcome: welcome) + #expect(OnboardingFlowPage.pending(in: flow, includeWelcome: false).map(\.id) == [first, middle, last]) + flow.record(true, for: welcome) + #expect(OnboardingFlowPage.pending(in: flow, includeWelcome: true).map(\.id) == [first, middle, last]) + #expect(OnboardingFlowPage.pending(in: makeFlow(welcome: nil), includeWelcome: true).map(\.id) == [first, middle, last]) + } + + @Test("An answered step in the middle drops out without reordering the rest") + func answeredMiddleStepIsSkipped() { + let flow = makeFlow(welcome: welcome) + flow.record(true, for: welcome) + flow.record(false, for: middle) + let pages = OnboardingFlowPage.pending(in: flow, includeWelcome: true) + #expect(pages.map(\.id) == [first, last]) + #expect(pages.compactMap(\.step?.id) == [first, last]) + } + + @Test("Moving forward passes every index left behind; standing still or going back passes none") + func indicesPassed() { + #expect(Array(OnboardingFlowPage.indicesPassed(from: 0, to: 1)) == [0]) + #expect(Array(OnboardingFlowPage.indicesPassed(from: 1, to: 3)) == [1, 2]) + #expect(OnboardingFlowPage.indicesPassed(from: 2, to: 1).isEmpty) + #expect(OnboardingFlowPage.indicesPassed(from: 2, to: 2).isEmpty) + } + + @Test("A page is reported when it is new, never twice in a row, and never when absent") + func nextShownGuard() { + #expect(OnboardingFlowPage.nextShown(first, after: nil) == first) + #expect(OnboardingFlowPage.nextShown(middle, after: first) == middle) + #expect(OnboardingFlowPage.nextShown(first, after: first) == nil) + #expect(OnboardingFlowPage.nextShown(nil, after: first) == nil) + #expect(OnboardingFlowPage.nextShown(nil, after: nil) == nil) + } +} diff --git a/Tests/HowdyKitUITests/OnboardingThemeTests.swift b/Tests/HowdyKitUITests/OnboardingThemeTests.swift index 838d446..ea82eeb 100644 --- a/Tests/HowdyKitUITests/OnboardingThemeTests.swift +++ b/Tests/HowdyKitUITests/OnboardingThemeTests.swift @@ -25,4 +25,22 @@ struct OnboardingThemeTests { #expect(theme.headlineFont == OnboardingTheme.default.headlineFont) #expect(theme.bodyFont == OnboardingTheme.default.bodyFont) } + + @Test func accentDefaultsToPrimary() { + #expect(OnboardingTheme(primaryColor: .red).accentColor == .red) + #expect(OnboardingTheme().accentColor == Color.primary) + } + + @Test func explicitAccentIsKept() { + let theme = OnboardingTheme(primaryColor: .red, accentColor: .orange) + #expect(theme.primaryColor == .red) + #expect(theme.accentColor == .orange) + } + + @Test func cardBackgroundIsNilByDefaultAndSetWhenGiven() { + #expect(OnboardingTheme().cardBackground == nil) + #expect(OnboardingTheme.default.cardBackground == nil) + #expect(OnboardingTheme(cardBackground: AnyShapeStyle(.regularMaterial)).cardBackground != nil) + #expect(OnboardingTheme(cardBackground: AnyShapeStyle(Color.white.opacity(0.85))).cardBackground != nil) + } }