reworked home screen widgets to mirror the in-app timetable, hidden below android 12
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
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
|
||||
|
||||
let RULER_TIMES_MIN_CELL: CGFloat = 30
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user