Files
Client/ios/TimetableWidgetExtension/TimetableDayView.swift
T

435 lines
16 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)
.padding(.vertical, 2)
}
}
.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
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(lesson.tileColor)
.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)
}
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)
}