Add tooltip coach mark tutorial for new users

- TutorialManager: tracks 4 steps (NLP, Calendar, Matrix, Workout)
  persisted via AppStorage; shown once then never again
- TutorialView: fullscreen overlay with even-odd spotlight cutout,
  animated accent border, ultraThinMaterial callout card, pill progress
  dots, Next/Get started button, and Skip
- TutorialFrameKey: PreferenceKey collects CGRect for each anchor index;
  .tutorialAnchor() modifier wired to FAB (step 0) and all 4 tab buttons
- Callout auto-positions above or below the spotlight based on screen half
- ContentView collects frames via onPreferenceChange and layers the overlay

Co-Authored-By: Kutesir <tqwyy79vzn@privaterelay.appleid.com>
This commit is contained in:
Robin Kutesa
2026-06-01 02:23:12 +03:00
parent 409b4f8ee6
commit 8f8825ac04
5 changed files with 248 additions and 2 deletions

View File

@@ -0,0 +1,48 @@
import SwiftUI
struct TutorialStep {
let title: String
let body: String
let icon: String
let anchorIndex: Int
}
@MainActor
final class TutorialManager: ObservableObject {
static let shared = TutorialManager()
@AppStorage("kisani.tutorial.done.v1") private(set) var completed: Bool = false
@Published var step: Int = 0
let steps: [TutorialStep] = [
.init(title: "Add Tasks with NLP",
body: "Type naturally — \"dentist tomorrow 3pm\" or \"mum's birthday on 8th June every year\" — Kisani parses the date, time, and recurrence for you.",
icon: "sparkles",
anchorIndex: 0),
.init(title: "Calendar",
body: "See tasks and iPhone calendar events side-by-side, day by day. Nothing falls through the cracks.",
icon: "calendar",
anchorIndex: 1),
.init(title: "Eisenhower Matrix",
body: "Sort tasks into Urgent, Schedule, Delegate, or Eliminate. Focus on what actually moves the needle.",
icon: "square.grid.2x2",
anchorIndex: 2),
.init(title: "Workout Tracker",
body: "Build programs, log sets, and keep your streak alive. Your gym routine lives right here.",
icon: "dumbbell",
anchorIndex: 3),
]
var isActive: Bool { !completed }
var current: TutorialStep { steps[min(step, steps.count - 1)] }
var isLast: Bool { step == steps.count - 1 }
func advance() {
if isLast { withAnimation(KisaniSpring.exit) { completed = true } }
else { withAnimation(KisaniSpring.entrance) { step += 1 } }
}
func skip() {
withAnimation(KisaniSpring.exit) { completed = true }
}
}