Shared onboarding for a family of apps: one storage layer every platform target uses the same way, and themeable step views for the platforms that run a real multi-step flow.
Foundation and Observation only, no SwiftUI, no platform-specific UI dependency. A watch target that only needs "is setup done" can depend on just this.
OnboardingStepID: identifies one step across app versions. Reverse-DNS by convention (com.example.app.reqs.health) so records never collide across apps, and a step added later is just a new, unrecognized ID. Required-ness is not part of the ID, it lives onOnboardingStepConfiginstead, so flipping a step between required and optional later never orphans existing records.OnboardingStorage: reads/writes one step's tri-state record,nil(never recorded),true(granted/completed/acknowledged), orfalse(declined/skipped).nilis the whole mechanism behind adding a step a year later: a returning user has real records for every step that existed when they onboarded, andnilfor the new one.UserDefaultsOnboardingStorage: the default implementation, one suite, one key per step (prefixed, so one suite can hold more than one app's or flow's records). Shares naturally with same-device extensions/widgets via an App Group suite. Does not reach a paired Watch on its own, that's a separate device with its own sandbox; bridge it withNSUbiquitousKeyValueStoreorWatchConnectivityinstead, not App Groups.OnboardingFlow: ties a step list to storage.welcomeID/needsWelcome: an optional welcome ID the flow tracks but does not page (it isn't one ofsteps).needsWelcomeis true while that ID is unrecorded, and always false for a flow created withoutwelcome:.reset()clears it along with the steps.needsOnboarding: true while the welcome or any step is unrecorded. The entire watchOS surface is this one property, no view, no step model needed there.nextStepNeeded: the first unrecorded step, in declaration order.allRequiredSatisfied: every required step answeredtruespecifically, not just answered, declining a required step doesn't satisfy it.recordIfNeeded(leaving:): call with the step'sOnboardingStepConfigwhen its view is being left (its own button, or a swipe past it in a paged container) and may still be unanswered. Writing nothing on a swipe-past would make the step look never-seen and show it again later; this backfills the step's default exactly once, readingstep.isSkippable:falsefor a skippable step (leaving it unanswered is the same as tapping Skip),truefor a non-skippable, acknowledge-only step (there's only one way to leave it). Never overwrites a real answer.reconcile(isSatisfied:): recordstruefor 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'sidplus two independent flags.isRequired: the flow isn't satisfied until the step is recordedtrue.isSkippable(defaulttrue): the step can be left unanswered, which counts as declining. So swiping past a required permission step recordsfalseandallRequiredSatisfiedstaysfalse; it never counts as granted. A non-skippable step is acknowledge-only (an info screen) and is normally not required.
Separate from HowdyKit so a watchOS target depending only on the storage
layer never compiles SwiftUI it doesn't use.
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. A brand (custom font, custom background) and a plain look are the same view with different themes, not different views.OnboardingHeader: icon (optional), title, headline (optional). The title and headline are balanced (even lines, no one-word last line)..balancedText(): the same balancing for anyText, for a step's own paragraph:Text(message).multilineTextAlignment(.center).balancedText().lineBreakStrategy = .pushOutdoes not fix a one-word last line; this does, by laying the text out at the narrowest width that keeps its line count.OnboardingAction: a title and a handler, not a state machine. A permission step's primary button changes label as its state changes (Allow Location → Open Settings → Next); that's the app's own logic recomputing whichOnboardingActionto pass on each render, the view itself just renders whatever it's given right now.OnboardingStepView: header + arbitrary@ViewBuildercontent + primary/optional-secondary actions, themed, drawn inside a rounded card when the theme setscardBackground. Renders one step. Inside a flow it publishes its actions toOnboardingFlowView'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 recordstruefor the flow'swelcomeIDitself. Still themed and still the sameOnboardingFlowAPI as every other step, just its own config so the layout can be as custom as a brand intro or as plain as a one-line "Welcome" 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 pagingScrollViewwith 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 onneedsWelcomeitself. An app hands it per-step content (keyed byOnboardingStepConfig) and anadvanceclosure to call once that step's own action has recorded an answer; the container handles paging, swipe-past backfill (flow.recordIfNeeded(leaving: step), driven bystep.isSkippable; swiping past a required step is allowed and recordsfalse), and callsonFinishedonce the last step advances andonPageShownwith the step ID each time a page is shown (for analytics). PassisAlreadySatisfied:(for example, "is the OS permission already granted") and steps it accepts are recordedtruethroughflow.reconcilebefore paging, and again when the app becomes active, so they never get a page. This is the turnkey piece: every app gets the same flow mechanics, not just the same per-step look.
All three are genuinely turnkey. An app can still build its own container
instead (a branching NavigationStack, a tvOS focus-driven push flow) when
OnboardingFlowView's linear paging doesn't fit, but that's an escape hatch,
not the expected path.