From 070dd77b4e4a8b5a9783a1f9801dee4c5cd4f812 Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Mon, 1 Jun 2026 19:40:00 +0300 Subject: [PATCH] feat: page tutorials for Today & Workout, FAB fix, quadrant color consistency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add first-visit tutorial cards for TodayView (3 steps) and WorkoutView (3 steps) using new InViewTutorialCard component; each has independent AppStorage completion flag - Fix FAB hidden behind custom tab bar in MatrixView by moving NavigationStack inside outer ZStack so FAB sits at the correct safe area level - Fix task colors throughout app to use quadrant.color/softColor instead of taskColor — tasks now consistently show orange/yellow/blue/green matching their matrix quadrant in Today timeline, calendar dots, and day view - Fix WorkoutView background (move ignoresSafeArea out of ZStack children into .background modifier) Co-Authored-By: Kutesir --- KisaniCal/Managers/TutorialManager.swift | 59 +++++++++++++++++++ KisaniCal/Views/CalendarView.swift | 12 ++-- KisaniCal/Views/MatrixView.swift | 44 +++++++------- KisaniCal/Views/TodayView.swift | 30 +++++++--- KisaniCal/Views/TutorialView.swift | 75 ++++++++++++++++++++++++ KisaniCal/Views/WorkoutView.swift | 19 +++++- 6 files changed, 202 insertions(+), 37 deletions(-) diff --git a/KisaniCal/Managers/TutorialManager.swift b/KisaniCal/Managers/TutorialManager.swift index c9d5714..b0c6bd7 100644 --- a/KisaniCal/Managers/TutorialManager.swift +++ b/KisaniCal/Managers/TutorialManager.swift @@ -7,10 +7,17 @@ struct TutorialStep { let anchorIndex: Int } +struct PageTip { + let title: String + let body: String + let icon: String +} + @MainActor final class TutorialManager: ObservableObject { static let shared = TutorialManager() + // MARK: - Tab onboarding (existing) @AppStorage("kisani.tutorial.done.v1") private(set) var completed: Bool = false @Published var step: Int = 0 @@ -45,4 +52,56 @@ final class TutorialManager: ObservableObject { func skip() { withAnimation(KisaniSpring.exit) { completed = true } } + + // MARK: - Today view tutorial + @AppStorage("kisani.tutorial.today.v1") var todayDone: Bool = false + @Published var todayStep: Int = 0 + + let todayTips: [PageTip] = [ + .init(title: "Today's Focus", + body: "Everything due today lives here — overdue items appear at the top so nothing slips through. Tap any task to open it.", + icon: "checkmark.circle"), + .init(title: "Add Tasks Instantly", + body: "Tap + and type naturally. \"Doctor Wednesday 2pm\" or \"Neo's birthday 15 June yearly\" — Kisani handles dates, times, and recurrence.", + icon: "sparkles"), + .init(title: "Quick Actions", + body: "Long-press a task to pin, postpone, or move it to a matrix quadrant. Swipe left to delete.", + icon: "hand.tap"), + ] + + var todayIsActive: Bool { !todayDone } + var todayCurrent: PageTip { todayTips[min(todayStep, todayTips.count - 1)] } + var todayIsLast: Bool { todayStep == todayTips.count - 1 } + + func advanceToday() { + if todayIsLast { withAnimation(KisaniSpring.exit) { todayDone = true } } + else { withAnimation(KisaniSpring.entrance) { todayStep += 1 } } + } + func skipToday() { withAnimation(KisaniSpring.exit) { todayDone = true } } + + // MARK: - Workout view tutorial + @AppStorage("kisani.tutorial.workout.v1") var workoutDone: Bool = false + @Published var workoutStep: Int = 0 + + let workoutTips: [PageTip] = [ + .init(title: "Set Up Your Programs", + body: "Tap ··· to create workout programs and assign them to days of the week. Each program holds your exercises and target sets.", + icon: "calendar.badge.plus"), + .init(title: "Log Your Sets", + body: "Tap each set row to mark it complete as you train. The dot grid at the top shows your session progress in real-time.", + icon: "checkmark.square"), + .init(title: "Build Your Streak", + body: "Complete every scheduled workout day to grow your streak. Consistency compounds — your streak is shown at the top.", + icon: "flame"), + ] + + var workoutIsActive: Bool { !workoutDone } + var workoutCurrent: PageTip { workoutTips[min(workoutStep, workoutTips.count - 1)] } + var workoutIsLast: Bool { workoutStep == workoutTips.count - 1 } + + func advanceWorkout() { + if workoutIsLast { withAnimation(KisaniSpring.exit) { workoutDone = true } } + else { withAnimation(KisaniSpring.entrance) { workoutStep += 1 } } + } + func skipWorkout() { withAnimation(KisaniSpring.exit) { workoutDone = true } } } diff --git a/KisaniCal/Views/CalendarView.swift b/KisaniCal/Views/CalendarView.swift index 3bcfea1..e9829fe 100644 --- a/KisaniCal/Views/CalendarView.swift +++ b/KisaniCal/Views/CalendarView.swift @@ -368,7 +368,7 @@ struct CalendarView: View { .filter { !$0.isComplete } .filter { guard let d = $0.dueDate else { return false }; return cal.isDate(d, inSameDayAs: date) } .prefix(max(0, 3 - dots.count)) - .map { $0.taskColor.color() } + .map { $0.quadrant.color } dots.append(contentsOf: taskDots) return dots } @@ -538,7 +538,7 @@ struct CalendarView: View { ForEach(dayTasks) { task in HStack(spacing: 12) { RoundedRectangle(cornerRadius: 3) - .strokeBorder(task.taskColor.color(), lineWidth: 1.5) + .strokeBorder(task.quadrant.color, lineWidth: 1.5) .frame(width: 18, height: 18) Text(task.title).font(AppFonts.sans(13)).foregroundColor(AppColors.text(cs)) Spacer() @@ -852,7 +852,7 @@ struct CalendarView: View { guard let d = task.dueDate, cal.isDate(d, inSameDayAs: date) else { continue } let h = cal.component(.hour, from: d), m = cal.component(.minute, from: d) guard h != 0 || m != 0 else { continue } - items.append(CVTimeItem(title: task.title, color: task.taskColor.color(), startMin: h * 60 + m, endMin: h * 60 + m + 30)) + items.append(CVTimeItem(title: task.title, color: task.quadrant.color, startMin: h * 60 + m, endMin: h * 60 + m + 30)) } for ev in calStore.isAuthorized ? calStore.events(for: date) : [] { guard let s = ev.startDate, let e = ev.endDate, !ev.isAllDay else { continue } @@ -1432,7 +1432,7 @@ struct DayTimelineView: View { out.append(DayTLEntry( timeLabel: "All Day", title: t.title, - color: t.isComplete ? AppColors.green : t.taskColor.color(), + color: t.isComplete ? AppColors.green : t.quadrant.color, isComplete: t.isComplete, isAllDay: true, sortKey: -1, sortGroup: grp, taskRef: t )) @@ -1469,7 +1469,7 @@ struct DayTimelineView: View { let grp = t.isComplete ? 1 : 0 out.append(DayTLEntry( timeLabel: "\(hd)\(ms)", ampm: h < 12 ? "AM" : "PM", - title: t.title, color: t.isComplete ? AppColors.green : t.taskColor.color(), + title: t.title, color: t.isComplete ? AppColors.green : t.quadrant.color, isComplete: t.isComplete, isAllDay: false, sortKey: h * 60 + m, sortGroup: grp, taskRef: t )) @@ -1693,7 +1693,7 @@ private struct CalTaskRow: View { .frame(width: 22, height: 22) RoundedRectangle(cornerRadius: 6) .strokeBorder( - task.isComplete ? Color.clear : task.taskColor.color(), + task.isComplete ? Color.clear : task.quadrant.color, lineWidth: 1.5 ) .frame(width: 22, height: 22) diff --git a/KisaniCal/Views/MatrixView.swift b/KisaniCal/Views/MatrixView.swift index e503964..f9a1750 100644 --- a/KisaniCal/Views/MatrixView.swift +++ b/KisaniCal/Views/MatrixView.swift @@ -11,8 +11,8 @@ struct MatrixView: View { @State private var showDrill = false var body: some View { - NavigationStack { ZStack(alignment: .bottomTrailing) { + NavigationStack { VStack(spacing: 0) { // ── Header ── HStack { @@ -132,30 +132,30 @@ struct MatrixView: View { .padding(.bottom, 8) } .frame(maxHeight: .infinity) - - FAB { showAddTask = true } - .padding(.trailing, 20) - .padding(.bottom, 10) - } - .background(AppColors.background(cs).ignoresSafeArea()) - .sheet(isPresented: $showAddTask) { - AddTaskSheet() - .environmentObject(taskVM) - .presentationDetents([.height(150)]) - .presentationDragIndicator(.visible) - } - .sheet(isPresented: $showUserGuide) { - MatrixUserGuideSheet() - .presentationDetents([.medium]) - .presentationDragIndicator(.visible) - } - .navigationDestination(isPresented: $showDrill) { - if let q = drillQuadrant { - QuadrantDetailView(quadrant: q) + .sheet(isPresented: $showAddTask) { + AddTaskSheet() .environmentObject(taskVM) + .presentationDetents([.height(150)]) + .presentationDragIndicator(.visible) + } + .sheet(isPresented: $showUserGuide) { + MatrixUserGuideSheet() + .presentationDetents([.medium]) + .presentationDragIndicator(.visible) + } + .navigationDestination(isPresented: $showDrill) { + if let q = drillQuadrant { + QuadrantDetailView(quadrant: q) + .environmentObject(taskVM) + } } - } } // NavigationStack + + FAB { showAddTask = true } + .padding(.trailing, 20) + .padding(.bottom, 10) + } // outer ZStack + .background(AppColors.background(cs).ignoresSafeArea()) } } diff --git a/KisaniCal/Views/TodayView.swift b/KisaniCal/Views/TodayView.swift index d9cffee..c5c411a 100644 --- a/KisaniCal/Views/TodayView.swift +++ b/KisaniCal/Views/TodayView.swift @@ -10,6 +10,7 @@ struct TodayView: View { @State private var showAddTask = false @State private var showWorkoutSession = false @AppStorage("todayHideCompleted") private var hideCompleted = false + @ObservedObject private var tm = TutorialManager.shared private let dateFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "EEEE, MMM d"; return f @@ -140,6 +141,21 @@ struct TodayView: View { .tutorialAnchor(0) .padding(.trailing, 20) .padding(.bottom, 10) + + if tm.todayIsActive { + VStack { + Spacer() + InViewTutorialCard( + tips: tm.todayTips, + step: $tm.todayStep, + onAdvance: { tm.advanceToday() }, + onSkip: { tm.skipToday() } + ) + .padding(.bottom, 84) + } + .transition(.opacity) + .zIndex(10) + } } .background(AppColors.background(cs).ignoresSafeArea()) .sheet(isPresented: $showAddTask) { @@ -213,7 +229,7 @@ struct UnifiedTodayTimelineView: View { timeLabel: timeLabel, ampm: ampm, title: task.title, subtitle: task.isRecurring ? "Annual" : nil, - color: task.taskColor.color(), + color: task.quadrant.color, isComplete: task.isComplete, isAllDay: task.isComplete ? false : !task.hasTime, task: task @@ -396,7 +412,7 @@ private struct TodayTLRow: View { TagChip( text: entry.task.category.rawValue, color: entry.color, - bg: entry.task.taskColor.soft() + bg: entry.task.quadrant.softColor ) Button(action: onToggle) { Image(systemName: entry.isComplete ? "checkmark.circle.fill" : "circle") @@ -997,10 +1013,10 @@ struct TaskRowView: View { Button(action: onToggle) { ZStack { Circle() - .fill(task.taskColor.soft()) + .fill(task.quadrant.softColor) .frame(width: 22, height: 22) Circle() - .stroke(task.taskColor.color(), lineWidth: 2) + .stroke(task.quadrant.color, lineWidth: 2) .frame(width: 22, height: 22) } .frame(width: 36, height: 44) @@ -1032,8 +1048,8 @@ struct TaskRowView: View { TagChip( text: task.category.rawValue, - color: task.taskColor.color(), - bg: task.taskColor.soft() + color: task.quadrant.color, + bg: task.quadrant.softColor ) .padding(.trailing, 9) } @@ -1041,7 +1057,7 @@ struct TaskRowView: View { .background(AppColors.surface(cs)) .overlay(alignment: .leading) { Rectangle() - .fill(task.taskColor.color()) + .fill(task.quadrant.color) .frame(width: 2.5) } .clipShape(RoundedRectangle(cornerRadius: AppRadius.small)) diff --git a/KisaniCal/Views/TutorialView.swift b/KisaniCal/Views/TutorialView.swift index 51f6408..957a24d 100644 --- a/KisaniCal/Views/TutorialView.swift +++ b/KisaniCal/Views/TutorialView.swift @@ -178,3 +178,78 @@ struct TutorialOverlay: View { .position(x: cx, y: cy) } } + +// MARK: - In-View Tutorial Card (Today / Workout page tutorials) + +struct InViewTutorialCard: View { + let tips: [PageTip] + @Binding var step: Int + let onAdvance: () -> Void + let onSkip: () -> Void + + private var current: PageTip { tips[min(step, tips.count - 1)] } + private var isLast: Bool { step == tips.count - 1 } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + ZStack { + RoundedRectangle(cornerRadius: 9) + .fill(AppColors.accent.opacity(0.18)) + .frame(width: 32, height: 32) + Image(systemName: current.icon) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(AppColors.accent) + } + Text(current.title) + .font(AppFonts.sans(15, weight: .bold)) + .foregroundColor(.white) + Spacer() + Button("Skip") { onSkip() } + .font(AppFonts.sans(11)) + .foregroundColor(.white.opacity(0.45)) + .buttonStyle(.plain) + } + + Text(current.body) + .font(AppFonts.sans(13)) + .foregroundColor(.white.opacity(0.80)) + .fixedSize(horizontal: false, vertical: true) + .lineSpacing(2) + + HStack { + HStack(spacing: 5) { + ForEach(0..