import SwiftUI import WidgetKit struct TimetableWeekView: View { let entry: TimetableEntry var body: some View { ZStack { if !entry.isLoggedIn { placeholder("Bitte einloggen, um den Stundenplan zu laden") } else if let data = entry.data { content(data: data) } else { placeholder("Lade…") } } .background(MarianumWatermark()) .widgetThemeOverride(entry.themeMode) } @ViewBuilder private func content(data: WidgetTimetableData) -> some View { VStack(alignment: .leading, spacing: 4) { header(data: data) dayHeaderRow(data: data) GeometryReader { geo in let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES) let hourHeight = max( MIN_HOUR_HEIGHT, min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60) ) let dayColumnWidth = (geo.size.width - 28 - 4) / 5 let subjectOnly = dayColumnWidth < 70 HStack(alignment: .top, spacing: 0) { timeLabelsColumn(hourHeight: hourHeight, periods: data.periods) .frame(width: 28, alignment: .topTrailing) columnDivider ForEach(0..<5, id: \.self) { offset in column( data: data, offset: offset, hourHeight: hourHeight, subjectOnly: subjectOnly ) .frame(maxWidth: .infinity) if offset < 4 { columnDivider } } } } } } private var columnDivider: some View { Rectangle() .fill(Color.primary.opacity(0.13)) .frame(width: 1) } private func header(data: WidgetTimetableData) -> some View { let cal = Calendar.current let week = cal.component(.weekOfYear, from: data.anchorDate) let endDate = cal.date(byAdding: .day, value: 4, to: data.anchorDate) ?? data.anchorDate return HStack { Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(.primary) Spacer() Text("Stand: \(freshnessLabel(for: data.fetchedAt))") .font(.system(size: 10)) .foregroundStyle(.secondary) } } private func dayHeaderRow(data: WidgetTimetableData) -> some View { let cal = Calendar.current return HStack(spacing: 0) { Spacer().frame(width: 28) columnDivider ForEach(0..<5, id: \.self) { offset in let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate VStack(spacing: 0) { Text(weekday(for: day)) .font(.system(size: 11, weight: .bold)) .foregroundStyle(.primary) Text(shortDate(day)) .font(.system(size: 9)) .foregroundStyle(.secondary) } .frame(maxWidth: .infinity) if offset < 4 { columnDivider } } } } private func timeLabelsColumn(hourHeight: CGFloat, periods: [WidgetPeriod]) -> some View { let totalMin = periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES let totalHeight = CGFloat(totalMin) * hourHeight / 60.0 return ZStack(alignment: .topTrailing) { ForEach(periodBoundaries(periods), id: \.self) { virtualMin in Rectangle() .fill(Color.primary.opacity(0.08)) .frame(height: 1) .offset(y: CGFloat(virtualMin) * hourHeight / 60.0) } ForEach(periods, id: \.startMinutes) { period in VStack(alignment: .trailing, spacing: -2) { Text(String(format: "%02d:%02d", period.startMinutes / 60, period.startMinutes % 60)) .font(.system(size: 8)) .foregroundStyle(.primary) .lineLimit(1) Text("\(period.name).") .font(.system(size: 6, weight: .bold)) .foregroundStyle(.secondary) .lineLimit(1) } .offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0) } } .frame(height: totalHeight, alignment: .topTrailing) } private func column( data: WidgetTimetableData, offset: Int, hourHeight: CGFloat, subjectOnly: Bool ) -> some View { let cal = Calendar.current let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate let lessonsForDay = data.lessons.filter { cal.isDate($0.start, inSameDayAs: day) } return TimeGridView( lessons: lessonsForDay, periods: data.periods, anchorDate: day, hourHeight: hourHeight, showRoom: !subjectOnly, showTeacher: !subjectOnly, showTimeLabels: false, horizontalPadding: 3 ) } private func weekday(for date: Date) -> String { let f = DateFormatter() f.locale = Locale(identifier: "de_DE") f.dateFormat = "EE" return f.string(from: date) } private func placeholder(_ message: String) -> some View { VStack(spacing: 4) { Text("Marianum Stundenplan") .font(.system(size: 14, weight: .semibold)) Text(message) .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity, maxHeight: .infinity) } }