From 58fb843f3d572d625d86e2791a63989381eda542 Mon Sep 17 00:00:00 2001 From: Marianum Date: Wed, 13 May 2026 16:09:43 +0200 Subject: [PATCH] fixed iOS widget layout --- .../mobile/client/widgets/WidgetRenderer.kt | 5 +- .../TimetableDayView.swift | 42 +++++++++++---- .../TimetableWeekView.swift | 52 +++++++++++++------ .../TimetableWidgetExtension.swift | 11 +++- 4 files changed, 80 insertions(+), 30 deletions(-) diff --git a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetRenderer.kt b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetRenderer.kt index a9d1c22..506d4a2 100644 --- a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetRenderer.kt +++ b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetRenderer.kt @@ -644,7 +644,10 @@ object WidgetRenderer { WidgetLessonStatus.TEACHER_CHANGED -> R.drawable.widget_lesson_block_teacher_changed WidgetLessonStatus.PAST -> R.drawable.widget_lesson_block_past WidgetLessonStatus.EVENT -> R.drawable.widget_lesson_block_event_orange - WidgetLessonStatus.ONGOING -> R.drawable.widget_lesson_block_ongoing + // ONGOING collapses into REGULAR — widgets only refresh every + // ~30min so "the current lesson" is stale most of the time and + // the visual highlight would mislead more than help. + WidgetLessonStatus.ONGOING, WidgetLessonStatus.REGULAR -> R.drawable.widget_lesson_block_regular } } diff --git a/ios/TimetableWidgetExtension/TimetableDayView.swift b/ios/TimetableWidgetExtension/TimetableDayView.swift index 7ef52c4..5678b0d 100644 --- a/ios/TimetableWidgetExtension/TimetableDayView.swift +++ b/ios/TimetableWidgetExtension/TimetableDayView.swift @@ -39,8 +39,11 @@ func realMinutesToVirtual(_ realMin: Int, periods: [WidgetPeriod]) -> CGFloat { return 0 } -let BLOCK_SHOW_ROOM_MIN: CGFloat = 18 -let BLOCK_SHOW_TEACHER_SEPARATE_MIN: CGFloat = 30 +// Below these tile heights the secondary stack collapses progressively: +// shorter than ROOM_MIN drops everything, between ROOM_MIN and +// TEACHER_SEPARATE_MIN keeps the room but drops the teacher. +let BLOCK_SHOW_ROOM_MIN: CGFloat = 13 +let BLOCK_SHOW_TEACHER_SEPARATE_MIN: CGFloat = 20 let MIN_SUBJECT_FONT: CGFloat = 9 let MAX_SUBJECT_FONT: CGFloat = 14 @@ -70,7 +73,7 @@ struct TimetableDayView: View { @ViewBuilder private func content(data: WidgetTimetableData) -> some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 3) { header(data: data) if data.isHoliday { emptyState(text: data.holidayName ?? "Ferien") @@ -96,14 +99,18 @@ struct TimetableDayView: View { } private func header(data: WidgetTimetableData) -> some View { - HStack { + HStack(spacing: 4) { Text(dayLabel(for: data.anchorDate)) - .font(.system(size: 14, weight: .semibold)) + .font(.system(size: 13, weight: .semibold)) .foregroundStyle(palette.textPrimary) - Spacer() + .lineLimit(1) + .minimumScaleFactor(0.7) + Spacer(minLength: 4) Text("Stand: \(freshnessLabel(for: data.fetchedAt))") - .font(.system(size: 10)) + .font(.system(size: 9)) .foregroundStyle(palette.textSecondary) + .lineLimit(1) + .minimumScaleFactor(0.7) } } @@ -212,6 +219,14 @@ struct TimeGridView: View { } private var gridLines: some View { + // `.frame(height: totalHeight, alignment: .top)` — without the + // explicit `.top`, SwiftUI's frame modifier defaults to `.center`. + // Every Rectangle here has layout-y=0 (the `.offset()`s shift the + // rendered position only, not layout), so the inner ZStack's + // natural height is just 1pt; a 1pt ZStack centred in a 300pt + // frame anchors at the middle, and every line then renders + // relative to that midpoint — the grid visually starts halfway + // down the column and the later periods clip past the bottom. ZStack(alignment: .top) { ForEach(periodBoundaries(periods), id: \.self) { virtualMin in Rectangle() @@ -220,10 +235,11 @@ struct TimeGridView: View { .offset(y: CGFloat(virtualMin) * hourHeight / 60.0) } } - .frame(height: totalHeight) + .frame(height: totalHeight, alignment: .top) } private var breakBlocks: some View { + // Same `.center`-vs-`.top` bug as gridLines — see comment above. ZStack(alignment: .top) { ForEach(0.. String { @@ -357,8 +373,12 @@ struct TimeGridView: View { } } 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) + // `.ongoing` deliberately collapses into the regular red — the + // widget timeline only ticks every ~30min, so highlighting the + // "current" lesson would be stale most of the day and misleads + // more than it helps. + case .regular, .past, .ongoing: + return Color(red: 153/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) diff --git a/ios/TimetableWidgetExtension/TimetableWeekView.swift b/ios/TimetableWidgetExtension/TimetableWeekView.swift index 53904a6..3e3eb22 100644 --- a/ios/TimetableWidgetExtension/TimetableWeekView.swift +++ b/ios/TimetableWidgetExtension/TimetableWeekView.swift @@ -20,7 +20,7 @@ struct TimetableWeekView: View { @ViewBuilder private func content(data: WidgetTimetableData) -> some View { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 0) { header(data: data) dayHeaderRow(data: data) GeometryReader { geo in @@ -30,7 +30,10 @@ struct TimetableWeekView: View { min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60) ) let dayColumnWidth = (geo.size.width - 28 - 4) / 5 - let subjectOnly = dayColumnWidth < 70 + // 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 HStack(alignment: .top, spacing: 0) { timeLabelsColumn(hourHeight: hourHeight, periods: data.periods) .frame(width: 28, alignment: .topTrailing) @@ -62,12 +65,15 @@ struct TimetableWeekView: View { 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)) + .font(.system(size: 12, weight: .semibold)) .foregroundStyle(palette.textPrimary) + .lineLimit(1) + .minimumScaleFactor(0.8) Spacer() Text("Stand: \(freshnessLabel(for: data.fetchedAt))") - .font(.system(size: 10)) + .font(.system(size: 9)) .foregroundStyle(palette.textSecondary) + .lineLimit(1) } } @@ -85,24 +91,30 @@ struct TimetableWeekView: View { columnDivider ForEach(0..<5, id: \.self) { offset in let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate - VStack(spacing: 0) { + VStack(spacing: -1) { Text(weekday(for: day)) - .font(.system(size: 11, weight: .bold)) + .font(.system(size: 10, weight: .bold)) .foregroundStyle(palette.textPrimary) Text(shortDate(day)) - .font(.system(size: 9)) + .font(.system(size: 8)) .foregroundStyle(palette.textSecondary) } .frame(maxWidth: .infinity) if offset < 4 { columnDivider } } } - .frame(maxWidth: .infinity, minHeight: 26, maxHeight: 26) + // 20pt fits 10pt weekday + 8pt date with tight -1pt spacing. + .frame(maxWidth: .infinity, minHeight: 20, maxHeight: 20) } 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() @@ -112,15 +124,23 @@ struct TimetableWeekView: View { } 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(palette.textPrimary) - .lineLimit(1) - Text("\(period.name).") - .font(.system(size: 6, weight: .bold)) - .foregroundStyle(palette.textSecondary) - .lineLimit(1) + 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) } } diff --git a/ios/TimetableWidgetExtension/TimetableWidgetExtension.swift b/ios/TimetableWidgetExtension/TimetableWidgetExtension.swift index 3b1be5c..e231ea1 100644 --- a/ios/TimetableWidgetExtension/TimetableWidgetExtension.swift +++ b/ios/TimetableWidgetExtension/TimetableWidgetExtension.swift @@ -20,7 +20,11 @@ struct TimetableDayWidget: Widget { } .configurationDisplayName("Marianum · Heute") .description("Stundenplan und Vertretungen für den anstehenden Schultag.") - .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + // Only .systemLarge: iOS gives us no tall-portrait family, so the + // 162×162 .systemSmall can't fit a readable 11-period day grid, + // and .systemMedium is short-wide which crunches the labels. + // Single useful size beats three crippled ones. + .supportedFamilies([.systemLarge]) } } @@ -56,7 +60,10 @@ struct TimetableWeekWidget: Widget { } .configurationDisplayName("Marianum · Woche") .description("Stundenplan und Vertretungen für die ganze Schulwoche.") - .supportedFamilies([.systemMedium, .systemLarge, .systemExtraLarge]) + // .systemMedium is dropped — 5 day columns plus a time-label column + // in a 4×2 cell-strip yields per-column widths well below the + // subject-only threshold and the labels in the time sidebar overlap. + .supportedFamilies([.systemLarge, .systemExtraLarge]) } }