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>
454 lines
17 KiB
Swift
454 lines
17 KiB
Swift
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)
|
|
.minimumScaleFactor(0.8)
|
|
.padding(.vertical, 1)
|
|
}
|
|
}
|
|
.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
|
|
|
|
@Environment(\.widgetRenderingMode) private var renderingMode
|
|
|
|
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 { tileBackground(shape) }
|
|
.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)
|
|
}
|
|
|
|
/// Tinted / clear home screens flatten an opaque colour block into a
|
|
/// solid slab the white text disappears on; a translucent fill with an
|
|
/// outline keeps tile and text apart in every rendering mode.
|
|
@ViewBuilder
|
|
private func tileBackground(_ shape: RoundedRectangle) -> some View {
|
|
if renderingMode == .fullColor {
|
|
lesson.tileColor
|
|
} else {
|
|
ZStack {
|
|
shape.fill(Color.white.opacity(0.18))
|
|
shape.strokeBorder(Color.white.opacity(0.55), lineWidth: 1)
|
|
}
|
|
.widgetAccentable()
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|