452 lines
18 KiB
Swift
452 lines
18 KiB
Swift
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
|
|
}
|
|
|
|
let BLOCK_SHOW_ROOM_MIN: CGFloat = 18
|
|
let BLOCK_SHOW_TEACHER_SEPARATE_MIN: CGFloat = 30
|
|
|
|
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
|
|
|
|
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())
|
|
.widgetThemeOverride(entry.themeMode)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func content(data: WidgetTimetableData) -> some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
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 totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES)
|
|
TimeGridView(
|
|
lessons: data.lessons,
|
|
periods: data.periods,
|
|
anchorDate: data.anchorDate,
|
|
hourHeight: max(
|
|
MIN_HOUR_HEIGHT,
|
|
min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60)
|
|
),
|
|
showRoom: true,
|
|
showTeacher: true,
|
|
showTimeLabels: true
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func header(data: WidgetTimetableData) -> some View {
|
|
HStack {
|
|
Text(dayLabel(for: data.anchorDate))
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
Spacer()
|
|
Text("Stand: \(freshnessLabel(for: data.fetchedAt))")
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private func emptyState(text: String) -> some View {
|
|
VStack {
|
|
Spacer()
|
|
Text(text)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
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))
|
|
Text(message)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
|
|
struct TimeGridView: View {
|
|
let lessons: [WidgetLesson]
|
|
let periods: [WidgetPeriod]
|
|
let anchorDate: Date
|
|
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
|
|
|
|
private var totalVirtualMinutes: Int {
|
|
periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
|
|
}
|
|
|
|
private var totalHeight: CGFloat {
|
|
CGFloat(totalVirtualMinutes) * 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 {
|
|
timeLabelsColumn
|
|
.frame(width: 32, alignment: .topTrailing)
|
|
Rectangle()
|
|
.fill(Color.primary.opacity(0.13))
|
|
.frame(width: 1)
|
|
}
|
|
ZStack(alignment: .top) {
|
|
gridLines
|
|
breakBlocks
|
|
ForEach(lessons.indices, id: \.self) { idx in
|
|
lessonBlock(lessons[idx])
|
|
}
|
|
if Calendar.current.isDate(anchorDate, inSameDayAs: Date()) {
|
|
nowIndicator
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: totalHeight, alignment: .top)
|
|
}
|
|
}
|
|
|
|
private var timeLabelsColumn: some View {
|
|
ZStack(alignment: .topTrailing) {
|
|
// Hour rules continue through the time-label column so it reads
|
|
// as a real table column rather than a free-floating tick list.
|
|
// Hour rules extend through the time-label column so it reads
|
|
// as a table column rather than a free-floating tick list.
|
|
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
|
|
Rectangle()
|
|
.fill(Color.primary.opacity(0.08))
|
|
.frame(height: 1)
|
|
.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(.primary)
|
|
.lineLimit(1)
|
|
} else {
|
|
Text(formatHm(period.startMinutes))
|
|
.font(.system(size: 9))
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(1)
|
|
Text("\(period.name).")
|
|
.font(.system(size: 7, weight: .bold))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
.padding(.trailing, 4)
|
|
.offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
|
|
}
|
|
}
|
|
.frame(height: totalHeight, alignment: .topTrailing)
|
|
}
|
|
|
|
private var gridLines: some View {
|
|
ZStack(alignment: .top) {
|
|
// Hour rules continue through the time-label column so it reads
|
|
// as a real table column rather than a free-floating tick list.
|
|
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
|
|
Rectangle()
|
|
.fill(Color.primary.opacity(0.08))
|
|
.frame(height: 1)
|
|
.offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
|
|
}
|
|
}
|
|
.frame(height: totalHeight)
|
|
}
|
|
|
|
private var breakBlocks: some View {
|
|
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(Color.primary.opacity(0.03))
|
|
.frame(height: CGFloat(virtualGap) * hourHeight / 60.0)
|
|
.padding(.horizontal, 1)
|
|
.offset(y: CGFloat(curr.virtualEndMinutes) * hourHeight / 60.0)
|
|
}
|
|
}
|
|
}
|
|
.frame(height: totalHeight)
|
|
}
|
|
|
|
private func formatHm(_ minutes: Int) -> String {
|
|
String(format: "%02d:%02d", minutes / 60, minutes % 60)
|
|
}
|
|
|
|
@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)
|
|
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 {
|
|
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)
|
|
.offset(y: top)
|
|
}
|
|
}
|
|
|
|
private var nowIndicator: some View {
|
|
let cal = Calendar.current
|
|
let comps = cal.dateComponents([.hour, .minute], from: Date())
|
|
let nowMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
|
|
let inside: Bool
|
|
if let first = periods.first, let last = periods.last {
|
|
inside = nowMinutes >= first.startMinutes && nowMinutes <= last.endMinutes
|
|
} else {
|
|
inside = true
|
|
}
|
|
let top = realMinutesToVirtual(nowMinutes, periods: periods) * hourHeight / 60.0
|
|
return Group {
|
|
if inside {
|
|
Rectangle()
|
|
.fill(Color.red)
|
|
.frame(height: 2)
|
|
.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 {
|
|
case .regular, .past: return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0)
|
|
case .ongoing: return Color(red: 200/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)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
|
|
func dayLabel(for date: Date) -> String {
|
|
let cal = Calendar.current
|
|
let today = cal.startOfDay(for: Date())
|
|
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))"
|
|
}
|
|
let formatter = DateFormatter()
|
|
formatter.locale = Locale(identifier: "de_DE")
|
|
formatter.dateFormat = "EEEE · dd.MM."
|
|
return formatter.string(from: date)
|
|
}
|
|
|
|
func shortDate(_ date: Date) -> String {
|
|
let f = DateFormatter()
|
|
f.locale = Locale(identifier: "de_DE")
|
|
f.dateFormat = "dd.MM."
|
|
return f.string(from: date)
|
|
}
|
|
|
|
func freshnessLabel(for fetchedAt: Date) -> String {
|
|
let cal = Calendar.current
|
|
let today = cal.startOfDay(for: Date())
|
|
let fetchedDay = cal.startOfDay(for: fetchedAt)
|
|
let timeFmt = DateFormatter()
|
|
timeFmt.locale = Locale(identifier: "de_DE")
|
|
timeFmt.dateFormat = "HH:mm"
|
|
if fetchedDay == today {
|
|
return timeFmt.string(from: fetchedAt)
|
|
}
|
|
if let yesterday = cal.date(byAdding: .day, value: -1, to: today),
|
|
fetchedDay == yesterday {
|
|
return "gestern \(timeFmt.string(from: fetchedAt))"
|
|
}
|
|
let dateTimeFmt = DateFormatter()
|
|
dateTimeFmt.locale = Locale(identifier: "de_DE")
|
|
dateTimeFmt.dateFormat = "dd.MM. HH:mm"
|
|
return dateTimeFmt.string(from: fetchedAt)
|
|
}
|