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()
|
||||
}()
|
||||
@State private var viewMode: CalendarViewMode = .month
|
||||
@State private var calTaskListMode: Bool = false
|
||||
@State private var showAddTask = false
|
||||
@State private var showCalendarConnect = false
|
||||
@State private var showWorkoutSession = false
|
||||
@@ -127,13 +128,8 @@ struct CalendarView: View {
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// ── Header ──
|
||||
HStack {
|
||||
Text(monthShortTitle)
|
||||
.font(AppFonts.sans(26, weight: .bold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Spacer()
|
||||
HStack(spacing: 0) {
|
||||
// View mode picker
|
||||
// Left circle: view mode picker
|
||||
Menu {
|
||||
Picker(selection: $viewMode, label: EmptyView()) {
|
||||
ForEach(CalendarViewMode.allCases, id: \.self) { mode in
|
||||
@@ -141,17 +137,40 @@ struct CalendarView: View {
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "slider.horizontal.3")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
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)
|
||||
.font(AppFonts.sans(17, weight: .semibold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Spacer()
|
||||
|
||||
// Right: controls pill (view mode picker + more)
|
||||
HStack(spacing: 0) {
|
||||
// Right pill left button: task list toggle
|
||||
Button {
|
||||
withAnimation(KisaniSpring.snappy) { calTaskListMode.toggle() }
|
||||
} label: {
|
||||
Image(systemName: calTaskListMode ? "calendar" : "slider.horizontal.3")
|
||||
.font(.system(size: 14, weight: calTaskListMode ? .semibold : .medium))
|
||||
.foregroundColor(calTaskListMode ? AppColors.accent : AppColors.text(cs))
|
||||
.frame(width: 46, height: 36)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
// Divider
|
||||
Rectangle()
|
||||
.fill(AppColors.border(cs))
|
||||
.frame(width: 1, height: 18)
|
||||
// More options
|
||||
Menu {
|
||||
Button { } label: { Label("Filter View Range", systemImage: "line.3.horizontal.decrease") }
|
||||
Button { } label: { Label("View Options", systemImage: "gearshape.2") }
|
||||
@@ -169,8 +188,9 @@ struct CalendarView: View {
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
.frame(width: 46, height: 36)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.menuStyle(.automatic)
|
||||
}
|
||||
.background(AppColors.surface2(cs))
|
||||
.clipShape(Capsule())
|
||||
@@ -178,10 +198,17 @@ struct CalendarView: View {
|
||||
}
|
||||
.padding(.horizontal, 18).padding(.top, 8)
|
||||
|
||||
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) {
|
||||
AddTaskSheet(prefilledDate: selectedDate)
|
||||
@@ -257,6 +284,67 @@ struct CalendarView: View {
|
||||
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
|
||||
|
||||
@ViewBuilder private var calendarContent: some View {
|
||||
@@ -303,18 +391,24 @@ struct CalendarView: View {
|
||||
Divider().background(AppColors.border(cs)).padding(.horizontal, 16).padding(.top, 6)
|
||||
|
||||
ScrollView(showsIndicators: false) {
|
||||
DayStripView(
|
||||
DayTimelineView(
|
||||
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) : [],
|
||||
workout: workoutVM.program(for: selectedDate),
|
||||
onWorkoutTap: {
|
||||
if let w = workoutVM.program(for: selectedDate),
|
||||
workoutVM.activeProgramId != w.id { workoutVM.switchProgram(to: w.id) }
|
||||
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
|
||||
struct DayStripView: View {
|
||||
// MARK: - Day Timeline
|
||||
|
||||
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
|
||||
|
||||
let date: Date
|
||||
let tasks: [TaskItem]
|
||||
let calEvents: [EKEvent]
|
||||
var workout: WorkoutProgram? = nil
|
||||
let workout: WorkoutProgram?
|
||||
var onWorkoutTap: (() -> Void)? = nil
|
||||
var onToggle: ((TaskItem) -> Void)? = nil
|
||||
|
||||
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] {
|
||||
tasks.filter { guard let d = $0.dueDate else { return false }; return cal.isDate(d, inSameDayAs: date) }
|
||||
private var entries: [DayTLEntry] {
|
||||
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
|
||||
))
|
||||
}
|
||||
|
||||
var isEmpty: Bool { dayTasks.isEmpty && calEvents.isEmpty && workout == nil }
|
||||
// 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 body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Text(fmt.string(from: date).uppercased())
|
||||
.font(AppFonts.mono(10.5, weight: .bold))
|
||||
.foregroundColor(AppColors.accent)
|
||||
let total = dayTasks.count + calEvents.count + (workout != nil ? 1 : 0)
|
||||
Text(isEmpty ? "Nothing scheduled" : "\(total) event\(total == 1 ? "" : "s")")
|
||||
.font(AppFonts.sans(10.5))
|
||||
if entries.isEmpty {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: "calendar.badge.checkmark")
|
||||
.font(.system(size: 24))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
Text("Nothing scheduled")
|
||||
.font(AppFonts.sans(13, weight: .medium))
|
||||
.foregroundColor(AppColors.text2(cs))
|
||||
Text("Tap + to add a task")
|
||||
.font(AppFonts.sans(11))
|
||||
.foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
|
||||
if isEmpty {
|
||||
VStack(spacing: 2) {
|
||||
Text("You have a free day").font(AppFonts.sans(12.5, weight: .medium)).foregroundColor(AppColors.text2(cs))
|
||||
Text("Take it easy").font(AppFonts.sans(10.5)).foregroundColor(AppColors.text3(cs))
|
||||
}
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity).padding(.vertical, 36)
|
||||
} else {
|
||||
// Workout block
|
||||
if let w = workout {
|
||||
CalendarWorkoutBlock(program: w, onTap: onWorkoutTap)
|
||||
}
|
||||
|
||||
// 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))
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(entries.enumerated()), id: \.element.id) { i, entry in
|
||||
DayTLRow(
|
||||
entry: entry,
|
||||
isFirst: i == 0,
|
||||
isLast: i == entries.count - 1,
|
||||
cs: cs,
|
||||
onWorkoutTap: entry.workoutRef != nil ? onWorkoutTap : nil,
|
||||
onToggle: entry.taskRef.map { t in { onToggle?(t) } }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Calendar Workout Block
|
||||
struct CalendarWorkoutBlock: View {
|
||||
@Environment(\.colorScheme) private var cs
|
||||
let program: WorkoutProgram
|
||||
var onTap: (() -> Void)? = nil
|
||||
@State private var isExpanded = false
|
||||
private struct DayTLRow: View {
|
||||
let entry: DayTLEntry
|
||||
let isFirst: Bool
|
||||
let isLast: Bool
|
||||
let cs: ColorScheme
|
||||
var onWorkoutTap: (() -> Void)?
|
||||
var onToggle: (() -> Void)?
|
||||
|
||||
private let circleD: CGFloat = 18
|
||||
private let timeW: CGFloat = 46
|
||||
private let trackW: CGFloat = 26
|
||||
private let topGap: CGFloat = 10
|
||||
|
||||
@ViewBuilder private var marker: some View {
|
||||
ZStack {
|
||||
if entry.isComplete {
|
||||
Circle()
|
||||
.fill(entry.color.opacity(0.14))
|
||||
.frame(width: circleD, height: circleD)
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 7.5, weight: .bold))
|
||||
.foregroundColor(entry.color)
|
||||
} else if entry.workoutRef != nil {
|
||||
Circle()
|
||||
.fill(AppColors.blue.opacity(0.14))
|
||||
.frame(width: circleD, height: circleD)
|
||||
Image(systemName: "dumbbell.fill")
|
||||
.font(.system(size: 8))
|
||||
.foregroundColor(AppColors.blue)
|
||||
} else if entry.isAllDay {
|
||||
Circle()
|
||||
.strokeBorder(entry.color, lineWidth: 1.5)
|
||||
.frame(width: circleD, height: circleD)
|
||||
} else {
|
||||
Circle()
|
||||
.fill(entry.color)
|
||||
.frame(width: 9, height: 9)
|
||||
}
|
||||
}
|
||||
.frame(width: circleD, height: circleD)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// ── Header row — taps open session ──
|
||||
HStack(spacing: 8) {
|
||||
WorkoutSectionBar(count: program.sections.count)
|
||||
.frame(width: 30, height: 30)
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text(program.name)
|
||||
.font(AppFonts.sans(12, weight: .semibold))
|
||||
.foregroundColor(AppColors.text(cs))
|
||||
Text("\(program.totalExercises) exercise\(program.totalExercises == 1 ? "" : "s") · \(program.sections.count) section\(program.sections.count == 1 ? "" : "s")")
|
||||
.font(AppFonts.mono(9))
|
||||
// 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))
|
||||
}
|
||||
Spacer()
|
||||
HStack(spacing: 6) {
|
||||
TagChip(text: "Workout", color: AppColors.blue, bg: AppColors.blueSoft)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundColor(AppColors.blue.opacity(0.45))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10).padding(.vertical, 8)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { onTap?() }
|
||||
.frame(width: timeW, alignment: .trailing)
|
||||
.padding(.top, topGap)
|
||||
|
||||
// ── Expand toggle ──
|
||||
if !program.sections.isEmpty {
|
||||
Button {
|
||||
withAnimation(KisaniSpring.snappy) { isExpanded.toggle() }
|
||||
} 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))
|
||||
// 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)
|
||||
}
|
||||
.foregroundColor(AppColors.blue.opacity(0.55))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.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))
|
||||
|
||||
// ── 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()
|
||||
|
||||
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, 10).padding(.top, 6).padding(.bottom, 10)
|
||||
.transition(.opacity.combined(with: .move(edge: .top)))
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(AppColors.blue.opacity(0.07))
|
||||
.clipShape(RoundedRectangle(cornerRadius: AppRadius.small))
|
||||
.overlay(RoundedRectangle(cornerRadius: AppRadius.small).stroke(AppColors.blue.opacity(0.2), lineWidth: 1))
|
||||
.padding(.horizontal, 16).padding(.vertical, 13)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user