reworked home screen widgets to mirror the in-app timetable, hidden below android 12
This commit is contained in:
@@ -1,59 +1,6 @@
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
// Layout constants — must mirror WidgetRenderer.kt on Android, otherwise
|
||||
// the platforms drift apart on the same widget size.
|
||||
let FALLBACK_VIRTUAL_MINUTES = 11 * 60
|
||||
let MIN_HOUR_HEIGHT: CGFloat = 18
|
||||
let MAX_HOUR_HEIGHT: CGFloat = 72
|
||||
let MIN_BLOCK_HEIGHT: CGFloat = 16
|
||||
let LESSON_GAP: CGFloat = 1.5
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// Below these tile heights the secondary stack collapses progressively:
|
||||
// shorter than ROOM_MIN drops everything, between ROOM_MIN and
|
||||
// TEACHER_SEPARATE_MIN keeps the room but drops the teacher.
|
||||
let BLOCK_SHOW_ROOM_MIN: CGFloat = 13
|
||||
let BLOCK_SHOW_TEACHER_SEPARATE_MIN: CGFloat = 20
|
||||
|
||||
let MIN_SUBJECT_FONT: CGFloat = 9
|
||||
let MAX_SUBJECT_FONT: CGFloat = 14
|
||||
let MIN_SECONDARY_FONT: CGFloat = 7
|
||||
|
||||
func subjectFont(forHourHeight hourHeight: CGFloat) -> CGFloat {
|
||||
let t = max(0, min(1, (hourHeight - MIN_HOUR_HEIGHT) / (MAX_HOUR_HEIGHT - MIN_HOUR_HEIGHT)))
|
||||
return MIN_SUBJECT_FONT + t * (MAX_SUBJECT_FONT - MIN_SUBJECT_FONT)
|
||||
}
|
||||
|
||||
struct TimetableDayView: View {
|
||||
let entry: TimetableEntry
|
||||
@Environment(\.widgetPalette) private var palette
|
||||
@@ -73,7 +20,7 @@ struct TimetableDayView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func content(data: WidgetTimetableData) -> some View {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
VStack(alignment: .leading, spacing: GRID_MARGIN_TOP) {
|
||||
header(data: data)
|
||||
if data.isHoliday {
|
||||
emptyState(text: data.holidayName ?? "Ferien")
|
||||
@@ -81,37 +28,41 @@ struct TimetableDayView: View {
|
||||
emptyState(text: "Keine Stunden")
|
||||
} else {
|
||||
GeometryReader { geo in
|
||||
let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES)
|
||||
TimeGridView(
|
||||
lessons: data.lessons,
|
||||
periods: data.periods,
|
||||
hourHeight: max(
|
||||
MIN_HOUR_HEIGHT,
|
||||
min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60)
|
||||
),
|
||||
showRoom: true,
|
||||
showTeacher: true,
|
||||
showTimeLabels: true
|
||||
)
|
||||
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: 4) {
|
||||
HStack(spacing: 6) {
|
||||
Text(dayLabel(for: data.anchorDate, relativeTo: entry.date))
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.font(.system(size: 14, weight: .bold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
Spacer(minLength: 4)
|
||||
.minimumScaleFactor(0.65)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
|
||||
.font(.system(size: 9))
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
.fixedSize()
|
||||
}
|
||||
.frame(height: TITLE_ROW_HEIGHT)
|
||||
}
|
||||
|
||||
private func emptyState(text: String) -> some View {
|
||||
@@ -139,54 +90,23 @@ struct TimetableDayView: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct TimeGridView: View {
|
||||
let lessons: [WidgetLesson]
|
||||
/// 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
|
||||
let showRoom: Bool
|
||||
let showTeacher: Bool
|
||||
let showTimeLabels: Bool
|
||||
/// Week-widget passes 3 for narrow columns; day-widget keeps 7.
|
||||
var horizontalPadding: CGFloat = 7
|
||||
|
||||
@Environment(\.widgetPalette) private var palette
|
||||
|
||||
private var totalVirtualMinutes: Int {
|
||||
periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
|
||||
}
|
||||
|
||||
private var totalHeight: CGFloat {
|
||||
CGFloat(totalVirtualMinutes) * hourHeight / 60.0
|
||||
CGFloat(totalVirtualMinutes(periods)) * hourHeight / 60.0
|
||||
}
|
||||
|
||||
/// Below this per-hour height the two-line label collapses to a single
|
||||
/// period number — time + number overlap otherwise.
|
||||
private var compactLabels: Bool { hourHeight < 26 }
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
if showTimeLabels {
|
||||
// 28pt matches the Week widget's time-label column so the
|
||||
// visual rhythm is identical across both widgets.
|
||||
timeLabelsColumn
|
||||
.frame(width: 28, alignment: .topTrailing)
|
||||
Rectangle()
|
||||
.fill(palette.divider)
|
||||
.frame(width: 1)
|
||||
}
|
||||
ZStack(alignment: .top) {
|
||||
gridLines
|
||||
breakBlocks
|
||||
ForEach(lessons.indices, id: \.self) { idx in
|
||||
lessonBlock(lessons[idx])
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, minHeight: totalHeight, alignment: .top)
|
||||
}
|
||||
}
|
||||
|
||||
private var timeLabelsColumn: some View {
|
||||
ZStack(alignment: .topTrailing) {
|
||||
// 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)
|
||||
@@ -194,39 +114,91 @@ struct TimeGridView: View {
|
||||
.offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
|
||||
}
|
||||
ForEach(periods, id: \.startMinutes) { period in
|
||||
VStack(alignment: .trailing, spacing: -2) {
|
||||
if compactLabels {
|
||||
Text("\(period.name).")
|
||||
.font(.system(size: 9, weight: .bold))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
} else {
|
||||
Text(formatHm(period.startMinutes))
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
Text("\(period.name).")
|
||||
.font(.system(size: 7, weight: .bold))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
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)
|
||||
}
|
||||
.padding(.trailing, 4)
|
||||
.offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
|
||||
}
|
||||
}
|
||||
.frame(height: totalHeight, alignment: .topTrailing)
|
||||
.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 {
|
||||
// `.frame(height: totalHeight, alignment: .top)` — without the
|
||||
// explicit `.top`, SwiftUI's frame modifier defaults to `.center`.
|
||||
// Every Rectangle here has layout-y=0 (the `.offset()`s shift the
|
||||
// rendered position only, not layout), so the inner ZStack's
|
||||
// natural height is just 1pt; a 1pt ZStack centred in a 300pt
|
||||
// frame anchors at the middle, and every line then renders
|
||||
// relative to that midpoint — the grid visually starts halfway
|
||||
// down the column and the later periods clip past the bottom.
|
||||
// `.top` on both the ZStack and the frame — see PeriodRuler.
|
||||
ZStack(alignment: .top) {
|
||||
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
|
||||
Rectangle()
|
||||
@@ -239,166 +211,173 @@ struct TimeGridView: View {
|
||||
}
|
||||
|
||||
private var breakBlocks: some View {
|
||||
// Same `.center`-vs-`.top` bug as gridLines — see comment above.
|
||||
ZStack(alignment: .top) {
|
||||
ForEach(0..<max(0, periods.count - 1), id: \.self) { i in
|
||||
let curr = periods[i]
|
||||
let next = periods[i + 1]
|
||||
let virtualGap = next.virtualStartMinutes - curr.virtualEndMinutes
|
||||
if virtualGap > 0 {
|
||||
Rectangle()
|
||||
.fill(palette.breakBlock)
|
||||
.frame(height: CGFloat(virtualGap) * hourHeight / 60.0)
|
||||
.padding(.horizontal, 1)
|
||||
.offset(y: CGFloat(curr.virtualEndMinutes) * hourHeight / 60.0)
|
||||
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)
|
||||
}
|
||||
|
||||
private func formatHm(_ minutes: Int) -> String {
|
||||
String(format: "%02d:%02d", minutes / 60, minutes % 60)
|
||||
/// 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) -> some View {
|
||||
let cal = Calendar.current
|
||||
let comps = cal.dateComponents([.hour, .minute], from: lesson.start)
|
||||
let startMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
|
||||
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
|
||||
)
|
||||
let subjectSize = subjectFont(forHourHeight: hourHeight)
|
||||
let secondarySize = max(MIN_SECONDARY_FONT, subjectSize - 2)
|
||||
let room = lesson.room
|
||||
let teacher = lesson.teacher ?? lesson.originalTeacher
|
||||
let hasSecondary = (room?.isEmpty == false) || (teacher?.isEmpty == false)
|
||||
HStack(alignment: .top, spacing: 4) {
|
||||
Text(subjectLabel(lesson))
|
||||
.font(.system(size: subjectSize, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(hasSecondary ? 1 : 2)
|
||||
.minimumScaleFactor(0.5)
|
||||
if hasSecondary {
|
||||
Spacer(minLength: 0)
|
||||
VStack(alignment: .trailing, spacing: -1) {
|
||||
if showRoom && height >= BLOCK_SHOW_ROOM_MIN {
|
||||
if let room, !room.isEmpty {
|
||||
Text(room)
|
||||
.font(.system(size: secondarySize))
|
||||
.foregroundStyle(.white.opacity(0.85))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.5)
|
||||
}
|
||||
if showTeacher,
|
||||
height >= BLOCK_SHOW_TEACHER_SEPARATE_MIN,
|
||||
let teacher,
|
||||
!teacher.isEmpty {
|
||||
Text(teacher)
|
||||
.font(.system(size: secondarySize))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.padding(.vertical, 3)
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
.frame(height: height, alignment: .topLeading)
|
||||
.background(blockColor(lesson))
|
||||
.cornerRadius(6)
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
// Separate fixed-size badge so the +N hint stays readable
|
||||
// when the subject autoshrinks on narrow tiles.
|
||||
if let count = lesson.siblingCount, count > 0 {
|
||||
Text("+\(count)")
|
||||
.font(.system(size: 12, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.leading, horizontalPadding)
|
||||
.padding(.bottom, 2)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
// CrossPainter parity: clip cross to the rounded shape so
|
||||
// the diagonals don't bleed past the corners.
|
||||
if lesson.status == .cancelled {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.stroke(Color.red.opacity(0.78), lineWidth: 1.5)
|
||||
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(Color.red.opacity(0.78), lineWidth: 3)
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, showRoom ? 2 : 1)
|
||||
LessonTile(
|
||||
lesson: lesson,
|
||||
height: height,
|
||||
metrics: metrics,
|
||||
layout: TileTextLayout(
|
||||
lesson: lesson,
|
||||
innerHeight: height - 2 * TILE_PADDING_V,
|
||||
innerWidth: innerWidth,
|
||||
metrics: metrics
|
||||
)
|
||||
)
|
||||
.offset(y: top)
|
||||
}
|
||||
}
|
||||
|
||||
private func subjectLabel(_ lesson: WidgetLesson) -> String {
|
||||
!lesson.subjectShort.isEmpty
|
||||
? lesson.subjectShort
|
||||
: (lesson.subjectLong ?? "—")
|
||||
}
|
||||
|
||||
/// Mirrors lesson_color.dart + custom_event_colors.dart so the widget
|
||||
/// matches the in-app calendar exactly.
|
||||
private func blockColor(_ lesson: WidgetLesson) -> Color {
|
||||
if lesson.status == .event, let custom = lesson.customColor {
|
||||
switch custom {
|
||||
case "orange": return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0)
|
||||
case "red": return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0)
|
||||
case "green": return Color(red: 76/255.0, green: 175/255.0, blue: 80/255.0)
|
||||
case "blue": return Color(red: 33/255.0, green: 150/255.0, blue: 243/255.0)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
switch lesson.status {
|
||||
// `.ongoing` deliberately collapses into the regular red — the
|
||||
// widget timeline only ticks every ~30min, so highlighting the
|
||||
// "current" lesson would be stale most of the day and misleads
|
||||
// more than it helps.
|
||||
case .regular, .past, .ongoing:
|
||||
return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0)
|
||||
case .cancelled: return .black
|
||||
case .irregular: return Color(red: 143/255.0, green: 25/255.0, blue: 179/255.0)
|
||||
case .teacherChanged: return Color(red: 41/255.0, green: 99/255.0, blue: 155/255.0)
|
||||
case .event: return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0)
|
||||
case .duty: return Color(red: 0/255.0, green: 121/255.0, blue: 107/255.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) }
|
||||
/// 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
|
||||
}
|
||||
return result.sorted()
|
||||
}
|
||||
|
||||
/// Fixed-locale formatters cached once — DateFormatter setup is the
|
||||
|
||||
Reference in New Issue
Block a user