reworked home screen widgets to mirror the in-app timetable, hidden below android 12

This commit is contained in:
2026-09-27 14:07:52 +02:00
parent 2aed3d104c
commit 2ca9feb49a
44 changed files with 2401 additions and 1245 deletions
@@ -20,36 +20,44 @@ struct TimetableWeekView: View {
@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(data: data)
dayHeaderRow(days: days)
.padding(.top, GRID_MARGIN_TOP)
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
// 45pt is the minimum where a 7pt-font room (e.g. "F2.04")
// plus the subject fits without `minimumScaleFactor`
// shrinking the subject into illegibility.
let subjectOnly = dayColumnWidth < 45
let height = resolveHourHeight(forGridHeight: geo.size.height, periods: periods)
HStack(alignment: .top, spacing: 0) {
timeLabelsColumn(hourHeight: hourHeight, periods: data.periods)
.frame(width: 28, alignment: .topTrailing)
PeriodRuler(periods: periods, hourHeight: height)
columnDivider
ForEach(0..<5, id: \.self) { offset in
column(
data: data,
offset: offset,
hourHeight: hourHeight,
subjectOnly: subjectOnly
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)
)
.frame(maxWidth: .infinity)
if offset < 4 { columnDivider }
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)
}
}
@@ -63,112 +71,55 @@ struct TimetableWeekView: 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 {
return HStack(spacing: 6) {
Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))")
.font(.system(size: 12, weight: .semibold))
.font(.system(size: 14, weight: .bold))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.8)
Spacer()
.minimumScaleFactor(0.65)
.frame(maxWidth: .infinity, alignment: .leading)
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
.font(.system(size: 9))
.font(.system(size: 10))
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.fixedSize()
}
.frame(height: TITLE_ROW_HEIGHT)
}
private func dayHeaderRow(data: WidgetTimetableData) -> some View {
let cal = Calendar.current
// `.frame(maxWidth: .infinity)` is critical: without an explicit
// greedy width (or a true `Spacer()` inside), this HStack collapses
// to the sum of its fixed-width children in a `.leading` VStack
// and the 5 day-columns end up sharing ~5pt of width — the labels
// crunch to the left and stop aligning with the grid columns below.
/// 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.
return HStack(spacing: 0) {
Spacer().frame(width: 28)
// Rectangle from stealing space from the GeometryReader below.
HStack(spacing: 0) {
Spacer().frame(width: RULER_WIDTH)
columnDivider
ForEach(0..<5, id: \.self) { offset in
let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate
VStack(spacing: -1) {
Text(weekday(for: day))
.font(.system(size: 10, weight: .bold))
.foregroundStyle(palette.textPrimary)
Text(shortDate(day))
.font(.system(size: 8))
.foregroundStyle(palette.textSecondary)
}
.frame(maxWidth: .infinity)
if offset < 4 { columnDivider }
ForEach(days.indices, id: \.self) { index in
dayHeaderCell(days[index])
.frame(maxWidth: .infinity)
if index < 4 { columnDivider }
}
}
// 20pt fits 10pt weekday + 8pt date with tight -1pt spacing.
.frame(maxWidth: .infinity, minHeight: 20, maxHeight: 20)
.frame(maxWidth: .infinity, minHeight: WEEK_HEADER_HEIGHT, maxHeight: WEEK_HEADER_HEIGHT)
}
private func timeLabelsColumn(hourHeight: CGFloat, periods: [WidgetPeriod]) -> some View {
let totalMin = periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
let totalHeight = CGFloat(totalMin) * hourHeight / 60.0
// Mirrors TimeGridView.compactLabels — below 26pt/hour a stacked
// time+name no longer fits in one period slot and the two labels
// visually clip into the next slot. Drop to a single bold period
// number until there's room to read both lines.
let compact = hourHeight < 26
return ZStack(alignment: .topTrailing) {
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
VStack(alignment: .trailing, spacing: -2) {
if compact {
Text("\(period.name).")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
} else {
Text(String(format: "%02d:%02d", period.startMinutes / 60, period.startMinutes % 60))
.font(.system(size: 9))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
Text("\(period.name).")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
}
}
.padding(.trailing, 4)
.offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
}
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 ? palette.onAccent : palette.textPrimary)
.lineLimit(1)
.frame(width: 18, height: 18)
.background(Circle().fill(isToday ? palette.accent : Color.clear))
}
.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,
hourHeight: hourHeight,
showRoom: !subjectOnly,
showTeacher: !subjectOnly,
showTimeLabels: false,
horizontalPadding: 3
)
}
private func weekday(for date: Date) -> String {
WidgetDateFormatters.weekdayShort.string(from: date)
}
private func placeholder(_ message: String) -> some View {