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:
@@ -30,6 +30,8 @@
|
||||
F1A2B3C4D5E6F7A8B9C0D1E2 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A4B5C6D7E8F9A0B1C2D3E4 /* AuthManager.swift */; };
|
||||
F5A6B7C8D9E0F1A2B3C4D5E6 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A8B9C0D1E2F3A4B5C6D7E8 /* AuthView.swift */; };
|
||||
F9B0C1D2E3F4A5B6C7D8E9F0 /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8A9B0C1D2E3F4A5B6C7D8E9 /* ProfileSetupView.swift */; };
|
||||
A1B2C3D4E5F6A7B8C9D0E1F2 /* TutorialManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0B1C2D3E4F5A6B7C8D9E0F1 /* TutorialManager.swift */; };
|
||||
B2C3D4E5F6A7B8C9D0E1F2A3 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C2D3E4F5A6B7C8D9E0F1A2 /* TutorialView.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
@@ -58,6 +60,8 @@
|
||||
F3A4B5C6D7E8F9A0B1C2D3E4 /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
|
||||
F7A8B9C0D1E2F3A4B5C6D7E8 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
|
||||
F8A9B0C1D2E3F4A5B6C7D8E9 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = "<group>"; };
|
||||
A0B1C2D3E4F5A6B7C8D9E0F1 /* TutorialManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialManager.swift; sourceTree = "<group>"; };
|
||||
B1C2D3E4F5A6B7C8D9E0F1A2 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@@ -100,6 +104,7 @@
|
||||
children = (
|
||||
F7A8B9C0D1E2F3A4B5C6D7E8 /* AuthView.swift */,
|
||||
F8A9B0C1D2E3F4A5B6C7D8E9 /* ProfileSetupView.swift */,
|
||||
B1C2D3E4F5A6B7C8D9E0F1A2 /* TutorialView.swift */,
|
||||
768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */,
|
||||
ADF6CCD95A587E26E30F5712 /* CalendarView.swift */,
|
||||
D44530A77DF12A17E52AAF34 /* MatrixView.swift */,
|
||||
@@ -152,6 +157,7 @@
|
||||
B38135952FCAF9CE0037DC41 /* HealthKitManager.swift */,
|
||||
93D045FE3DEB1D22D908A29F /* NotificationManager.swift */,
|
||||
326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */,
|
||||
A0B1C2D3E4F5A6B7C8D9E0F1 /* TutorialManager.swift */,
|
||||
);
|
||||
path = Managers;
|
||||
sourceTree = "<group>";
|
||||
@@ -230,6 +236,8 @@
|
||||
F1A2B3C4D5E6F7A8B9C0D1E2 /* AuthManager.swift in Sources */,
|
||||
F5A6B7C8D9E0F1A2B3C4D5E6 /* AuthView.swift in Sources */,
|
||||
F9B0C1D2E3F4A5B6C7D8E9F0 /* ProfileSetupView.swift in Sources */,
|
||||
A1B2C3D4E5F6A7B8C9D0E1F2 /* TutorialManager.swift in Sources */,
|
||||
B2C3D4E5F6A7B8C9D0E1F2A3 /* TutorialView.swift in Sources */,
|
||||
A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */,
|
||||
9070521B1D36A5551976C275 /* CalendarView.swift in Sources */,
|
||||
13E4B9854F595394FC9D5912 /* ContentView.swift in Sources */,
|
||||
|
||||
@@ -14,6 +14,8 @@ struct ContentView: View {
|
||||
@State private var showQuickAddTask = false
|
||||
@StateObject private var tabState = FloatingTabState()
|
||||
@ObservedObject private var shortcuts = ShortcutHandler.shared
|
||||
@ObservedObject private var tutorial = TutorialManager.shared
|
||||
@State private var tutorialFrames: [Int: CGRect] = [:]
|
||||
@State private var iPadSelection: Int? = 0
|
||||
@State private var calIconImage: Image = ContentView.buildCalIcon()
|
||||
|
||||
@@ -124,8 +126,7 @@ struct ContentView: View {
|
||||
@ViewBuilder
|
||||
private func iPhoneLayout() -> some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
TabView(selection: $selectedTab) {
|
||||
TodayView()
|
||||
TabView(selection: $selectedTab) { TodayView()
|
||||
.environmentObject(taskVM)
|
||||
.environmentObject(workoutVM)
|
||||
.tag(0)
|
||||
@@ -170,7 +171,14 @@ struct ContentView: View {
|
||||
showMatrix: showMatrixTab,
|
||||
showWorkout: showWorkoutTab
|
||||
)
|
||||
|
||||
if tutorial.isActive {
|
||||
TutorialOverlay(frames: tutorialFrames)
|
||||
.transition(.opacity)
|
||||
.zIndex(99)
|
||||
}
|
||||
}
|
||||
.onPreferenceChange(TutorialFrameKey.self) { tutorialFrames = $0 }
|
||||
.onAppear { UITabBar.appearance().isHidden = true }
|
||||
}
|
||||
|
||||
@@ -259,6 +267,7 @@ struct KisaniTabBar: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: 52)
|
||||
.tutorialAnchor(entry.id)
|
||||
}
|
||||
.buttonStyle(PressButtonStyle(scale: 0.90))
|
||||
}
|
||||
|
||||
48
KisaniCal/Managers/TutorialManager.swift
Normal file
48
KisaniCal/Managers/TutorialManager.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,7 @@ struct TodayView: View {
|
||||
}
|
||||
|
||||
FAB { showAddTask = true }
|
||||
.tutorialAnchor(0)
|
||||
.padding(.trailing, 20)
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
|
||||
180
KisaniCal/Views/TutorialView.swift
Normal file
180
KisaniCal/Views/TutorialView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user