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

@@ -127,6 +127,7 @@ struct TodayView: View {
}
FAB { showAddTask = true }
.tutorialAnchor(0)
.padding(.trailing, 20)
.padding(.bottom, 10)
}

View File

@@ -0,0 +1,180 @@
import SwiftUI
// MARK: - Preference Key
struct TutorialFrameKey: PreferenceKey {
static var defaultValue: [Int: CGRect] = [:]
static func reduce(value: inout [Int: CGRect], nextValue: () -> [Int: CGRect]) {
value.merge(nextValue()) { _, new in new }
}
}
extension View {
func tutorialAnchor(_ index: Int) -> some View {
background(GeometryReader { geo in
Color.clear.preference(
key: TutorialFrameKey.self,
value: [index: geo.frame(in: .global)]
)
})
}
}
// MARK: - Overlay
struct TutorialOverlay: View {
@ObservedObject private var tm = TutorialManager.shared
let frames: [Int: CGRect]
@Environment(\.colorScheme) private var cs
private var spotlight: CGRect? {
guard let f = frames[tm.current.anchorIndex], f.width > 4 else { return nil }
return f.insetBy(dx: -12, dy: -8)
}
var body: some View {
GeometryReader { geo in
ZStack {
dimLayer(geo: geo)
spotlightBorder
skipButton
callout(geo: geo)
.id(tm.step)
.transition(.asymmetric(
insertion: .opacity.combined(with: .scale(scale: 0.96)),
removal: .opacity
))
}
.ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture { }
.animation(KisaniSpring.snappy, value: tm.step)
}
}
// MARK: Dim
@ViewBuilder
private func dimLayer(geo: GeometryProxy) -> some View {
if let sp = spotlight {
Path { p in
p.addRect(CGRect(origin: .zero, size: geo.size))
p.addRoundedRect(in: sp, cornerSize: .init(width: 16, height: 16))
}
.fill(style: FillStyle(eoFill: true))
.foregroundColor(.black.opacity(0.72))
.animation(KisaniSpring.snappy, value: sp.origin)
} else {
Color.black.opacity(0.72)
}
}
// MARK: Spotlight border
@ViewBuilder
private var spotlightBorder: some View {
if let sp = spotlight {
RoundedRectangle(cornerRadius: 16)
.stroke(AppColors.accent, lineWidth: 2)
.frame(width: sp.width, height: sp.height)
.position(x: sp.midX, y: sp.midY)
.animation(KisaniSpring.snappy, value: sp.origin)
}
}
// MARK: Skip
private var skipButton: some View {
VStack {
HStack {
Spacer()
Button("Skip") { tm.skip() }
.font(AppFonts.sans(13))
.foregroundColor(.white.opacity(0.50))
.padding(.top, 58)
.padding(.trailing, 24)
}
Spacer()
}
}
// MARK: Callout
private func callout(geo: GeometryProxy) -> some View {
let step = tm.current
let sp = spotlight
let maxW = min(geo.size.width - 48, 320.0)
let estH: CGFloat = 160
let midY = sp?.midY ?? geo.size.height / 2
let above = midY > geo.size.height * 0.5
let cy: CGFloat = above
? max(estH / 2 + 56, (sp?.minY ?? midY) - 24 - estH / 2)
: min(geo.size.height - estH / 2 - 20, (sp?.maxY ?? midY) + 24 + estH / 2)
let midX = sp?.midX ?? geo.size.width / 2
let cx = min(max(maxW / 2 + 24, midX), geo.size.width - maxW / 2 - 24)
return VStack(alignment: .leading, spacing: 12) {
// Header
HStack(spacing: 10) {
ZStack {
RoundedRectangle(cornerRadius: 9)
.fill(AppColors.accent.opacity(0.18))
.frame(width: 32, height: 32)
Image(systemName: step.icon)
.font(.system(size: 14, weight: .semibold))
.foregroundColor(AppColors.accent)
}
Text(step.title)
.font(AppFonts.sans(15, weight: .bold))
.foregroundColor(.white)
Spacer()
Text("\(tm.step + 1) / \(tm.steps.count)")
.font(AppFonts.mono(9.5, weight: .bold))
.foregroundColor(.white.opacity(0.35))
}
// Body
Text(step.body)
.font(AppFonts.sans(13))
.foregroundColor(.white.opacity(0.80))
.fixedSize(horizontal: false, vertical: true)
.lineSpacing(2)
// Progress + action
HStack(spacing: 0) {
HStack(spacing: 5) {
ForEach(0..<tm.steps.count, id: \.self) { i in
Capsule()
.fill(i == tm.step ? AppColors.accent : Color.white.opacity(0.20))
.frame(width: i == tm.step ? 18 : 6, height: 6)
.animation(KisaniSpring.micro, value: tm.step)
}
}
Spacer()
Button { tm.advance() } label: {
HStack(spacing: 5) {
Text(tm.isLast ? "Get started" : "Next")
.font(AppFonts.sans(14, weight: .semibold))
Image(systemName: tm.isLast ? "checkmark" : "arrow.right")
.font(.system(size: 11, weight: .bold))
}
.foregroundColor(.white)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(AppColors.accent)
.clipShape(Capsule())
}
.buttonStyle(PressButtonStyle(scale: 0.94))
}
}
.padding(16)
.frame(maxWidth: maxW)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20))
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.white.opacity(0.10), lineWidth: 1))
.position(x: cx, y: cy)
}
}