From d8a5ccfe806cd685e82e6d8938ae94aeafaab4c6 Mon Sep 17 00:00:00 2001 From: Marianum Date: Tue, 12 May 2026 16:46:56 +0200 Subject: [PATCH] iOS widget enhancements --- .../MarianumWatermark.swift | 43 +++++++-------- .../TimetableDayView.swift | 37 +++++++------ .../TimetableWeekView.swift | 29 ++++++---- .../TimetableWidgetExtension.swift | 40 +++++++++++--- .../WidgetPalette.swift | 53 +++++++++++++++++++ 5 files changed, 145 insertions(+), 57 deletions(-) create mode 100644 ios/TimetableWidgetExtension/WidgetPalette.swift diff --git a/ios/TimetableWidgetExtension/MarianumWatermark.swift b/ios/TimetableWidgetExtension/MarianumWatermark.swift index ad6e247..614c44a 100644 --- a/ios/TimetableWidgetExtension/MarianumWatermark.swift +++ b/ios/TimetableWidgetExtension/MarianumWatermark.swift @@ -1,28 +1,29 @@ import SwiftUI /// Marianum-M peeking out of the bottom-right corner. Sized to the longer -/// widget edge so it scales with resize; offset nudges a sliver behind the -/// edge. +/// widget edge so it scales with the picked WidgetFamily; offset nudges a +/// sliver behind the edge. Opacity comes from the widget palette so the +/// watermark matches Android's `watermarkAlpha` at the same brightness. struct MarianumWatermark: View { - @Environment(\.colorScheme) private var colorScheme + @Environment(\.widgetPalette) private var palette - var body: some View { - GeometryReader { geo in - let markSize = min(400, max(160, max(geo.size.width, geo.size.height) * 0.8)) - let offsetX = markSize * 0.18 - let offsetY = markSize * 0.18 - ZStack(alignment: .bottomTrailing) { - Color.clear - Image("marianum_m") - .resizable() - .renderingMode(.template) - .aspectRatio(contentMode: .fit) - .foregroundStyle(.primary) - .frame(width: markSize, height: markSize) - .opacity(colorScheme == .dark ? 0.025 : 0.014) - .offset(x: offsetX, y: offsetY) - } - } - .clipped() + var body: some View { + GeometryReader { geo in + let markSize = min(400, max(160, max(geo.size.width, geo.size.height) * 0.8)) + let offsetX = markSize * 0.18 + let offsetY = markSize * 0.18 + ZStack(alignment: .bottomTrailing) { + Color.clear + Image("marianum_m") + .resizable() + .renderingMode(.template) + .aspectRatio(contentMode: .fit) + .foregroundStyle(palette.textPrimary) + .frame(width: markSize, height: markSize) + .opacity(palette.watermarkOpacity) + .offset(x: offsetX, y: offsetY) + } } + .clipped() + } } diff --git a/ios/TimetableWidgetExtension/TimetableDayView.swift b/ios/TimetableWidgetExtension/TimetableDayView.swift index ea4b977..7ef52c4 100644 --- a/ios/TimetableWidgetExtension/TimetableDayView.swift +++ b/ios/TimetableWidgetExtension/TimetableDayView.swift @@ -53,6 +53,7 @@ func subjectFont(forHourHeight hourHeight: CGFloat) -> CGFloat { struct TimetableDayView: View { let entry: TimetableEntry + @Environment(\.widgetPalette) private var palette var body: some View { ZStack { @@ -65,7 +66,6 @@ struct TimetableDayView: View { } } .background(MarianumWatermark()) - .widgetThemeOverride(entry.themeMode) } @ViewBuilder @@ -99,11 +99,11 @@ struct TimetableDayView: View { HStack { Text(dayLabel(for: data.anchorDate)) .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(.primary) + .foregroundStyle(palette.textPrimary) Spacer() Text("Stand: \(freshnessLabel(for: data.fetchedAt))") .font(.system(size: 10)) - .foregroundStyle(.secondary) + .foregroundStyle(palette.textSecondary) } } @@ -112,7 +112,7 @@ struct TimetableDayView: View { Spacer() Text(text) .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(palette.textSecondary) Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -122,9 +122,10 @@ struct TimetableDayView: View { VStack(spacing: 4) { Text("Marianum Stundenplan") .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(palette.textPrimary) Text(message) .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(palette.textSecondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -141,6 +142,8 @@ struct TimeGridView: View { /// 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 } @@ -156,10 +159,12 @@ struct TimeGridView: View { 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: 32, alignment: .topTrailing) + .frame(width: 28, alignment: .topTrailing) Rectangle() - .fill(Color.primary.opacity(0.13)) + .fill(palette.divider) .frame(width: 1) } ZStack(alignment: .top) { @@ -175,13 +180,9 @@ struct TimeGridView: View { 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)) + .fill(palette.divider) .frame(height: 1) .offset(y: CGFloat(virtualMin) * hourHeight / 60.0) } @@ -190,16 +191,16 @@ struct TimeGridView: View { if compactLabels { Text("\(period.name).") .font(.system(size: 9, weight: .bold)) - .foregroundStyle(.primary) + .foregroundStyle(palette.textPrimary) .lineLimit(1) } else { Text(formatHm(period.startMinutes)) .font(.system(size: 9)) - .foregroundStyle(.primary) + .foregroundStyle(palette.textPrimary) .lineLimit(1) Text("\(period.name).") .font(.system(size: 7, weight: .bold)) - .foregroundStyle(.secondary) + .foregroundStyle(palette.textSecondary) .lineLimit(1) } } @@ -212,11 +213,9 @@ struct TimeGridView: View { 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)) + .fill(palette.divider) .frame(height: 1) .offset(y: CGFloat(virtualMin) * hourHeight / 60.0) } @@ -232,7 +231,7 @@ struct TimeGridView: View { let virtualGap = next.virtualStartMinutes - curr.virtualEndMinutes if virtualGap > 0 { Rectangle() - .fill(Color.primary.opacity(0.03)) + .fill(palette.breakBlock) .frame(height: CGFloat(virtualGap) * hourHeight / 60.0) .padding(.horizontal, 1) .offset(y: CGFloat(curr.virtualEndMinutes) * hourHeight / 60.0) diff --git a/ios/TimetableWidgetExtension/TimetableWeekView.swift b/ios/TimetableWidgetExtension/TimetableWeekView.swift index 4a03a2c..53904a6 100644 --- a/ios/TimetableWidgetExtension/TimetableWeekView.swift +++ b/ios/TimetableWidgetExtension/TimetableWeekView.swift @@ -3,6 +3,7 @@ import WidgetKit struct TimetableWeekView: View { let entry: TimetableEntry + @Environment(\.widgetPalette) private var palette var body: some View { ZStack { @@ -15,7 +16,6 @@ struct TimetableWeekView: View { } } .background(MarianumWatermark()) - .widgetThemeOverride(entry.themeMode) } @ViewBuilder @@ -52,7 +52,7 @@ struct TimetableWeekView: View { private var columnDivider: some View { Rectangle() - .fill(Color.primary.opacity(0.13)) + .fill(palette.divider) .frame(width: 1) } @@ -63,16 +63,23 @@ struct TimetableWeekView: View { return HStack { Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))") .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.primary) + .foregroundStyle(palette.textPrimary) Spacer() Text("Stand: \(freshnessLabel(for: data.fetchedAt))") .font(.system(size: 10)) - .foregroundStyle(.secondary) + .foregroundStyle(palette.textSecondary) } } private func dayHeaderRow(data: WidgetTimetableData) -> some View { let cal = Calendar.current + // `.frame(maxWidth: .infinity)` is critical: without an explicit + // greedy width (or a true `Spacer()` inside), this HStack collapses + // to the sum of its fixed-width children in a `.leading` VStack + // and the 5 day-columns end up sharing ~5pt of width — the labels + // crunch to the left and stop aligning with the grid columns below. + // The fixed height keeps `columnDivider`'s vertically-flexible + // Rectangle from stealing space from the GeometryReader. return HStack(spacing: 0) { Spacer().frame(width: 28) columnDivider @@ -81,15 +88,16 @@ struct TimetableWeekView: View { VStack(spacing: 0) { Text(weekday(for: day)) .font(.system(size: 11, weight: .bold)) - .foregroundStyle(.primary) + .foregroundStyle(palette.textPrimary) Text(shortDate(day)) .font(.system(size: 9)) - .foregroundStyle(.secondary) + .foregroundStyle(palette.textSecondary) } .frame(maxWidth: .infinity) if offset < 4 { columnDivider } } } + .frame(maxWidth: .infinity, minHeight: 26, maxHeight: 26) } private func timeLabelsColumn(hourHeight: CGFloat, periods: [WidgetPeriod]) -> some View { @@ -98,7 +106,7 @@ struct TimetableWeekView: View { return ZStack(alignment: .topTrailing) { ForEach(periodBoundaries(periods), id: \.self) { virtualMin in Rectangle() - .fill(Color.primary.opacity(0.08)) + .fill(palette.divider) .frame(height: 1) .offset(y: CGFloat(virtualMin) * hourHeight / 60.0) } @@ -106,11 +114,11 @@ struct TimetableWeekView: View { VStack(alignment: .trailing, spacing: -2) { Text(String(format: "%02d:%02d", period.startMinutes / 60, period.startMinutes % 60)) .font(.system(size: 8)) - .foregroundStyle(.primary) + .foregroundStyle(palette.textPrimary) .lineLimit(1) Text("\(period.name).") .font(.system(size: 6, weight: .bold)) - .foregroundStyle(.secondary) + .foregroundStyle(palette.textSecondary) .lineLimit(1) } .offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0) @@ -150,9 +158,10 @@ struct TimetableWeekView: View { VStack(spacing: 4) { Text("Marianum Stundenplan") .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(palette.textPrimary) Text(message) .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(palette.textSecondary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/ios/TimetableWidgetExtension/TimetableWidgetExtension.swift b/ios/TimetableWidgetExtension/TimetableWidgetExtension.swift index b0540c4..3b1be5c 100644 --- a/ios/TimetableWidgetExtension/TimetableWidgetExtension.swift +++ b/ios/TimetableWidgetExtension/TimetableWidgetExtension.swift @@ -16,7 +16,7 @@ struct TimetableDayWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: TimetableDayProvider()) { entry in - TimetableDayView(entry: entry).widgetContainerBackground() + TimetableDayView(entry: entry).widgetSurface(entry: entry) } .configurationDisplayName("Marianum · Heute") .description("Stundenplan und Vertretungen für den anstehenden Schultag.") @@ -52,7 +52,7 @@ struct TimetableWeekWidget: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: TimetableWeekProvider()) { entry in - TimetableWeekView(entry: entry).widgetContainerBackground() + TimetableWeekView(entry: entry).widgetSurface(entry: entry) } .configurationDisplayName("Marianum · Woche") .description("Stundenplan und Vertretungen für die ganze Schulwoche.") @@ -116,6 +116,8 @@ struct TimetableEntry: TimelineEntry { } extension View { + /// Applies the user's chosen light/dark override on top of the system + /// scheme so the widget honours the in-app theme setting. @ViewBuilder func widgetThemeOverride(_ mode: String) -> some View { switch mode { @@ -125,14 +127,38 @@ extension View { } } - /// `.containerBackground(_:for:)` is iOS 17+. Older iOS uses the - /// implicit `.background(...)` model and renders fine without it. + /// Wraps the widget view in the Marianum palette + container background + /// so all subviews can read `\.widgetPalette` and so the widget renders + /// in our warm off-white / dark-clay instead of system grey. @ViewBuilder - func widgetContainerBackground() -> some View { + func widgetSurface(entry: TimetableEntry) -> some View { + WidgetSurface(entry: entry) { self } + } +} + +private struct WidgetSurface: View { + let entry: TimetableEntry + @ViewBuilder let content: () -> Content + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + let palette = WidgetPalette.resolve( + themeMode: entry.themeMode, + colorScheme: colorScheme + ) + return AnyView( + background(content: content(), palette: palette) + .environment(\.widgetPalette, palette) + .widgetThemeOverride(entry.themeMode) + ) + } + + @ViewBuilder + private func background(content: C, palette: WidgetPalette) -> some View { if #available(iOS 17.0, *) { - self.containerBackground(.fill.tertiary, for: .widget) + content.containerBackground(palette.background, for: .widget) } else { - self + content.background(palette.background) } } } diff --git a/ios/TimetableWidgetExtension/WidgetPalette.swift b/ios/TimetableWidgetExtension/WidgetPalette.swift new file mode 100644 index 0000000..c197273 --- /dev/null +++ b/ios/TimetableWidgetExtension/WidgetPalette.swift @@ -0,0 +1,53 @@ +import SwiftUI + +/// Mirrors the Kotlin `WidgetPalette` in WidgetRenderer.kt so day/week widgets +/// look identical across platforms. All values are hex tokens from the in-app +/// LightAppTheme / DarkAppTheme — do not swap to system colors, the whole +/// point is platform-independent branding. +struct WidgetPalette { + let background: Color + let textPrimary: Color + let textSecondary: Color + let divider: Color + let breakBlock: Color + let watermarkOpacity: Double + + static let light = WidgetPalette( + background: Color(red: 0xFC / 255, green: 0xF7 / 255, blue: 0xF5 / 255), + textPrimary: Color(red: 0x11 / 255, green: 0x11 / 255, blue: 0x11 / 255), + textSecondary: Color(red: 0x55 / 255, green: 0x55 / 255, blue: 0x55 / 255), + divider: Color.black.opacity(0x22 / 255.0), + breakBlock: Color.black.opacity(0x0C / 255.0), + watermarkOpacity: 0.014 + ) + + static let dark = WidgetPalette( + background: Color(red: 0x1F / 255, green: 0x17 / 255, blue: 0x16 / 255), + textPrimary: Color(red: 0xF1 / 255, green: 0xF1 / 255, blue: 0xF1 / 255), + textSecondary: Color(red: 0xB0 / 255, green: 0xB0 / 255, blue: 0xB0 / 255), + divider: Color.white.opacity(0x33 / 255.0), + breakBlock: Color.white.opacity(0x14 / 255.0), + watermarkOpacity: 0.025 + ) + + static func resolve(themeMode: String, colorScheme: ColorScheme) -> WidgetPalette { + let isDark: Bool + switch themeMode { + case "light": isDark = false + case "dark": isDark = true + default: isDark = colorScheme == .dark + } + return isDark ? .dark : .light + } +} + +private struct WidgetPaletteKey: EnvironmentKey { + static let defaultValue = WidgetPalette.light +} + +extension EnvironmentValues { + var widgetPalette: WidgetPalette { + get { self[WidgetPaletteKey.self] } + set { self[WidgetPaletteKey.self] = newValue } + } +}