feat: page tutorials for Today & Workout, FAB fix, quadrant color consistency

- 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 <tqwyy79vzn@privaterelay.appleid.com>
This commit is contained in:
Robin Kutesa
2026-06-01 19:40:00 +03:00
parent f96f09ec29
commit 070dd77b4e
6 changed files with 202 additions and 37 deletions

View File

@@ -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 } }
}

View File

@@ -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)

View File

@@ -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,12 +132,6 @@ 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)
@@ -156,6 +150,12 @@ struct MatrixView: View {
}
}
} // NavigationStack
FAB { showAddTask = true }
.padding(.trailing, 20)
.padding(.bottom, 10)
} // outer ZStack
.background(AppColors.background(cs).ignoresSafeArea())
}
}

View File

@@ -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))

View File

@@ -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..<tips.count, id: \.self) { i in
Capsule()
.fill(i == step ? AppColors.accent : Color.white.opacity(0.20))
.frame(width: i == step ? 18 : 6, height: 6)
.animation(KisaniSpring.micro, value: step)
}
}
Spacer()
Button(action: onAdvance) {
HStack(spacing: 5) {
Text(isLast ? "Got it" : "Next")
.font(AppFonts.sans(14, weight: .semibold))
Image(systemName: 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)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20))
.overlay(RoundedRectangle(cornerRadius: 20).stroke(Color.white.opacity(0.10), lineWidth: 1))
.padding(.horizontal, 20)
.id(step)
.transition(.asymmetric(
insertion: .opacity.combined(with: .scale(scale: 0.97)),
removal: .opacity
))
}
}

View File

@@ -62,6 +62,7 @@ struct WorkoutView: View {
@State private var showManage = false
@State private var showAddSection = false
@State private var isReordering = false
@ObservedObject private var tm = TutorialManager.shared
private let restTick = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
private var headerDate: String {
@@ -71,8 +72,6 @@ struct WorkoutView: View {
var body: some View {
ZStack(alignment: .bottomTrailing) {
AppColors.background(cs).ignoresSafeArea()
if isReordering {
Color.black.opacity(0.001)
.ignoresSafeArea()
@@ -219,7 +218,23 @@ struct WorkoutView: View {
.foregroundColor(AppColors.accent)
}
}
if tm.workoutIsActive {
VStack {
Spacer()
InViewTutorialCard(
tips: tm.workoutTips,
step: $tm.workoutStep,
onAdvance: { tm.advanceWorkout() },
onSkip: { tm.skipWorkout() }
)
.padding(.bottom, 84)
}
.transition(.opacity)
.zIndex(10)
}
}
.background(AppColors.background(cs).ignoresSafeArea())
.overlay(alignment: .top) {
if isReordering {
Button {