diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 123c275..7ebf31e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -81,6 +81,7 @@ the home_widget Flutter plugin resolves them as .. --> @@ -93,6 +94,7 @@ diff --git a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetData.kt b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetData.kt index 5c0ccd2..57b8324 100644 --- a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetData.kt +++ b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetData.kt @@ -11,7 +11,7 @@ import java.util.TimeZone // Mirror of lib/widget_data/widget_data.dart — JSON keys + enum names // must stay in sync. enum class WidgetLessonStatus { - REGULAR, ONGOING, PAST, CANCELLED, IRREGULAR, TEACHER_CHANGED, EVENT; + REGULAR, ONGOING, PAST, CANCELLED, IRREGULAR, TEACHER_CHANGED, EVENT, DUTY; companion object { fun fromWire(raw: String?): WidgetLessonStatus = when (raw) { @@ -22,6 +22,7 @@ enum class WidgetLessonStatus { "irregular" -> IRREGULAR "teacherChanged" -> TEACHER_CHANGED "event" -> EVENT + "duty" -> DUTY else -> REGULAR } } @@ -39,6 +40,9 @@ data class WidgetLesson( val originalTeacher: String?, val status: WidgetLessonStatus, val customColor: String?, + // Tile colour resolved in Dart (subject colours, palettes); null only for + // payloads written before the field existed. + val color: Int?, val siblingCount: Int, ) @@ -50,6 +54,12 @@ data class WidgetPeriod( val virtualEndMinutes: Int, ) +data class WidgetDayInfo( + val date: Date, + val isHoliday: Boolean, + val holidayName: String?, +) + data class WidgetTimetableData( val fetchedAt: Date, val anchorDate: Date, @@ -57,6 +67,8 @@ data class WidgetTimetableData( val periods: List, val isHoliday: Boolean, val holidayName: String?, + // Week payload only; empty for the day payload. + val days: List, ) object WidgetDataParser { @@ -81,6 +93,11 @@ object WidgetDataParser { } } + private fun parseColor(raw: String?): Int? { + if (raw == null || !Regex("#[0-9A-Fa-f]{6}").matches(raw)) return null + return 0xFF000000.toInt() or raw.substring(1).toInt(16) + } + fun parse(json: String?): WidgetTimetableData? { if (json.isNullOrEmpty()) return null return try { @@ -102,6 +119,7 @@ object WidgetDataParser { originalTeacher = obj.stringOrNull("originalTeacher"), status = WidgetLessonStatus.fromWire(obj.stringOrNull("status")), customColor = obj.stringOrNull("customColor"), + color = parseColor(obj.stringOrNull("color")), siblingCount = obj.optInt("siblingCount", 0), ) } @@ -120,6 +138,18 @@ object WidgetDataParser { ) } } + val daysArray = root.optJSONArray("days") + val days = mutableListOf() + if (daysArray != null) { + for (i in 0 until daysArray.length()) { + val obj = daysArray.optJSONObject(i) ?: continue + days += WidgetDayInfo( + date = parseDate(obj.stringOrNull("date")) ?: continue, + isHoliday = obj.optBoolean("isHoliday", false), + holidayName = obj.stringOrNull("holidayName"), + ) + } + } WidgetTimetableData( fetchedAt = parseDate(root.stringOrNull("fetchedAt")) ?: Date(), anchorDate = parseDate(root.stringOrNull("anchorDate")) ?: Date(), @@ -127,6 +157,7 @@ object WidgetDataParser { periods = periods, isHoliday = root.optBoolean("isHoliday", false), holidayName = root.stringOrNull("holidayName"), + days = days, ) } catch (_: JSONException) { null diff --git a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetRenderer.kt b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetRenderer.kt index 08c5875..23a9c3a 100644 --- a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetRenderer.kt +++ b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetRenderer.kt @@ -5,8 +5,14 @@ import android.appwidget.AppWidgetManager import android.content.Context import android.content.Intent import android.content.SharedPreferences +import android.content.res.ColorStateList import android.content.res.Configuration +import android.graphics.Typeface import android.os.Bundle +import android.text.SpannableString +import android.text.Spanned +import android.text.TextPaint +import android.text.style.StyleSpan import android.util.TypedValue import android.view.View import android.widget.RemoteViews @@ -16,40 +22,89 @@ import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date import java.util.Locale +import kotlin.math.floor import kotlin.math.max +import kotlin.math.min /** - * Renders the day and week widgets as a time-grid: lesson blocks absolutely - * positioned on a vertical axis, mirroring the in-app Syncfusion calendar. - * Per-hour dp is computed from the widget bundle so the grid scales with - * resize, clamped to [MIN_HOUR_HEIGHT_DP, MAX_HOUR_HEIGHT_DP]. + * Renders the day and week widgets as a period grid that mirrors the in-app + * timetable (AppointmentTile, _PeriodLabel, _DayHeaderCell): lesson tiles + * absolutely positioned on a vertical axis, scaled down from the app's + * proportions to whatever height the launcher grants. */ object WidgetRenderer { private const val FALLBACK_VIRTUAL_MINUTES = 11 * 60 + private const val FALLBACK_SLOT_MINUTES = 45 + private const val FALLBACK_HOUR_HEIGHT_DP = 32f - private const val DAY_CHROME_DP = 40 - private const val WEEK_CHROME_DP = 64 + // Chrome metrics — must match widget_day.xml / widget_week.xml, the grid + // height is derived from them. + private const val CHROME_PADDING_H_DP = 16f + private const val CHROME_PADDING_V_DP = 18f + private const val TITLE_ROW_DP = 20f + private const val SUBTITLE_BELOW_DP = 12f + private const val GRID_MARGIN_TOP_DP = 4f + private const val WEEK_HEADER_DP = 32f + 2f + private const val RULER_WIDTH_DP = 30f + 1f + private const val WEEK_DIVIDERS_DP = 4f - private const val MIN_HOUR_HEIGHT_DP = 18 - private const val MAX_HOUR_HEIGHT_DP = 72 + /// Below this width title and freshness label no longer fit side by side. + private const val DAY_NARROW_WIDTH_DP = 200f - private const val MIN_BLOCK_HEIGHT_DP = 16 + /// 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. + private const val MIN_VISIBLE_PERIODS = 6 + + private const val MIN_HOUR_HEIGHT_DP = 18f + private const val MAX_HOUR_HEIGHT_DP = 72f + + private const val MIN_BLOCK_HEIGHT_DP = 16f private const val LESSON_GAP_DP = 1.5f - // Below SHOW_ROOM_MIN: subject only. Below SHOW_TEACHER_SEPARATE_MIN: - // subject + room. Above: subject + room + teacher stacked. - private const val BLOCK_SHOW_ROOM_MIN_DP = 18 - private const val BLOCK_SHOW_TEACHER_SEPARATE_MIN_DP = 30 + // Tile metrics — must match widget_lesson_block.xml. + private const val TILE_MARGIN_H_DP = 1f + private const val TILE_PADDING_H_DP = 4f + private const val TILE_PADDING_V_DP = 2f - /// Below this column width autoSize can't fit subject + room — drop - /// room/teacher entirely on the week-widget. - private const val WEEK_COLUMN_TIGHT_DP = 45 + // In-app tile: 15sp title over 10sp body lines. The widget keeps that + // ratio and scales both so title + room + teacher fill one period. + private const val TITLE_TO_BODY_RATIO = 1.5f + private const val MAX_BODY_FONT_DP = 10f + private const val MIN_BODY_FONT_DP = 6.5f + private const val LINE_BOX_PER_FONT = 1.2f + + /// If stacking room and teacher would push the body font below this, + /// both share one line instead so the text stays readable. + private const val STACKED_BODY_MIN_FONT_DP = 7.5f + + /// 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. + private const val TITLE_WRAP_BELOW_FONT_DP = 8f + + /// Slack for the launcher-reported width when sizing text ahead of layout. + private const val WIDTH_ESTIMATE_SAFETY = 0.95f + + // Footprint of the "+N" badge in the tile's bottom end corner. + private const val BADGE_WIDTH_DP = 16f + private const val BADGE_HEIGHT_DP = 11f + + private const val RULER_TIMES_MIN_CELL_DP = 30f + + /// Space the holiday column keeps free above and below the rotated name + /// for the icon and the "Schulfrei" label. + private const val HOLIDAY_NAME_INSET_DP = 44f + private const val BREAK_ICON_MIN_HEIGHT_DP = 12f private val timeFormat = SimpleDateFormat("HH:mm", Locale.GERMAN) private val dateShort = SimpleDateFormat("dd.MM.", Locale.GERMAN) private val weekdayShort = SimpleDateFormat("EE", Locale.GERMAN) private val dateTimeShort = SimpleDateFormat("dd.MM. HH:mm", Locale.GERMAN) + private val titleTypeface: Typeface by lazy { + Typeface.create("sans-serif-medium", Typeface.NORMAL) + } + /// Hex values mirror LightAppTheme / DarkAppTheme tokens so the widget /// matches the app's branding rather than the generic system look. private data class WidgetPalette( @@ -57,7 +112,11 @@ object WidgetRenderer { val textPrimary: Int, val textSecondary: Int, val divider: Int, + val accent: Int, + val onAccent: Int, val breakBlock: Int, + val todayTint: Int, + val holidayTint: Int, val watermarkAlpha: Float, ) @@ -66,7 +125,11 @@ object WidgetRenderer { textPrimary = 0xFF1A1A1A.toInt(), textSecondary = 0xFF555555.toInt(), divider = 0x22000000, - breakBlock = 0x0C000000, + accent = 0xFF904A47.toInt(), + onAccent = 0xFFFFFFFF.toInt(), + breakBlock = 0x32904A47, + todayTint = 0x0E904A47, + holidayTint = 0x32000000, watermarkAlpha = 0.014f, ) @@ -75,10 +138,54 @@ object WidgetRenderer { textPrimary = 0xFFF1F1F1.toInt(), textSecondary = 0xFFB0B0B0.toInt(), divider = 0x33FFFFFF, - breakBlock = 0x14FFFFFF, + accent = 0xFFFFB3AF.toInt(), + onAccent = 0xFF571D1D.toInt(), + breakBlock = 0x32FFB3AF, + todayTint = 0x0EFFB3AF, + holidayTint = 0x32FFFFFF, watermarkAlpha = 0.025f, ) + private data class SizeDp(val width: Float, val height: Float) + + /// Font and line-box sizes shared by every tile of one widget, derived + /// from the height of a single period. + private class TileMetrics(slotHeightDp: Float) { + val maxBodyLines: Int + val titleBoxDp: Float + val bodyBoxDp: Float + val bodyFontDp: Float + + init { + val inner = slotHeightDp - LESSON_GAP_DP - 2 * TILE_PADDING_V_DP + fun bodyFont(lines: Int) = + inner / (LINE_BOX_PER_FONT * (TITLE_TO_BODY_RATIO + lines)) + maxBodyLines = if (bodyFont(2) < STACKED_BODY_MIN_FONT_DP) 1 else 2 + bodyFontDp = bodyFont(maxBodyLines) + .coerceIn(MIN_BODY_FONT_DP, MAX_BODY_FONT_DP) + titleBoxDp = bodyFontDp * TITLE_TO_BODY_RATIO * LINE_BOX_PER_FONT + bodyBoxDp = bodyFontDp * LINE_BOX_PER_FONT + } + } + + private class GridContext( + val context: Context, + val packageName: String, + val palette: WidgetPalette, + val periods: List, + val hourHeightDp: Float, + /// Null when the launcher reported no size — text wrapping decisions + /// that depend on the tile width are skipped then. + val tileInnerWidthDp: Float?, + ) { + val metrics = TileMetrics( + (periods.firstOrNull()?.let { it.virtualEndMinutes - it.virtualStartMinutes } + ?: FALLBACK_SLOT_MINUTES) * hourHeightDp / 60f, + ) + + fun dp(virtualMinutes: Float): Float = virtualMinutes * hourHeightDp / 60f + } + private fun resolvePalette(context: Context, themeMode: String?): WidgetPalette { val isDark = when (themeMode) { "light" -> false @@ -114,63 +221,9 @@ object WidgetRenderer { context.getString(R.string.widget_loading), palette, ) - - val totalVirtualMin = data.periods.lastOrNull()?.virtualEndMinutes - ?: FALLBACK_VIRTUAL_MINUTES - val hourHeightDp = resolveHourHeight(options, DAY_CHROME_DP, totalVirtualMin) - - val views = RemoteViews(packageName, R.layout.widget_day) - applyChrome(views, palette, options) - views.setTextColor(R.id.widget_day_title, palette.textPrimary) - views.setTextColor(R.id.widget_day_subtitle, palette.textSecondary) - views.setTextColor(R.id.widget_day_empty, palette.textSecondary) - views.setTextViewText( - R.id.widget_day_title, - "${dayLabel(context, data.anchorDate)} · ${dateShort.format(data.anchorDate)}", - ) - views.setTextViewText( - R.id.widget_day_subtitle, - context.getString(R.string.widget_status_label, freshnessLabel(context, data.fetchedAt)), - ) - - views.removeAllViews(R.id.widget_day_time_labels) - views.removeAllViews(R.id.widget_day_grid) - - if (data.isHoliday) { - views.setViewVisibility(R.id.widget_day_empty, View.VISIBLE) - views.setTextViewText( - R.id.widget_day_empty, - data.holidayName ?: context.getString(R.string.widget_holiday), - ) - } else if (data.lessons.isEmpty()) { - views.setViewVisibility(R.id.widget_day_empty, View.VISIBLE) - views.setTextViewText( - R.id.widget_day_empty, - context.getString(R.string.widget_no_lessons), - ) - } else { - views.setViewVisibility(R.id.widget_day_empty, View.GONE) - populateGridLines(packageName, views, R.id.widget_day_time_labels, hourHeightDp, palette, data.periods) - populateTimeLabels(packageName, views, R.id.widget_day_time_labels, hourHeightDp, palette, data.periods) - populateGridLines(packageName, views, R.id.widget_day_grid, hourHeightDp, palette, data.periods) - populateBreakBlocks(packageName, views, R.id.widget_day_grid, hourHeightDp, palette, data.periods) - for (lesson in data.lessons) { - addLessonBlock( - context = context, - packageName = packageName, - parent = views, - containerId = R.id.widget_day_grid, - lesson = lesson, - hourHeightDp = hourHeightDp, - periods = data.periods, - subjectOnly = false, - horizontalPaddingDp = 7, - ) - } + return forBothOrientations(options) { size -> + renderDay(context, packageName, palette, data, size) } - - views.setOnClickPendingIntent(R.id.widget_root, openAppIntent(context)) - return views } fun buildWeek( @@ -195,13 +248,152 @@ object WidgetRenderer { context.getString(R.string.widget_loading), palette, ) + return forBothOrientations(options) { size -> + renderWeek(context, packageName, palette, data, size) + } + } - val totalVirtualMin = data.periods.lastOrNull()?.virtualEndMinutes - ?: FALLBACK_VIRTUAL_MINUTES - val hourHeightDp = resolveHourHeight(options, WEEK_CHROME_DP, totalVirtualMin) + /// The launcher swaps between the two layouts on rotation without asking + /// the provider again, so both have to be laid out for their own size. + private fun forBothOrientations( + options: Bundle?, + render: (SizeDp?) -> RemoteViews, + ): RemoteViews { + val sizes = widgetSizes(options) ?: return render(null) + val (portrait, landscape) = sizes + if (portrait == landscape) return render(portrait) + return RemoteViews(render(landscape), render(portrait)) + } + + /// Launchers report the widget as a min/max box: portrait is the narrow + /// and tall combination, landscape the wide and short one. + private fun widgetSizes(options: Bundle?): Pair? { + if (options == null) return null + val minWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 0) + val maxWidth = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, 0) + val minHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0) + val maxHeight = options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0) + val portrait = SizeDp( + (if (minWidth > 0) minWidth else maxWidth).toFloat(), + (if (maxHeight > 0) maxHeight else minHeight).toFloat(), + ) + val landscape = SizeDp( + (if (maxWidth > 0) maxWidth else minWidth).toFloat(), + (if (minHeight > 0) minHeight else maxHeight).toFloat(), + ) + if (portrait.width <= 0 || portrait.height <= 0) return null + return Pair(portrait, landscape) + } + + private fun renderDay( + context: Context, + packageName: String, + palette: WidgetPalette, + data: WidgetTimetableData, + size: SizeDp?, + ): RemoteViews { + val narrow = size != null && size.width < DAY_NARROW_WIDTH_DP + val chromeDp = CHROME_PADDING_V_DP + TITLE_ROW_DP + GRID_MARGIN_TOP_DP + + (if (narrow) SUBTITLE_BELOW_DP else 0f) + val periods = trimTrailingPeriods(data.periods, data.lessons) + val grid = GridContext( + context = context, + packageName = packageName, + palette = palette, + periods = periods, + hourHeightDp = resolveHourHeight(size, chromeDp, periods), + tileInnerWidthDp = size?.let { + it.width - CHROME_PADDING_H_DP - RULER_WIDTH_DP - tileInsetsDp() + }, + ) + + val views = RemoteViews(packageName, R.layout.widget_day) + applyChrome(views, palette, size) + views.setTextColor(R.id.widget_day_title, palette.textPrimary) + views.setTextColor(R.id.widget_day_subtitle, palette.textSecondary) + views.setTextColor(R.id.widget_day_subtitle_below, palette.textSecondary) + views.setTextColor(R.id.widget_day_empty, palette.textSecondary) + views.setTextViewText( + R.id.widget_day_title, + "${dayLabel(context, data.anchorDate)} · ${dateShort.format(data.anchorDate)}", + ) + val freshness = context.getString( + R.string.widget_status_label, + freshnessLabel(context, data.fetchedAt), + ) + views.setTextViewText(R.id.widget_day_subtitle, freshness) + views.setTextViewText(R.id.widget_day_subtitle_below, freshness) + views.setViewVisibility( + R.id.widget_day_subtitle, + if (narrow) View.GONE else View.VISIBLE, + ) + views.setViewVisibility( + R.id.widget_day_subtitle_below, + if (narrow) View.VISIBLE else View.GONE, + ) + + views.removeAllViews(R.id.widget_day_time_labels) + views.removeAllViews(R.id.widget_day_grid) + + if (data.isHoliday) { + views.setViewVisibility(R.id.widget_day_empty, View.VISIBLE) + views.setTextViewText( + R.id.widget_day_empty, + data.holidayName ?: context.getString(R.string.widget_holiday), + ) + } else if (data.lessons.isEmpty()) { + views.setViewVisibility(R.id.widget_day_empty, View.VISIBLE) + views.setTextViewText( + R.id.widget_day_empty, + context.getString(R.string.widget_no_lessons), + ) + } else { + views.setViewVisibility(R.id.widget_day_empty, View.GONE) + populateRuler(grid, views, R.id.widget_day_time_labels) + populateGridLines(grid, views, R.id.widget_day_grid) + populateBreakBlocks(grid, views, R.id.widget_day_grid) + for (lesson in data.lessons) { + addLessonBlock(grid, views, R.id.widget_day_grid, lesson) + } + } + + views.setOnClickPendingIntent(R.id.widget_root, openAppIntent(context)) + return views + } + + private fun renderWeek( + context: Context, + packageName: String, + palette: WidgetPalette, + data: WidgetTimetableData, + size: SizeDp?, + ): RemoteViews { + val chromeDp = CHROME_PADDING_V_DP + TITLE_ROW_DP + GRID_MARGIN_TOP_DP + + WEEK_HEADER_DP + val weekDays = (0 until 5).map { offset -> + Calendar.getInstance().apply { + time = data.anchorDate + add(Calendar.DAY_OF_YEAR, offset) + }.time + } + val weekLessons = data.lessons.filter { lesson -> + weekDays.any { WidgetDateUtils.isSameDay(lesson.start, it) } + } + val periods = trimTrailingPeriods(data.periods, weekLessons) + val grid = GridContext( + context = context, + packageName = packageName, + palette = palette, + periods = periods, + hourHeightDp = resolveHourHeight(size, chromeDp, periods), + tileInnerWidthDp = size?.let { + (it.width - CHROME_PADDING_H_DP - RULER_WIDTH_DP - WEEK_DIVIDERS_DP) / 5f - + tileInsetsDp() + }, + ) val views = RemoteViews(packageName, R.layout.widget_week) - applyChrome(views, palette, options) + applyChrome(views, palette, size) views.setTextColor(R.id.widget_week_title, palette.textPrimary) views.setTextColor(R.id.widget_week_subtitle, palette.textSecondary) val cal = Calendar.getInstance().apply { time = data.anchorDate } @@ -235,45 +427,36 @@ object WidgetRenderer { R.id.widget_week_col_fri, ) views.removeAllViews(R.id.widget_week_time_labels) - populateGridLines(packageName, views, R.id.widget_week_time_labels, hourHeightDp, palette, data.periods) - populateTimeLabels(packageName, views, R.id.widget_week_time_labels, hourHeightDp, palette, data.periods) - - val (weekWidthDp, _) = widgetSizeDp(options) - // Time-label column is 28dp wide; the rest is split across 5 days - // plus thin dividers (negligible). Drop room/teacher only on the - // very narrowest week widgets — autoSize handles the in-between - // sizes. - val dayColumnWidthDp = (weekWidthDp - 28f - 20f) / 5f - val subjectOnly = dayColumnWidthDp < WEEK_COLUMN_TIGHT_DP + populateRuler(grid, views, R.id.widget_week_time_labels) + val today = Date() for ((index, columnId) in columnIds.withIndex()) { views.removeAllViews(headerIds[index]) views.removeAllViews(columnId) - val day = Calendar.getInstance().apply { - time = data.anchorDate - add(Calendar.DAY_OF_YEAR, index) - }.time - val header = RemoteViews(packageName, R.layout.widget_week_day_header) - header.setTextColor(R.id.widget_week_day_header_weekday, palette.textPrimary) - header.setTextColor(R.id.widget_week_day_header_date, palette.textSecondary) - header.setTextViewText(R.id.widget_week_day_header_weekday, weekdayShort.format(day)) - header.setTextViewText(R.id.widget_week_day_header_date, dateShort.format(day)) - views.addView(headerIds[index], header) + val day = weekDays[index] + val isToday = WidgetDateUtils.isSameDay(day, today) + val holiday = data.days.firstOrNull { + it.isHoliday && WidgetDateUtils.isSameDay(it.date, day) + } + views.addView(headerIds[index], buildDayHeader(grid, day, isToday)) + views.setInt( + columnId, + "setBackgroundColor", + when { + holiday != null -> palette.holidayTint + isToday -> palette.todayTint + else -> 0 + }, + ) - populateGridLines(packageName, views, columnId, hourHeightDp, palette, data.periods) - populateBreakBlocks(packageName, views, columnId, hourHeightDp, palette, data.periods) - for (lesson in data.lessons.filter { WidgetDateUtils.isSameDay(it.start, day) }) { - addLessonBlock( - context = context, - packageName = packageName, - parent = views, - containerId = columnId, - lesson = lesson, - hourHeightDp = hourHeightDp, - periods = data.periods, - subjectOnly = subjectOnly, - horizontalPaddingDp = 3, - ) + if (holiday != null) { + views.addView(columnId, buildHolidayColumn(grid, holiday)) + } else { + populateGridLines(grid, views, columnId) + populateBreakBlocks(grid, views, columnId) + } + for (lesson in weekLessons.filter { WidgetDateUtils.isSameDay(it.start, day) }) { + addLessonBlock(grid, views, columnId, lesson) } } @@ -281,27 +464,90 @@ object WidgetRenderer { return views } - /// Pulls the launcher-reported widget size out of the AppWidget options - /// bundle. The grid now spans `totalVirtualMin` minutes (lessons + - /// preserved big breaks), so we divide by that instead of a fixed hour - /// count to keep tiles readable across different timetables. - private fun resolveHourHeight( - options: Bundle?, - chromeDp: Int, - totalVirtualMin: Int, - ): Float { - val virtualHours = (totalVirtualMin / 60.0f).coerceAtLeast(1f) - val rawHeightDp = options?.let { - max( - it.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0), - it.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0), + private fun trimTrailingPeriods( + periods: List, + lessons: List, + ): List { + if (lessons.isEmpty()) return periods + val cal = Calendar.getInstance() + val latestEnd = lessons.maxOf { lesson -> + cal.time = lesson.end + cal.get(Calendar.HOUR_OF_DAY) * 60 + cal.get(Calendar.MINUTE) + } + val lastUsed = periods.indexOfLast { it.startMinutes < latestEnd } + return periods.take(max(lastUsed + 1, MIN_VISIBLE_PERIODS)) + } + + private fun tileInsetsDp(): Float = 2 * (TILE_MARGIN_H_DP + TILE_PADDING_H_DP) + + private fun buildDayHeader(grid: GridContext, day: Date, isToday: Boolean): RemoteViews { + val palette = grid.palette + val header = RemoteViews(grid.packageName, R.layout.widget_week_day_header) + header.setTextViewText( + R.id.widget_week_day_header_weekday, + weekdayShort.format(day).uppercase(Locale.GERMAN), + ) + header.setTextColor( + R.id.widget_week_day_header_weekday, + if (isToday) palette.accent else palette.textSecondary, + ) + val dayOfMonth = Calendar.getInstance().apply { time = day } + .get(Calendar.DAY_OF_MONTH).toString() + if (isToday) { + val bold = SpannableString(dayOfMonth).apply { + setSpan(StyleSpan(Typeface.BOLD), 0, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + header.setTextViewText(R.id.widget_week_day_header_date, bold) + header.setTextColor(R.id.widget_week_day_header_date, palette.onAccent) + header.setInt( + R.id.widget_week_day_header_date, + "setBackgroundResource", + R.drawable.widget_today_circle, ) - } ?: 0 - if (rawHeightDp <= 0) return 32f - val gridHeightDp = (rawHeightDp - chromeDp) - .coerceAtLeast((MIN_HOUR_HEIGHT_DP * virtualHours).toInt()) - return (gridHeightDp.toFloat() / virtualHours) - .coerceIn(MIN_HOUR_HEIGHT_DP.toFloat(), MAX_HOUR_HEIGHT_DP.toFloat()) + header.setColorStateList( + R.id.widget_week_day_header_date, + "setBackgroundTintList", + ColorStateList.valueOf(palette.accent), + ) + } else { + header.setTextViewText(R.id.widget_week_day_header_date, dayOfMonth) + header.setTextColor(R.id.widget_week_day_header_date, palette.textPrimary) + header.setInt(R.id.widget_week_day_header_date, "setBackgroundResource", 0) + } + return header + } + + private fun buildHolidayColumn(grid: GridContext, holiday: WidgetDayInfo): RemoteViews { + val palette = grid.palette + val column = RemoteViews(grid.packageName, R.layout.widget_holiday_column) + column.setInt(R.id.widget_holiday_icon, "setColorFilter", palette.textPrimary) + column.setTextColor(R.id.widget_holiday_label, palette.textPrimary) + column.setTextColor(R.id.widget_holiday_name, palette.textPrimary) + column.setTextViewText(R.id.widget_holiday_name, holiday.holidayName ?: "") + val totalVirtualMin = grid.periods.lastOrNull()?.virtualEndMinutes + ?: FALLBACK_VIRTUAL_MINUTES + column.setViewLayoutWidth( + R.id.widget_holiday_name, + (grid.dp(totalVirtualMin.toFloat()) - 2 * HOLIDAY_NAME_INSET_DP).coerceAtLeast(40f), + TypedValue.COMPLEX_UNIT_DIP, + ) + return column + } + + /// The grid spans the periods' virtual minutes (lessons plus preserved + /// big breaks), so the per-hour height follows the actual timetable + /// instead of a fixed hour count. + private fun resolveHourHeight( + size: SizeDp?, + chromeDp: Float, + periods: List, + ): Float { + if (size == null) return FALLBACK_HOUR_HEIGHT_DP + val totalVirtualMin = periods.lastOrNull()?.virtualEndMinutes + ?: FALLBACK_VIRTUAL_MINUTES + val virtualHours = (totalVirtualMin / 60.0f).coerceAtLeast(1f) + return ((size.height - chromeDp) / virtualHours) + .coerceIn(MIN_HOUR_HEIGHT_DP, MAX_HOUR_HEIGHT_DP) } /// Real wall-clock minute → position on the virtual axis. Inside a @@ -343,74 +589,64 @@ object WidgetRenderer { return 0f } - /// Below this per-hour height the two-line label collapses to a single - /// period number — time + number overlap otherwise. - private const val TIME_LABEL_COMPACT_THRESHOLD_DP = 26f - - private fun populateTimeLabels( - packageName: String, - parent: RemoteViews, - containerId: Int, - hourHeightDp: Float, - palette: WidgetPalette, - periods: List, - ) { - val compact = hourHeightDp < TIME_LABEL_COMPACT_THRESHOLD_DP - for (period in periods) { - val label = RemoteViews(packageName, R.layout.widget_time_label) - label.setTextViewText(R.id.widget_time_label_number, "${period.name}.") - label.setTextViewText(R.id.widget_time_label_time, formatHm(period.startMinutes)) - if (compact) { - label.setViewVisibility(R.id.widget_time_label_time, View.GONE) - label.setTextViewTextSize( - R.id.widget_time_label_number, - TypedValue.COMPLEX_UNIT_SP, - 9f, - ) - label.setTextColor(R.id.widget_time_label_number, palette.textPrimary) - } else { - label.setViewVisibility(R.id.widget_time_label_time, View.VISIBLE) - label.setTextViewTextSize( - R.id.widget_time_label_number, - TypedValue.COMPLEX_UNIT_SP, - 7f, - ) - label.setTextColor(R.id.widget_time_label_number, palette.textSecondary) - } - label.setTextColor(R.id.widget_time_label_time, palette.textPrimary) - val topDp = period.virtualStartMinutes * hourHeightDp / 60.0f - label.setViewLayoutMargin( + private fun populateRuler(grid: GridContext, parent: RemoteViews, containerId: Int) { + populateGridLines(grid, parent, containerId) + val palette = grid.palette + for (period in grid.periods) { + val cellDp = grid.dp((period.virtualEndMinutes - period.virtualStartMinutes).toFloat()) + val label = RemoteViews(grid.packageName, R.layout.widget_time_label) + positionVertically( + label, R.id.widget_time_label_root, - RemoteViews.MARGIN_TOP, - topDp, - TypedValue.COMPLEX_UNIT_DIP, + grid.dp(period.virtualStartMinutes.toFloat()), + cellDp, ) + label.setTextViewText(R.id.widget_time_label_number, period.name) + label.setTextColor(R.id.widget_time_label_number, palette.textPrimary) + label.setTextViewTextSize( + R.id.widget_time_label_number, + TypedValue.COMPLEX_UNIT_DIP, + (cellDp * 0.3f).coerceIn(8f, 13f), + ) + val showTimes = cellDp >= RULER_TIMES_MIN_CELL_DP + for ((viewId, minutes) in listOf( + R.id.widget_time_label_start to period.startMinutes, + R.id.widget_time_label_end to period.endMinutes, + )) { + label.setViewVisibility(viewId, if (showTimes) View.VISIBLE else View.GONE) + label.setTextViewText(viewId, formatHm(minutes)) + label.setTextColor(viewId, palette.textSecondary) + label.setTextViewTextSize( + viewId, + TypedValue.COMPLEX_UNIT_DIP, + (cellDp * 0.19f).coerceIn(6f, 8.5f), + ) + } + parent.addView(containerId, label) + } + forEachBreak(grid) { topDp, heightDp -> + if (heightDp < BREAK_ICON_MIN_HEIGHT_DP) return@forEachBreak + val label = RemoteViews(grid.packageName, R.layout.widget_break_label) + positionVertically(label, R.id.widget_break_label_root, topDp, heightDp) + label.setInt(R.id.widget_break_label_icon, "setColorFilter", palette.textSecondary) parent.addView(containerId, label) } } - private fun populateGridLines( - packageName: String, - parent: RemoteViews, - containerId: Int, - hourHeightDp: Float, - palette: WidgetPalette, - periods: List, - ) { + private fun populateGridLines(grid: GridContext, parent: RemoteViews, containerId: Int) { // Lines at every period start + end, deduped by virtual minute so // adjacent periods share a line and big-break boundaries get both // upper and lower edges. val drawn = mutableSetOf() - for (period in periods) { + for (period in grid.periods) { for (virtualMin in listOf(period.virtualStartMinutes, period.virtualEndMinutes)) { if (!drawn.add(virtualMin)) continue - val line = RemoteViews(packageName, R.layout.widget_grid_line) - line.setInt(R.id.widget_grid_line_root, "setBackgroundColor", palette.divider) - val topDp = virtualMin * hourHeightDp / 60.0f + val line = RemoteViews(grid.packageName, R.layout.widget_grid_line) + line.setInt(R.id.widget_grid_line_root, "setBackgroundColor", grid.palette.divider) line.setViewLayoutMargin( R.id.widget_grid_line_root, RemoteViews.MARGIN_TOP, - topDp, + grid.dp(virtualMin.toFloat()), TypedValue.COMPLEX_UNIT_DIP, ) parent.addView(containerId, line) @@ -418,45 +654,46 @@ object WidgetRenderer { } } - /// Faint translucent block in any virtual gap between two periods — - /// only big breaks (Hofpause, Mittagspause) survive the mapper's - /// small-break collapse. - private fun populateBreakBlocks( - packageName: String, - parent: RemoteViews, - containerId: Int, - hourHeightDp: Float, - palette: WidgetPalette, - periods: List, - ) { - for (i in 0 until periods.size - 1) { - val curr = periods[i] - val next = periods[i + 1] - val virtualGap = next.virtualStartMinutes - curr.virtualEndMinutes - if (virtualGap <= 0) continue - val block = RemoteViews(packageName, R.layout.widget_break_block) - val topDp = curr.virtualEndMinutes * hourHeightDp / 60.0f - val heightDp = virtualGap * hourHeightDp / 60.0f - block.setViewLayoutMargin( - R.id.widget_break_block_root, - RemoteViews.MARGIN_TOP, - topDp, - TypedValue.COMPLEX_UNIT_DIP, - ) - block.setViewLayoutHeight( - R.id.widget_break_block_root, - heightDp, - TypedValue.COMPLEX_UNIT_DIP, - ) + /// Tinted band with a centred icon in any virtual gap between two + /// periods — only big breaks (Hofpause, Mittagspause) survive the + /// mapper's small-break collapse. + private fun populateBreakBlocks(grid: GridContext, parent: RemoteViews, containerId: Int) { + forEachBreak(grid) { topDp, heightDp -> + val block = RemoteViews(grid.packageName, R.layout.widget_break_block) + positionVertically(block, R.id.widget_break_block_root, topDp, heightDp) block.setInt( R.id.widget_break_block_root, "setBackgroundColor", - palette.breakBlock, + grid.palette.breakBlock, + ) + block.setInt(R.id.widget_break_block_icon, "setColorFilter", grid.palette.accent) + block.setViewVisibility( + R.id.widget_break_block_icon, + if (heightDp < BREAK_ICON_MIN_HEIGHT_DP) View.GONE else View.VISIBLE, ) parent.addView(containerId, block) } } + private inline fun forEachBreak(grid: GridContext, action: (topDp: Float, heightDp: Float) -> Unit) { + for (i in 0 until grid.periods.size - 1) { + val curr = grid.periods[i] + val virtualGap = grid.periods[i + 1].virtualStartMinutes - curr.virtualEndMinutes + if (virtualGap <= 0) continue + action(grid.dp(curr.virtualEndMinutes.toFloat()), grid.dp(virtualGap.toFloat())) + } + } + + private fun positionVertically(views: RemoteViews, viewId: Int, topDp: Float, heightDp: Float) { + views.setViewLayoutMargin( + viewId, + RemoteViews.MARGIN_TOP, + topDp, + TypedValue.COMPLEX_UNIT_DIP, + ) + views.setViewLayoutHeight(viewId, heightDp, TypedValue.COMPLEX_UNIT_DIP) + } + private fun formatHm(minutesSinceMidnight: Int): String { val h = minutesSinceMidnight / 60 val m = minutesSinceMidnight % 60 @@ -466,15 +703,15 @@ object WidgetRenderer { /// Overrides the chrome XML's `@color/widget_*` defaults when the user /// pins a fixed light/dark theme, and resizes the watermark M to match /// the current widget bounds. - private fun applyChrome(views: RemoteViews, palette: WidgetPalette, options: Bundle?) { + private fun applyChrome(views: RemoteViews, palette: WidgetPalette, size: SizeDp?) { views.setInt(R.id.widget_root, "setBackgroundColor", palette.background) views.setInt(R.id.widget_watermark, "setColorFilter", palette.textPrimary) views.setFloat(R.id.widget_watermark, "setAlpha", palette.watermarkAlpha) - val (widthDp, heightDp) = widgetSizeDp(options) // Sized to the longer edge so the M scales with widget resize. // Negative end/bottom margin lets a sliver tuck behind the edge. - val markSize = (max(widthDp, heightDp) * 0.8f).coerceIn(160f, 400f) + val longerEdge = size?.let { max(it.width, it.height) } ?: 220f + val markSize = (longerEdge * 0.8f).coerceIn(160f, 400f) val offsetEnd = -(markSize * 0.18f) val offsetBottom = -(markSize * 0.18f) views.setViewLayoutWidth( @@ -501,29 +738,11 @@ object WidgetRenderer { ) } - private fun widgetSizeDp(options: Bundle?): Pair { - if (options == null) return Pair(220, 220) - val width = max( - options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH, 0), - options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH, 0), - ).coerceAtLeast(140) - val height = max( - options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT, 0), - options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT, 0), - ).coerceAtLeast(140) - return Pair(width, height) - } - private fun addLessonBlock( - context: Context, - packageName: String, + grid: GridContext, parent: RemoteViews, containerId: Int, lesson: WidgetLesson, - hourHeightDp: Float, - periods: List, - subjectOnly: Boolean, - horizontalPaddingDp: Int, ) { val cal = Calendar.getInstance() cal.time = lesson.start @@ -532,46 +751,28 @@ object WidgetRenderer { val endMinutes = cal.get(Calendar.HOUR_OF_DAY) * 60 + cal.get(Calendar.MINUTE) val durationMinutes = (endMinutes - startMinutes).coerceAtLeast(15) - val virtualStart = realMinutesToVirtual(startMinutes, periods) - val virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods) + val virtualStart = realMinutesToVirtual(startMinutes, grid.periods) + val virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, grid.periods) if (virtualEnd <= virtualStart) return // Half the gap above + half below so the grid line under the tile // stays visible. - val topDp = virtualStart * hourHeightDp / 60.0f + LESSON_GAP_DP / 2f - val heightDp = ((virtualEnd - virtualStart) * hourHeightDp / 60.0f - LESSON_GAP_DP) - .coerceAtLeast(MIN_BLOCK_HEIGHT_DP.toFloat()) + val topDp = grid.dp(virtualStart) + LESSON_GAP_DP / 2f + val heightDp = (grid.dp(virtualEnd - virtualStart) - LESSON_GAP_DP) + .coerceAtLeast(MIN_BLOCK_HEIGHT_DP) - val block = RemoteViews(packageName, R.layout.widget_lesson_block) - block.setViewLayoutMargin( + val block = RemoteViews(grid.packageName, R.layout.widget_lesson_block) + positionVertically(block, R.id.widget_lesson_block_root, topDp, heightDp) + block.setColorStateList( R.id.widget_lesson_block_root, - RemoteViews.MARGIN_TOP, - topDp, - TypedValue.COMPLEX_UNIT_DIP, + "setBackgroundTintList", + ColorStateList.valueOf(lesson.color ?: fallbackColor(lesson)), ) - block.setViewLayoutHeight( - R.id.widget_lesson_block_root, - heightDp, - TypedValue.COMPLEX_UNIT_DIP, - ) - block.setInt( - R.id.widget_lesson_block_root, - "setBackgroundResource", - statusDrawable(lesson), + block.setViewVisibility( + R.id.widget_lesson_cancelled, + if (lesson.status == WidgetLessonStatus.CANCELLED) View.VISIBLE else View.GONE, ) - val density = context.resources.displayMetrics.density - val padXPx = (horizontalPaddingDp * density).toInt() - val padYPx = (3 * density).toInt() - block.setViewPadding( - R.id.widget_lesson_block_root, - padXPx, padYPx, padXPx, padYPx, - ) - - block.setTextViewText(R.id.widget_lesson_subject, subjectLabel(lesson)) - - // Separate fixed-size badge so the +N hint stays readable when - // autoSize shrinks the subject on narrow tiles. if (lesson.siblingCount > 0) { block.setTextViewText( R.id.widget_lesson_sibling_badge, @@ -582,82 +783,140 @@ object WidgetRenderer { block.setViewVisibility(R.id.widget_lesson_sibling_badge, View.GONE) } - val room = roomLabel(lesson) - val teacher = teacherLabel(lesson) - val noSecondaryContent = room.isNullOrEmpty() && teacher.isNullOrEmpty() - val hideSecondary = subjectOnly || - heightDp < BLOCK_SHOW_ROOM_MIN_DP || - noSecondaryContent - block.setViewVisibility( - R.id.widget_lesson_secondary_stack, - if (hideSecondary) View.GONE else View.VISIBLE, - ) - // Custom-events have no room/teacher → let the subject wrap to 2 lines - // so long titles don't autoshrink to nothing. - block.setInt( - R.id.widget_lesson_subject, - "setMaxLines", - if (noSecondaryContent) 2 else 1, - ) - when { - hideSecondary -> { - applyOptionalText(block, R.id.widget_lesson_room, null) - applyOptionalText(block, R.id.widget_lesson_teacher, null) - } - heightDp < BLOCK_SHOW_TEACHER_SEPARATE_MIN_DP -> { - applyOptionalText(block, R.id.widget_lesson_room, room) - applyOptionalText(block, R.id.widget_lesson_teacher, null) - } - else -> { - applyOptionalText(block, R.id.widget_lesson_room, room) - applyOptionalText(block, R.id.widget_lesson_teacher, teacher) - } - } - + applyTileText(grid, block, lesson, heightDp - 2 * TILE_PADDING_V_DP) parent.addView(containerId, block) } - private fun applyOptionalText(views: RemoteViews, viewId: Int, text: String?) { - if (text.isNullOrEmpty()) { - views.setViewVisibility(viewId, View.GONE) + /// Distributes the tile height like the in-app _TileContent: the title + /// comes first, then as many body lines as fit. The line boxes are what + /// caps the font size — autoSize fills each box and shrinks a line only + /// when its text is wider than the tile. + private fun applyTileText( + grid: GridContext, + block: RemoteViews, + lesson: WidgetLesson, + innerHeightDp: Float, + ) { + val metrics = grid.metrics + val title = subjectLabel(lesson) + val body = listOfNotNull(lesson.room, teacherLabel(lesson)).filter { it.isNotBlank() } + + var titleBoxDp = min(metrics.titleBoxDp, innerHeightDp) + val bodyCapacity = floor((innerHeightDp - titleBoxDp) / metrics.bodyBoxDp + 0.05f) + .toInt() + .coerceIn(0, metrics.maxBodyLines) + val shownBody = when { + bodyCapacity >= body.size -> body + // Rather share one line than drop the teacher entirely. + bodyCapacity == 1 -> listOf(body.joinToString(" · ")) + else -> emptyList() + } + // No room for a line below the title: the room moves next to it, + // unless the title already names it ("Hof Aufsicht"). + val inline = if (bodyCapacity == 0) { + body.firstOrNull()?.takeUnless { title.contains(it) } } else { - views.setTextViewText(viewId, text) - views.setViewVisibility(viewId, View.VISIBLE) + null + } + + val spareDp = innerHeightDp - titleBoxDp - shownBody.size * metrics.bodyBoxDp + val wrappedLineDp = if (spareDp >= titleBoxDp) wrappedTitleLineBox(grid, title) else null + if (wrappedLineDp != null) titleBoxDp = 2 * wrappedLineDp + + // Keep the last line clear of the "+N" badge when it reaches down + // into the badge's corner. + val contentDp = titleBoxDp + shownBody.size * metrics.bodyBoxDp + val badgeInsetPx = if ( + lesson.siblingCount > 0 && innerHeightDp - contentDp < BADGE_HEIGHT_DP + ) { + (BADGE_WIDTH_DP * grid.context.resources.displayMetrics.density).toInt() + } else { + 0 + } + val lastLineId = listOf( + R.id.widget_lesson_title_row, + R.id.widget_lesson_room, + R.id.widget_lesson_teacher, + )[shownBody.size] + block.setViewPadding(lastLineId, 0, 0, badgeInsetPx, 0) + + if (inline != null) { + block.setViewVisibility(R.id.widget_lesson_inline, View.VISIBLE) + block.setTextViewText(R.id.widget_lesson_inline, inline) + block.setTextViewTextSize( + R.id.widget_lesson_inline, + TypedValue.COMPLEX_UNIT_DIP, + metrics.bodyFontDp, + ) + } else { + block.setViewVisibility(R.id.widget_lesson_inline, View.GONE) + } + + block.setTextViewText(R.id.widget_lesson_subject, title) + block.setInt( + R.id.widget_lesson_subject, + "setMaxLines", + if (wrappedLineDp != null) 2 else 1, + ) + block.setViewLayoutHeight( + R.id.widget_lesson_subject, + titleBoxDp, + TypedValue.COMPLEX_UNIT_DIP, + ) + for ((index, viewId) in listOf( + R.id.widget_lesson_room, + R.id.widget_lesson_teacher, + ).withIndex()) { + val text = shownBody.getOrNull(index) + if (text == null) { + block.setViewVisibility(viewId, View.GONE) + continue + } + block.setViewVisibility(viewId, View.VISIBLE) + block.setTextViewText(viewId, text) + block.setViewLayoutHeight(viewId, metrics.bodyBoxDp, TypedValue.COMPLEX_UNIT_DIP) } } - /// Custom-events use the user-picked palette (orange/red/green/blue, - /// mirroring CustomTimetableColors). - private fun statusDrawable(lesson: WidgetLesson): Int { - if (lesson.status == WidgetLessonStatus.EVENT && lesson.customColor != null) { - return when (lesson.customColor) { - "orange" -> R.drawable.widget_lesson_block_event_orange - "red" -> R.drawable.widget_lesson_block_event_red - "green" -> R.drawable.widget_lesson_block_event_green - "blue" -> R.drawable.widget_lesson_block_event_blue - else -> R.drawable.widget_lesson_block_event_orange - } - } - return when (lesson.status) { - WidgetLessonStatus.CANCELLED -> R.drawable.widget_lesson_block_cancelled - WidgetLessonStatus.IRREGULAR -> R.drawable.widget_lesson_block_irregular - WidgetLessonStatus.TEACHER_CHANGED -> R.drawable.widget_lesson_block_teacher_changed - WidgetLessonStatus.PAST -> R.drawable.widget_lesson_block_past - WidgetLessonStatus.EVENT -> R.drawable.widget_lesson_block_event_orange - // ONGOING collapses into REGULAR — widgets only refresh every - // ~30min so "the current lesson" is stale most of the time and - // the visual highlight would mislead more than help. - WidgetLessonStatus.ONGOING, - WidgetLessonStatus.REGULAR -> R.drawable.widget_lesson_block_regular + /// Line box for a title wrapped onto two lines, or null 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 — autoSize would otherwise pick a larger font and break + /// mid-word. + private fun wrappedTitleLineBox(grid: GridContext, title: String): Float? { + val widthDp = (grid.tileInnerWidthDp ?: return null) * WIDTH_ESTIMATE_SAFETY + val paint = TextPaint().apply { + typeface = titleTypeface + textSize = 100f } + fun widthPerFontDp(text: String) = paint.measureText(text) / 100f + if (widthPerFontDp(title) * TITLE_WRAP_BELOW_FONT_DP <= widthDp) return null + val words = title.split(Regex("(?<=[\\s-])")).map { it.trim() }.filter { it.isNotEmpty() } + if (words.size < 2) return null + val longestWordFontDp = widthDp / words.maxOf { widthPerFontDp(it) } + return min(grid.metrics.titleBoxDp, longestWordFontDp * LINE_BOX_PER_FONT) + } + + /// Only reached for payloads written before Dart shipped the resolved + /// tile colour; mirrors lesson_color.dart. + private fun fallbackColor(lesson: WidgetLesson): Int = when (lesson.status) { + WidgetLessonStatus.CANCELLED -> 0xFF000000.toInt() + WidgetLessonStatus.IRREGULAR -> 0xFF8F19B3.toInt() + WidgetLessonStatus.TEACHER_CHANGED -> 0xFF29639B.toInt() + WidgetLessonStatus.DUTY -> 0xFF00796B.toInt() + WidgetLessonStatus.EVENT -> + if (lesson.customColor != null) 0xFFFFA000.toInt() else 0xFF2E7D32.toInt() + // No time-based highlight: the widget only refreshes every ~30min, + // so "current" and "past" would be stale most of the time. + WidgetLessonStatus.PAST, + WidgetLessonStatus.ONGOING, + WidgetLessonStatus.REGULAR -> 0xFF993333.toInt() } private fun subjectLabel(lesson: WidgetLesson): String { return lesson.subjectShort.ifEmpty { lesson.subjectLong ?: "—" } } - private fun roomLabel(lesson: WidgetLesson): String? = lesson.room - private fun teacherLabel(lesson: WidgetLesson): String? = lesson.teacher ?: lesson.originalTeacher diff --git a/android/app/src/main/res/drawable/widget_icon_coffee.xml b/android/app/src/main/res/drawable/widget_icon_coffee.xml new file mode 100644 index 0000000..e02dade --- /dev/null +++ b/android/app/src/main/res/drawable/widget_icon_coffee.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_icon_event.xml b/android/app/src/main/res/drawable/widget_icon_event.xml new file mode 100644 index 0000000..0a601af --- /dev/null +++ b/android/app/src/main/res/drawable/widget_icon_event.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_icon_restaurant.xml b/android/app/src/main/res/drawable/widget_icon_restaurant.xml new file mode 100644 index 0000000..bc8913f --- /dev/null +++ b/android/app/src/main/res/drawable/widget_icon_restaurant.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_lesson_block.xml b/android/app/src/main/res/drawable/widget_lesson_block.xml new file mode 100644 index 0000000..0f4e532 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_lesson_block.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/widget_lesson_block_cancelled.xml b/android/app/src/main/res/drawable/widget_lesson_block_cancelled.xml deleted file mode 100644 index 7427de0..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_cancelled.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_block_event_blue.xml b/android/app/src/main/res/drawable/widget_lesson_block_event_blue.xml deleted file mode 100644 index f50c66a..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_event_blue.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_block_event_green.xml b/android/app/src/main/res/drawable/widget_lesson_block_event_green.xml deleted file mode 100644 index bedb7ac..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_event_green.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_block_event_orange.xml b/android/app/src/main/res/drawable/widget_lesson_block_event_orange.xml deleted file mode 100644 index 1cbb295..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_event_orange.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_block_irregular.xml b/android/app/src/main/res/drawable/widget_lesson_block_irregular.xml deleted file mode 100644 index b9bfdc5..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_irregular.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_block_ongoing.xml b/android/app/src/main/res/drawable/widget_lesson_block_ongoing.xml deleted file mode 100644 index 2bdab1c..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_ongoing.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_block_past.xml b/android/app/src/main/res/drawable/widget_lesson_block_past.xml deleted file mode 100644 index b25ab7c..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_past.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_block_regular.xml b/android/app/src/main/res/drawable/widget_lesson_block_regular.xml deleted file mode 100644 index ca78a54..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_regular.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_block_teacher_changed.xml b/android/app/src/main/res/drawable/widget_lesson_block_teacher_changed.xml deleted file mode 100644 index 58cb161..0000000 --- a/android/app/src/main/res/drawable/widget_lesson_block_teacher_changed.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/drawable/widget_lesson_cancelled_overlay.xml b/android/app/src/main/res/drawable/widget_lesson_cancelled_overlay.xml new file mode 100644 index 0000000..ed82b67 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_lesson_cancelled_overlay.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_lesson_cancelled_x.xml b/android/app/src/main/res/drawable/widget_lesson_cancelled_x.xml index d396613..b9a7ffe 100644 --- a/android/app/src/main/res/drawable/widget_lesson_cancelled_x.xml +++ b/android/app/src/main/res/drawable/widget_lesson_cancelled_x.xml @@ -5,7 +5,7 @@ android:viewportWidth="100" android:viewportHeight="100"> diff --git a/android/app/src/main/res/drawable/widget_lesson_block_event_red.xml b/android/app/src/main/res/drawable/widget_today_circle.xml similarity index 51% rename from android/app/src/main/res/drawable/widget_lesson_block_event_red.xml rename to android/app/src/main/res/drawable/widget_today_circle.xml index 9576750..34f2dd4 100644 --- a/android/app/src/main/res/drawable/widget_lesson_block_event_red.xml +++ b/android/app/src/main/res/drawable/widget_today_circle.xml @@ -1,6 +1,5 @@ - - + android:shape="oval"> + diff --git a/android/app/src/main/res/layout/widget_break_block.xml b/android/app/src/main/res/layout/widget_break_block.xml index 8cc7900..6804e0f 100644 --- a/android/app/src/main/res/layout/widget_break_block.xml +++ b/android/app/src/main/res/layout/widget_break_block.xml @@ -3,6 +3,14 @@ android:id="@+id/widget_break_block_root" android:layout_width="match_parent" android:layout_height="14dp" - android:layout_marginTop="0dp" - android:layout_marginStart="2dp" - android:layout_marginEnd="2dp" /> + android:layout_marginTop="0dp"> + + + diff --git a/android/app/src/main/res/layout/widget_break_label.xml b/android/app/src/main/res/layout/widget_break_label.xml new file mode 100644 index 0000000..505cf4d --- /dev/null +++ b/android/app/src/main/res/layout/widget_break_label.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/android/app/src/main/res/layout/widget_day.xml b/android/app/src/main/res/layout/widget_day.xml index 841d14e..32cf8c5 100644 --- a/android/app/src/main/res/layout/widget_day.xml +++ b/android/app/src/main/res/layout/widget_day.xml @@ -1,16 +1,18 @@ + the actual widget content without disturbing the LinearLayout flow. + Text sizes and chrome heights are dp: WidgetRenderer derives the grid + height from them, so they must not follow the system font scale. --> + android:background="@drawable/app_widget_background" + tools:ignore="SpUsage,SmallSp"> - + + android:paddingStart="8dp" + android:paddingEnd="8dp" + android:paddingTop="10dp" + android:paddingBottom="8dp"> + + + + + android:background="@color/widget_background" + tools:ignore="SpUsage,SmallSp,HardcodedText,UseAppTint"> + android:paddingStart="8dp" + android:paddingEnd="8dp" + android:paddingTop="10dp" + android:paddingBottom="8dp"> - - - - + android:layout_height="20dp" + android:gravity="start|center_vertical" + android:includeFontPadding="false" + android:textSize="14dp" + android:textStyle="bold" + android:textColor="@color/widget_text_primary" + android:maxLines="1" + android:text="Heute · 06.05." /> + android:layout_marginTop="4dp" + android:orientation="horizontal"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + - - - + + + + + + - - - + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_holiday_column.xml b/android/app/src/main/res/layout/widget_holiday_column.xml new file mode 100644 index 0000000..130df81 --- /dev/null +++ b/android/app/src/main/res/layout/widget_holiday_column.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/widget_lesson_block.xml b/android/app/src/main/res/layout/widget_lesson_block.xml index 8cdbb39..e163555 100644 --- a/android/app/src/main/res/layout/widget_lesson_block.xml +++ b/android/app/src/main/res/layout/widget_lesson_block.xml @@ -1,90 +1,113 @@ + + android:background="@drawable/widget_lesson_block" + tools:ignore="SpUsage,SmallSp"> + android:orientation="vertical" + android:paddingStart="4dp" + android:paddingEnd="4dp" + android:paddingTop="2dp" + android:paddingBottom="2dp"> + + + + + + + + + android:autoSizeMinTextSize="5.5dp" + android:autoSizeMaxTextSize="10dp" + android:autoSizeStepGranularity="0.5dp" + tools:text="A12" /> - - - - - - + + + - + + tools:ignore="SpUsage,SmallSp"> + + - + android:maxLines="1" + android:includeFontPadding="false" + tools:text="08:40" /> + diff --git a/android/app/src/main/res/layout/widget_week.xml b/android/app/src/main/res/layout/widget_week.xml index da2babb..5a4b688 100644 --- a/android/app/src/main/res/layout/widget_week.xml +++ b/android/app/src/main/res/layout/widget_week.xml @@ -1,11 +1,18 @@ + + android:background="@drawable/app_widget_background" + tools:ignore="SpUsage,SmallSp"> + + android:paddingStart="8dp" + android:paddingEnd="8dp" + android:paddingTop="10dp" + android:paddingBottom="8dp"> + android:layout_height="32dp" + android:layout_marginTop="4dp" + android:orientation="horizontal"> + android:layout_width="30dp" + android:layout_height="match_parent" /> @@ -123,7 +141,7 @@ + + tools:ignore="SpUsage,SmallSp"> + android:fontFamily="sans-serif-medium" + android:textSize="9dp" + android:textColor="@color/widget_text_secondary" + android:maxLines="1" + android:includeFontPadding="false" + tools:text="MO." /> + android:layout_width="18dp" + android:layout_height="18dp" + android:layout_marginTop="1dp" + android:gravity="center" + android:textSize="11dp" + android:textColor="@color/widget_text_primary" + android:maxLines="1" + android:includeFontPadding="false" + tools:text="6" /> diff --git a/android/app/src/main/res/layout/widget_week_preview.xml b/android/app/src/main/res/layout/widget_week_preview.xml index 821eaaf..ba90ce6 100644 --- a/android/app/src/main/res/layout/widget_week_preview.xml +++ b/android/app/src/main/res/layout/widget_week_preview.xml @@ -1,10 +1,11 @@ - + + android:background="@color/widget_background" + tools:ignore="SpUsage,SmallSp,HardcodedText,UseAppTint"> + android:paddingStart="8dp" + android:paddingEnd="8dp" + android:paddingTop="10dp" + android:paddingBottom="8dp"> + + - - - - - - - + android:layout_height="32dp" + android:layout_marginTop="4dp" + android:orientation="horizontal"> + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - + android:layout_marginTop="2dp" + android:orientation="horizontal"> - - - - - - - - - - - + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values-night/widget_colors.xml b/android/app/src/main/res/values-night/widget_colors.xml index d1856c7..4074646 100644 --- a/android/app/src/main/res/values-night/widget_colors.xml +++ b/android/app/src/main/res/values-night/widget_colors.xml @@ -4,4 +4,6 @@ #FFF1F1F1 #FFB0B0B0 #33FFFFFF + #FFFFB3AF + #32FFB3AF diff --git a/android/app/src/main/res/values-v31/widget_support.xml b/android/app/src/main/res/values-v31/widget_support.xml new file mode 100644 index 0000000..dc1b9fd --- /dev/null +++ b/android/app/src/main/res/values-v31/widget_support.xml @@ -0,0 +1,4 @@ + + + true + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 7d0ae4c..db7fd9b 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -6,6 +6,7 @@ Stundenplan und Vertretungen für die ganze Schulwoche. Keine Stunden Ferien + Schulfrei Bitte einloggen, um den Stundenplan zu laden Lade… Stand: %1$s diff --git a/android/app/src/main/res/values/widget_colors.xml b/android/app/src/main/res/values/widget_colors.xml index db5e003..a653f1a 100644 --- a/android/app/src/main/res/values/widget_colors.xml +++ b/android/app/src/main/res/values/widget_colors.xml @@ -1,17 +1,9 @@ - - #FF993333 - #FFC83333 - #FF993333 - #FF000000 - #FF8F19B3 - #FF29639B - #FF2E7D32 - #FFFCF7F5 #FF111111 #FF555555 #22000000 + #FF904A47 + #32904A47 diff --git a/android/app/src/main/res/values/widget_support.xml b/android/app/src/main/res/values/widget_support.xml new file mode 100644 index 0000000..82b7382 --- /dev/null +++ b/android/app/src/main/res/values/widget_support.xml @@ -0,0 +1,7 @@ + + + + false + diff --git a/ios/TimetableWidgetExtension/TimetableDayView.swift b/ios/TimetableWidgetExtension/TimetableDayView.swift index 7ab20e3..1817a33 100644 --- a/ios/TimetableWidgetExtension/TimetableDayView.swift +++ b/ios/TimetableWidgetExtension/TimetableDayView.swift @@ -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.. 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.. 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() - 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 diff --git a/ios/TimetableWidgetExtension/TimetableLayout.swift b/ios/TimetableWidgetExtension/TimetableLayout.swift new file mode 100644 index 0000000..b37b28a --- /dev/null +++ b/ios/TimetableWidgetExtension/TimetableLayout.swift @@ -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.. 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() + 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.. 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) + } + } +} diff --git a/ios/TimetableWidgetExtension/TimetableWeekView.swift b/ios/TimetableWidgetExtension/TimetableWeekView.swift index 123b0d0..6a55873 100644 --- a/ios/TimetableWidgetExtension/TimetableWeekView.swift +++ b/ios/TimetableWidgetExtension/TimetableWeekView.swift @@ -20,36 +20,44 @@ struct TimetableWeekView: View { @ViewBuilder private func content(data: WidgetTimetableData) -> some View { + let cal = Calendar.current + let days = (0..<5).map { offset in + cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate + } + let weekLessons = data.lessons.filter { lesson in + days.contains { cal.isDate(lesson.start, inSameDayAs: $0) } + } + let periods = trimTrailingPeriods(data.periods, lessons: weekLessons) VStack(alignment: .leading, spacing: 0) { header(data: data) - dayHeaderRow(data: data) + dayHeaderRow(days: days) + .padding(.top, GRID_MARGIN_TOP) GeometryReader { geo in - let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES) - let hourHeight = max( - MIN_HOUR_HEIGHT, - min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60) - ) - let dayColumnWidth = (geo.size.width - 28 - 4) / 5 - // 45pt is the minimum where a 7pt-font room (e.g. "F2.04") - // plus the subject fits without `minimumScaleFactor` - // shrinking the subject into illegibility. - let subjectOnly = dayColumnWidth < 45 + let height = resolveHourHeight(forGridHeight: geo.size.height, periods: periods) HStack(alignment: .top, spacing: 0) { - timeLabelsColumn(hourHeight: hourHeight, periods: data.periods) - .frame(width: 28, alignment: .topTrailing) + PeriodRuler(periods: periods, hourHeight: height) columnDivider - ForEach(0..<5, id: \.self) { offset in - column( - data: data, - offset: offset, - hourHeight: hourHeight, - subjectOnly: subjectOnly + ForEach(days.indices, id: \.self) { index in + TimeGridView( + lessons: weekLessons.filter { + cal.isDate($0.start, inSameDayAs: days[index]) + }, + periods: periods, + hourHeight: height, + holiday: holiday(on: days[index], data: data), + isToday: cal.isDate(days[index], inSameDayAs: entry.date) ) - .frame(maxWidth: .infinity) - if offset < 4 { columnDivider } + if index < 4 { columnDivider } } } } + .padding(.top, 2) + } + } + + private func holiday(on day: Date, data: WidgetTimetableData) -> WidgetDayInfo? { + data.days?.first { + $0.isHoliday && Calendar.current.isDate($0.date, inSameDayAs: day) } } @@ -63,112 +71,55 @@ struct TimetableWeekView: View { let cal = Calendar.current let week = cal.component(.weekOfYear, from: data.anchorDate) let endDate = cal.date(byAdding: .day, value: 4, to: data.anchorDate) ?? data.anchorDate - return HStack { + return HStack(spacing: 6) { Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))") - .font(.system(size: 12, weight: .semibold)) + .font(.system(size: 14, weight: .bold)) .foregroundStyle(palette.textPrimary) .lineLimit(1) - .minimumScaleFactor(0.8) - Spacer() + .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) + .fixedSize() } + .frame(height: TITLE_ROW_HEIGHT) } - 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. + /// Mirrors the in-app _DayHeaderCell: weekday in caps, day number below, + /// today inside an accent circle. + private func dayHeaderRow(days: [Date]) -> some View { // The fixed height keeps `columnDivider`'s vertically-flexible - // Rectangle from stealing space from the GeometryReader. - return HStack(spacing: 0) { - Spacer().frame(width: 28) + // Rectangle from stealing space from the GeometryReader below. + HStack(spacing: 0) { + Spacer().frame(width: RULER_WIDTH) columnDivider - ForEach(0..<5, id: \.self) { offset in - let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate - VStack(spacing: -1) { - Text(weekday(for: day)) - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(palette.textPrimary) - Text(shortDate(day)) - .font(.system(size: 8)) - .foregroundStyle(palette.textSecondary) - } - .frame(maxWidth: .infinity) - if offset < 4 { columnDivider } + ForEach(days.indices, id: \.self) { index in + dayHeaderCell(days[index]) + .frame(maxWidth: .infinity) + if index < 4 { columnDivider } } } - // 20pt fits 10pt weekday + 8pt date with tight -1pt spacing. - .frame(maxWidth: .infinity, minHeight: 20, maxHeight: 20) + .frame(maxWidth: .infinity, minHeight: WEEK_HEADER_HEIGHT, maxHeight: WEEK_HEADER_HEIGHT) } - private func timeLabelsColumn(hourHeight: CGFloat, periods: [WidgetPeriod]) -> some View { - let totalMin = periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES - let totalHeight = CGFloat(totalMin) * hourHeight / 60.0 - // Mirrors TimeGridView.compactLabels — below 26pt/hour a stacked - // time+name no longer fits in one period slot and the two labels - // visually clip into the next slot. Drop to a single bold period - // number until there's room to read both lines. - let compact = hourHeight < 26 - return ZStack(alignment: .topTrailing) { - ForEach(periodBoundaries(periods), id: \.self) { virtualMin in - Rectangle() - .fill(palette.divider) - .frame(height: 1) - .offset(y: CGFloat(virtualMin) * hourHeight / 60.0) - } - ForEach(periods, id: \.startMinutes) { period in - VStack(alignment: .trailing, spacing: -2) { - if compact { - Text("\(period.name).") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(palette.textPrimary) - .lineLimit(1) - } else { - Text(String(format: "%02d:%02d", period.startMinutes / 60, period.startMinutes % 60)) - .font(.system(size: 9)) - .foregroundStyle(palette.textPrimary) - .lineLimit(1) - Text("\(period.name).") - .font(.system(size: 7, weight: .bold)) - .foregroundStyle(palette.textSecondary) - .lineLimit(1) - } - } - .padding(.trailing, 4) - .offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0) - } + private func dayHeaderCell(_ day: Date) -> some View { + // `entry.date`, not `Date()` — WidgetKit archives entries ahead of + // time, the midnight entry has to mark its own day. + let isToday = Calendar.current.isDate(day, inSameDayAs: entry.date) + return VStack(spacing: 1) { + Text(WidgetDateFormatters.weekdayShort.string(from: day).uppercased()) + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(isToday ? palette.accent : palette.textSecondary) + .lineLimit(1) + Text("\(Calendar.current.component(.day, from: day))") + .font(.system(size: 11, weight: isToday ? .bold : .regular)) + .foregroundStyle(isToday ? palette.onAccent : palette.textPrimary) + .lineLimit(1) + .frame(width: 18, height: 18) + .background(Circle().fill(isToday ? palette.accent : Color.clear)) } - .frame(height: totalHeight, alignment: .topTrailing) - } - - private func column( - data: WidgetTimetableData, - offset: Int, - hourHeight: CGFloat, - subjectOnly: Bool - ) -> some View { - let cal = Calendar.current - let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate - let lessonsForDay = data.lessons.filter { cal.isDate($0.start, inSameDayAs: day) } - return TimeGridView( - lessons: lessonsForDay, - periods: data.periods, - hourHeight: hourHeight, - showRoom: !subjectOnly, - showTeacher: !subjectOnly, - showTimeLabels: false, - horizontalPadding: 3 - ) - } - - private func weekday(for date: Date) -> String { - WidgetDateFormatters.weekdayShort.string(from: date) } private func placeholder(_ message: String) -> some View { diff --git a/ios/TimetableWidgetExtension/WidgetData.swift b/ios/TimetableWidgetExtension/WidgetData.swift index 721e8a4..6846f19 100644 --- a/ios/TimetableWidgetExtension/WidgetData.swift +++ b/ios/TimetableWidgetExtension/WidgetData.swift @@ -33,6 +33,9 @@ struct WidgetLesson: Codable { let originalTeacher: String? let status: WidgetLessonStatus let customColor: String? + // Tile colour resolved in Dart as "#RRGGBB" (subject colours, palettes); + // nil only for payloads written before the field existed. + let color: String? let siblingCount: Int? } diff --git a/ios/TimetableWidgetExtension/WidgetPalette.swift b/ios/TimetableWidgetExtension/WidgetPalette.swift index c197273..d6aafec 100644 --- a/ios/TimetableWidgetExtension/WidgetPalette.swift +++ b/ios/TimetableWidgetExtension/WidgetPalette.swift @@ -9,15 +9,27 @@ struct WidgetPalette { let textPrimary: Color let textSecondary: Color let divider: Color + let accent: Color + let onAccent: Color let breakBlock: Color + let todayTint: Color + let holidayTint: Color let watermarkOpacity: Double + // ColorScheme.fromSeed(#993333).primary of the app's light / dark theme. + private static let lightAccent = Color(red: 0x90 / 255, green: 0x4A / 255, blue: 0x47 / 255) + private static let darkAccent = Color(red: 0xFF / 255, green: 0xB3 / 255, blue: 0xAF / 255) + 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), + accent: lightAccent, + onAccent: .white, + breakBlock: lightAccent.opacity(0x32 / 255.0), + todayTint: lightAccent.opacity(0x0E / 255.0), + holidayTint: Color.black.opacity(0x32 / 255.0), watermarkOpacity: 0.014 ) @@ -26,7 +38,11 @@ struct WidgetPalette { 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), + accent: darkAccent, + onAccent: Color(red: 0x57 / 255, green: 0x1D / 255, blue: 0x1D / 255), + breakBlock: darkAccent.opacity(0x32 / 255.0), + todayTint: darkAccent.opacity(0x0E / 255.0), + holidayTint: Color.white.opacity(0x32 / 255.0), watermarkOpacity: 0.025 ) diff --git a/lib/widget_data/widget_data.dart b/lib/widget_data/widget_data.dart index 8ccd1a4..af0704d 100644 --- a/lib/widget_data/widget_data.dart +++ b/lib/widget_data/widget_data.dart @@ -14,9 +14,6 @@ enum WidgetLessonStatus { irregular, teacherChanged, event, - // Sonder-Lesson-Types (Aufsicht, Sprechstunde, …) — Native-Widget-Renderer, - // die den Status noch nicht kennen, fallen still auf den regulären Stil - // zurück, sobald der String dort unbekannt ist. duty, } @@ -35,6 +32,9 @@ abstract class WidgetLesson with _$WidgetLesson { String? originalTeacher, required WidgetLessonStatus status, String? customColor, + /// Fertig aufgelöste Kachelfarbe als `#RRGGBB`. Native Renderer kennen die + /// Paletten nicht — Änderungen daran brauchen so keinen nativen Eingriff. + String? color, @Default(0) int siblingCount, }) = _WidgetLesson; diff --git a/lib/widget_data/widget_data.freezed.dart b/lib/widget_data/widget_data.freezed.dart index 08f085c..0b01dda 100644 --- a/lib/widget_data/widget_data.freezed.dart +++ b/lib/widget_data/widget_data.freezed.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND // coverage:ignore-file -// ignore_for_file: type=lint +// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package // ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark part of 'widget_data.dart'; @@ -9,6 +9,7 @@ part of 'widget_data.dart'; // FreezedGenerator // ************************************************************************** +// GENERATED CODE - DO NOT MODIFY BY HAND // dart format off T _$identity(T value) => value; @@ -18,7 +19,9 @@ mixin _$WidgetLesson { DateTime get start; DateTime get end; String get subjectShort; String? get subjectLong; String? get room;/// On teacher accounts this carries the class label ("7a") instead of the /// teacher short name — see `WidgetDataMapper` `showClassInsteadOfTeacher`; /// [originalTeacher] is null in that case. - String? get teacher; String? get originalTeacher; WidgetLessonStatus get status; String? get customColor; int get siblingCount; + String? get teacher; String? get originalTeacher; WidgetLessonStatus get status; String? get customColor;/// Fertig aufgelöste Kachelfarbe als `#RRGGBB`. Native Renderer kennen die +/// Paletten nicht — Änderungen daran brauchen so keinen nativen Eingriff. + String? get color; int get siblingCount; /// Create a copy of WidgetLesson /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -31,16 +34,21 @@ $WidgetLessonCopyWith get copyWith => _$WidgetLessonCopyWithImpl Object.hash(runtimeType,start,end,subjectShort,subjectLong,room,teacher,originalTeacher,status,customColor,siblingCount); +int get hashCode { + final _this = this as WidgetLesson; + return Object.hash(runtimeType,_this.start,_this.end,_this.subjectShort,_this.subjectLong,_this.room,_this.teacher,_this.originalTeacher,_this.status,_this.customColor,_this.color,_this.siblingCount); +} @override String toString() { - return 'WidgetLesson(start: $start, end: $end, subjectShort: $subjectShort, subjectLong: $subjectLong, room: $room, teacher: $teacher, originalTeacher: $originalTeacher, status: $status, customColor: $customColor, siblingCount: $siblingCount)'; + final _this = this as WidgetLesson; + return 'WidgetLesson(start: ${_this.start}, end: ${_this.end}, subjectShort: ${_this.subjectShort}, subjectLong: ${_this.subjectLong}, room: ${_this.room}, teacher: ${_this.teacher}, originalTeacher: ${_this.originalTeacher}, status: ${_this.status}, customColor: ${_this.customColor}, color: ${_this.color}, siblingCount: ${_this.siblingCount})'; } @@ -51,7 +59,7 @@ abstract mixin class $WidgetLessonCopyWith<$Res> { factory $WidgetLessonCopyWith(WidgetLesson value, $Res Function(WidgetLesson) _then) = _$WidgetLessonCopyWithImpl; @useResult $Res call({ - DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount + DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, String? color, int siblingCount }); @@ -68,8 +76,8 @@ class _$WidgetLessonCopyWithImpl<$Res> /// Create a copy of WidgetLesson /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? start = null,Object? end = null,Object? subjectShort = null,Object? subjectLong = freezed,Object? room = freezed,Object? teacher = freezed,Object? originalTeacher = freezed,Object? status = null,Object? customColor = freezed,Object? siblingCount = null,}) { - return _then(_self.copyWith( +@pragma('vm:prefer-inline') @override $Res call({Object? start = null,Object? end = null,Object? subjectShort = null,Object? subjectLong = freezed,Object? room = freezed,Object? teacher = freezed,Object? originalTeacher = freezed,Object? status = null,Object? customColor = freezed,Object? color = freezed,Object? siblingCount = null,}) { + return _then(WidgetLesson( start: null == start ? _self.start : start // ignore: cast_nullable_to_non_nullable as DateTime,end: null == end ? _self.end : end // ignore: cast_nullable_to_non_nullable as DateTime,subjectShort: null == subjectShort ? _self.subjectShort : subjectShort // ignore: cast_nullable_to_non_nullable @@ -79,6 +87,7 @@ as String?,teacher: freezed == teacher ? _self.teacher : teacher // ignore: cast as String?,originalTeacher: freezed == originalTeacher ? _self.originalTeacher : originalTeacher // ignore: cast_nullable_to_non_nullable as String?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as WidgetLessonStatus,customColor: freezed == customColor ? _self.customColor : customColor // ignore: cast_nullable_to_non_nullable +as String?,color: freezed == color ? _self.color : color // ignore: cast_nullable_to_non_nullable as String?,siblingCount: null == siblingCount ? _self.siblingCount : siblingCount // ignore: cast_nullable_to_non_nullable as int, )); @@ -165,10 +174,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, String? color, int siblingCount)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _WidgetLesson() when $default != null: -return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that.room,_that.teacher,_that.originalTeacher,_that.status,_that.customColor,_that.siblingCount);case _: +return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that.room,_that.teacher,_that.originalTeacher,_that.status,_that.customColor,_that.color,_that.siblingCount);case _: return orElse(); } @@ -186,10 +195,10 @@ return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, String? color, int siblingCount) $default,) {final _that = this; switch (_that) { case _WidgetLesson(): -return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that.room,_that.teacher,_that.originalTeacher,_that.status,_that.customColor,_that.siblingCount);case _: +return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that.room,_that.teacher,_that.originalTeacher,_that.status,_that.customColor,_that.color,_that.siblingCount);case _: throw StateError('Unexpected subclass'); } @@ -206,10 +215,10 @@ return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, String? color, int siblingCount)? $default,) {final _that = this; switch (_that) { case _WidgetLesson() when $default != null: -return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that.room,_that.teacher,_that.originalTeacher,_that.status,_that.customColor,_that.siblingCount);case _: +return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that.room,_that.teacher,_that.originalTeacher,_that.status,_that.customColor,_that.color,_that.siblingCount);case _: return null; } @@ -221,7 +230,7 @@ return $default(_that.start,_that.end,_that.subjectShort,_that.subjectLong,_that @JsonSerializable() class _WidgetLesson implements WidgetLesson { - const _WidgetLesson({required this.start, required this.end, required this.subjectShort, this.subjectLong, this.room, this.teacher, this.originalTeacher, required this.status, this.customColor, this.siblingCount = 0}); + const _WidgetLesson({required this.start, required this.end, required this.subjectShort, this.subjectLong, this.room, this.teacher, this.originalTeacher, required this.status, this.customColor, this.color, this.siblingCount = 0}); factory _WidgetLesson.fromJson(Map json) => _$WidgetLessonFromJson(json); @override final DateTime start; @@ -236,6 +245,9 @@ class _WidgetLesson implements WidgetLesson { @override final String? originalTeacher; @override final WidgetLessonStatus status; @override final String? customColor; +/// Fertig aufgelöste Kachelfarbe als `#RRGGBB`. Native Renderer kennen die +/// Paletten nicht — Änderungen daran brauchen so keinen nativen Eingriff. +@override final String? color; @override@JsonKey() final int siblingCount; /// Create a copy of WidgetLesson @@ -251,16 +263,18 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetLesson&&(identical(other.start, start) || other.start == start)&&(identical(other.end, end) || other.end == end)&&(identical(other.subjectShort, subjectShort) || other.subjectShort == subjectShort)&&(identical(other.subjectLong, subjectLong) || other.subjectLong == subjectLong)&&(identical(other.room, room) || other.room == room)&&(identical(other.teacher, teacher) || other.teacher == teacher)&&(identical(other.originalTeacher, originalTeacher) || other.originalTeacher == originalTeacher)&&(identical(other.status, status) || other.status == status)&&(identical(other.customColor, customColor) || other.customColor == customColor)&&(identical(other.siblingCount, siblingCount) || other.siblingCount == siblingCount)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetLesson&&(identical(other.start, start) || other.start == start)&&(identical(other.end, end) || other.end == end)&&(identical(other.subjectShort, subjectShort) || other.subjectShort == subjectShort)&&(identical(other.subjectLong, subjectLong) || other.subjectLong == subjectLong)&&(identical(other.room, room) || other.room == room)&&(identical(other.teacher, teacher) || other.teacher == teacher)&&(identical(other.originalTeacher, originalTeacher) || other.originalTeacher == originalTeacher)&&(identical(other.status, status) || other.status == status)&&(identical(other.customColor, customColor) || other.customColor == customColor)&&(identical(other.color, color) || other.color == color)&&(identical(other.siblingCount, siblingCount) || other.siblingCount == siblingCount)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,start,end,subjectShort,subjectLong,room,teacher,originalTeacher,status,customColor,siblingCount); +int get hashCode { + return Object.hash(runtimeType,start,end,subjectShort,subjectLong,room,teacher,originalTeacher,status,customColor,color,siblingCount); +} @override String toString() { - return 'WidgetLesson(start: $start, end: $end, subjectShort: $subjectShort, subjectLong: $subjectLong, room: $room, teacher: $teacher, originalTeacher: $originalTeacher, status: $status, customColor: $customColor, siblingCount: $siblingCount)'; + return 'WidgetLesson(start: $start, end: $end, subjectShort: $subjectShort, subjectLong: $subjectLong, room: $room, teacher: $teacher, originalTeacher: $originalTeacher, status: $status, customColor: $customColor, color: $color, siblingCount: $siblingCount)'; } @@ -271,7 +285,7 @@ abstract mixin class _$WidgetLessonCopyWith<$Res> implements $WidgetLessonCopyWi factory _$WidgetLessonCopyWith(_WidgetLesson value, $Res Function(_WidgetLesson) _then) = __$WidgetLessonCopyWithImpl; @override @useResult $Res call({ - DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, int siblingCount + DateTime start, DateTime end, String subjectShort, String? subjectLong, String? room, String? teacher, String? originalTeacher, WidgetLessonStatus status, String? customColor, String? color, int siblingCount }); @@ -288,7 +302,7 @@ class __$WidgetLessonCopyWithImpl<$Res> /// Create a copy of WidgetLesson /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? start = null,Object? end = null,Object? subjectShort = null,Object? subjectLong = freezed,Object? room = freezed,Object? teacher = freezed,Object? originalTeacher = freezed,Object? status = null,Object? customColor = freezed,Object? siblingCount = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? start = null,Object? end = null,Object? subjectShort = null,Object? subjectLong = freezed,Object? room = freezed,Object? teacher = freezed,Object? originalTeacher = freezed,Object? status = null,Object? customColor = freezed,Object? color = freezed,Object? siblingCount = null,}) { return _then(_WidgetLesson( start: null == start ? _self.start : start // ignore: cast_nullable_to_non_nullable as DateTime,end: null == end ? _self.end : end // ignore: cast_nullable_to_non_nullable @@ -299,6 +313,7 @@ as String?,teacher: freezed == teacher ? _self.teacher : teacher // ignore: cast as String?,originalTeacher: freezed == originalTeacher ? _self.originalTeacher : originalTeacher // ignore: cast_nullable_to_non_nullable as String?,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as WidgetLessonStatus,customColor: freezed == customColor ? _self.customColor : customColor // ignore: cast_nullable_to_non_nullable +as String?,color: freezed == color ? _self.color : color // ignore: cast_nullable_to_non_nullable as String?,siblingCount: null == siblingCount ? _self.siblingCount : siblingCount // ignore: cast_nullable_to_non_nullable as int, )); @@ -332,16 +347,21 @@ $WidgetPeriodCopyWith get copyWith => _$WidgetPeriodCopyWithImpl Object.hash(runtimeType,name,startMinutes,endMinutes,virtualStartMinutes,virtualEndMinutes); +int get hashCode { + final _this = this as WidgetPeriod; + return Object.hash(runtimeType,_this.name,_this.startMinutes,_this.endMinutes,_this.virtualStartMinutes,_this.virtualEndMinutes); +} @override String toString() { - return 'WidgetPeriod(name: $name, startMinutes: $startMinutes, endMinutes: $endMinutes, virtualStartMinutes: $virtualStartMinutes, virtualEndMinutes: $virtualEndMinutes)'; + final _this = this as WidgetPeriod; + return 'WidgetPeriod(name: ${_this.name}, startMinutes: ${_this.startMinutes}, endMinutes: ${_this.endMinutes}, virtualStartMinutes: ${_this.virtualStartMinutes}, virtualEndMinutes: ${_this.virtualEndMinutes})'; } @@ -370,7 +390,7 @@ class _$WidgetPeriodCopyWithImpl<$Res> /// Create a copy of WidgetPeriod /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? startMinutes = null,Object? endMinutes = null,Object? virtualStartMinutes = null,Object? virtualEndMinutes = null,}) { - return _then(_self.copyWith( + return _then(WidgetPeriod( name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String,startMinutes: null == startMinutes ? _self.startMinutes : startMinutes // ignore: cast_nullable_to_non_nullable as int,endMinutes: null == endMinutes ? _self.endMinutes : endMinutes // ignore: cast_nullable_to_non_nullable @@ -547,16 +567,18 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetPeriod&&(identical(other.name, name) || other.name == name)&&(identical(other.startMinutes, startMinutes) || other.startMinutes == startMinutes)&&(identical(other.endMinutes, endMinutes) || other.endMinutes == endMinutes)&&(identical(other.virtualStartMinutes, virtualStartMinutes) || other.virtualStartMinutes == virtualStartMinutes)&&(identical(other.virtualEndMinutes, virtualEndMinutes) || other.virtualEndMinutes == virtualEndMinutes)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetPeriod&&(identical(other.name, name) || other.name == name)&&(identical(other.startMinutes, startMinutes) || other.startMinutes == startMinutes)&&(identical(other.endMinutes, endMinutes) || other.endMinutes == endMinutes)&&(identical(other.virtualStartMinutes, virtualStartMinutes) || other.virtualStartMinutes == virtualStartMinutes)&&(identical(other.virtualEndMinutes, virtualEndMinutes) || other.virtualEndMinutes == virtualEndMinutes)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,name,startMinutes,endMinutes,virtualStartMinutes,virtualEndMinutes); +int get hashCode { + return Object.hash(runtimeType,name,startMinutes,endMinutes,virtualStartMinutes,virtualEndMinutes); +} @override String toString() { - return 'WidgetPeriod(name: $name, startMinutes: $startMinutes, endMinutes: $endMinutes, virtualStartMinutes: $virtualStartMinutes, virtualEndMinutes: $virtualEndMinutes)'; + return 'WidgetPeriod(name: $name, startMinutes: $startMinutes, endMinutes: $endMinutes, virtualStartMinutes: $virtualStartMinutes, virtualEndMinutes: $virtualEndMinutes)'; } @@ -615,16 +637,21 @@ $WidgetDayInfoCopyWith get copyWith => _$WidgetDayInfoCopyWithImp @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is WidgetDayInfo&&(identical(other.date, date) || other.date == date)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName)); + final _this = this as WidgetDayInfo; + return identical(this, other) || (other.runtimeType == runtimeType&&other is WidgetDayInfo&&(identical(other.date, _this.date) || other.date == _this.date)&&(identical(other.isHoliday, _this.isHoliday) || other.isHoliday == _this.isHoliday)&&(identical(other.holidayName, _this.holidayName) || other.holidayName == _this.holidayName)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,date,isHoliday,holidayName); +int get hashCode { + final _this = this as WidgetDayInfo; + return Object.hash(runtimeType,_this.date,_this.isHoliday,_this.holidayName); +} @override String toString() { - return 'WidgetDayInfo(date: $date, isHoliday: $isHoliday, holidayName: $holidayName)'; + final _this = this as WidgetDayInfo; + return 'WidgetDayInfo(date: ${_this.date}, isHoliday: ${_this.isHoliday}, holidayName: ${_this.holidayName})'; } @@ -653,7 +680,7 @@ class _$WidgetDayInfoCopyWithImpl<$Res> /// Create a copy of WidgetDayInfo /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({Object? date = null,Object? isHoliday = null,Object? holidayName = freezed,}) { - return _then(_self.copyWith( + return _then(WidgetDayInfo( date: null == date ? _self.date : date // ignore: cast_nullable_to_non_nullable as DateTime,isHoliday: null == isHoliday ? _self.isHoliday : isHoliday // ignore: cast_nullable_to_non_nullable as bool,holidayName: freezed == holidayName ? _self.holidayName : holidayName // ignore: cast_nullable_to_non_nullable @@ -818,16 +845,18 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetDayInfo&&(identical(other.date, date) || other.date == date)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetDayInfo&&(identical(other.date, date) || other.date == date)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,date,isHoliday,holidayName); +int get hashCode { + return Object.hash(runtimeType,date,isHoliday,holidayName); +} @override String toString() { - return 'WidgetDayInfo(date: $date, isHoliday: $isHoliday, holidayName: $holidayName)'; + return 'WidgetDayInfo(date: $date, isHoliday: $isHoliday, holidayName: $holidayName)'; } @@ -888,16 +917,21 @@ $WidgetTimetableDataCopyWith get copyWith => _$WidgetTimeta @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is WidgetTimetableData&&(identical(other.fetchedAt, fetchedAt) || other.fetchedAt == fetchedAt)&&(identical(other.anchorDate, anchorDate) || other.anchorDate == anchorDate)&&const DeepCollectionEquality().equals(other.lessons, lessons)&&const DeepCollectionEquality().equals(other.periods, periods)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName)&&const DeepCollectionEquality().equals(other.days, days)); + final _this = this as WidgetTimetableData; + return identical(this, other) || (other.runtimeType == runtimeType&&other is WidgetTimetableData&&(identical(other.fetchedAt, _this.fetchedAt) || other.fetchedAt == _this.fetchedAt)&&(identical(other.anchorDate, _this.anchorDate) || other.anchorDate == _this.anchorDate)&&const DeepCollectionEquality().equals(other.lessons, _this.lessons)&&const DeepCollectionEquality().equals(other.periods, _this.periods)&&(identical(other.isHoliday, _this.isHoliday) || other.isHoliday == _this.isHoliday)&&(identical(other.holidayName, _this.holidayName) || other.holidayName == _this.holidayName)&&const DeepCollectionEquality().equals(other.days, _this.days)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,fetchedAt,anchorDate,const DeepCollectionEquality().hash(lessons),const DeepCollectionEquality().hash(periods),isHoliday,holidayName,const DeepCollectionEquality().hash(days)); +int get hashCode { + final _this = this as WidgetTimetableData; + return Object.hash(runtimeType,_this.fetchedAt,_this.anchorDate,const DeepCollectionEquality().hash(_this.lessons),const DeepCollectionEquality().hash(_this.periods),_this.isHoliday,_this.holidayName,const DeepCollectionEquality().hash(_this.days)); +} @override String toString() { - return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName, days: $days)'; + final _this = this as WidgetTimetableData; + return 'WidgetTimetableData(fetchedAt: ${_this.fetchedAt}, anchorDate: ${_this.anchorDate}, lessons: ${_this.lessons}, periods: ${_this.periods}, isHoliday: ${_this.isHoliday}, holidayName: ${_this.holidayName}, days: ${_this.days})'; } @@ -926,7 +960,7 @@ class _$WidgetTimetableDataCopyWithImpl<$Res> /// Create a copy of WidgetTimetableData /// with the given fields replaced by the non-null parameter values. @pragma('vm:prefer-inline') @override $Res call({Object? fetchedAt = null,Object? anchorDate = null,Object? lessons = null,Object? periods = null,Object? isHoliday = null,Object? holidayName = freezed,Object? days = null,}) { - return _then(_self.copyWith( + return _then(WidgetTimetableData( fetchedAt: null == fetchedAt ? _self.fetchedAt : fetchedAt // ignore: cast_nullable_to_non_nullable as DateTime,anchorDate: null == anchorDate ? _self.anchorDate : anchorDate // ignore: cast_nullable_to_non_nullable as DateTime,lessons: null == lessons ? _self.lessons : lessons // ignore: cast_nullable_to_non_nullable @@ -1075,7 +1109,7 @@ return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_th @JsonSerializable() class _WidgetTimetableData implements WidgetTimetableData { - const _WidgetTimetableData({required this.fetchedAt, required this.anchorDate, required final List lessons, final List periods = const [], this.isHoliday = false, this.holidayName, final List days = const []}): _lessons = lessons,_periods = periods,_days = days; + const _WidgetTimetableData({required this.fetchedAt, required this.anchorDate, required List lessons, List periods = const [], this.isHoliday = false, this.holidayName, List days = const []}): _lessons = lessons,_periods = periods,_days = days; factory _WidgetTimetableData.fromJson(Map json) => _$WidgetTimetableDataFromJson(json); @override final DateTime fetchedAt; @@ -1122,16 +1156,18 @@ Map toJson() { @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetTimetableData&&(identical(other.fetchedAt, fetchedAt) || other.fetchedAt == fetchedAt)&&(identical(other.anchorDate, anchorDate) || other.anchorDate == anchorDate)&&const DeepCollectionEquality().equals(other._lessons, _lessons)&&const DeepCollectionEquality().equals(other._periods, _periods)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName)&&const DeepCollectionEquality().equals(other._days, _days)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _WidgetTimetableData&&(identical(other.fetchedAt, fetchedAt) || other.fetchedAt == fetchedAt)&&(identical(other.anchorDate, anchorDate) || other.anchorDate == anchorDate)&&const DeepCollectionEquality().equals(other.lessons, _lessons)&&const DeepCollectionEquality().equals(other.periods, _periods)&&(identical(other.isHoliday, isHoliday) || other.isHoliday == isHoliday)&&(identical(other.holidayName, holidayName) || other.holidayName == holidayName)&&const DeepCollectionEquality().equals(other.days, _days)); } @JsonKey(includeFromJson: false, includeToJson: false) @override -int get hashCode => Object.hash(runtimeType,fetchedAt,anchorDate,const DeepCollectionEquality().hash(_lessons),const DeepCollectionEquality().hash(_periods),isHoliday,holidayName,const DeepCollectionEquality().hash(_days)); +int get hashCode { + return Object.hash(runtimeType,fetchedAt,anchorDate,const DeepCollectionEquality().hash(_lessons),const DeepCollectionEquality().hash(_periods),isHoliday,holidayName,const DeepCollectionEquality().hash(_days)); +} @override String toString() { - return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName, days: $days)'; + return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName, days: $days)'; } diff --git a/lib/widget_data/widget_data.g.dart b/lib/widget_data/widget_data.g.dart index 6a836c3..de71751 100644 --- a/lib/widget_data/widget_data.g.dart +++ b/lib/widget_data/widget_data.g.dart @@ -17,6 +17,7 @@ _WidgetLesson _$WidgetLessonFromJson(Map json) => originalTeacher: json['originalTeacher'] as String?, status: $enumDecode(_$WidgetLessonStatusEnumMap, json['status']), customColor: json['customColor'] as String?, + color: json['color'] as String?, siblingCount: (json['siblingCount'] as num?)?.toInt() ?? 0, ); @@ -31,6 +32,7 @@ Map _$WidgetLessonToJson(_WidgetLesson instance) => 'originalTeacher': instance.originalTeacher, 'status': _$WidgetLessonStatusEnumMap[instance.status]!, 'customColor': instance.customColor, + 'color': instance.color, 'siblingCount': instance.siblingCount, }; diff --git a/lib/widget_data/widget_data_mapper.dart b/lib/widget_data/widget_data_mapper.dart index 1e2bd1a..79a37dd 100644 --- a/lib/widget_data/widget_data_mapper.dart +++ b/lib/widget_data/widget_data_mapper.dart @@ -1,4 +1,5 @@ import 'dart:developer'; +import 'dart:ui'; import '../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; import '../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart'; @@ -9,11 +10,13 @@ import '../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; import '../extensions/date_time.dart'; import '../utils/recurrence_occurrences.dart'; +import '../view/pages/timetable/data/lesson_color.dart'; import '../view/pages/timetable/data/lesson_labels.dart'; import '../view/pages/timetable/data/lesson_merger.dart'; import '../view/pages/timetable/data/lesson_period_schedule.dart'; import '../view/pages/timetable/data/lesson_status.dart'; import '../view/pages/timetable/data/lesson_type_label.dart'; +import '../view/pages/timetable/data/subject_color_palette.dart'; import 'widget_data.dart'; class WidgetDataMapper { @@ -322,22 +325,26 @@ class WidgetDataMapper { }) { final start = lesson.startDateTime; final end = lesson.endDateTime; - final status = _mapStatus( - LessonStatusClassifier.classify(lesson, start, end, now), - ); final subjectShortRaw = lesson.subjects.firstOrNull?.trim() ?? ''; - // Webuntis sometimes ships subject-less entries (Wandertag etc.). Prefer - // their free text, else "Event" so the tile isn't just a dash. + // Gleiche Einstufung wie TimetableAppointmentFactory, damit Aufsicht, + // Prüfung & Co. im Widget dieselbe Farbe tragen wie in der App. + final lessonStatus = LessonStatusClassifier.classify( + lesson, + start, + end, + now, + isEvent: subjectShortRaw.isEmpty && lesson.lessonType == 'LESSON', + isDuty: lesson.lessonType != 'LESSON', + ); final subjectShort = subjectShortRaw.isEmpty - ? LessonTypeLabel.entryText(lesson) ?? LessonTypeLabel.fallback + ? LessonTypeLabel.forEntry(lesson) : subjectShortRaw; - String? subjectLong; - if (subjects != null && subjectShortRaw.isNotEmpty) { - subjectLong = subjects.result - .where((s) => s.shortName == subjectShortRaw) - .firstOrNull - ?.longName; - } + final subject = subjects == null || subjectShortRaw.isEmpty + ? null + : subjects.result + .where((s) => s.shortName == subjectShortRaw) + .firstOrNull; + final subjectLong = subject?.longName; final roomShort = lesson.rooms.firstOrNull; var roomName = roomShort; if (rooms != null && roomShort != null) { @@ -365,10 +372,29 @@ class WidgetDataMapper { room: roomName, teacher: teacherName, originalTeacher: originalTeacher, - status: status, + status: _mapStatus(lessonStatus), + color: _hex(_lessonColor(lessonStatus, subject?.color)), ); } + /// Das Widget kennt keine Zeitzustände: laufende Stunden bekommen dieselbe + /// Farbe wie reguläre, sonst würde die Hervorhebung zwischen zwei + /// Aktualisierungen veralten. + static Color _lessonColor(LessonStatus status, String? subjectColor) { + final isRegular = + status == LessonStatus.regular || + status == LessonStatus.ongoing || + status == LessonStatus.past; + if (!isRegular) return LessonColor.forStatus(status); + return SubjectColorPalette.colorFromName(subjectColor) ?? + LessonColor.regular; + } + + static String _hex(Color color) { + final rgb = color.toARGB32() & 0xFFFFFF; + return '#${rgb.toRadixString(16).padLeft(6, '0').toUpperCase()}'; + } + static WidgetLessonStatus _mapStatus(LessonStatus status) { switch (status) { case LessonStatus.cancelled: @@ -480,6 +506,12 @@ class WidgetDataMapper { subjectLong: title.isEmpty ? null : title, status: WidgetLessonStatus.event, customColor: event.color, + color: _hex( + SubjectColorPalette.colorFromName(event.color) ?? + SubjectColorPalette.displayOf( + SubjectColorPalette.defaultColor, + ).color, + ), ); final isAllDay = duration == Duration.zero && _isMidnight(event.startDate); diff --git a/test/widget_data/widget_data_mapper_test.dart b/test/widget_data/widget_data_mapper_test.dart index 91fa69c..c23d286 100644 --- a/test/widget_data/widget_data_mapper_test.dart +++ b/test/widget_data/widget_data_mapper_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart'; +import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart'; import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import 'package:marianum_mobile/api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import 'package:marianum_mobile/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; @@ -33,6 +34,8 @@ McTimetableEntry _lesson({ String? teacherName, String? teacherOrgname, String? substText, + String lessonType = 'LESSON', + List classNames = const [], }) => McTimetableEntry( id: 1, date: date, @@ -50,8 +53,8 @@ McTimetableEntry _lesson({ ] : const [], rooms: room != null ? [room] : const [], - classNames: const [], - lessonType: 'LESSON', + classNames: classNames, + lessonType: lessonType, status: status, substitutionText: substText, lessonText: null, @@ -117,8 +120,18 @@ void main() { test('only includes lessons on the anchor day', () { final lessons = [ - _lesson(date: DateTime(2026, 5, 6), startHhmm: 800, endHhmm: 845, subjectName: 'MA'), - _lesson(date: DateTime(2026, 5, 7), startHhmm: 800, endHhmm: 845, subjectName: 'EN'), + _lesson( + date: DateTime(2026, 5, 6), + startHhmm: 800, + endHhmm: 845, + subjectName: 'MA', + ), + _lesson( + date: DateTime(2026, 5, 7), + startHhmm: 800, + endHhmm: 845, + subjectName: 'EN', + ), ]; final data = WidgetDataMapper.buildDayData( now: now, @@ -164,14 +177,11 @@ void main() { rooms: null, holidays: null, ); - expect( - data.lessons.map((l) => l.status).toList(), - [ - WidgetLessonStatus.cancelled, - WidgetLessonStatus.irregular, - WidgetLessonStatus.teacherChanged, - ], - ); + expect(data.lessons.map((l) => l.status).toList(), [ + WidgetLessonStatus.cancelled, + WidgetLessonStatus.irregular, + WidgetLessonStatus.teacherChanged, + ]); }); test('marks day as holiday when in holiday range', () { @@ -224,10 +234,11 @@ void main() { rooms: null, holidays: null, ); - expect( - data.lessons.map((l) => l.subjectShort).toList(), - ['MA', 'EN', 'BIO'], - ); + expect(data.lessons.map((l) => l.subjectShort).toList(), [ + 'MA', + 'EN', + 'BIO', + ]); }); }); @@ -236,9 +247,24 @@ void main() { test('long event bumps every regular lesson it covers', () { final lessons = [ - _lesson(date: DateTime(2026, 5, 6), startHhmm: 800, endHhmm: 845, subjectName: 'MA'), - _lesson(date: DateTime(2026, 5, 6), startHhmm: 900, endHhmm: 945, subjectName: 'EN'), - _lesson(date: DateTime(2026, 5, 6), startHhmm: 1000, endHhmm: 1045, subjectName: 'BIO'), + _lesson( + date: DateTime(2026, 5, 6), + startHhmm: 800, + endHhmm: 845, + subjectName: 'MA', + ), + _lesson( + date: DateTime(2026, 5, 6), + startHhmm: 900, + endHhmm: 945, + subjectName: 'EN', + ), + _lesson( + date: DateTime(2026, 5, 6), + startHhmm: 1000, + endHhmm: 1045, + subjectName: 'BIO', + ), ]; final events = GetCustomTimetableEventResponse([ _event( @@ -264,8 +290,18 @@ void main() { test('event + same-slot duplicate regular: kept lesson shows +2', () { final lessons = [ - _lesson(date: DateTime(2026, 5, 6), startHhmm: 900, endHhmm: 945, subjectName: 'EN'), - _lesson(date: DateTime(2026, 5, 6), startHhmm: 900, endHhmm: 945, subjectName: 'MA'), + _lesson( + date: DateTime(2026, 5, 6), + startHhmm: 900, + endHhmm: 945, + subjectName: 'EN', + ), + _lesson( + date: DateTime(2026, 5, 6), + startHhmm: 900, + endHhmm: 945, + subjectName: 'MA', + ), ]; final events = GetCustomTimetableEventResponse([ _event( @@ -315,7 +351,12 @@ void main() { test('two events covering the same regular lesson bump it twice', () { final lessons = [ - _lesson(date: DateTime(2026, 5, 6), startHhmm: 900, endHhmm: 945, subjectName: 'EN'), + _lesson( + date: DateTime(2026, 5, 6), + startHhmm: 900, + endHhmm: 945, + subjectName: 'EN', + ), ]; final events = GetCustomTimetableEventResponse([ _event( @@ -361,16 +402,198 @@ void main() { }); }); + group('teacher plan', () { + final now = DateTime(2026, 5, 6, 7); + final day = DateTime(2026, 5, 6); + + WidgetLesson single(McTimetableEntry lesson) => + WidgetDataMapper.buildDayData( + now: now, + lessons: [lesson], + subjects: null, + rooms: null, + holidays: null, + showClassInsteadOfTeacher: true, + ).lessons.single; + + test('shows the classes in place of the teacher', () { + final lesson = single( + _lesson( + date: day, + startHhmm: 755, + endHhmm: 840, + subjectName: 'MA', + teacherName: 'MUE', + teacherOrgname: 'KUN', + classNames: const ['10a', '10b'], + ), + ); + expect(lesson.teacher, '10a, 10b'); + expect(lesson.originalTeacher, isNull); + }); + + test('leaves the slot empty for entries without a class', () { + final lesson = single( + _lesson( + date: day, + startHhmm: 920, + endHhmm: 935, + room: 'Hof', + teacherName: 'MUE', + lessonType: 'BREAK_SUPERVISION', + ), + ); + expect(lesson.teacher, isNull); + }); + }); + + group('tile color', () { + final now = DateTime(2026, 5, 6, 7); + final day = DateTime(2026, 5, 6); + + WidgetLesson single( + McTimetableEntry lesson, { + TimetableGetSubjectsResponse? subjects, + }) => WidgetDataMapper.buildDayData( + now: now, + lessons: [lesson], + subjects: subjects, + rooms: null, + holidays: null, + ).lessons.single; + + test('regular lesson without subject color uses the status red', () { + final lesson = single( + _lesson( + date: day, + startHhmm: 755, + endHhmm: 840, + subjectName: 'MA', + teacherName: 'MUE', + ), + ); + expect(lesson.color, '#993333'); + }); + + test('regular lesson takes the personal subject color', () { + final lesson = single( + _lesson( + date: day, + startHhmm: 755, + endHhmm: 840, + subjectName: 'MA', + teacherName: 'MUE', + ), + subjects: TimetableGetSubjectsResponse( + result: [ + McSubject( + id: 1, + shortName: 'MA', + longName: 'Mathematik', + color: 'indigo', + ), + ], + ), + ); + expect(lesson.color, '#5C6BC0'); + }); + + test('substitution keeps its signal color despite a subject color', () { + final lesson = single( + _lesson( + date: day, + startHhmm: 755, + endHhmm: 840, + status: 'IRREGULAR', + subjectName: 'MA', + teacherName: 'MUE', + ), + subjects: TimetableGetSubjectsResponse( + result: [ + McSubject( + id: 1, + shortName: 'MA', + longName: 'Mathematik', + color: 'indigo', + ), + ], + ), + ); + expect(lesson.status, WidgetLessonStatus.irregular); + expect(lesson.color, '#8F19B3'); + }); + + test('supervision is classified as duty with room in the title', () { + final lesson = single( + _lesson( + date: day, + startHhmm: 920, + endHhmm: 935, + room: 'Hof', + teacherName: 'MUE', + lessonType: 'BREAK_SUPERVISION', + ), + ); + expect(lesson.status, WidgetLessonStatus.duty); + expect(lesson.subjectShort, 'Hof Aufsicht'); + expect(lesson.color, '#00796B'); + }); + + test('custom event resolves legacy color names', () { + final data = WidgetDataMapper.buildDayData( + now: now, + lessons: const [], + subjects: null, + rooms: null, + holidays: null, + customEvents: GetCustomTimetableEventResponse([ + _event( + id: 'e', + title: 'Probe', + start: DateTime(2026, 5, 6, 14), + end: DateTime(2026, 5, 6, 15), + ), + ]), + ); + expect(data.lessons.single.color, '#FFA000'); + }); + }); + group('buildWeekData', () { final now = DateTime(2026, 5, 5, 10); // Tuesday test('covers the 14-day window including next week', () { final lessons = [ - _lesson(date: DateTime(2026, 5, 4), startHhmm: 800, endHhmm: 845, subjectName: 'MO'), - _lesson(date: DateTime(2026, 5, 6), startHhmm: 800, endHhmm: 845, subjectName: 'WE'), - _lesson(date: DateTime(2026, 5, 8), startHhmm: 800, endHhmm: 845, subjectName: 'FR'), - _lesson(date: DateTime(2026, 5, 11), startHhmm: 800, endHhmm: 845, subjectName: 'NEXT'), - _lesson(date: DateTime(2026, 5, 18), startHhmm: 800, endHhmm: 845, subjectName: 'FAR'), + _lesson( + date: DateTime(2026, 5, 4), + startHhmm: 800, + endHhmm: 845, + subjectName: 'MO', + ), + _lesson( + date: DateTime(2026, 5, 6), + startHhmm: 800, + endHhmm: 845, + subjectName: 'WE', + ), + _lesson( + date: DateTime(2026, 5, 8), + startHhmm: 800, + endHhmm: 845, + subjectName: 'FR', + ), + _lesson( + date: DateTime(2026, 5, 11), + startHhmm: 800, + endHhmm: 845, + subjectName: 'NEXT', + ), + _lesson( + date: DateTime(2026, 5, 18), + startHhmm: 800, + endHhmm: 845, + subjectName: 'FAR', + ), ]; final data = WidgetDataMapper.buildWeekData( now: now, @@ -380,10 +603,12 @@ void main() { holidays: null, ); expect(data.anchorDate, DateTime(2026, 5, 4)); - expect( - data.lessons.map((l) => l.subjectShort).toList(), - ['MO', 'WE', 'FR', 'NEXT'], - ); + expect(data.lessons.map((l) => l.subjectShort).toList(), [ + 'MO', + 'WE', + 'FR', + 'NEXT', + ]); }); test('window stays on the current week when the anchor rolls forward', () { @@ -391,8 +616,18 @@ void main() { // still starts at the current week's Monday so today stays available. final fridayEvening = DateTime(2026, 5, 8, 18); final lessons = [ - _lesson(date: DateTime(2026, 5, 8), startHhmm: 800, endHhmm: 845, subjectName: 'FR'), - _lesson(date: DateTime(2026, 5, 11), startHhmm: 800, endHhmm: 845, subjectName: 'NEXT'), + _lesson( + date: DateTime(2026, 5, 8), + startHhmm: 800, + endHhmm: 845, + subjectName: 'FR', + ), + _lesson( + date: DateTime(2026, 5, 11), + startHhmm: 800, + endHhmm: 845, + subjectName: 'NEXT', + ), ]; final data = WidgetDataMapper.buildWeekData( now: fridayEvening, @@ -402,10 +637,7 @@ void main() { holidays: null, ); expect(data.anchorDate, DateTime(2026, 5, 11)); - expect( - data.lessons.map((l) => l.subjectShort).toList(), - ['FR', 'NEXT'], - ); + expect(data.lessons.map((l) => l.subjectShort).toList(), ['FR', 'NEXT']); }); test('carries per-day holiday info in days', () { @@ -429,8 +661,10 @@ void main() { expect(data.days, hasLength(WidgetDataMapper.weekWindowDays)); expect(data.days.first.date, DateTime(2026, 5, 4)); expect(data.days.last.date, DateTime(2026, 5, 17)); - final holidayDays = - data.days.where((d) => d.isHoliday).map((d) => d.date).toList(); + final holidayDays = data.days + .where((d) => d.isHoliday) + .map((d) => d.date) + .toList(); expect(holidayDays, [DateTime(2026, 5, 14), DateTime(2026, 5, 15)]); expect( data.days.firstWhere((d) => d.isHoliday).holidayName,