import SwiftUI import UIKit // Layout constants and rules โ€” must mirror WidgetRenderer.kt on Android, // otherwise the platforms drift apart on the same widget size. let FALLBACK_VIRTUAL_MINUTES = 11 * 60 let FALLBACK_SLOT_MINUTES = 45 /// Trailing periods without lessons are cut off so the tiles get more /// height โ€” but never below this many periods, or a short day would blow up /// to a handful of oversized tiles. let MIN_VISIBLE_PERIODS = 6 let MIN_HOUR_HEIGHT: CGFloat = 18 let MAX_HOUR_HEIGHT: CGFloat = 72 let MIN_BLOCK_HEIGHT: CGFloat = 16 let LESSON_GAP: CGFloat = 1.5 let TITLE_ROW_HEIGHT: CGFloat = 20 let GRID_MARGIN_TOP: CGFloat = 4 let WEEK_HEADER_HEIGHT: CGFloat = 32 let RULER_WIDTH: CGFloat = 30 let TILE_MARGIN_H: CGFloat = 1 let TILE_PADDING_H: CGFloat = 4 let TILE_PADDING_V: CGFloat = 2 let TILE_CORNER_RADIUS: CGFloat = 7 // In-app tile: 15sp title over 10sp body lines. The widget keeps that ratio // and scales both so title + room + teacher fill one period. let TITLE_TO_BODY_RATIO: CGFloat = 1.5 let MAX_BODY_FONT: CGFloat = 10 let MIN_BODY_FONT: CGFloat = 6.5 let MIN_TITLE_FONT: CGFloat = 6.5 let MIN_BODY_LINE_FONT: CGFloat = 5.5 let LINE_BOX_PER_FONT: CGFloat = 1.2 /// If stacking room and teacher would push the body font below this, both /// share one line instead so the text stays readable. let STACKED_BODY_MIN_FONT: CGFloat = 7.5 /// A title that needs to shrink below this to fit on one line wraps onto a /// second line instead, if the tile has the height for it. let TITLE_WRAP_BELOW_FONT: CGFloat = 8 // Footprint of the "+N" badge in the tile's bottom trailing corner. let BADGE_WIDTH: CGFloat = 16 let BADGE_HEIGHT: CGFloat = 11 /// Lower than Android's 30dp: a .systemLarge widget only gets ~27pt per /// period, so iOS packs the ruler cell tighter (see PeriodRuler.cell) /// instead of dropping the times. let RULER_TIMES_MIN_CELL: CGFloat = 24 let BREAK_ICON_MIN_HEIGHT: CGFloat = 12 /// Space the holiday column keeps free above and below the rotated name for /// the icon and the "Schulfrei" label. let HOLIDAY_NAME_INSET: CGFloat = 44 func realMinutesToVirtual(_ realMin: Int, periods: [WidgetPeriod]) -> CGFloat { guard !periods.isEmpty else { return CGFloat(realMin) } for p in periods where realMin >= p.startMinutes && realMin <= p.endMinutes { return CGFloat(p.virtualStartMinutes + (realMin - p.startMinutes)) } let first = periods.first! if realMin < first.startMinutes { return CGFloat(realMin - first.startMinutes + first.virtualStartMinutes) } let last = periods.last! if realMin > last.endMinutes { return CGFloat(last.virtualEndMinutes + (realMin - last.endMinutes)) } var prev = first for i in 1.. prev.endMinutes && realMin < curr.startMinutes { let gap = curr.startMinutes - prev.endMinutes let virtualGap = curr.virtualStartMinutes - prev.virtualEndMinutes if gap > 0 { return CGFloat(prev.virtualEndMinutes) + CGFloat(realMin - prev.endMinutes) * CGFloat(virtualGap) / CGFloat(gap) } return CGFloat(curr.virtualStartMinutes) } prev = curr } return 0 } func minutesOfDay(_ date: Date) -> Int { let comps = Calendar.current.dateComponents([.hour, .minute], from: date) return (comps.hour ?? 0) * 60 + (comps.minute ?? 0) } func trimTrailingPeriods(_ periods: [WidgetPeriod], lessons: [WidgetLesson]) -> [WidgetPeriod] { guard let latestEnd = lessons.map({ minutesOfDay($0.end) }).max() else { return periods } let lastUsed = periods.lastIndex { $0.startMinutes < latestEnd } ?? -1 return Array(periods.prefix(max(lastUsed + 1, MIN_VISIBLE_PERIODS))) } func totalVirtualMinutes(_ periods: [WidgetPeriod]) -> Int { periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES } func resolveHourHeight(forGridHeight height: CGFloat, periods: [WidgetPeriod]) -> CGFloat { let virtualHours = max(CGFloat(totalVirtualMinutes(periods)) / 60, 1) return max(MIN_HOUR_HEIGHT, min(MAX_HOUR_HEIGHT, height / virtualHours)) } /// Period boundaries deduped: adjacent periods share a line, periods on /// either side of a break get their own (bracketing the break block). func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] { var seen = Set() var result: [Int] = [] for p in periods { for v in [p.virtualStartMinutes, p.virtualEndMinutes] { if seen.insert(v).inserted { result.append(v) } } } return result.sorted() } struct PeriodBreak: Hashable { let virtualStart: Int let virtualMinutes: Int } /// Only big breaks (Hofpause, Mittagspause) survive the mapper's small-break /// collapse as a virtual gap between two periods. func periodBreaks(_ periods: [WidgetPeriod]) -> [PeriodBreak] { guard periods.count > 1 else { return [] } return (0.. 0 ? PeriodBreak(virtualStart: periods[i].virtualEndMinutes, virtualMinutes: gap) : nil } } /// Font and line-box sizes shared by every tile of one widget, derived from /// the height of a single period. struct TileMetrics { let maxBodyLines: Int let bodyFont: CGFloat let titleBox: CGFloat let bodyBox: CGFloat init(periods: [WidgetPeriod], hourHeight: CGFloat) { let slotMinutes = periods.first.map { $0.virtualEndMinutes - $0.virtualStartMinutes } ?? FALLBACK_SLOT_MINUTES let slotHeight = CGFloat(slotMinutes) * hourHeight / 60 let inner = slotHeight - LESSON_GAP - 2 * TILE_PADDING_V func font(lines: CGFloat) -> CGFloat { inner / (LINE_BOX_PER_FONT * (TITLE_TO_BODY_RATIO + lines)) } maxBodyLines = font(lines: 2) < STACKED_BODY_MIN_FONT ? 1 : 2 bodyFont = max(MIN_BODY_FONT, min(MAX_BODY_FONT, font(lines: CGFloat(maxBodyLines)))) titleBox = bodyFont * TITLE_TO_BODY_RATIO * LINE_BOX_PER_FONT bodyBox = bodyFont * LINE_BOX_PER_FONT } } /// Distributes the tile height like the in-app _TileContent: the title comes /// first, then as many body lines as fit. struct TileTextLayout { let title: String let titleBox: CGFloat let titleFont: CGFloat let titleLines: Int let body: [String] /// Set when there is no room for a line below the title: the room moves /// next to it instead. let inline: String? /// Index of the line (0 = title row) that has to stay clear of the "+N" /// badge, or nil when the text ends above it. let badgeLine: Int? init(lesson: WidgetLesson, innerHeight: CGFloat, innerWidth: CGFloat, metrics: TileMetrics) { title = !lesson.subjectShort.isEmpty ? lesson.subjectShort : (lesson.subjectLong ?? "โ€”") let candidates = [lesson.room, lesson.teacher ?? lesson.originalTeacher] .compactMap { $0 } .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } let singleTitleBox = min(metrics.titleBox, innerHeight) let capacity = max( 0, min(metrics.maxBodyLines, Int((innerHeight - singleTitleBox) / metrics.bodyBox + 0.05)) ) if capacity >= candidates.count { body = candidates } else if capacity == 1 { // Rather share one line than drop the teacher entirely. body = [candidates.joined(separator: " ยท ")] } else { body = [] } if capacity == 0, let first = candidates.first, !title.contains(first) { inline = first } else { inline = nil } let spare = innerHeight - singleTitleBox - CGFloat(body.count) * metrics.bodyBox if spare >= singleTitleBox, let wrappedLine = TileTextLayout.wrappedTitleLineBox( title, innerWidth: innerWidth, metrics: metrics ) { titleBox = 2 * wrappedLine titleFont = wrappedLine / LINE_BOX_PER_FONT titleLines = 2 } else { titleBox = singleTitleBox titleFont = singleTitleBox / LINE_BOX_PER_FONT titleLines = 1 } let content = titleBox + CGFloat(body.count) * metrics.bodyBox let hasBadge = (lesson.siblingCount ?? 0) > 0 badgeLine = hasBadge && innerHeight - content < BADGE_HEIGHT ? body.count : nil } /// Line box for a title wrapped onto two lines, or nil when it should /// stay on one: it fits there at a readable size, or it has no word /// boundary to break at. The box is capped so the longest word still /// fits the tile width. private static func wrappedTitleLineBox( _ title: String, innerWidth: CGFloat, metrics: TileMetrics ) -> CGFloat? { func widthPerFontPoint(_ text: String) -> CGFloat { let font = UIFont.systemFont(ofSize: 100, weight: .medium) return (text as NSString).size(withAttributes: [.font: font]).width / 100 } if widthPerFontPoint(title) * TITLE_WRAP_BELOW_FONT <= innerWidth { return nil } let words = title .replacingOccurrences(of: "-", with: "- ") .split(separator: " ") .map(String.init) guard words.count > 1, let widest = words.map(widthPerFontPoint).max(), widest > 0 else { return nil } return min(metrics.titleBox, innerWidth / widest * LINE_BOX_PER_FONT) } } extension Color { /// Parses the `#RRGGBB` tile colour resolved in Dart. init?(widgetHex hex: String?) { guard let hex, hex.count == 7, hex.hasPrefix("#"), let value = UInt32(hex.dropFirst(), radix: 16) else { return nil } self.init( red: Double((value >> 16) & 0xFF) / 255, green: Double((value >> 8) & 0xFF) / 255, blue: Double(value & 0xFF) / 255 ) } } extension WidgetLesson { var tileColor: Color { if let resolved = Color(widgetHex: color) { return resolved } // Only reached for payloads written before Dart shipped the resolved // tile colour; mirrors lesson_color.dart. switch status { // No time-based highlight: the widget timeline only ticks every // ~30min, so "current" and "past" would be stale most of the day. case .regular, .past, .ongoing: return Color(red: 153 / 255, green: 51 / 255, blue: 51 / 255) case .cancelled: return .black case .irregular: return Color(red: 143 / 255, green: 25 / 255, blue: 179 / 255) case .teacherChanged: return Color(red: 41 / 255, green: 99 / 255, blue: 155 / 255) case .duty: return Color(red: 0, green: 121 / 255, blue: 107 / 255) case .event: return customColor != nil ? Color(red: 1, green: 160 / 255, blue: 0) : Color(red: 46 / 255, green: 125 / 255, blue: 50 / 255) } } }