A .systemLarge widget only gets ~27pt per period, below the 30pt threshold shared with Android, so iOS now shows the times from 24pt with a tighter cell. In non-full-color rendering modes the opaque tiles and the today circle flattened into solid slabs hiding their text; they are drawn translucent with an outline there and use the light-text palette. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
149 lines
6.0 KiB
Swift
149 lines
6.0 KiB
Swift
import SwiftUI
|
||
import WidgetKit
|
||
|
||
struct TimetableWeekView: View {
|
||
let entry: TimetableEntry
|
||
@Environment(\.widgetPalette) private var palette
|
||
@Environment(\.widgetRenderingMode) private var renderingMode
|
||
|
||
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 {
|
||
let cal = Calendar.current
|
||
let days = (0..<5).map { offset in
|
||
cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate
|
||
}
|
||
let weekLessons = data.lessons.filter { lesson in
|
||
days.contains { cal.isDate(lesson.start, inSameDayAs: $0) }
|
||
}
|
||
let periods = trimTrailingPeriods(data.periods, lessons: weekLessons)
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
header(data: data)
|
||
dayHeaderRow(days: days)
|
||
.padding(.top, GRID_MARGIN_TOP)
|
||
GeometryReader { geo in
|
||
let height = resolveHourHeight(forGridHeight: geo.size.height, periods: periods)
|
||
HStack(alignment: .top, spacing: 0) {
|
||
PeriodRuler(periods: periods, hourHeight: height)
|
||
columnDivider
|
||
ForEach(days.indices, id: \.self) { index in
|
||
TimeGridView(
|
||
lessons: weekLessons.filter {
|
||
cal.isDate($0.start, inSameDayAs: days[index])
|
||
},
|
||
periods: periods,
|
||
hourHeight: height,
|
||
holiday: holiday(on: days[index], data: data),
|
||
isToday: cal.isDate(days[index], inSameDayAs: entry.date)
|
||
)
|
||
if index < 4 { columnDivider }
|
||
}
|
||
}
|
||
}
|
||
.padding(.top, 2)
|
||
}
|
||
}
|
||
|
||
private func holiday(on day: Date, data: WidgetTimetableData) -> WidgetDayInfo? {
|
||
data.days?.first {
|
||
$0.isHoliday && Calendar.current.isDate($0.date, inSameDayAs: day)
|
||
}
|
||
}
|
||
|
||
private var columnDivider: some View {
|
||
Rectangle()
|
||
.fill(palette.divider)
|
||
.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(spacing: 6) {
|
||
Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))")
|
||
.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)
|
||
}
|
||
|
||
/// Mirrors the in-app _DayHeaderCell: weekday in caps, day number below,
|
||
/// today inside an accent circle.
|
||
private func dayHeaderRow(days: [Date]) -> some View {
|
||
// The fixed height keeps `columnDivider`'s vertically-flexible
|
||
// Rectangle from stealing space from the GeometryReader below.
|
||
HStack(spacing: 0) {
|
||
Spacer().frame(width: RULER_WIDTH)
|
||
columnDivider
|
||
ForEach(days.indices, id: \.self) { index in
|
||
dayHeaderCell(days[index])
|
||
.frame(maxWidth: .infinity)
|
||
if index < 4 { columnDivider }
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, minHeight: WEEK_HEADER_HEIGHT, maxHeight: WEEK_HEADER_HEIGHT)
|
||
}
|
||
|
||
private func dayHeaderCell(_ day: Date) -> some View {
|
||
// `entry.date`, not `Date()` — WidgetKit archives entries ahead of
|
||
// time, the midnight entry has to mark its own day.
|
||
let isToday = Calendar.current.isDate(day, inSameDayAs: entry.date)
|
||
return VStack(spacing: 1) {
|
||
Text(WidgetDateFormatters.weekdayShort.string(from: day).uppercased())
|
||
.font(.system(size: 9, weight: .medium))
|
||
.foregroundStyle(isToday ? palette.accent : palette.textSecondary)
|
||
.lineLimit(1)
|
||
Text("\(Calendar.current.component(.day, from: day))")
|
||
.font(.system(size: 11, weight: isToday ? .bold : .regular))
|
||
.foregroundStyle(isToday ? todayNumberColor : palette.textPrimary)
|
||
.lineLimit(1)
|
||
.frame(width: 18, height: 18)
|
||
.background(Circle().fill(isToday ? todayCircleColor : Color.clear))
|
||
}
|
||
}
|
||
|
||
// An opaque accent circle flattens to the same shade as its number on
|
||
// tinted / clear home screens; keep the circle translucent there.
|
||
private var todayCircleColor: Color {
|
||
renderingMode == .fullColor ? palette.accent : Color.white.opacity(0.3)
|
||
}
|
||
|
||
private var todayNumberColor: Color {
|
||
renderingMode == .fullColor ? palette.onAccent : palette.textPrimary
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|