import SwiftUI import WidgetKit // Layout constants — must mirror WidgetRenderer.kt on Android, otherwise // the platforms drift apart on the same widget size. let FALLBACK_VIRTUAL_MINUTES = 11 * 60 let MIN_HOUR_HEIGHT: CGFloat = 18 let MAX_HOUR_HEIGHT: CGFloat = 72 let MIN_BLOCK_HEIGHT: CGFloat = 16 let LESSON_GAP: CGFloat = 1.5 func realMinutesToVirtual(_ realMin: Int, periods: [WidgetPeriod]) -> CGFloat { guard !periods.isEmpty else { return CGFloat(realMin) } for p in periods where realMin >= p.startMinutes && realMin <= p.endMinutes { return CGFloat(p.virtualStartMinutes + (realMin - p.startMinutes)) } let first = periods.first! if realMin < first.startMinutes { return CGFloat(realMin - first.startMinutes + first.virtualStartMinutes) } let last = periods.last! if realMin > last.endMinutes { return CGFloat(last.virtualEndMinutes + (realMin - last.endMinutes)) } var prev = first for i in 1.. prev.endMinutes && realMin < curr.startMinutes { let gap = curr.startMinutes - prev.endMinutes let virtualGap = curr.virtualStartMinutes - prev.virtualEndMinutes if gap > 0 { return CGFloat(prev.virtualEndMinutes) + CGFloat(realMin - prev.endMinutes) * CGFloat(virtualGap) / CGFloat(gap) } return CGFloat(curr.virtualStartMinutes) } prev = curr } return 0 } let BLOCK_SHOW_ROOM_MIN: CGFloat = 18 let BLOCK_SHOW_TEACHER_SEPARATE_MIN: CGFloat = 30 let MIN_SUBJECT_FONT: CGFloat = 9 let MAX_SUBJECT_FONT: CGFloat = 14 let MIN_SECONDARY_FONT: CGFloat = 7 func subjectFont(forHourHeight hourHeight: CGFloat) -> CGFloat { let t = max(0, min(1, (hourHeight - MIN_HOUR_HEIGHT) / (MAX_HOUR_HEIGHT - MIN_HOUR_HEIGHT))) return MIN_SUBJECT_FONT + t * (MAX_SUBJECT_FONT - MIN_SUBJECT_FONT) } struct TimetableDayView: 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: 6) { 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 totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES) TimeGridView( lessons: data.lessons, periods: data.periods, hourHeight: max( MIN_HOUR_HEIGHT, min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60) ), showRoom: true, showTeacher: true, showTimeLabels: true ) } } } } private func header(data: WidgetTimetableData) -> some View { HStack { Text(dayLabel(for: data.anchorDate)) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(.primary) Spacer() Text("Stand: \(freshnessLabel(for: data.fetchedAt))") .font(.system(size: 10)) .foregroundStyle(.secondary) } } private func emptyState(text: String) -> some View { VStack { Spacer() Text(text) .font(.caption) .foregroundStyle(.secondary) 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)) Text(message) .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity, maxHeight: .infinity) } } struct TimeGridView: View { let lessons: [WidgetLesson] let periods: [WidgetPeriod] let hourHeight: CGFloat let showRoom: Bool let showTeacher: Bool let showTimeLabels: Bool /// Week-widget passes 3 for narrow columns; day-widget keeps 7. var horizontalPadding: CGFloat = 7 private var totalVirtualMinutes: Int { periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES } private var totalHeight: CGFloat { CGFloat(totalVirtualMinutes) * hourHeight / 60.0 } /// Below this per-hour height the two-line label collapses to a single /// period number — time + number overlap otherwise. private var compactLabels: Bool { hourHeight < 26 } var body: some View { HStack(alignment: .top, spacing: 0) { if showTimeLabels { timeLabelsColumn .frame(width: 32, alignment: .topTrailing) Rectangle() .fill(Color.primary.opacity(0.13)) .frame(width: 1) } ZStack(alignment: .top) { gridLines breakBlocks ForEach(lessons.indices, id: \.self) { idx in lessonBlock(lessons[idx]) } } .frame(maxWidth: .infinity, minHeight: totalHeight, alignment: .top) } } private var timeLabelsColumn: some View { ZStack(alignment: .topTrailing) { // Hour rules continue through the time-label column so it reads // as a real table column rather than a free-floating tick list. // Hour rules extend through the time-label column so it reads // as a table column rather than a free-floating tick list. 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) { if compactLabels { Text("\(period.name).") .font(.system(size: 9, weight: .bold)) .foregroundStyle(.primary) .lineLimit(1) } else { Text(formatHm(period.startMinutes)) .font(.system(size: 9)) .foregroundStyle(.primary) .lineLimit(1) Text("\(period.name).") .font(.system(size: 7, weight: .bold)) .foregroundStyle(.secondary) .lineLimit(1) } } .padding(.trailing, 4) .offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0) } } .frame(height: totalHeight, alignment: .topTrailing) } private var gridLines: some View { ZStack(alignment: .top) { // Hour rules continue through the time-label column so it reads // as a real table column rather than a free-floating tick list. ForEach(periodBoundaries(periods), id: \.self) { virtualMin in Rectangle() .fill(Color.primary.opacity(0.08)) .frame(height: 1) .offset(y: CGFloat(virtualMin) * hourHeight / 60.0) } } .frame(height: totalHeight) } private var breakBlocks: some View { ZStack(alignment: .top) { ForEach(0.. 0 { Rectangle() .fill(Color.primary.opacity(0.03)) .frame(height: CGFloat(virtualGap) * hourHeight / 60.0) .padding(.horizontal, 1) .offset(y: CGFloat(curr.virtualEndMinutes) * hourHeight / 60.0) } } } .frame(height: totalHeight) } private func formatHm(_ minutes: Int) -> String { String(format: "%02d:%02d", minutes / 60, minutes % 60) } @ViewBuilder private func lessonBlock(_ lesson: WidgetLesson) -> some View { let cal = Calendar.current let comps = cal.dateComponents([.hour, .minute], from: lesson.start) let startMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0) 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 { let top = virtualStart * hourHeight / 60.0 + LESSON_GAP / 2 let height = max( MIN_BLOCK_HEIGHT, (virtualEnd - virtualStart) * hourHeight / 60.0 - LESSON_GAP ) let subjectSize = subjectFont(forHourHeight: hourHeight) let secondarySize = max(MIN_SECONDARY_FONT, subjectSize - 2) let room = lesson.room let teacher = lesson.teacher ?? lesson.originalTeacher let hasSecondary = (room?.isEmpty == false) || (teacher?.isEmpty == false) HStack(alignment: .top, spacing: 4) { Text(subjectLabel(lesson)) .font(.system(size: subjectSize, weight: .semibold)) .foregroundStyle(.white) .lineLimit(hasSecondary ? 1 : 2) .minimumScaleFactor(0.5) if hasSecondary { Spacer(minLength: 0) VStack(alignment: .trailing, spacing: -1) { if showRoom && height >= BLOCK_SHOW_ROOM_MIN { if let room, !room.isEmpty { Text(room) .font(.system(size: secondarySize)) .foregroundStyle(.white.opacity(0.85)) .lineLimit(1) .minimumScaleFactor(0.5) } if showTeacher, height >= BLOCK_SHOW_TEACHER_SEPARATE_MIN, let teacher, !teacher.isEmpty { Text(teacher) .font(.system(size: secondarySize)) .foregroundStyle(.white.opacity(0.7)) .lineLimit(1) .minimumScaleFactor(0.5) } } } } } .padding(.horizontal, horizontalPadding) .padding(.vertical, 3) .frame(maxWidth: .infinity, alignment: .topLeading) .frame(height: height, alignment: .topLeading) .background(blockColor(lesson)) .cornerRadius(6) .overlay(alignment: .bottomLeading) { // Separate fixed-size badge so the +N hint stays readable // when the subject autoshrinks on narrow tiles. if let count = lesson.siblingCount, count > 0 { Text("+\(count)") .font(.system(size: 12, weight: .bold)) .foregroundStyle(.white) .padding(.leading, horizontalPadding) .padding(.bottom, 2) } } .overlay { // CrossPainter parity: clip cross to the rounded shape so // the diagonals don't bleed past the corners. if lesson.status == .cancelled { ZStack { RoundedRectangle(cornerRadius: 6) .stroke(Color.red.opacity(0.78), lineWidth: 1.5) 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(Color.red.opacity(0.78), lineWidth: 3) } } .clipShape(RoundedRectangle(cornerRadius: 6)) } } .padding(.horizontal, showRoom ? 2 : 1) .offset(y: top) } } private func subjectLabel(_ lesson: WidgetLesson) -> String { !lesson.subjectShort.isEmpty ? lesson.subjectShort : (lesson.subjectLong ?? "—") } /// Mirrors lesson_color.dart + custom_event_colors.dart so the widget /// matches the in-app calendar exactly. private func blockColor(_ lesson: WidgetLesson) -> Color { if lesson.status == .event, let custom = lesson.customColor { switch custom { case "orange": return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0) case "red": return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0) case "green": return Color(red: 76/255.0, green: 175/255.0, blue: 80/255.0) case "blue": return Color(red: 33/255.0, green: 150/255.0, blue: 243/255.0) default: break } } switch lesson.status { case .regular, .past: return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0) case .ongoing: return Color(red: 200/255.0, green: 51/255.0, blue: 51/255.0) case .cancelled: return .black case .irregular: return Color(red: 143/255.0, green: 25/255.0, blue: 179/255.0) case .teacherChanged: return Color(red: 41/255.0, green: 99/255.0, blue: 155/255.0) case .event: return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0) } } } /// Period boundaries deduped: adjacent periods share a line, periods on /// either side of a break get their own (bracketing the break block). func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] { var seen = Set() var result: [Int] = [] for p in periods { for v in [p.virtualStartMinutes, p.virtualEndMinutes] { if seen.insert(v).inserted { result.append(v) } } } return result.sorted() } func dayLabel(for date: Date) -> String { let cal = Calendar.current let today = cal.startOfDay(for: Date()) 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))" } let formatter = DateFormatter() formatter.locale = Locale(identifier: "de_DE") formatter.dateFormat = "EEEE · dd.MM." return formatter.string(from: date) } func shortDate(_ date: Date) -> String { let f = DateFormatter() f.locale = Locale(identifier: "de_DE") f.dateFormat = "dd.MM." return f.string(from: date) } func freshnessLabel(for fetchedAt: Date) -> String { let cal = Calendar.current let today = cal.startOfDay(for: Date()) let fetchedDay = cal.startOfDay(for: fetchedAt) let timeFmt = DateFormatter() timeFmt.locale = Locale(identifier: "de_DE") timeFmt.dateFormat = "HH:mm" if fetchedDay == today { return timeFmt.string(from: fetchedAt) } if let yesterday = cal.date(byAdding: .day, value: -1, to: today), fetchedDay == yesterday { return "gestern \(timeFmt.string(from: fetchedAt))" } let dateTimeFmt = DateFormatter() dateTimeFmt.locale = Locale(identifier: "de_DE") dateTimeFmt.dateFormat = "dd.MM. HH:mm" return dateTimeFmt.string(from: fetchedAt) }