import SwiftUI import WidgetKit struct TimetableDayView: View { let entry: TimetableEntry @Environment(\.widgetPalette) private var palette 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()) } @ViewBuilder private func content(data: WidgetTimetableData) -> some View { VStack(alignment: .leading, spacing: GRID_MARGIN_TOP) { header(data: data) if data.isHoliday { emptyState(text: data.holidayName ?? "Ferien") } else if data.lessons.isEmpty { emptyState(text: "Keine Stunden") } else { GeometryReader { geo in let periods = trimTrailingPeriods(data.periods, lessons: data.lessons) HStack(alignment: .top, spacing: 0) { PeriodRuler( periods: periods, hourHeight: resolveHourHeight(forGridHeight: geo.size.height, periods: periods) ) Rectangle() .fill(palette.divider) .frame(width: 1) TimeGridView( lessons: data.lessons, periods: periods, hourHeight: resolveHourHeight(forGridHeight: geo.size.height, periods: periods) ) } } } } } private func header(data: WidgetTimetableData) -> some View { HStack(spacing: 6) { Text(dayLabel(for: data.anchorDate, relativeTo: entry.date)) .font(.system(size: 14, weight: .bold)) .foregroundStyle(palette.textPrimary) .lineLimit(1) .minimumScaleFactor(0.65) .frame(maxWidth: .infinity, alignment: .leading) Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))") .font(.system(size: 10)) .foregroundStyle(palette.textSecondary) .lineLimit(1) .fixedSize() } .frame(height: TITLE_ROW_HEIGHT) } private func emptyState(text: String) -> some View { VStack { Spacer() Text(text) .font(.caption) .foregroundStyle(palette.textSecondary) Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) } private func placeholder(_ message: String) -> some View { VStack(spacing: 4) { Text("Marianum Stundenplan") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(palette.textPrimary) Text(message) .font(.caption) .foregroundStyle(palette.textSecondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity, maxHeight: .infinity) } } /// Mirrors the in-app _PeriodLabel: period number centred, start time above, /// end time below, a cup in the breaks. struct PeriodRuler: View { let periods: [WidgetPeriod] let hourHeight: CGFloat @Environment(\.widgetPalette) private var palette private var totalHeight: CGFloat { CGFloat(totalVirtualMinutes(periods)) * hourHeight / 60.0 } var body: some View { // Every child has layout-y 0 (`.offset` only shifts the rendering), // so both the ZStack and its frame need `.top` — the default // `.center` would anchor the whole ruler at the column's midpoint. ZStack(alignment: .top) { ForEach(periodBoundaries(periods), id: \.self) { virtualMin in Rectangle() .fill(palette.divider) .frame(height: 1) .offset(y: CGFloat(virtualMin) * hourHeight / 60.0) } ForEach(periods, id: \.startMinutes) { period in cell(period) } ForEach(periodBreaks(periods), id: \.self) { pause in let height = CGFloat(pause.virtualMinutes) * hourHeight / 60.0 if height >= BREAK_ICON_MIN_HEIGHT { Image(systemName: "cup.and.saucer") .font(.system(size: 8)) .foregroundStyle(palette.textSecondary) .frame(width: RULER_WIDTH, height: height) .offset(y: CGFloat(pause.virtualStart) * hourHeight / 60.0) } } } .frame(width: RULER_WIDTH, height: totalHeight, alignment: .top) } private func cell(_ period: WidgetPeriod) -> some View { let height = CGFloat(period.virtualEndMinutes - period.virtualStartMinutes) * hourHeight / 60.0 let timeFont = max(6, min(8.5, height * 0.19)) return ZStack { Text(period.name) .font(.system(size: max(8, min(13, height * 0.3)), weight: .medium)) .foregroundStyle(palette.textPrimary) .lineLimit(1) if height >= RULER_TIMES_MIN_CELL { VStack(spacing: 0) { Text(formatHm(period.startMinutes)) Spacer(minLength: 0) Text(formatHm(period.endMinutes)) } .font(.system(size: timeFont)) .foregroundStyle(palette.textSecondary) .lineLimit(1) .padding(.vertical, 2) } } .frame(width: RULER_WIDTH, height: height) .offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0) } private func formatHm(_ minutes: Int) -> String { String(format: "%02d:%02d", minutes / 60, minutes % 60) } } /// One day column: grid lines, break bands and the lesson tiles. Shared by /// the day widget and each of the week widget's five columns. struct TimeGridView: View { let lessons: [WidgetLesson] let periods: [WidgetPeriod] let hourHeight: CGFloat var holiday: WidgetDayInfo? = nil var isToday: Bool = false @Environment(\.widgetPalette) private var palette private var totalHeight: CGFloat { CGFloat(totalVirtualMinutes(periods)) * hourHeight / 60.0 } var body: some View { GeometryReader { geo in let metrics = TileMetrics(periods: periods, hourHeight: hourHeight) let innerWidth = geo.size.width - 2 * (TILE_MARGIN_H + TILE_PADDING_H) ZStack(alignment: .top) { if let holiday { holidayColumn(holiday) } else { if isToday { palette.todayTint.frame(height: totalHeight) } gridLines breakBlocks } ForEach(lessons.indices, id: \.self) { idx in lessonBlock(lessons[idx], metrics: metrics, innerWidth: innerWidth) } } .frame(width: geo.size.width, height: totalHeight, alignment: .top) } .frame(height: totalHeight) } private var gridLines: some View { // `.top` on both the ZStack and the frame — see PeriodRuler. ZStack(alignment: .top) { ForEach(periodBoundaries(periods), id: \.self) { virtualMin in Rectangle() .fill(palette.divider) .frame(height: 1) .offset(y: CGFloat(virtualMin) * hourHeight / 60.0) } } .frame(height: totalHeight, alignment: .top) } private var breakBlocks: some View { ZStack(alignment: .top) { ForEach(periodBreaks(periods), id: \.self) { pause in let height = CGFloat(pause.virtualMinutes) * hourHeight / 60.0 ZStack { palette.breakBlock if height >= BREAK_ICON_MIN_HEIGHT { Image(systemName: "fork.knife") .font(.system(size: 9)) .foregroundStyle(palette.accent) } } .frame(height: height) .offset(y: CGFloat(pause.virtualStart) * hourHeight / 60.0) } } .frame(height: totalHeight, alignment: .top) } /// Mirrors the in-app holiday region: icon and "Schulfrei" on top, the /// holiday name rotated along the column. private func holidayColumn(_ holiday: WidgetDayInfo) -> some View { let nameLength = max(40, totalHeight - 2 * HOLIDAY_NAME_INSET) return ZStack { palette.holidayTint VStack(spacing: 2) { Image(systemName: "calendar") .font(.system(size: 12)) Text("Schulfrei") .font(.system(size: 9)) .lineLimit(1) .minimumScaleFactor(0.6) } .padding(.top, 8) .padding(.horizontal, 2) .frame(maxHeight: .infinity, alignment: .top) // The outer frame carries the rotated footprint: rotationEffect // alone leaves the layout box at its unrotated size. Text(holiday.holidayName ?? "") .font(.system(size: 11, weight: .bold)) .kerning(1) .lineLimit(1) .minimumScaleFactor(0.6) .frame(width: nameLength, height: 14) .rotationEffect(.degrees(90)) .frame(width: 14, height: nameLength) } .foregroundStyle(palette.textPrimary) .frame(height: totalHeight) } @ViewBuilder private func lessonBlock( _ lesson: WidgetLesson, metrics: TileMetrics, innerWidth: CGFloat ) -> some View { let startMinutes = minutesOfDay(lesson.start) let durationMinutes = max(15, Int(lesson.end.timeIntervalSince(lesson.start) / 60)) let virtualStart = realMinutesToVirtual(startMinutes, periods: periods) let virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods: periods) if virtualEnd > virtualStart { // Half the gap above + half below so the grid line under the // tile stays visible. let top = virtualStart * hourHeight / 60.0 + LESSON_GAP / 2 let height = max( MIN_BLOCK_HEIGHT, (virtualEnd - virtualStart) * hourHeight / 60.0 - LESSON_GAP ) LessonTile( lesson: lesson, height: height, metrics: metrics, layout: TileTextLayout( lesson: lesson, innerHeight: height - 2 * TILE_PADDING_V, innerWidth: innerWidth, metrics: metrics ) ) .offset(y: top) } } } /// Mirrors the in-app AppointmentTile: title on top, room and teacher /// stacked below, each line shrinking on its own to fit the tile width. private struct LessonTile: View { let lesson: WidgetLesson let height: CGFloat let metrics: TileMetrics let layout: TileTextLayout private static let cancelledRed = Color(red: 244 / 255, green: 67 / 255, blue: 54 / 255).opacity(200 / 255) var body: some View { let shape = RoundedRectangle(cornerRadius: TILE_CORNER_RADIUS) VStack(alignment: .leading, spacing: 0) { titleRow ForEach(Array(layout.body.enumerated()), id: \.offset) { index, line in Text(line) .font(.system(size: metrics.bodyFont)) .lineLimit(1) .minimumScaleFactor(MIN_BODY_LINE_FONT / metrics.bodyFont) .frame(maxWidth: .infinity, alignment: .leading) .frame(height: metrics.bodyBox) .padding(.trailing, badgeInset(line: index + 1)) } } .foregroundStyle(.white) .padding(.horizontal, TILE_PADDING_H) .padding(.vertical, TILE_PADDING_V) .frame(maxWidth: .infinity, alignment: .topLeading) .frame(height: height, alignment: .topLeading) .background(lesson.tileColor) .clipShape(shape) .overlay(alignment: .bottomTrailing) { if let count = lesson.siblingCount, count > 0 { Text("+\(count)") .font(.system(size: 9, weight: .bold)) .foregroundStyle(.white) .padding(.trailing, TILE_PADDING_H) .padding(.bottom, TILE_PADDING_V) } } .overlay { if lesson.status == .cancelled { ZStack { shape.strokeBorder(LessonTile.cancelledRed, lineWidth: 2) GeometryReader { geo in Path { p in p.move(to: .zero) p.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height)) p.move(to: CGPoint(x: geo.size.width, y: 0)) p.addLine(to: CGPoint(x: 0, y: geo.size.height)) } .stroke(LessonTile.cancelledRed, lineWidth: 2) } } .clipShape(shape) } } .padding(.horizontal, TILE_MARGIN_H) } private var titleRow: some View { HStack(alignment: .center, spacing: 3) { Text(layout.title) .font(.system(size: layout.titleFont, weight: .medium)) .lineLimit(layout.titleLines) .minimumScaleFactor(min(1, MIN_TITLE_FONT / layout.titleFont)) .frame(maxWidth: .infinity, alignment: .leading) if let inline = layout.inline { Text(inline) .font(.system(size: metrics.bodyFont)) .lineLimit(1) .fixedSize() } } .frame(height: layout.titleBox) .padding(.trailing, badgeInset(line: 0)) } private func badgeInset(line: Int) -> CGFloat { layout.badgeLine == line ? BADGE_WIDTH : 0 } } /// Fixed-locale formatters cached once — DateFormatter setup is the /// expensive part and the multi-entry timelines render up to ~8 entries per /// reload. Only touched from WidgetKit's archival rendering, so the shared /// instances are safe. enum WidgetDateFormatters { static let shortDate = make("dd.MM.") static let weekdayShort = make("EE") static let weekdayDate = make("EEEE · dd.MM.") static let time = make("HH:mm") static let dateTime = make("dd.MM. HH:mm") private static func make(_ format: String) -> DateFormatter { let f = DateFormatter() f.locale = Locale(identifier: "de_DE") f.dateFormat = format return f } } /// `now` is the timeline entry's date, not `Date()`: WidgetKit archives /// entries ahead of time, so wall-clock reads would be wrong for every /// entry after the first. func dayLabel(for date: Date, relativeTo now: Date) -> String { let cal = Calendar.current let today = cal.startOfDay(for: now) let anchor = cal.startOfDay(for: date) if anchor == today { return "Heute · \(shortDate(date))" } if let tomorrow = cal.date(byAdding: .day, value: 1, to: today), anchor == tomorrow { return "Morgen · \(shortDate(date))" } return WidgetDateFormatters.weekdayDate.string(from: date) } func shortDate(_ date: Date) -> String { WidgetDateFormatters.shortDate.string(from: date) } func freshnessLabel(for fetchedAt: Date, relativeTo now: Date) -> String { let cal = Calendar.current let today = cal.startOfDay(for: now) let fetchedDay = cal.startOfDay(for: fetchedAt) if fetchedDay == today { return WidgetDateFormatters.time.string(from: fetchedAt) } if let yesterday = cal.date(byAdding: .day, value: -1, to: today), fetchedDay == yesterday { return "gestern \(WidgetDateFormatters.time.string(from: fetchedAt))" } return WidgetDateFormatters.dateTime.string(from: fetchedAt) }