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:
kutesir
2026-05-29 13:51:37 +03:00
parent 98eaa9fb27
commit e0e4f709b6

View File

@@ -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)
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) } }
)
}
}
}
}
}
// 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))
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 {
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()
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
struct CalendarWorkoutBlock: View {
@Environment(\.colorScheme) private var cs
let program: WorkoutProgram
var onTap: (() -> Void)? = nil
@State private var isExpanded = false
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)
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))
.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?() }
// 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))
}
.foregroundColor(AppColors.blue.opacity(0.55))
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
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())
}
}