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:
@@ -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 } }
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user