Files
Calendarr-IOS/Calendarr iOS/Views/Calendar/WeekView.swift
Scarriffle 554ad425b1 Week view: render multi-day all-day events as continuous spanning bars
The all-day strip repeated a multi-day event once per day; now events span their
start→end columns and pack into lanes (like the month view) via a GeometryReader
layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 11:20:21 +02:00

271 lines
11 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
struct WeekView: View {
let store: CalendarStore
let onEventTap: (CalEvent) -> Void
let onCreateEvent: (Date) -> Void
let onShowMonth: (Date) -> Void
let onShowDay: (Date) -> Void
@AppStorage("appLanguage") private var appLang = "system"
@AppStorage("todayColor") private var todayHex = "#4285f4"
@AppStorage("textColor") private var textHex = "#FFFFFF"
@AppStorage("lineColor") private var lineHex = "#3A3A52"
@AppStorage("textContrast") private var textContrast = 3
@AppStorage("lineContrast") private var lineContrast = 3
@AppStorage("hourHeight") private var hourHeightPref = 60 // observed for live re-layout
private var cal: Calendar { store.userCalendar }
private var weekDays: [Date] {
let start = cal.date(from: cal.dateComponents([.yearForWeekOfYear, .weekOfYear], from: store.currentDate))!
return (0..<7).compactMap { cal.date(byAdding: .day, value: $0, to: start) }
}
private var timedEvents: [(Int, CalEvent)] {
weekDays.enumerated().flatMap { idx, day in
store.events(on: day)
.filter { !$0.isAllDay && !eventSpansMultipleDays($0) }
.map { (idx, $0) }
}
}
private var allDayEvents: [CalEvent] {
let s = weekDays.first ?? .now
let e = cal.date(byAdding: .day, value: 1, to: weekDays.last ?? .now)!
return store.events(in: s, end: e).filter { $0.isAllDay || eventSpansMultipleDays($0) }
}
private var todayIndex: Int? {
weekDays.firstIndex(where: { cal.isDateInToday($0) })
}
private let headerFmt: DateFormatter = {
let f = DateFormatter(); f.dateFormat = "EEE d"; return f
}()
var body: some View {
VStack(spacing: 0) {
columnHeaders
Divider()
if !allDayEvents.isEmpty { allDayRow }
timeGrid
}
}
// MARK: Column headers (fixed height no Color.clear tricks)
private var columnHeaders: some View {
HStack(spacing: 0) {
Spacer().frame(width: timeColumnWidth, height: 36) // fixed height!
ForEach(weekDays, id: \.self) { day in
Text(headerFmt.string(from: day).uppercased())
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(cal.isDateInToday(day) ? Color(hex: todayHex) : Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
.frame(maxWidth: .infinity, minHeight: 36)
.overlay(alignment: .trailing) {
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
}
}
}
}
// MARK: All-day strip
private let allDayLaneHeight: CGFloat = 16
/// All-day / multi-day events laid out as continuous bars: each event spans
/// its startend columns (clamped to this week) and is packed into lanes so
/// overlapping bars stack mirrors the month view instead of repeating the
/// event once per day.
private func allDayBars() -> [(ev: CalEvent, start: Int, end: Int, lane: Int)] {
var spans: [(ev: CalEvent, start: Int, end: Int)] = []
for ev in allDayEvents {
var start: Int? = nil
var end = 0
for (i, day) in weekDays.enumerated() {
let ds = cal.startOfDay(for: day)
let de = cal.date(byAdding: .day, value: 1, to: ds)!
if ev.startDate < de && ev.endDate > ds {
if start == nil { start = i }
end = i
}
}
if let s = start { spans.append((ev, s, end)) }
}
// Longer / earlier bars first, then greedily pack into lanes.
spans.sort { a, b in a.start != b.start ? a.start < b.start : (a.end - a.start) > (b.end - b.start) }
var laneEnd: [Int] = [] // last occupied column index per lane
var out: [(ev: CalEvent, start: Int, end: Int, lane: Int)] = []
for s in spans {
var lane = 0
while lane < laneEnd.count && laneEnd[lane] >= s.start { lane += 1 }
if lane == laneEnd.count { laneEnd.append(s.end) } else { laneEnd[lane] = s.end }
out.append((s.ev, s.start, s.end, lane))
}
return out
}
private var allDayRow: some View {
let bars = allDayBars()
let laneCount = max(1, (bars.map { $0.lane }.max() ?? -1) + 1)
return GeometryReader { geo in
let colW = (geo.size.width - timeColumnWidth) / 7
ZStack(alignment: .topLeading) {
ForEach(bars, id: \.ev.id) { bar in
Button { onEventTap(bar.ev) } label: {
EventLabel(event: bar.ev)
.font(.system(size: 9, weight: .medium))
.foregroundStyle(.white)
.lineLimit(1)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 3)
.frame(height: allDayLaneHeight - 2)
.background(Color(hex: bar.ev.effectiveColor))
.clipShape(RoundedRectangle(cornerRadius: 2))
}
.buttonStyle(.plain)
.frame(width: colW * CGFloat(bar.end - bar.start + 1) - 3)
.offset(x: timeColumnWidth + colW * CGFloat(bar.start) + 1.5,
y: CGFloat(bar.lane) * allDayLaneHeight)
}
}
}
.frame(height: CGFloat(laneCount) * allDayLaneHeight)
.padding(.vertical, 4)
.overlay(alignment: .bottom) { Divider() }
}
// MARK: Time grid (GeometryReader OUTSIDE ScrollView clean layout)
private var timeGrid: some View {
GeometryReader { geo in
let colW = (geo.size.width - timeColumnWidth) / 7
ScrollViewReader { proxy in
ScrollView {
ZStack(alignment: .topLeading) {
// Background: time labels + per-hour cells per day
HStack(alignment: .top, spacing: 0) {
timeLabels
ForEach(Array(weekDays.enumerated()), id: \.offset) { _, day in
VStack(spacing: 0) {
ForEach(hours, id: \.self) { hour in
HourSlot(day: day, hour: hour,
hourHeight: hourHeight,
language: appLang,
onCreateEvent: onCreateEvent,
onShowMonth: onShowMonth,
onShowDay: onShowDay)
}
}
.frame(width: colW)
.overlay(alignment: .trailing) {
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(width: 0.5)
}
}
}
// Events positioned using known column widths (no GeometryReader inside)
ForEach(timedEvents, id: \.1.id) { dayIdx, ev in
Button(action: { onEventTap(ev) }) {
EventBlock(event: ev)
}
.buttonStyle(.plain)
.frame(width: colW - 2, height: max(eventHeight(ev), 18))
.offset(x: timeColumnWidth + CGFloat(dayIdx) * colW + 1,
y: eventTop(ev))
}
// Current time line
if let ti = todayIndex {
let lineY = eventTop(Date.now)
let nowColor = Color(hex: todayHex)
HStack(spacing: 0) {
Spacer().frame(width: timeColumnWidth + CGFloat(ti) * colW - 4)
Circle().fill(nowColor).frame(width: 8, height: 8)
Rectangle().fill(nowColor).frame(width: colW - 4, height: 1.5)
}
.offset(y: lineY - 0.75)
}
}
.frame(width: geo.size.width, height: hourHeight * 24 + 80)
.id("grid")
}
.onAppear { scrollToCurrentHour(proxy) }
.onChange(of: store.currentDate) { _, _ in scrollToCurrentHour(proxy) }
}
}
}
private var timeLabels: some View {
VStack(spacing: 0) {
ForEach(hours, id: \.self) { h in
ZStack(alignment: .topTrailing) {
Color.clear.frame(height: hourHeight)
Text(String(format: "%02d:00", h))
.font(.system(size: 10))
.foregroundStyle(Color(hex: textHex).opacity(secondaryTextOpacity(textContrast)))
.offset(y: -6)
}
}
Color.clear.frame(height: 80) // FAB space
}
.frame(width: timeColumnWidth)
}
private func scrollToCurrentHour(_ proxy: ScrollViewProxy) {
let h = Calendar.current.component(.hour, from: .now)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
withAnimation(.easeOut(duration: 0.3)) {
proxy.scrollTo("grid", anchor: UnitPoint(x: 0, y: CGFloat(max(h - 1, 0)) / 24.0))
}
}
}
}
// Trick to compute eventTop from a Date instead of CalEvent
private func eventTop(_ date: Date) -> CGFloat {
let cal = Calendar.current
let h = CGFloat(cal.component(.hour, from: date))
let m = CGFloat(cal.component(.minute, from: date))
return h * hourHeight + m * hourHeight / 60
}
// One-hour slot with native long-press context menu.
struct HourSlot: View {
let day: Date
let hour: Int
let hourHeight: CGFloat
let language: String
let onCreateEvent: (Date) -> Void
let onShowMonth: (Date) -> Void
let onShowDay: (Date) -> Void
@AppStorage("lineColor") private var lineHex = "#3A3A52"
@AppStorage("lineContrast") private var lineContrast = 3
private var date: Date {
Calendar.current.date(bySettingHour: hour, minute: 0, second: 0, of: day) ?? day
}
var body: some View {
VStack(spacing: 0) {
Rectangle().fill(Color(hex: lineHex).opacity(gridLineOpacity(lineContrast))).frame(height: 0.5)
Color.clear.frame(height: hourHeight - 0.5)
}
.contentShape(Rectangle())
.contextMenu {
Button { onCreateEvent(date) } label: {
Label(L10n.t("cal.new_event", language), systemImage: "plus")
}
Button { onShowMonth(date) } label: {
Label(L10n.t("cal.show_in_month_view", language), systemImage: "calendar")
}
Button { onShowDay(date) } label: {
Label(L10n.t("cal.show_in_day_view", language), systemImage: "sun.max")
}
}
}
}