feat: replace day strip with vertical timeline in month view
Selected-day panel below the month grid now renders as a proper vertical timeline: time labels in the left gutter, continuous track line with circle markers, and white cards for each item. All-day tasks and events get a stroke circle; timed entries get a solid colored dot; workout gets a dumbbell icon; completed items show a checkmark circle with strikethrough. Tasks can be toggled directly from the timeline card. Cal events show end time as subtitle. Empty state shows a calendar icon.
This commit is contained in:
@@ -114,6 +114,7 @@ struct CalendarView: View {
|
|||||||
return c.date(from: c.dateComponents([.year, .month], from: Date())) ?? Date()
|
return c.date(from: c.dateComponents([.year, .month], from: Date())) ?? Date()
|
||||||
}()
|
}()
|
||||||
@State private var viewMode: CalendarViewMode = .month
|
@State private var viewMode: CalendarViewMode = .month
|
||||||
|
@State private var calTaskListMode: Bool = false
|
||||||
@State private var showAddTask = false
|
@State private var showAddTask = false
|
||||||
@State private var showCalendarConnect = false
|
@State private var showCalendarConnect = false
|
||||||
@State private var showWorkoutSession = false
|
@State private var showWorkoutSession = false
|
||||||
@@ -127,31 +128,49 @@ struct CalendarView: View {
|
|||||||
|
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// ── Header ──
|
// ── Header ──
|
||||||
HStack {
|
HStack(spacing: 0) {
|
||||||
|
// Left circle: view mode picker
|
||||||
|
Menu {
|
||||||
|
Picker(selection: $viewMode, label: EmptyView()) {
|
||||||
|
ForEach(CalendarViewMode.allCases, id: \.self) { mode in
|
||||||
|
Label(mode.rawValue, systemImage: mode.sfSymbol).tag(mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: viewMode.sfSymbol)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundColor(AppColors.text(cs))
|
||||||
|
.frame(width: 46, height: 46)
|
||||||
|
.background(AppColors.surface(cs))
|
||||||
|
.clipShape(Circle())
|
||||||
|
.contentShape(Circle())
|
||||||
|
.shadow(color: .black.opacity(0.08), radius: 10, x: 0, y: 3)
|
||||||
|
}
|
||||||
|
.menuStyle(.automatic)
|
||||||
|
|
||||||
|
// Center: month name
|
||||||
|
Spacer()
|
||||||
Text(monthShortTitle)
|
Text(monthShortTitle)
|
||||||
.font(AppFonts.sans(26, weight: .bold))
|
.font(AppFonts.sans(17, weight: .semibold))
|
||||||
.foregroundColor(AppColors.text(cs))
|
.foregroundColor(AppColors.text(cs))
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
// Right: controls pill (view mode picker + more)
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
// View mode picker
|
// Right pill left button: task list toggle
|
||||||
Menu {
|
Button {
|
||||||
Picker(selection: $viewMode, label: EmptyView()) {
|
withAnimation(KisaniSpring.snappy) { calTaskListMode.toggle() }
|
||||||
ForEach(CalendarViewMode.allCases, id: \.self) { mode in
|
|
||||||
Label(mode.rawValue, systemImage: mode.sfSymbol).tag(mode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "slider.horizontal.3")
|
Image(systemName: calTaskListMode ? "calendar" : "slider.horizontal.3")
|
||||||
.font(.system(size: 14, weight: .medium))
|
.font(.system(size: 14, weight: calTaskListMode ? .semibold : .medium))
|
||||||
.foregroundColor(AppColors.text(cs))
|
.foregroundColor(calTaskListMode ? AppColors.accent : AppColors.text(cs))
|
||||||
.frame(width: 46, height: 36)
|
.frame(width: 46, height: 36)
|
||||||
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
// Divider
|
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(AppColors.border(cs))
|
.fill(AppColors.border(cs))
|
||||||
.frame(width: 1, height: 18)
|
.frame(width: 1, height: 18)
|
||||||
// More options
|
|
||||||
Menu {
|
Menu {
|
||||||
Button { } label: { Label("Filter View Range", systemImage: "line.3.horizontal.decrease") }
|
Button { } label: { Label("Filter View Range", systemImage: "line.3.horizontal.decrease") }
|
||||||
Button { } label: { Label("View Options", systemImage: "gearshape.2") }
|
Button { } label: { Label("View Options", systemImage: "gearshape.2") }
|
||||||
@@ -169,8 +188,9 @@ struct CalendarView: View {
|
|||||||
.font(.system(size: 14, weight: .medium))
|
.font(.system(size: 14, weight: .medium))
|
||||||
.foregroundColor(AppColors.text(cs))
|
.foregroundColor(AppColors.text(cs))
|
||||||
.frame(width: 46, height: 36)
|
.frame(width: 46, height: 36)
|
||||||
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.menuStyle(.automatic)
|
||||||
}
|
}
|
||||||
.background(AppColors.surface2(cs))
|
.background(AppColors.surface2(cs))
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
@@ -178,10 +198,17 @@ struct CalendarView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, 18).padding(.top, 8)
|
.padding(.horizontal, 18).padding(.top, 8)
|
||||||
|
|
||||||
calendarContent
|
if calTaskListMode {
|
||||||
|
cvWeekStrip.padding(.top, 12).padding(.bottom, 6)
|
||||||
|
Divider().background(AppColors.border(cs)).padding(.horizontal, 16)
|
||||||
|
calDayTaskListView
|
||||||
|
} else {
|
||||||
|
calendarContent
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
FAB { showAddTask = true }.padding(.trailing, 20).padding(.bottom, 10)
|
FAB { showAddTask = true }
|
||||||
|
.padding(.trailing, 20).padding(.bottom, 10)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showAddTask) {
|
.sheet(isPresented: $showAddTask) {
|
||||||
AddTaskSheet(prefilledDate: selectedDate)
|
AddTaskSheet(prefilledDate: selectedDate)
|
||||||
@@ -257,6 +284,67 @@ struct CalendarView: View {
|
|||||||
return dots
|
return dots
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Task list mode (toggle view)
|
||||||
|
|
||||||
|
private var calDayTaskListView: some View {
|
||||||
|
let all = taskVM.tasks.filter { t in
|
||||||
|
guard let d = t.dueDate else { return false }
|
||||||
|
return cal.isDate(d, inSameDayAs: selectedDate)
|
||||||
|
}
|
||||||
|
let incomplete = all.filter { !$0.isComplete }
|
||||||
|
let complete = all.filter { $0.isComplete }
|
||||||
|
|
||||||
|
return ScrollView(showsIndicators: false) {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
if !incomplete.isEmpty {
|
||||||
|
Text("Today")
|
||||||
|
.font(AppFonts.sans(12, weight: .semibold))
|
||||||
|
.foregroundColor(AppColors.text2(cs))
|
||||||
|
.padding(.horizontal, 18).padding(.top, 14).padding(.bottom, 8)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(Array(incomplete.enumerated()), id: \.element.id) { i, task in
|
||||||
|
CalTaskRow(task: task) { taskVM.toggle(task) }
|
||||||
|
if i < incomplete.count - 1 {
|
||||||
|
Divider().background(AppColors.border(cs)).padding(.leading, 52)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(AppColors.surface(cs))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !complete.isEmpty {
|
||||||
|
Text("Completed")
|
||||||
|
.font(AppFonts.sans(12, weight: .semibold))
|
||||||
|
.foregroundColor(AppColors.text3(cs))
|
||||||
|
.padding(.horizontal, 18).padding(.top, 16).padding(.bottom, 8)
|
||||||
|
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
ForEach(Array(complete.enumerated()), id: \.element.id) { i, task in
|
||||||
|
CalTaskRow(task: task) { taskVM.toggle(task) }
|
||||||
|
if i < complete.count - 1 {
|
||||||
|
Divider().background(AppColors.border(cs)).padding(.leading, 52)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.background(AppColors.surface(cs))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: AppRadius.large))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: AppRadius.large).stroke(AppColors.border(cs), lineWidth: 1))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
if incomplete.isEmpty && complete.isEmpty {
|
||||||
|
cvEmptyDay.padding(.top, 48)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer().frame(height: 90)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Content switcher
|
// MARK: - Content switcher
|
||||||
|
|
||||||
@ViewBuilder private var calendarContent: some View {
|
@ViewBuilder private var calendarContent: some View {
|
||||||
@@ -303,18 +391,24 @@ struct CalendarView: View {
|
|||||||
Divider().background(AppColors.border(cs)).padding(.horizontal, 16).padding(.top, 6)
|
Divider().background(AppColors.border(cs)).padding(.horizontal, 16).padding(.top, 6)
|
||||||
|
|
||||||
ScrollView(showsIndicators: false) {
|
ScrollView(showsIndicators: false) {
|
||||||
DayStripView(
|
DayTimelineView(
|
||||||
date: selectedDate,
|
date: selectedDate,
|
||||||
tasks: taskVM.tasks.filter { !$0.isComplete },
|
tasks: taskVM.tasks.filter { t in
|
||||||
|
guard let d = t.dueDate else { return false }
|
||||||
|
return cal.isDate(d, inSameDayAs: selectedDate)
|
||||||
|
},
|
||||||
calEvents: calStore.isAuthorized ? calStore.events(for: selectedDate) : [],
|
calEvents: calStore.isAuthorized ? calStore.events(for: selectedDate) : [],
|
||||||
workout: workoutVM.program(for: selectedDate),
|
workout: workoutVM.program(for: selectedDate),
|
||||||
onWorkoutTap: {
|
onWorkoutTap: {
|
||||||
if let w = workoutVM.program(for: selectedDate),
|
if let w = workoutVM.program(for: selectedDate),
|
||||||
workoutVM.activeProgramId != w.id { workoutVM.switchProgram(to: w.id) }
|
workoutVM.activeProgramId != w.id { workoutVM.switchProgram(to: w.id) }
|
||||||
showWorkoutSession = true
|
showWorkoutSession = true
|
||||||
|
},
|
||||||
|
onToggle: { task in
|
||||||
|
withAnimation(KisaniSpring.snappy) { taskVM.toggle(task) }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.padding(.horizontal, 16).padding(.vertical, 10)
|
.padding(.horizontal, 16).padding(.top, 12).padding(.bottom, 20)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -868,169 +962,319 @@ struct DayCell: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Day Strip
|
// MARK: - Day Timeline
|
||||||
struct DayStripView: View {
|
|
||||||
|
private struct DayTLEntry: Identifiable {
|
||||||
|
var id = UUID()
|
||||||
|
var timeLabel: String
|
||||||
|
var ampm: String?
|
||||||
|
var title: String
|
||||||
|
var subtitle: String?
|
||||||
|
var color: Color
|
||||||
|
var isComplete: Bool
|
||||||
|
var isAllDay: Bool
|
||||||
|
var sortKey: Int // < 0 = all-day/special; else minute-of-day
|
||||||
|
var taskRef: TaskItem?
|
||||||
|
var workoutRef: WorkoutProgram?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DayTimelineView: View {
|
||||||
@Environment(\.colorScheme) private var cs
|
@Environment(\.colorScheme) private var cs
|
||||||
let date: Date
|
|
||||||
let tasks: [TaskItem]
|
let date: Date
|
||||||
let calEvents: [EKEvent]
|
let tasks: [TaskItem]
|
||||||
var workout: WorkoutProgram? = nil
|
let calEvents: [EKEvent]
|
||||||
|
let workout: WorkoutProgram?
|
||||||
var onWorkoutTap: (() -> Void)? = nil
|
var onWorkoutTap: (() -> Void)? = nil
|
||||||
|
var onToggle: ((TaskItem) -> Void)? = nil
|
||||||
|
|
||||||
private let cal = Calendar.current
|
private let cal = Calendar.current
|
||||||
private let fmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "EEE d"; return f }()
|
|
||||||
private let timeFmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "h:mm a"; return f }()
|
|
||||||
|
|
||||||
var dayTasks: [TaskItem] {
|
private var entries: [DayTLEntry] {
|
||||||
tasks.filter { guard let d = $0.dueDate else { return false }; return cal.isDate(d, inSameDayAs: date) }
|
var out: [DayTLEntry] = []
|
||||||
|
|
||||||
|
// All-day tasks (no specific time)
|
||||||
|
for t in tasks where !t.hasTime {
|
||||||
|
out.append(DayTLEntry(
|
||||||
|
timeLabel: "All Day",
|
||||||
|
title: t.title,
|
||||||
|
color: t.taskColor.color(),
|
||||||
|
isComplete: t.isComplete,
|
||||||
|
isAllDay: true, sortKey: -1, taskRef: t
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// All-day calendar events
|
||||||
|
for ev in calEvents where ev.isAllDay {
|
||||||
|
out.append(DayTLEntry(
|
||||||
|
timeLabel: "All Day",
|
||||||
|
title: ev.title ?? "Event",
|
||||||
|
color: Color(cgColor: ev.calendar.cgColor),
|
||||||
|
isComplete: false, isAllDay: true, sortKey: -1
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workout
|
||||||
|
if let w = workout {
|
||||||
|
let done = w.doneSets == w.totalSets && w.totalSets > 0
|
||||||
|
out.append(DayTLEntry(
|
||||||
|
timeLabel: "Workout",
|
||||||
|
title: w.name,
|
||||||
|
subtitle: "\(w.totalExercises) exercise\(w.totalExercises == 1 ? "" : "s")",
|
||||||
|
color: AppColors.blue,
|
||||||
|
isComplete: done, isAllDay: true, sortKey: -2, workoutRef: w
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timed tasks
|
||||||
|
for t in tasks where t.hasTime {
|
||||||
|
guard let d = t.dueDate else { continue }
|
||||||
|
let h = cal.component(.hour, from: d), m = cal.component(.minute, from: d)
|
||||||
|
let hd = h == 0 ? 12 : (h > 12 ? h - 12 : h)
|
||||||
|
let ms = m == 0 ? "" : ":\(String(format: "%02d", m))"
|
||||||
|
out.append(DayTLEntry(
|
||||||
|
timeLabel: "\(hd)\(ms)", ampm: h < 12 ? "AM" : "PM",
|
||||||
|
title: t.title, color: t.taskColor.color(),
|
||||||
|
isComplete: t.isComplete, isAllDay: false,
|
||||||
|
sortKey: h * 60 + m, taskRef: t
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timed calendar events
|
||||||
|
for ev in calEvents where !ev.isAllDay {
|
||||||
|
guard let s = ev.startDate else { continue }
|
||||||
|
let h = cal.component(.hour, from: s), m = cal.component(.minute, from: s)
|
||||||
|
let hd = h == 0 ? 12 : (h > 12 ? h - 12 : h)
|
||||||
|
let ms = m == 0 ? "" : ":\(String(format: "%02d", m))"
|
||||||
|
let endLabel: String? = ev.endDate.map {
|
||||||
|
let eh = cal.component(.hour, from: $0), em = cal.component(.minute, from: $0)
|
||||||
|
let ehd = eh == 0 ? 12 : (eh > 12 ? eh - 12 : eh)
|
||||||
|
let ems = em == 0 ? "" : ":\(String(format: "%02d", em))"
|
||||||
|
let eap = eh < 12 ? "AM" : "PM"
|
||||||
|
return "until \(ehd)\(ems) \(eap)"
|
||||||
|
}
|
||||||
|
out.append(DayTLEntry(
|
||||||
|
timeLabel: "\(hd)\(ms)", ampm: h < 12 ? "AM" : "PM",
|
||||||
|
title: ev.title ?? "Event", subtitle: endLabel,
|
||||||
|
color: Color(cgColor: ev.calendar.cgColor),
|
||||||
|
isComplete: false, isAllDay: false, sortKey: h * 60 + m
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.sorted {
|
||||||
|
if $0.sortKey < 0 && $1.sortKey < 0 { return $0.sortKey > $1.sortKey }
|
||||||
|
if $0.sortKey < 0 { return true }
|
||||||
|
if $1.sortKey < 0 { return false }
|
||||||
|
return $0.sortKey < $1.sortKey
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var isEmpty: Bool { dayTasks.isEmpty && calEvents.isEmpty && workout == nil }
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
if entries.isEmpty {
|
||||||
HStack(spacing: 6) {
|
VStack(spacing: 6) {
|
||||||
Text(fmt.string(from: date).uppercased())
|
Image(systemName: "calendar.badge.checkmark")
|
||||||
.font(AppFonts.mono(10.5, weight: .bold))
|
.font(.system(size: 24))
|
||||||
.foregroundColor(AppColors.accent)
|
.foregroundColor(AppColors.text3(cs))
|
||||||
let total = dayTasks.count + calEvents.count + (workout != nil ? 1 : 0)
|
Text("Nothing scheduled")
|
||||||
Text(isEmpty ? "Nothing scheduled" : "\(total) event\(total == 1 ? "" : "s")")
|
.font(AppFonts.sans(13, weight: .medium))
|
||||||
.font(AppFonts.sans(10.5))
|
.foregroundColor(AppColors.text2(cs))
|
||||||
|
Text("Tap + to add a task")
|
||||||
|
.font(AppFonts.sans(11))
|
||||||
.foregroundColor(AppColors.text3(cs))
|
.foregroundColor(AppColors.text3(cs))
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity).padding(.vertical, 36)
|
||||||
if isEmpty {
|
} else {
|
||||||
VStack(spacing: 2) {
|
VStack(spacing: 0) {
|
||||||
Text("You have a free day").font(AppFonts.sans(12.5, weight: .medium)).foregroundColor(AppColors.text2(cs))
|
ForEach(Array(entries.enumerated()), id: \.element.id) { i, entry in
|
||||||
Text("Take it easy").font(AppFonts.sans(10.5)).foregroundColor(AppColors.text3(cs))
|
DayTLRow(
|
||||||
}
|
entry: entry,
|
||||||
.frame(maxWidth: .infinity).padding(.vertical, 6)
|
isFirst: i == 0,
|
||||||
} else {
|
isLast: i == entries.count - 1,
|
||||||
// Workout block
|
cs: cs,
|
||||||
if let w = workout {
|
onWorkoutTap: entry.workoutRef != nil ? onWorkoutTap : nil,
|
||||||
CalendarWorkoutBlock(program: w, onTap: onWorkoutTap)
|
onToggle: entry.taskRef.map { t in { onToggle?(t) } }
|
||||||
}
|
)
|
||||||
|
|
||||||
// Calendar events
|
|
||||||
ForEach(calEvents, id: \.eventIdentifier) { event in
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Circle().fill(Color(cgColor: event.calendar.cgColor)).frame(width: 6, height: 6)
|
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
|
||||||
Text(event.title ?? "Event")
|
|
||||||
.font(AppFonts.sans(12, weight: .medium))
|
|
||||||
.foregroundColor(AppColors.text(cs))
|
|
||||||
if let start = event.startDate {
|
|
||||||
Text(timeFmt.string(from: start))
|
|
||||||
.font(AppFonts.mono(9.5))
|
|
||||||
.foregroundColor(AppColors.text3(cs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
TagChip(text: "Cal", color: Color(cgColor: event.calendar.cgColor), bg: Color(cgColor: event.calendar.cgColor).opacity(0.12))
|
|
||||||
}
|
|
||||||
.padding(.vertical, 5).padding(.horizontal, 10)
|
|
||||||
.background(AppColors.surface(cs))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
|
|
||||||
.overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(AppColors.border(cs), lineWidth: 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Task items
|
|
||||||
ForEach(dayTasks) { task in
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
Circle().fill(task.taskColor.color()).frame(width: 6, height: 6)
|
|
||||||
Text(task.title).font(AppFonts.sans(12)).foregroundColor(AppColors.text(cs))
|
|
||||||
Spacer()
|
|
||||||
TagChip(text: task.category.rawValue, color: task.taskColor.color(), bg: task.taskColor.soft())
|
|
||||||
}
|
|
||||||
.padding(.vertical, 5).padding(.horizontal, 10)
|
|
||||||
.background(AppColors.surface(cs))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
|
|
||||||
.overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(AppColors.border(cs), lineWidth: 1))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Calendar Workout Block
|
private struct DayTLRow: View {
|
||||||
struct CalendarWorkoutBlock: View {
|
let entry: DayTLEntry
|
||||||
@Environment(\.colorScheme) private var cs
|
let isFirst: Bool
|
||||||
let program: WorkoutProgram
|
let isLast: Bool
|
||||||
var onTap: (() -> Void)? = nil
|
let cs: ColorScheme
|
||||||
@State private var isExpanded = false
|
var onWorkoutTap: (() -> Void)?
|
||||||
|
var onToggle: (() -> Void)?
|
||||||
|
|
||||||
var body: some View {
|
private let circleD: CGFloat = 18
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
private let timeW: CGFloat = 46
|
||||||
// ── Header row — taps open session ──
|
private let trackW: CGFloat = 26
|
||||||
HStack(spacing: 8) {
|
private let topGap: CGFloat = 10
|
||||||
WorkoutSectionBar(count: program.sections.count)
|
|
||||||
.frame(width: 30, height: 30)
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 1) {
|
@ViewBuilder private var marker: some View {
|
||||||
Text(program.name)
|
ZStack {
|
||||||
.font(AppFonts.sans(12, weight: .semibold))
|
if entry.isComplete {
|
||||||
.foregroundColor(AppColors.text(cs))
|
Circle()
|
||||||
Text("\(program.totalExercises) exercise\(program.totalExercises == 1 ? "" : "s") · \(program.sections.count) section\(program.sections.count == 1 ? "" : "s")")
|
.fill(entry.color.opacity(0.14))
|
||||||
.font(AppFonts.mono(9))
|
.frame(width: circleD, height: circleD)
|
||||||
.foregroundColor(AppColors.text3(cs))
|
Image(systemName: "checkmark")
|
||||||
}
|
.font(.system(size: 7.5, weight: .bold))
|
||||||
Spacer()
|
.foregroundColor(entry.color)
|
||||||
HStack(spacing: 6) {
|
} else if entry.workoutRef != nil {
|
||||||
TagChip(text: "Workout", color: AppColors.blue, bg: AppColors.blueSoft)
|
Circle()
|
||||||
Image(systemName: "chevron.right")
|
.fill(AppColors.blue.opacity(0.14))
|
||||||
.font(.system(size: 9, weight: .semibold))
|
.frame(width: circleD, height: circleD)
|
||||||
.foregroundColor(AppColors.blue.opacity(0.45))
|
Image(systemName: "dumbbell.fill")
|
||||||
}
|
.font(.system(size: 8))
|
||||||
}
|
.foregroundColor(AppColors.blue)
|
||||||
.padding(.horizontal, 10).padding(.vertical, 8)
|
} else if entry.isAllDay {
|
||||||
.contentShape(Rectangle())
|
Circle()
|
||||||
.onTapGesture { onTap?() }
|
.strokeBorder(entry.color, lineWidth: 1.5)
|
||||||
|
.frame(width: circleD, height: circleD)
|
||||||
// ── Expand toggle ──
|
} else {
|
||||||
if !program.sections.isEmpty {
|
Circle()
|
||||||
Button {
|
.fill(entry.color)
|
||||||
withAnimation(KisaniSpring.snappy) { isExpanded.toggle() }
|
.frame(width: 9, height: 9)
|
||||||
} label: {
|
|
||||||
HStack(spacing: 4) {
|
|
||||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
|
||||||
.font(.system(size: 7, weight: .bold))
|
|
||||||
Text(isExpanded ? "Less" : "Show \(program.sections.count) sections")
|
|
||||||
.font(AppFonts.mono(8.5, weight: .bold))
|
|
||||||
}
|
|
||||||
.foregroundColor(AppColors.blue.opacity(0.55))
|
|
||||||
.frame(maxWidth: .infinity)
|
|
||||||
.padding(.vertical, 6)
|
|
||||||
}
|
|
||||||
.buttonStyle(.plain)
|
|
||||||
|
|
||||||
// ── Section breakdown (expanded) ──
|
|
||||||
if isExpanded {
|
|
||||||
Divider().background(AppColors.blue.opacity(0.12))
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
ForEach(program.sections) { section in
|
|
||||||
HStack(alignment: .top, spacing: 6) {
|
|
||||||
Text(section.name.uppercased())
|
|
||||||
.font(AppFonts.mono(8, weight: .bold))
|
|
||||||
.foregroundColor(AppColors.blue.opacity(0.75))
|
|
||||||
.frame(width: 64, alignment: .leading)
|
|
||||||
Text(section.exercises.isEmpty
|
|
||||||
? "No exercises"
|
|
||||||
: section.exercises.map(\.name).joined(separator: " · "))
|
|
||||||
.font(AppFonts.sans(10.5))
|
|
||||||
.foregroundColor(AppColors.text2(cs))
|
|
||||||
.lineLimit(2)
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 10).padding(.top, 6).padding(.bottom, 10)
|
|
||||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.background(AppColors.blue.opacity(0.07))
|
.frame(width: circleD, height: circleD)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
|
}
|
||||||
.overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(AppColors.blue.opacity(0.2), lineWidth: 1))
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(alignment: .top, spacing: 0) {
|
||||||
|
|
||||||
|
// Time column
|
||||||
|
VStack(alignment: .trailing, spacing: 0) {
|
||||||
|
Text(entry.timeLabel)
|
||||||
|
.font(AppFonts.mono(
|
||||||
|
entry.isAllDay ? 8.5 : 11,
|
||||||
|
weight: entry.isAllDay ? .medium : .bold
|
||||||
|
))
|
||||||
|
.foregroundColor(entry.isAllDay ? AppColors.text3(cs) : AppColors.text(cs))
|
||||||
|
if let ap = entry.ampm {
|
||||||
|
Text(ap)
|
||||||
|
.font(AppFonts.mono(7, weight: .medium))
|
||||||
|
.foregroundColor(AppColors.text3(cs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: timeW, alignment: .trailing)
|
||||||
|
.padding(.top, topGap)
|
||||||
|
|
||||||
|
// Vertical track: top line → circle → bottom fill line
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(isFirst ? Color.clear : AppColors.border(cs))
|
||||||
|
.frame(width: 1, height: topGap)
|
||||||
|
.frame(maxWidth: trackW)
|
||||||
|
marker
|
||||||
|
Rectangle()
|
||||||
|
.fill(isLast ? Color.clear : AppColors.border(cs))
|
||||||
|
.frame(minWidth: 1, maxWidth: 1, maxHeight: .infinity)
|
||||||
|
.frame(maxWidth: trackW)
|
||||||
|
}
|
||||||
|
.frame(width: trackW)
|
||||||
|
|
||||||
|
// Event card
|
||||||
|
HStack(alignment: .center, spacing: 8) {
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text(entry.title)
|
||||||
|
.font(AppFonts.sans(13, weight: .medium))
|
||||||
|
.foregroundColor(entry.isComplete ? AppColors.text3(cs) : AppColors.text(cs))
|
||||||
|
.strikethrough(entry.isComplete, color: AppColors.text3(cs))
|
||||||
|
.lineLimit(2)
|
||||||
|
if let sub = entry.subtitle {
|
||||||
|
Text(sub)
|
||||||
|
.font(AppFonts.mono(9.5))
|
||||||
|
.foregroundColor(AppColors.text3(cs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
if let toggle = onToggle {
|
||||||
|
Button(action: toggle) {
|
||||||
|
Image(systemName: entry.isComplete ? "checkmark.circle.fill" : "circle")
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundColor(entry.isComplete ? entry.color : AppColors.text3(cs))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
} else if onWorkoutTap != nil {
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 10, weight: .semibold))
|
||||||
|
.foregroundColor(AppColors.text3(cs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 13).padding(.vertical, 11)
|
||||||
|
.background(AppColors.surface(cs))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.overlay(RoundedRectangle(cornerRadius: 10).stroke(AppColors.border(cs), lineWidth: 1))
|
||||||
|
.padding(.leading, 8)
|
||||||
|
.padding(.bottom, 8)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { if onWorkoutTap != nil { onWorkoutTap?() } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Calendar Task Row (used in task list toggle mode)
|
||||||
|
private struct CalTaskRow: View {
|
||||||
|
@Environment(\.colorScheme) private var cs
|
||||||
|
let task: TaskItem
|
||||||
|
let onToggle: () -> Void
|
||||||
|
|
||||||
|
private static let timeFmt: DateFormatter = {
|
||||||
|
let f = DateFormatter(); f.dateFormat = "H:mm"; return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var timeLabel: String? {
|
||||||
|
guard let d = task.dueDate, task.hasTime else { return nil }
|
||||||
|
let comps = Calendar.current.dateComponents([.hour, .minute], from: d)
|
||||||
|
guard comps.hour != 0 || comps.minute != 0 else { return nil }
|
||||||
|
return Self.timeFmt.string(from: d)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(action: onToggle) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(task.isComplete ? AppColors.surface3(cs) : Color.clear)
|
||||||
|
.frame(width: 22, height: 22)
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.strokeBorder(
|
||||||
|
task.isComplete ? Color.clear : task.taskColor.color(),
|
||||||
|
lineWidth: 1.5
|
||||||
|
)
|
||||||
|
.frame(width: 22, height: 22)
|
||||||
|
if task.isComplete {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.font(.system(size: 10, weight: .bold))
|
||||||
|
.foregroundColor(AppColors.text3(cs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(PressButtonStyle(scale: 0.88))
|
||||||
|
|
||||||
|
Text(task.title)
|
||||||
|
.font(AppFonts.sans(13, weight: task.isComplete ? .regular : .medium))
|
||||||
|
.foregroundColor(task.isComplete ? AppColors.text3(cs) : AppColors.text(cs))
|
||||||
|
.strikethrough(task.isComplete, color: AppColors.text3(cs))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let t = timeLabel {
|
||||||
|
Text(t)
|
||||||
|
.font(AppFonts.mono(11, weight: .semibold))
|
||||||
|
.foregroundColor(AppColors.accent)
|
||||||
|
} else if task.isComplete {
|
||||||
|
Text("Today")
|
||||||
|
.font(AppFonts.mono(10))
|
||||||
|
.foregroundColor(AppColors.text3(cs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 13)
|
||||||
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user