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>
283 lines
11 KiB
Swift
283 lines
11 KiB
Swift
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..<periods.count {
|
|
let curr = periods[i]
|
|
if realMin > 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<Int>()
|
|
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..<periods.count - 1).compactMap { i in
|
|
let gap = periods[i + 1].virtualStartMinutes - periods[i].virtualEndMinutes
|
|
return gap > 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)
|
|
}
|
|
}
|
|
}
|