From d9df87157e897d814fb92d9057d59a057c36beb4 Mon Sep 17 00:00:00 2001 From: "dryan-gh-actions[bot]" <254598264+dryan-gh-actions[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:59:45 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Theme=20accent=20color,=20optional?= =?UTF-8?q?=20card,=20onPageShown=20callback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things a faithful CrumbDB port needs: - OnboardingTheme.accentColor (defaults to primaryColor) drives the header icon, the footer buttons, and the current dot, so an app with orange highlights and Moss text keeps both. - cardBackground is optional and, when set, OnboardingStepView draws the header and content inside a rounded card filled with it. - OnboardingFlowView.onPageShown fires once per page shown (first page on appear, then every change), for page-view analytics; onAppear on page content can't do it since every page is created up front. Tests: 39, all passing. Co-Authored-By: Claude Fable 5.1 --- README.md | 8 ++--- Sources/HowdyKitUI/OnboardingFlowPage.swift | 7 +++++ Sources/HowdyKitUI/OnboardingFlowView.swift | 19 ++++++++++++ Sources/HowdyKitUI/OnboardingFooter.swift | 4 +-- .../HowdyKitUI/OnboardingPageIndicator.swift | 2 +- Sources/HowdyKitUI/OnboardingStepView.swift | 29 ++++++++++++++----- Sources/HowdyKitUI/OnboardingTheme.swift | 20 +++++++++---- .../OnboardingFlowPageTests.swift | 9 ++++++ .../OnboardingThemeTests.swift | 18 ++++++++++++ 9 files changed, 97 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index b36f02d..1ad6eed 100644 --- a/README.md +++ b/README.md @@ -62,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, @@ -75,7 +75,7 @@ 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. Inside a flow + 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 @@ -96,7 +96,7 @@ 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. Pass `isAlreadySatisfied:` (for example, "is the + 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 diff --git a/Sources/HowdyKitUI/OnboardingFlowPage.swift b/Sources/HowdyKitUI/OnboardingFlowPage.swift index d469550..9a9bf09 100644 --- a/Sources/HowdyKitUI/OnboardingFlowPage.swift +++ b/Sources/HowdyKitUI/OnboardingFlowPage.swift @@ -14,6 +14,13 @@ struct OnboardingFlowPage: Equatable { 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 diff --git a/Sources/HowdyKitUI/OnboardingFlowView.swift b/Sources/HowdyKitUI/OnboardingFlowView.swift index b5adabe..708fc0c 100644 --- a/Sources/HowdyKitUI/OnboardingFlowView.swift +++ b/Sources/HowdyKitUI/OnboardingFlowView.swift @@ -28,6 +28,7 @@ public struct OnboardingFlowView: View { private let flow: OnboardingFlow private let welcome: (() -> Welcome)? private let onFinished: () -> Void + private let onPageShown: ((OnboardingStepID) -> Void)? private let isAlreadySatisfied: (OnboardingStepConfig) -> Bool private let stepContent: (OnboardingStepConfig, @escaping () -> Void) -> StepContent @@ -36,6 +37,7 @@ public struct OnboardingFlowView: View { @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. @@ -44,6 +46,11 @@ public struct OnboardingFlowView: View { /// - 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 @@ -56,6 +63,7 @@ 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 @@ -63,6 +71,7 @@ public struct OnboardingFlowView: View { self.flow = flow self.welcome = welcome self.onFinished = onFinished + self.onPageShown = onPageShown self.isAlreadySatisfied = isAlreadySatisfied self.stepContent = stepContent flow.reconcile(isSatisfied: isAlreadySatisfied) @@ -132,7 +141,9 @@ public struct OnboardingFlowView: View { backfill(pages[passedIndex]) } highestVisitedIndex = max(highestVisitedIndex, newIndex) + reportShown(currentPageID) } + .onAppear { reportShown(currentPageID) } // The welcome records itself (`OnboardingWelcomeView`); slide on // once it has, if it's still the page on screen. .onChange(of: flow.needsWelcome) { _, needsWelcome in @@ -143,6 +154,12 @@ public struct OnboardingFlowView: View { } } + private func reportShown(_ id: OnboardingStepID?) { + guard let shown = OnboardingFlowPage.nextShown(id, after: lastShownPageID) else { return } + lastShownPageID = shown + onPageShown?(shown) + } + private var footer: some View { // One permanent view, never swapped for another, and never animated: // a page change is wrapped in `withAnimation`, and any animated change @@ -187,12 +204,14 @@ extension OnboardingFlowView where Welcome == EmptyView { public init( flow: OnboardingFlow, onFinished: @escaping () -> 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 flow.reconcile(isSatisfied: isAlreadySatisfied) diff --git a/Sources/HowdyKitUI/OnboardingFooter.swift b/Sources/HowdyKitUI/OnboardingFooter.swift index cfcdf42..416fabc 100644 --- a/Sources/HowdyKitUI/OnboardingFooter.swift +++ b/Sources/HowdyKitUI/OnboardingFooter.swift @@ -42,7 +42,7 @@ struct OnboardingFooter: View { Button(secondary?.title ?? "", action: secondary?.handler ?? {}) .buttonStyle(.plain) .font(.subheadline) - .foregroundStyle(theme.primaryColor.opacity(0.85)) + .foregroundStyle(theme.accentColor.opacity(0.85)) .opacity(secondary == nil ? 0 : 1) .disabled(secondary == nil) .accessibilityHidden(secondary == nil) @@ -50,7 +50,7 @@ struct OnboardingFooter: View { } // `.borderedProminent`/`.bordered` otherwise fall back to the // system accent color, not the theme. - .tint(theme.primaryColor) + .tint(theme.accentColor) .padding(.horizontal, 28) .padding(.top, 8) // The page control no longer overlaps this: the footer sits below diff --git a/Sources/HowdyKitUI/OnboardingPageIndicator.swift b/Sources/HowdyKitUI/OnboardingPageIndicator.swift index c3076e8..c26bd4c 100644 --- a/Sources/HowdyKitUI/OnboardingPageIndicator.swift +++ b/Sources/HowdyKitUI/OnboardingPageIndicator.swift @@ -13,7 +13,7 @@ struct OnboardingPageIndicator: View { HStack(spacing: 8) { ForEach(0..: 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) @@ -52,13 +47,33 @@ public struct OnboardingStepView: View { .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 private func headerView(_ header: OnboardingHeader) -> some View { VStack(spacing: 10) { if let icon = header.icon { Image(systemName: icon) .font(.system(size: 64)) - .foregroundStyle(theme.primaryColor) + .foregroundStyle(theme.accentColor) } Text(header.title) .font(theme.titleFont) 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/Tests/HowdyKitUITests/OnboardingFlowPageTests.swift b/Tests/HowdyKitUITests/OnboardingFlowPageTests.swift index 11b6430..02334db 100644 --- a/Tests/HowdyKitUITests/OnboardingFlowPageTests.swift +++ b/Tests/HowdyKitUITests/OnboardingFlowPageTests.swift @@ -57,4 +57,13 @@ struct OnboardingFlowPageTests { #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) + } }