diff --git a/KisaniCal.xcodeproj/project.pbxproj b/KisaniCal.xcodeproj/project.pbxproj index 0065bae..3f1675d 100644 --- a/KisaniCal.xcodeproj/project.pbxproj +++ b/KisaniCal.xcodeproj/project.pbxproj @@ -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 = ""; }; F7A8B9C0D1E2F3A4B5C6D7E8 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; F8A9B0C1D2E3F4A5B6C7D8E9 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = ""; }; + A0B1C2D3E4F5A6B7C8D9E0F1 /* TutorialManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialManager.swift; sourceTree = ""; }; + B1C2D3E4F5A6B7C8D9E0F1A2 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = ""; }; /* 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 = ""; @@ -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 */, diff --git a/KisaniCal/ContentView.swift b/KisaniCal/ContentView.swift index 6709cea..2002046 100644 --- a/KisaniCal/ContentView.swift +++ b/KisaniCal/ContentView.swift @@ -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)) } diff --git a/KisaniCal/Managers/TutorialManager.swift b/KisaniCal/Managers/TutorialManager.swift new file mode 100644 index 0000000..c9d5714 --- /dev/null +++ b/KisaniCal/Managers/TutorialManager.swift @@ -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 } + } +} diff --git a/KisaniCal/Views/TodayView.swift b/KisaniCal/Views/TodayView.swift index f62d23c..e9ba6f6 100644 --- a/KisaniCal/Views/TodayView.swift +++ b/KisaniCal/Views/TodayView.swift @@ -127,6 +127,7 @@ struct TodayView: View { } FAB { showAddTask = true } + .tutorialAnchor(0) .padding(.trailing, 20) .padding(.bottom, 10) } diff --git a/KisaniCal/Views/TutorialView.swift b/KisaniCal/Views/TutorialView.swift new file mode 100644 index 0000000..51f6408 --- /dev/null +++ b/KisaniCal/Views/TutorialView.swift @@ -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..