From 8f89d3c73b312f67b59956bc98861d4cf004d4b7 Mon Sep 17 00:00:00 2001 From: kutesir Date: Tue, 7 Jul 2026 00:17:11 +0300 Subject: [PATCH] Calendar month view: TickTick-style bars + collapse-to-week MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the referenced TickTick monthly view: - Day cells now show stacked colored schedule BARS (workout/events/tasks, up to 4) instead of dots — a glanceable overview of the month. - Selecting a day COLLAPSES the grid to just that week, giving the day's agenda room below (as in the tapped-day screenshot). A chevron grabber expands back to the full month. - DayCell: bars param + stacked RoundedRectangles, top-aligned, height 54 - monthView: renders week rows (all when expanded, only the selected week when collapsed) via weekRows()/selectedWeekRow(); grabber toggles - eventDots cap raised to 4 for the bar stack (Not build-verified — sim runtime removed to reclaim disk.) Co-Authored-By: Claude Opus 4.8 --- KisaniCal/Views/CalendarView.swift | 84 +++++++++++++++++++++++------- 1 file changed, 65 insertions(+), 19 deletions(-) diff --git a/KisaniCal/Views/CalendarView.swift b/KisaniCal/Views/CalendarView.swift index bc7cf72..be4d809 100644 --- a/KisaniCal/Views/CalendarView.swift +++ b/KisaniCal/Views/CalendarView.swift @@ -296,6 +296,9 @@ struct CalendarView: View { @State private var calTaskListMode: Bool = false /// Year currently at the top of the continuous year scroll (drives the header in .year mode). @State private var visibleYear: Int = Calendar.current.component(.year, from: Date()) + /// Month view: full grid when true, only the selected day's week when false + /// (TickTick-style — collapses to give the day agenda more room). + @State private var monthExpanded: Bool = true @State private var showAddTask = false @State private var showCalendarConnect = false @State private var showWorkoutSession = false @@ -462,10 +465,25 @@ struct CalendarView: View { CalendarGrid.monthGrid(for: displayMonth, calendar: cal) } + /// The month grid split into week rows of 7. + private func weekRows() -> [[Date]] { + let days = gridDays() + return stride(from: 0, to: days.count, by: 7).map { Array(days[$0.. [Date] { + let rows = weekRows() + return rows.first { row in row.contains { cal.isDate($0, inSameDayAs: selectedDate) } } ?? (rows.first ?? []) + } + + /// Colors of the schedule bars shown under a day in the month grid + /// (workout · calendar events · tasks), capped so cells stay compact. private func eventDots(for date: Date) -> [Color] { + let maxBars = 4 var dots: [Color] = [] if workoutVM.program(for: date) != nil { dots.append(AppColors.blue) } - if calStore.isAuthorized && dots.count < 3 { + if calStore.isAuthorized && dots.count < maxBars { let calDay = calStore.events(for: date) if !calDay.isEmpty { let calColor = calDay.first.flatMap { Color(cgColor: $0.calendar.cgColor) } ?? AppColors.green @@ -475,7 +493,7 @@ struct CalendarView: View { // Includes recurring occurrences that land on this date. let taskDots = taskVM.occurrences(on: date) .filter { !$0.isComplete } - .prefix(max(0, 3 - dots.count)) + .prefix(max(0, maxBars - dots.count)) .map { $0.quadrant.color } dots.append(contentsOf: taskDots) return dots @@ -564,16 +582,27 @@ struct CalendarView: View { } .padding(.horizontal, 16).padding(.top, 14).padding(.bottom, 3) - LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 1), count: 7), spacing: 1) { - ForEach(gridDays(), id: \.self) { date in - DayCell( - date: date, - isCurrentMonth: cal.isDate(date, equalTo: displayMonth, toGranularity: .month), - isToday: cal.isDateInToday(date), - isSelected: cal.isDate(date, inSameDayAs: selectedDate), - dots: eventDots(for: date) - ) - .onTapGesture { withAnimation(KisaniSpring.micro) { selectedDate = date } } + // Full month (expanded) or just the selected day's week (collapsed). + let rows = monthExpanded ? weekRows() : [selectedWeekRow()] + VStack(spacing: 1) { + ForEach(rows.indices, id: \.self) { ri in + HStack(spacing: 1) { + ForEach(rows[ri], id: \.self) { date in + DayCell( + date: date, + isCurrentMonth: cal.isDate(date, equalTo: displayMonth, toGranularity: .month), + isToday: cal.isDateInToday(date), + isSelected: cal.isDate(date, inSameDayAs: selectedDate), + bars: eventDots(for: date) + ) + .onTapGesture { + withAnimation(KisaniSpring.snappy) { + selectedDate = date + monthExpanded = false // collapse to this week, reveal the agenda + } + } + } + } } } .padding(.horizontal, 16) @@ -582,7 +611,19 @@ struct CalendarView: View { else if v.translation.width > 40 { navigateMonth(-1) } }) - Divider().background(AppColors.border(cs)).padding(.horizontal, 16).padding(.top, 6) + // Grabber — expand back to the full month, or collapse to the week. + Button { + withAnimation(KisaniSpring.snappy) { monthExpanded.toggle() } + } label: { + Image(systemName: monthExpanded ? "chevron.up" : "chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(AppColors.text3(cs)) + .frame(maxWidth: .infinity).frame(height: 22) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + + Divider().background(AppColors.border(cs)).padding(.horizontal, 16) ScrollView(showsIndicators: false) { DayTimelineView( @@ -1614,7 +1655,7 @@ struct AddCalendarHolidaysView: View { // MARK: - Day Cell struct DayCell: View { @Environment(\.colorScheme) private var cs - let date: Date; let isCurrentMonth: Bool; let isToday: Bool; let isSelected: Bool; let dots: [Color] + let date: Date; let isCurrentMonth: Bool; let isToday: Bool; let isSelected: Bool; let bars: [Color] private let cal = Calendar.current var dayNum: String { "\(cal.component(.day, from: date))" } /// Locale week-of-year (NOT ISO), intentionally matching the locale-driven grid @@ -1639,14 +1680,19 @@ struct DayCell: View { Text("W\(weekNum)").font(AppFonts.mono(6.5)).foregroundColor(AppColors.text3(cs)).offset(x: -2, y: -10) } } - HStack(spacing: 2) { - ForEach(dots.indices, id: \.self) { i in - Circle().fill(dots[i]).frame(width: 3.5, height: 3.5) + // Schedule bars — one per event/task, stacked (TickTick-style). + VStack(spacing: 1.5) { + ForEach(bars.indices, id: \.self) { i in + RoundedRectangle(cornerRadius: 1) + .fill(bars[i].opacity(0.85)) + .frame(height: 3) } } - .frame(height: 4) + .frame(maxWidth: .infinity, alignment: .top) + .padding(.horizontal, 3) + Spacer(minLength: 0) } - .frame(maxWidth: .infinity).frame(height: 42).contentShape(Rectangle()) + .frame(maxWidth: .infinity).frame(height: 54).contentShape(Rectangle()) } }