
Quick Win Session
- 2 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Generates a guided first-action onboarding flow that helps app users reach a meaningful result within 60 seconds to improve retention.
About
Generates a guided quick-win onboarding flow that walks new users through a high-impact first action within 60 seconds and celebrates completion. A developer uses it to reduce time-to-value and boost first-session retention.
- Surfaces the simplest high-impact task and guides the user step by step
- Targets first-minute retention and time-to-value optimization
Quick Win Session by the numbers
- 2 all-time installs (skills.sh)
- Ranked #888 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill quick-win-sessionAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Generates a guided first-action onboarding flow that helps app users reach a meaningful result within 60 seconds to improve retention.
Files
Quick Win Session Generator
Generate a "quick win" session system that guides users to complete a meaningful action within their first 60 seconds. Reduces time-to-value by surfacing the simplest high-impact task, walking the user through it step by step, and celebrating completion. Critical for onboarding retention — users who achieve a quick win in the first minute are significantly more likely to return.
When This Skill Activates
Use this skill when the user:
- Asks about "quick win" flows or "first action" experiences
- Wants to reduce "time to value" or "time to first success"
- Mentions "guided first task" or "onboarding action"
- Asks about a "first success moment" or "activation metric"
- Wants to "get users to do something useful immediately"
- Mentions "new user activation" or "onboarding retention"
Pre-Generation Checks
1. Project Context Detection
- [ ] Check deployment target (iOS 17+ / macOS 14+ required for @Observable)
- [ ] Check Swift version (requires Swift 5.9+)
- [ ] Identify source file locations
2. Existing Onboarding Detection
Search for existing onboarding code:
Glob: **/*Onboarding*.swift, **/*Welcome*.swift, **/*QuickWin*.swift, **/*FirstRun*.swift
Grep: "onboarding" or "firstLaunch" or "hasCompletedSetup" or "isNewUser"If existing onboarding found:
- Ask if quick win should run after the existing onboarding or replace it
- If running after, integrate as the next step in the onboarding flow
3. User State Tracking Detection
Search for existing user defaults or state tracking:
Grep: "UserDefaults" or "@AppStorage" or "isFirstLaunch"Determine how to persist quick win completion status (UserDefaults, AppStorage, SwiftData, etc.).
Configuration Questions
Ask user via AskUserQuestion:
1. Quick win type?
- Create first item (e.g., first note, first task, first photo)
- Complete profile (fill in name, avatar, preferences)
- Import data (bring in existing content from another source)
- Explore feature (guided tour of the single most valuable feature)
2. Guidance style?
- Step-by-step overlay (instruction cards at top, progress dots)
- Spotlight hints (dim screen, cut out spotlight on target element)
- Coach marks (tooltip arrows pointing at UI elements)
3. Celebrate on completion?
- Yes — animated checkmark, congratulations message, time stat
- Minimal — brief success banner, auto-dismiss
- No — silently mark as complete
4. Track as activation metric?
- Yes — log completion time, step-by-step progress, abandonment point
- No — just persist completed/not completed
Generation Process
Step 1: Read Templates
Read templates.md for production Swift code.
Step 2: Create Core Files
Generate these files: 1. QuickWinTask.swift — Model for a quick win task and its steps 2. QuickWinSession.swift — @Observable session manager: task selection, step tracking, timing, completion
Step 3: Create UI Files
3. QuickWinGuideView.swift — Overlay that shows instructions and progress 4. SpotlightHintView.swift — Spotlight cutout overlay with callout arrow 5. QuickWinCelebrationView.swift — Completion celebration with stats
Step 4: Create Integration File
6. QuickWinModifier.swift — ViewModifier that triggers the quick win for new users
Step 5: Determine File Location
Check project structure:
- If
Sources/exists →Sources/QuickWin/ - If
App/exists →App/QuickWin/ - Otherwise →
QuickWin/
Output Format
After generation, provide:
Files Created
QuickWin/
├── QuickWinTask.swift # Task model with steps
├── QuickWinSession.swift # Session manager (progress, timing)
├── QuickWinGuideView.swift # Step-by-step overlay UI
├── SpotlightHintView.swift # Spotlight cutout with callout
├── QuickWinCelebrationView.swift # Completion celebration
└── QuickWinModifier.swift # ViewModifier for root viewIntegration After Onboarding
Attach to your root view:
// In your main ContentView or post-onboarding view
ContentView()
.quickWinSession(task: .createFirstNote)Define your app's quick win task:
extension QuickWinTask {
static let createFirstNote = QuickWinTask(
id: "create-first-note",
title: "Create Your First Note",
description: "Let's get started — it only takes a few seconds.",
estimatedSeconds: 30,
steps: [
QuickWinStep(instruction: "Tap the + button to create a new note", actionType: .tap, targetView: "addButton"),
QuickWinStep(instruction: "Type a title for your note", actionType: .input, targetView: "titleField"),
QuickWinStep(instruction: "Tap Done to save", actionType: .tap, targetView: "doneButton")
],
completionCriteria: "firstNoteCreated",
iconName: "note.text.badge.plus"
)
}With spotlight hints:
// Mark target views for spotlight
TextField("Title", text: $title)
.quickWinTarget(id: "titleField")
Button("Done") { save() }
.quickWinTarget(id: "doneButton")Start session programmatically (after existing onboarding):
struct PostOnboardingView: View {
@State private var session = QuickWinSession()
var body: some View {
MainView()
.onAppear {
if !session.hasCompletedQuickWin {
session.start(task: .createFirstNote)
}
}
.quickWinOverlay(session: session)
}
}Testing
@Test
func quickWinSessionTracksProgress() async {
let session = QuickWinSession(storage: MockStorage())
let task = QuickWinTask.testTask(stepCount: 3)
session.start(task: task)
#expect(session.currentStepIndex == 0)
#expect(session.isActive)
session.completeCurrentStep()
#expect(session.currentStepIndex == 1)
session.completeCurrentStep()
#expect(session.currentStepIndex == 2)
session.completeCurrentStep()
#expect(session.isCompleted)
#expect(session.isActive == false)
}
@Test
func quickWinRecordsCompletionTime() async {
let session = QuickWinSession(storage: MockStorage())
let task = QuickWinTask.testTask(stepCount: 1)
session.start(task: task)
// Simulate time passing
try? await Task.sleep(for: .milliseconds(500))
session.completeCurrentStep()
#expect(session.completionTimeSeconds > 0)
#expect(session.completionTimeSeconds < 5)
}
@Test
func quickWinSkipsForReturningUsers() async {
let storage = MockStorage()
storage.set(true, forKey: "quickWin_create-first-note_completed")
let session = QuickWinSession(storage: storage)
session.start(task: .createFirstNote)
#expect(session.isActive == false) // Already completed, skip
}
@Test
func quickWinHandlesAbandonment() async {
let session = QuickWinSession(storage: MockStorage())
let task = QuickWinTask.testTask(stepCount: 3)
session.start(task: task)
session.completeCurrentStep()
session.abandon()
#expect(session.isActive == false)
#expect(session.isCompleted == false)
#expect(session.abandonedAtStep == 1)
}Common Patterns
Start Quick Win Session
// After onboarding completes
session.start(task: .createFirstNote)Show Guided Steps
// Session automatically advances through steps
// Each step shows instruction + highlights target
QuickWinGuideView(session: session)Celebrate Completion
// Automatically shown when all steps complete
QuickWinCelebrationView(
taskTitle: session.completedTask?.title ?? "",
completionTime: session.completionTimeSeconds
)Gotchas
- Don't block experienced users — Always check completion status before showing. Provide a "Skip" button. Never show again after completion or dismissal.
- Make it genuinely useful, not just a tutorial — The quick win should produce a real artifact (a note, a list item, an imported file). Users should feel they accomplished something, not that they watched a demo.
- Handle interruptions gracefully — If the user backgrounds the app, navigates away, or receives a notification mid-session, persist progress and resume where they left off.
- Different quick wins for different user types — A power user migrating from another app needs a different quick win (import data) than a brand-new user (create first item). Detect context and choose accordingly.
- Keep it under 60 seconds — If a quick win takes longer, it's not quick. Trim steps ruthlessly. Three steps is ideal, five is the maximum.
- Test with real timing — Run the flow yourself with a stopwatch. If it feels slow, it is slow.
References
- templates.md — All production Swift templates
- Related:
generators/onboarding-generator— Full onboarding flow generation - Related:
generators/milestone-celebration— Celebration UI for achievements beyond first action
Quick Win Session Code Templates
Production-ready Swift templates for a quick win session system. All code targets iOS 17+ / macOS 14+ and uses @Observable with modern Swift concurrency.
QuickWinTask.swift
import Foundation
/// A quick win task that guides a new user to their first meaningful action.
///
/// Each task has a sequence of steps and a completion criteria identifier
/// used to check whether the user has already achieved this quick win.
struct QuickWinTask: Identifiable, Codable, Sendable {
let id: String
let title: String
let description: String
let estimatedSeconds: Int
let steps: [QuickWinStep]
let completionCriteria: String
let iconName: String
var stepCount: Int { steps.count }
}
/// A single step within a quick win task.
///
/// Each step describes an instruction for the user and identifies
/// the target view element that the user should interact with.
struct QuickWinStep: Identifiable, Codable, Sendable {
var id: String { "\(actionType.rawValue)_\(targetView)" }
let instruction: String
let actionType: ActionType
let targetView: String
enum ActionType: String, Codable, Sendable {
case tap
case input
case navigate
}
}
// MARK: - Test Helpers
extension QuickWinTask {
/// Creates a test task with the specified number of placeholder steps.
static func testTask(stepCount: Int) -> QuickWinTask {
QuickWinTask(
id: "test-task",
title: "Test Task",
description: "A task for testing.",
estimatedSeconds: 30,
steps: (0..<stepCount).map { index in
QuickWinStep(
instruction: "Step \(index + 1)",
actionType: .tap,
targetView: "target_\(index)"
)
},
completionCriteria: "testCompleted",
iconName: "checkmark.circle"
)
}
}QuickWinSession.swift
import Foundation
import SwiftUI
/// Manages the lifecycle of a quick win session.
///
/// Tracks progress through task steps, measures completion time,
/// handles abandonment, and persists completion status so the
/// quick win is never shown again after finishing.
///
/// Usage:
/// ```swift
/// @State private var session = QuickWinSession()
///
/// .onAppear {
/// if !session.hasCompletedQuickWin(id: "create-first-note") {
/// session.start(task: .createFirstNote)
/// }
/// }
/// ```
@Observable
final class QuickWinSession {
// MARK: - Public State
private(set) var currentTask: QuickWinTask?
private(set) var currentStepIndex: Int = 0
private(set) var isActive: Bool = false
private(set) var isCompleted: Bool = false
private(set) var completionTimeSeconds: TimeInterval = 0
private(set) var abandonedAtStep: Int?
var completedTask: QuickWinTask? {
isCompleted ? currentTask : nil
}
var currentStep: QuickWinStep? {
guard let task = currentTask,
currentStepIndex < task.steps.count else { return nil }
return task.steps[currentStepIndex]
}
var progress: Double {
guard let task = currentTask, task.stepCount > 0 else { return 0 }
return Double(currentStepIndex) / Double(task.stepCount)
}
// MARK: - Private
private var startTime: Date?
private let storage: QuickWinStorage
// MARK: - Init
init(storage: QuickWinStorage = UserDefaultsQuickWinStorage()) {
self.storage = storage
}
// MARK: - Session Control
/// Start a quick win session for the given task.
///
/// If the user has already completed this task, the session
/// will not activate.
func start(task: QuickWinTask) {
guard !hasCompletedQuickWin(id: task.id) else { return }
currentTask = task
currentStepIndex = 0
isActive = true
isCompleted = false
completionTimeSeconds = 0
abandonedAtStep = nil
startTime = Date()
}
/// Mark the current step as complete and advance to the next.
///
/// If this was the final step, the session completes automatically.
func completeCurrentStep() {
guard isActive, let task = currentTask else { return }
let nextIndex = currentStepIndex + 1
if nextIndex >= task.stepCount {
completeSession()
} else {
currentStepIndex = nextIndex
}
}
/// Abandon the session without completing.
///
/// Records which step the user was on for analytics.
func abandon() {
guard isActive else { return }
abandonedAtStep = currentStepIndex
isActive = false
}
/// Skip the quick win entirely and mark it as dismissed.
func skip() {
guard isActive, let task = currentTask else { return }
storage.markDismissed(taskID: task.id)
abandonedAtStep = currentStepIndex
isActive = false
}
/// Resume a previously interrupted session.
///
/// Call this when the app returns to foreground during
/// an active quick win.
func resumeIfNeeded() {
// Session state is held in memory; just verify it's valid
guard currentTask != nil, !isCompleted else {
isActive = false
return
}
}
// MARK: - Query
/// Check whether a quick win has already been completed.
func hasCompletedQuickWin(id: String) -> Bool {
storage.isCompleted(taskID: id)
}
// MARK: - Private
private func completeSession() {
guard let task = currentTask, let startTime else { return }
completionTimeSeconds = Date().timeIntervalSince(startTime)
isCompleted = true
isActive = false
storage.markCompleted(taskID: task.id, timeSeconds: completionTimeSeconds)
}
}
// MARK: - Storage Protocol
/// Abstracts persistence of quick win completion status.
///
/// Conform to this protocol for custom storage backends
/// (SwiftData, CloudKit, etc.).
protocol QuickWinStorage: Sendable {
func isCompleted(taskID: String) -> Bool
func isDismissed(taskID: String) -> Bool
func markCompleted(taskID: String, timeSeconds: TimeInterval)
func markDismissed(taskID: String)
}
// MARK: - UserDefaults Storage
/// Default storage implementation using UserDefaults.
final class UserDefaultsQuickWinStorage: QuickWinStorage, @unchecked Sendable {
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}
func isCompleted(taskID: String) -> Bool {
defaults.bool(forKey: storageKey(taskID, suffix: "completed"))
}
func isDismissed(taskID: String) -> Bool {
defaults.bool(forKey: storageKey(taskID, suffix: "dismissed"))
}
func markCompleted(taskID: String, timeSeconds: TimeInterval) {
defaults.set(true, forKey: storageKey(taskID, suffix: "completed"))
defaults.set(timeSeconds, forKey: storageKey(taskID, suffix: "completionTime"))
}
func markDismissed(taskID: String) {
defaults.set(true, forKey: storageKey(taskID, suffix: "dismissed"))
}
private func storageKey(_ taskID: String, suffix: String) -> String {
"quickWin_\(taskID)_\(suffix)"
}
}
// MARK: - Mock Storage (Testing)
/// In-memory storage for unit tests.
final class MockQuickWinStorage: QuickWinStorage, @unchecked Sendable {
private var store: [String: Any] = [:]
func isCompleted(taskID: String) -> Bool {
store["quickWin_\(taskID)_completed"] as? Bool ?? false
}
func isDismissed(taskID: String) -> Bool {
store["quickWin_\(taskID)_dismissed"] as? Bool ?? false
}
func markCompleted(taskID: String, timeSeconds: TimeInterval) {
store["quickWin_\(taskID)_completed"] = true
store["quickWin_\(taskID)_completionTime"] = timeSeconds
}
func markDismissed(taskID: String) {
store["quickWin_\(taskID)_dismissed"] = true
}
func set(_ value: Any, forKey key: String) {
store[key] = value
}
}QuickWinGuideView.swift
import SwiftUI
/// Overlay that guides the user through quick win steps.
///
/// Displays the current instruction at the top of the screen,
/// a progress indicator, and a Skip button. Uses matched geometry
/// for smooth transitions between steps.
///
/// Usage:
/// ```swift
/// ZStack {
/// MainContentView()
/// QuickWinGuideView(session: session)
/// }
/// ```
struct QuickWinGuideView: View {
let session: QuickWinSession
@Namespace private var animation
var body: some View {
if session.isActive, let step = session.currentStep, let task = session.currentTask {
VStack(spacing: 0) {
instructionCard(task: task, step: step)
Spacer()
}
.transition(.move(edge: .top).combined(with: .opacity))
.animation(.easeInOut(duration: 0.3), value: session.currentStepIndex)
}
}
// MARK: - Instruction Card
@ViewBuilder
private func instructionCard(task: QuickWinTask, step: QuickWinStep) -> some View {
VStack(spacing: 12) {
// Progress dots
progressIndicator(
currentStep: session.currentStepIndex,
totalSteps: task.stepCount
)
// Instruction
HStack(spacing: 12) {
stepIcon(for: step.actionType)
.font(.title2)
.foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 2) {
Text("Step \(session.currentStepIndex + 1) of \(task.stepCount)")
.font(.caption)
.foregroundStyle(.secondary)
Text(step.instruction)
.font(.headline)
.fixedSize(horizontal: false, vertical: true)
}
Spacer()
}
// Skip button
HStack {
Spacer()
Button("Skip") {
withAnimation(.easeInOut(duration: 0.25)) {
session.skip()
}
}
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.padding()
.background {
RoundedRectangle(cornerRadius: 16)
.fill(.regularMaterial)
.shadow(color: .black.opacity(0.1), radius: 8, y: 4)
}
.padding(.horizontal)
.padding(.top, 8)
}
// MARK: - Progress Indicator
@ViewBuilder
private func progressIndicator(currentStep: Int, totalSteps: Int) -> some View {
HStack(spacing: 6) {
ForEach(0..<totalSteps, id: \.self) { index in
Capsule()
.fill(index <= currentStep ? Color.accentColor : Color.secondary.opacity(0.3))
.frame(height: 4)
.matchedGeometryEffect(
id: "progress_\(index)",
in: animation
)
}
}
}
// MARK: - Step Icon
private func stepIcon(for actionType: QuickWinStep.ActionType) -> Image {
switch actionType {
case .tap:
Image(systemName: "hand.tap")
case .input:
Image(systemName: "keyboard")
case .navigate:
Image(systemName: "arrow.right.circle")
}
}
}SpotlightHintView.swift
import SwiftUI
/// Overlay that cuts out a spotlight circle around a target view,
/// dimming everything else. Shows an instruction callout with an
/// arrow pointing to the target.
///
/// Usage:
/// ```swift
/// // 1. Mark target views with preference key
/// Button("Add") { }
/// .quickWinTarget(id: "addButton")
///
/// // 2. Show spotlight
/// SpotlightHintView(
/// targetID: "addButton",
/// instruction: "Tap here to create your first item"
/// )
/// ```
struct SpotlightHintView: View {
let targetID: String
let instruction: String
var onTapTarget: (() -> Void)?
@State private var targetFrame: CGRect = .zero
var body: some View {
GeometryReader { geometry in
ZStack {
// Dimmed background with spotlight cutout
spotlightMask(in: geometry.size)
.onTapGesture {
// Tapping the spotlight area triggers the action
if targetFrame.contains(CGPoint(x: targetFrame.midX, y: targetFrame.midY)) {
onTapTarget?()
}
}
// Instruction callout
calloutView
.position(calloutPosition(in: geometry.size))
}
}
.ignoresSafeArea()
.onPreferenceChange(QuickWinTargetPreferenceKey.self) { targets in
if let frame = targets[targetID] {
withAnimation(.easeInOut(duration: 0.3)) {
targetFrame = frame
}
}
}
}
// MARK: - Spotlight Mask
@ViewBuilder
private func spotlightMask(in size: CGSize) -> some View {
Canvas { context, canvasSize in
// Full dimmed overlay
context.fill(
Path(CGRect(origin: .zero, size: canvasSize)),
with: .color(.black.opacity(0.6))
)
// Cut out spotlight circle
let spotlightRadius: CGFloat = max(targetFrame.width, targetFrame.height) * 0.75 + 16
let center = CGPoint(x: targetFrame.midX, y: targetFrame.midY)
let spotlightRect = CGRect(
x: center.x - spotlightRadius,
y: center.y - spotlightRadius,
width: spotlightRadius * 2,
height: spotlightRadius * 2
)
context.blendMode = .destinationOut
context.fill(
Path(ellipseIn: spotlightRect),
with: .color(.white)
)
}
.compositingGroup()
.allowsHitTesting(true)
}
// MARK: - Callout
@ViewBuilder
private var calloutView: some View {
VStack(spacing: 8) {
Text(instruction)
.font(.callout.weight(.medium))
.multilineTextAlignment(.center)
.fixedSize(horizontal: false, vertical: true)
.foregroundStyle(.white)
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background {
RoundedRectangle(cornerRadius: 12)
.fill(Color.accentColor)
}
// Arrow pointing to target
Image(systemName: arrowDirection)
.font(.title2)
.foregroundStyle(Color.accentColor)
}
}
// MARK: - Positioning
private var arrowDirection: String {
// Arrow points down if callout is above target, up otherwise
targetFrame.minY > 200 ? "arrow.down" : "arrow.up"
}
private func calloutPosition(in containerSize: CGSize) -> CGPoint {
let x = min(max(targetFrame.midX, 100), containerSize.width - 100)
let isAbove = targetFrame.minY > 200
let y = isAbove
? targetFrame.minY - 80
: targetFrame.maxY + 80
return CGPoint(x: x, y: y)
}
}
// MARK: - Target Preference Key
/// Preference key that collects the frames of quick win target views.
struct QuickWinTargetPreferenceKey: PreferenceKey {
static let defaultValue: [String: CGRect] = [:]
static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {
value.merge(nextValue(), uniquingKeysWith: { $1 })
}
}
// MARK: - Target View Modifier
extension View {
/// Marks this view as a quick win spotlight target.
///
/// ```swift
/// Button("Add") { }
/// .quickWinTarget(id: "addButton")
/// ```
func quickWinTarget(id: String) -> some View {
self.background {
GeometryReader { geometry in
Color.clear.preference(
key: QuickWinTargetPreferenceKey.self,
value: [id: geometry.frame(in: .global)]
)
}
}
}
}QuickWinCelebrationView.swift
import SwiftUI
/// Compact celebration displayed when the user completes a quick win.
///
/// Shows an animated checkmark, congratulations message,
/// completion time stat, and a Continue CTA.
///
/// Usage:
/// ```swift
/// if session.isCompleted {
/// QuickWinCelebrationView(
/// taskTitle: session.completedTask?.title ?? "",
/// completionTime: session.completionTimeSeconds,
/// onContinue: { session.currentTask = nil }
/// )
/// }
/// ```
struct QuickWinCelebrationView: View {
let taskTitle: String
let completionTime: TimeInterval
var onContinue: (() -> Void)?
@State private var showCheckmark = false
@State private var showContent = false
var body: some View {
VStack(spacing: 20) {
// Animated checkmark
checkmarkAnimation
// Message
if showContent {
messageContent
.transition(.opacity.combined(with: .move(edge: .bottom)))
}
}
.padding(32)
.background {
RoundedRectangle(cornerRadius: 24)
.fill(.regularMaterial)
.shadow(color: .black.opacity(0.15), radius: 20, y: 10)
}
.padding(.horizontal, 40)
.onAppear {
withAnimation(.spring(response: 0.5, dampingFraction: 0.6).delay(0.1)) {
showCheckmark = true
}
withAnimation(.easeOut(duration: 0.4).delay(0.5)) {
showContent = true
}
}
}
// MARK: - Checkmark
@ViewBuilder
private var checkmarkAnimation: some View {
ZStack {
Circle()
.fill(Color.green.opacity(0.15))
.frame(width: 80, height: 80)
Circle()
.strokeBorder(Color.green, lineWidth: 3)
.frame(width: 80, height: 80)
Image(systemName: "checkmark")
.font(.system(size: 36, weight: .bold))
.foregroundStyle(.green)
.scaleEffect(showCheckmark ? 1.0 : 0.3)
.opacity(showCheckmark ? 1.0 : 0.0)
}
}
// MARK: - Message Content
@ViewBuilder
private var messageContent: some View {
VStack(spacing: 12) {
Text("Great job!")
.font(.title2.bold())
Text(taskTitle)
.font(.subheadline)
.foregroundStyle(.secondary)
// Completion time stat
HStack(spacing: 4) {
Image(systemName: "clock")
.foregroundStyle(.secondary)
Text("Completed in \(formattedTime)")
.foregroundStyle(.secondary)
}
.font(.caption)
// Continue button
Button {
withAnimation(.easeInOut(duration: 0.25)) {
onContinue?()
}
} label: {
Text("Continue")
.font(.headline)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.padding(.top, 8)
}
}
// MARK: - Formatting
private var formattedTime: String {
let seconds = Int(completionTime)
if seconds < 60 {
return "\(seconds) seconds"
} else {
let minutes = seconds / 60
let remaining = seconds % 60
return "\(minutes)m \(remaining)s"
}
}
}QuickWinModifier.swift
import SwiftUI
/// ViewModifier that triggers a quick win session for new users.
///
/// Attaches to the root view and checks whether the user has
/// already completed the specified quick win. If not, it presents
/// the guide overlay and celebration on completion.
///
/// Usage:
/// ```swift
/// ContentView()
/// .quickWinSession(task: .createFirstNote)
/// ```
struct QuickWinModifier: ViewModifier {
let task: QuickWinTask
var guidanceStyle: GuidanceStyle
@State private var session = QuickWinSession()
enum GuidanceStyle {
case stepByStep
case spotlight
}
func body(content: Content) -> some View {
content
.overlay {
if session.isActive {
quickWinOverlay
}
}
.overlay {
if session.isCompleted {
celebrationOverlay
}
}
.onAppear {
if !session.hasCompletedQuickWin(id: task.id) {
session.start(task: task)
}
}
.environment(session)
}
// MARK: - Overlays
@ViewBuilder
private var quickWinOverlay: some View {
switch guidanceStyle {
case .stepByStep:
QuickWinGuideView(session: session)
case .spotlight:
if let step = session.currentStep {
SpotlightHintView(
targetID: step.targetView,
instruction: step.instruction
) {
withAnimation {
session.completeCurrentStep()
}
}
}
}
}
@ViewBuilder
private var celebrationOverlay: some View {
ZStack {
Color.black.opacity(0.3)
.ignoresSafeArea()
QuickWinCelebrationView(
taskTitle: session.completedTask?.title ?? "",
completionTime: session.completionTimeSeconds
) {
withAnimation(.easeInOut(duration: 0.3)) {
session = QuickWinSession()
}
}
}
.transition(.opacity)
}
}
// MARK: - View Extension
extension View {
/// Attach a quick win session to this view.
///
/// The quick win will activate for new users and show a guided
/// overlay. Returning users who have already completed it will
/// see nothing.
///
/// ```swift
/// ContentView()
/// .quickWinSession(task: .createFirstNote)
/// ```
func quickWinSession(
task: QuickWinTask,
style: QuickWinModifier.GuidanceStyle = .stepByStep
) -> some View {
modifier(QuickWinModifier(task: task, guidanceStyle: style))
}
/// Attach a quick win overlay driven by an external session.
///
/// Use this when you manage the session lifecycle yourself
/// (e.g., starting the session after an existing onboarding flow).
///
/// ```swift
/// MainView()
/// .quickWinOverlay(session: session)
/// ```
func quickWinOverlay(
session: QuickWinSession,
style: QuickWinModifier.GuidanceStyle = .stepByStep
) -> some View {
self
.overlay {
if session.isActive {
switch style {
case .stepByStep:
QuickWinGuideView(session: session)
case .spotlight:
if let step = session.currentStep {
SpotlightHintView(
targetID: step.targetView,
instruction: step.instruction
) {
withAnimation {
session.completeCurrentStep()
}
}
}
}
}
}
.overlay {
if session.isCompleted {
ZStack {
Color.black.opacity(0.3)
.ignoresSafeArea()
QuickWinCelebrationView(
taskTitle: session.completedTask?.title ?? "",
completionTime: session.completionTimeSeconds
)
}
.transition(.opacity)
}
}
}
}