diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 3c24865..abe802a 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -2,7 +2,9 @@
+ android:icon="@mipmap/ic_launcher"
+ android:fullBackupContent="@xml/backup_rules"
+ android:dataExtractionRules="@xml/data_extraction_rules">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/MainActivity.kt b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/MainActivity.kt
index 5e1f387..cf439fc 100644
--- a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/MainActivity.kt
+++ b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/MainActivity.kt
@@ -1,5 +1,42 @@
package eu.mhsl.marianum.mobile.client
+import android.content.Intent
import io.flutter.embedding.android.FlutterActivity
+import io.flutter.embedding.engine.FlutterEngine
+import io.flutter.plugin.common.MethodChannel
-class MainActivity: FlutterActivity()
+class MainActivity : FlutterActivity() {
+ private val widgetChannel = "eu.mhsl.marianum.widget"
+ /// Last seen widget tap target. Cleared by Dart via `consumePendingNavigation`
+ /// so the same intent isn't replayed on every resume.
+ private var pendingTimetableTap: Boolean = false
+
+ override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
+ super.configureFlutterEngine(flutterEngine)
+ MethodChannel(
+ flutterEngine.dartExecutor.binaryMessenger,
+ widgetChannel
+ ).setMethodCallHandler { call, result ->
+ when (call.method) {
+ "consumePendingNavigation" -> {
+ val pending = pendingTimetableTap
+ pendingTimetableTap = false
+ result.success(pending)
+ }
+ else -> result.notImplemented()
+ }
+ }
+ consumeIntentData(intent)
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ consumeIntentData(intent)
+ }
+
+ private fun consumeIntentData(intent: Intent?) {
+ if (intent?.getBooleanExtra("widget_open_timetable", false) == true) {
+ pendingTimetableTap = true
+ }
+ }
+}
diff --git a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/TimetableDayWidget.kt b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/TimetableDayWidget.kt
new file mode 100644
index 0000000..9b7da6e
--- /dev/null
+++ b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/TimetableDayWidget.kt
@@ -0,0 +1,37 @@
+package eu.mhsl.marianum.mobile.client
+
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
+import android.content.Context
+import android.os.Bundle
+import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
+
+/**
+ * Lives at the package root (not under `widgets/`) because the home_widget
+ * Flutter plugin resolves the receiver class as `.`.
+ */
+class TimetableDayWidget : AppWidgetProvider() {
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray,
+ ) {
+ for (id in appWidgetIds) {
+ val options = appWidgetManager.getAppWidgetOptions(id)
+ val views = WidgetRenderer.buildDay(context, context.packageName, options)
+ appWidgetManager.updateAppWidget(id, views)
+ }
+ }
+
+ override fun onAppWidgetOptionsChanged(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetId: Int,
+ newOptions: Bundle,
+ ) {
+ // Re-render on resize, otherwise the tiles stay at install-time size
+ // and either clip or leave dead space.
+ val views = WidgetRenderer.buildDay(context, context.packageName, newOptions)
+ appWidgetManager.updateAppWidget(appWidgetId, views)
+ }
+}
diff --git a/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/TimetableWeekWidget.kt b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/TimetableWeekWidget.kt
new file mode 100644
index 0000000..9ffcf8f
--- /dev/null
+++ b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/TimetableWeekWidget.kt
@@ -0,0 +1,31 @@
+package eu.mhsl.marianum.mobile.client
+
+import android.appwidget.AppWidgetManager
+import android.appwidget.AppWidgetProvider
+import android.content.Context
+import android.os.Bundle
+import eu.mhsl.marianum.mobile.client.widgets.WidgetRenderer
+
+class TimetableWeekWidget : AppWidgetProvider() {
+ override fun onUpdate(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetIds: IntArray,
+ ) {
+ for (id in appWidgetIds) {
+ val options = appWidgetManager.getAppWidgetOptions(id)
+ val views = WidgetRenderer.buildWeek(context, context.packageName, options)
+ appWidgetManager.updateAppWidget(id, views)
+ }
+ }
+
+ override fun onAppWidgetOptionsChanged(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetId: Int,
+ newOptions: Bundle,
+ ) {
+ val views = WidgetRenderer.buildWeek(context, context.packageName, newOptions)
+ appWidgetManager.updateAppWidget(appWidgetId, views)
+ }
+}
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
new file mode 100644
index 0000000..402d503
--- /dev/null
+++ b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetData.kt
@@ -0,0 +1,157 @@
+package eu.mhsl.marianum.mobile.client.widgets
+
+import org.json.JSONException
+import org.json.JSONObject
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Date
+import java.util.Locale
+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;
+
+ companion object {
+ fun fromWire(raw: String?): WidgetLessonStatus = when (raw) {
+ "regular" -> REGULAR
+ "ongoing" -> ONGOING
+ "past" -> PAST
+ "cancelled" -> CANCELLED
+ "irregular" -> IRREGULAR
+ "teacherChanged" -> TEACHER_CHANGED
+ "event" -> EVENT
+ else -> REGULAR
+ }
+ }
+}
+
+data class WidgetLesson(
+ val start: Date,
+ val end: Date,
+ val subjectShort: String,
+ val subjectLong: String?,
+ val room: String?,
+ val teacher: String?,
+ val originalTeacher: String?,
+ val status: WidgetLessonStatus,
+ val customColor: String?,
+ val siblingCount: Int,
+)
+
+data class WidgetPeriod(
+ val name: String,
+ val startMinutes: Int,
+ val endMinutes: Int,
+ val virtualStartMinutes: Int,
+ val virtualEndMinutes: Int,
+)
+
+data class WidgetTimetableData(
+ val fetchedAt: Date,
+ val anchorDate: Date,
+ val lessons: List,
+ val periods: List,
+ val isHoliday: Boolean,
+ val holidayName: String?,
+)
+
+object WidgetDataParser {
+ private val isoFormat: SimpleDateFormat
+ get() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS", Locale.ROOT).apply {
+ timeZone = TimeZone.getDefault()
+ }
+
+ /// Dart's toIso8601String() ships microseconds (6 digits) when non-zero;
+ /// SimpleDateFormat only parses 3 → strip the extra digits. Local time
+ /// without Z is the default, so removeSuffix("Z") makes the parser
+ /// tolerate both shapes.
+ private fun parseDate(raw: String?): Date? {
+ if (raw.isNullOrEmpty()) return null
+ val cleaned = raw
+ .replace(Regex("([.,]\\d{3})\\d+"), "$1")
+ .removeSuffix("Z")
+ return try {
+ isoFormat.parse(cleaned)
+ } catch (_: Exception) {
+ null
+ }
+ }
+
+ fun parse(json: String?): WidgetTimetableData? {
+ if (json.isNullOrEmpty()) return null
+ return try {
+ val root = JSONObject(json)
+ val lessonsArray = root.optJSONArray("lessons")
+ val lessons = mutableListOf()
+ if (lessonsArray != null) {
+ for (i in 0 until lessonsArray.length()) {
+ val obj = lessonsArray.optJSONObject(i) ?: continue
+ val start = parseDate(obj.stringOrNull("start")) ?: continue
+ val end = parseDate(obj.stringOrNull("end")) ?: continue
+ lessons += WidgetLesson(
+ start = start,
+ end = end,
+ subjectShort = obj.stringOrNull("subjectShort") ?: "",
+ subjectLong = obj.stringOrNull("subjectLong"),
+ room = obj.stringOrNull("room"),
+ teacher = obj.stringOrNull("teacher"),
+ originalTeacher = obj.stringOrNull("originalTeacher"),
+ status = WidgetLessonStatus.fromWire(obj.stringOrNull("status")),
+ customColor = obj.stringOrNull("customColor"),
+ siblingCount = obj.optInt("siblingCount", 0),
+ )
+ }
+ }
+ val periodsArray = root.optJSONArray("periods")
+ val periods = mutableListOf()
+ if (periodsArray != null) {
+ for (i in 0 until periodsArray.length()) {
+ val obj = periodsArray.optJSONObject(i) ?: continue
+ periods += WidgetPeriod(
+ name = obj.stringOrNull("name") ?: "",
+ startMinutes = obj.optInt("startMinutes", 0),
+ endMinutes = obj.optInt("endMinutes", 0),
+ virtualStartMinutes = obj.optInt("virtualStartMinutes", 0),
+ virtualEndMinutes = obj.optInt("virtualEndMinutes", 0),
+ )
+ }
+ }
+ WidgetTimetableData(
+ fetchedAt = parseDate(root.stringOrNull("fetchedAt")) ?: Date(),
+ anchorDate = parseDate(root.stringOrNull("anchorDate")) ?: Date(),
+ lessons = lessons,
+ periods = periods,
+ isHoliday = root.optBoolean("isHoliday", false),
+ holidayName = root.stringOrNull("holidayName"),
+ )
+ } catch (_: JSONException) {
+ null
+ }
+ }
+
+ private fun JSONObject.stringOrNull(key: String): String? {
+ if (!has(key) || isNull(key)) return null
+ val raw = optString(key, "")
+ return if (raw.isEmpty()) null else raw
+ }
+}
+
+object WidgetDateUtils {
+ fun startOfDay(date: Date): Date {
+ val cal = Calendar.getInstance().apply { time = date }
+ cal.set(Calendar.HOUR_OF_DAY, 0)
+ cal.set(Calendar.MINUTE, 0)
+ cal.set(Calendar.SECOND, 0)
+ cal.set(Calendar.MILLISECOND, 0)
+ return cal.time
+ }
+
+ fun isSameDay(a: Date, b: Date): Boolean {
+ val ca = Calendar.getInstance().apply { time = a }
+ val cb = Calendar.getInstance().apply { time = b }
+ return ca.get(Calendar.YEAR) == cb.get(Calendar.YEAR) &&
+ ca.get(Calendar.DAY_OF_YEAR) == cb.get(Calendar.DAY_OF_YEAR)
+ }
+}
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
new file mode 100644
index 0000000..3c454dd
--- /dev/null
+++ b/android/app/src/main/kotlin/eu/mhsl/marianum/mobile/client/widgets/WidgetRenderer.kt
@@ -0,0 +1,786 @@
+package eu.mhsl.marianum.mobile.client.widgets
+
+import android.app.PendingIntent
+import android.appwidget.AppWidgetManager
+import android.content.Context
+import android.content.Intent
+import android.content.SharedPreferences
+import android.content.res.Configuration
+import android.os.Bundle
+import android.util.TypedValue
+import android.view.View
+import android.widget.RemoteViews
+import eu.mhsl.marianum.mobile.client.MainActivity
+import eu.mhsl.marianum.mobile.client.R
+import java.text.SimpleDateFormat
+import java.util.Calendar
+import java.util.Date
+import java.util.Locale
+import kotlin.math.max
+
+/**
+ * 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].
+ */
+object WidgetRenderer {
+ private const val FALLBACK_VIRTUAL_MINUTES = 11 * 60
+
+ private const val DAY_CHROME_DP = 40
+ private const val WEEK_CHROME_DP = 64
+
+ private const val MIN_HOUR_HEIGHT_DP = 18
+ private const val MAX_HOUR_HEIGHT_DP = 72
+
+ private const val MIN_BLOCK_HEIGHT_DP = 16
+ 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
+
+ /// 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
+
+ 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)
+
+ /// Hex values mirror LightAppTheme / DarkAppTheme tokens so the widget
+ /// matches the app's branding rather than the generic system look.
+ private data class WidgetPalette(
+ val background: Int,
+ val textPrimary: Int,
+ val textSecondary: Int,
+ val divider: Int,
+ val breakBlock: Int,
+ val watermarkAlpha: Float,
+ )
+
+ private val lightPalette = WidgetPalette(
+ background = 0xFFFCF7F5.toInt(),
+ textPrimary = 0xFF1A1A1A.toInt(),
+ textSecondary = 0xFF555555.toInt(),
+ divider = 0x22000000,
+ breakBlock = 0x0C000000,
+ watermarkAlpha = 0.014f,
+ )
+
+ private val darkPalette = WidgetPalette(
+ background = 0xFF1F1716.toInt(),
+ textPrimary = 0xFFF1F1F1.toInt(),
+ textSecondary = 0xFFB0B0B0.toInt(),
+ divider = 0x33FFFFFF,
+ breakBlock = 0x14FFFFFF,
+ watermarkAlpha = 0.025f,
+ )
+
+ private fun resolvePalette(context: Context, themeMode: String?): WidgetPalette {
+ val isDark = when (themeMode) {
+ "light" -> false
+ "dark" -> true
+ else -> {
+ val uiMode = context.resources.configuration.uiMode and
+ Configuration.UI_MODE_NIGHT_MASK
+ uiMode == Configuration.UI_MODE_NIGHT_YES
+ }
+ }
+ return if (isDark) darkPalette else lightPalette
+ }
+
+ fun buildDay(
+ context: Context,
+ packageName: String,
+ options: Bundle? = null,
+ ): RemoteViews {
+ val prefs = sharedPrefs(context)
+ val palette = resolvePalette(context, prefs.getString(KEY_THEME_MODE, "system"))
+ if (!prefs.getBoolean(KEY_LOGGED_IN, false)) {
+ return buildPlaceholder(
+ context,
+ packageName,
+ context.getString(R.string.widget_login_required),
+ palette,
+ )
+ }
+ val data = WidgetDataParser.parse(prefs.getString(KEY_DAY_DATA, null))
+ ?: return buildPlaceholder(
+ context,
+ packageName,
+ 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,
+ )
+ }
+ maybeAddNowIndicator(
+ packageName,
+ views,
+ R.id.widget_day_grid,
+ hourHeightDp,
+ anchorDate = data.anchorDate,
+ periods = data.periods,
+ )
+ }
+
+ views.setOnClickPendingIntent(R.id.widget_root, openAppIntent(context))
+ return views
+ }
+
+ fun buildWeek(
+ context: Context,
+ packageName: String,
+ options: Bundle? = null,
+ ): RemoteViews {
+ val prefs = sharedPrefs(context)
+ val palette = resolvePalette(context, prefs.getString(KEY_THEME_MODE, "system"))
+ if (!prefs.getBoolean(KEY_LOGGED_IN, false)) {
+ return buildPlaceholder(
+ context,
+ packageName,
+ context.getString(R.string.widget_login_required),
+ palette,
+ )
+ }
+ val data = WidgetDataParser.parse(prefs.getString(KEY_WEEK_DATA, null))
+ ?: return buildPlaceholder(
+ context,
+ packageName,
+ context.getString(R.string.widget_loading),
+ palette,
+ )
+
+ val totalVirtualMin = data.periods.lastOrNull()?.virtualEndMinutes
+ ?: FALLBACK_VIRTUAL_MINUTES
+ val hourHeightDp = resolveHourHeight(options, WEEK_CHROME_DP, totalVirtualMin)
+
+ val views = RemoteViews(packageName, R.layout.widget_week)
+ applyChrome(views, palette, options)
+ 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 }
+ val weekNumber = cal.get(Calendar.WEEK_OF_YEAR)
+ val end = Calendar.getInstance().apply {
+ time = data.anchorDate
+ add(Calendar.DAY_OF_YEAR, 4)
+ }.time
+ val kwPrefix = context.getString(R.string.widget_calendar_week_prefix)
+ views.setTextViewText(
+ R.id.widget_week_title,
+ "$kwPrefix $weekNumber · ${dateShort.format(data.anchorDate)}–${dateShort.format(end)}",
+ )
+ views.setTextViewText(
+ R.id.widget_week_subtitle,
+ context.getString(R.string.widget_status_label, freshnessLabel(context, data.fetchedAt)),
+ )
+
+ val headerIds = listOf(
+ R.id.widget_week_header_mon,
+ R.id.widget_week_header_tue,
+ R.id.widget_week_header_wed,
+ R.id.widget_week_header_thu,
+ R.id.widget_week_header_fri,
+ )
+ val columnIds = listOf(
+ R.id.widget_week_col_mon,
+ R.id.widget_week_col_tue,
+ R.id.widget_week_col_wed,
+ R.id.widget_week_col_thu,
+ 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
+
+ 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)
+
+ 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 (WidgetDateUtils.isSameDay(day, Date())) {
+ maybeAddNowIndicator(
+ packageName,
+ views,
+ columnId,
+ hourHeightDp,
+ anchorDate = day,
+ periods = data.periods,
+ )
+ }
+ }
+
+ views.setOnClickPendingIntent(R.id.widget_root, openAppIntent(context))
+ 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),
+ )
+ } ?: 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())
+ }
+
+ /// Real wall-clock minute → position on the virtual axis. Inside a
+ /// period: linear. In a gap: linear across the virtual gap (zero for
+ /// squeezed small breaks, real width for big breaks).
+ private fun realMinutesToVirtual(
+ realMin: Int,
+ periods: List,
+ ): Float {
+ if (periods.isEmpty()) return realMin.toFloat()
+ for (period in periods) {
+ if (realMin in period.startMinutes..period.endMinutes) {
+ return period.virtualStartMinutes + (realMin - period.startMinutes).toFloat()
+ }
+ }
+ val first = periods.first()
+ if (realMin < first.startMinutes) {
+ return (realMin - first.startMinutes + first.virtualStartMinutes).toFloat()
+ }
+ val last = periods.last()
+ if (realMin > last.endMinutes) {
+ return last.virtualEndMinutes + (realMin - last.endMinutes).toFloat()
+ }
+ var prev = first
+ for (i in 1 until periods.size) {
+ val curr = periods[i]
+ if (realMin in (prev.endMinutes + 1) until curr.startMinutes) {
+ val gap = curr.startMinutes - prev.endMinutes
+ val virtualGap = curr.virtualStartMinutes - prev.virtualEndMinutes
+ return if (gap > 0) {
+ prev.virtualEndMinutes +
+ (realMin - prev.endMinutes).toFloat() * virtualGap / gap
+ } else {
+ curr.virtualStartMinutes.toFloat()
+ }
+ }
+ prev = curr
+ }
+ 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(
+ R.id.widget_time_label_root,
+ RemoteViews.MARGIN_TOP,
+ topDp,
+ TypedValue.COMPLEX_UNIT_DIP,
+ )
+ parent.addView(containerId, label)
+ }
+ }
+
+ private fun populateGridLines(
+ packageName: String,
+ parent: RemoteViews,
+ containerId: Int,
+ hourHeightDp: Float,
+ palette: WidgetPalette,
+ periods: List,
+ ) {
+ // 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 (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
+ line.setViewLayoutMargin(
+ R.id.widget_grid_line_root,
+ RemoteViews.MARGIN_TOP,
+ topDp,
+ TypedValue.COMPLEX_UNIT_DIP,
+ )
+ parent.addView(containerId, line)
+ }
+ }
+ }
+
+ /// 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,
+ )
+ block.setInt(
+ R.id.widget_break_block_root,
+ "setBackgroundColor",
+ palette.breakBlock,
+ )
+ parent.addView(containerId, block)
+ }
+ }
+
+ private fun formatHm(minutesSinceMidnight: Int): String {
+ val h = minutesSinceMidnight / 60
+ val m = minutesSinceMidnight % 60
+ return "%02d:%02d".format(h, m)
+ }
+
+ /// 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?) {
+ 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 offsetEnd = -(markSize * 0.18f)
+ val offsetBottom = -(markSize * 0.18f)
+ views.setViewLayoutWidth(
+ R.id.widget_watermark,
+ markSize,
+ TypedValue.COMPLEX_UNIT_DIP,
+ )
+ views.setViewLayoutHeight(
+ R.id.widget_watermark,
+ markSize,
+ TypedValue.COMPLEX_UNIT_DIP,
+ )
+ views.setViewLayoutMargin(
+ R.id.widget_watermark,
+ RemoteViews.MARGIN_END,
+ offsetEnd,
+ TypedValue.COMPLEX_UNIT_DIP,
+ )
+ views.setViewLayoutMargin(
+ R.id.widget_watermark,
+ RemoteViews.MARGIN_BOTTOM,
+ offsetBottom,
+ TypedValue.COMPLEX_UNIT_DIP,
+ )
+ }
+
+ 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,
+ parent: RemoteViews,
+ containerId: Int,
+ lesson: WidgetLesson,
+ hourHeightDp: Float,
+ periods: List,
+ subjectOnly: Boolean,
+ horizontalPaddingDp: Int,
+ ) {
+ val cal = Calendar.getInstance()
+ cal.time = lesson.start
+ val startMinutes = cal.get(Calendar.HOUR_OF_DAY) * 60 + cal.get(Calendar.MINUTE)
+ cal.time = lesson.end
+ 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)
+ 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 block = RemoteViews(packageName, R.layout.widget_lesson_block)
+ block.setViewLayoutMargin(
+ R.id.widget_lesson_block_root,
+ RemoteViews.MARGIN_TOP,
+ topDp,
+ TypedValue.COMPLEX_UNIT_DIP,
+ )
+ block.setViewLayoutHeight(
+ R.id.widget_lesson_block_root,
+ heightDp,
+ TypedValue.COMPLEX_UNIT_DIP,
+ )
+ block.setInt(
+ R.id.widget_lesson_block_root,
+ "setBackgroundResource",
+ statusDrawable(lesson),
+ )
+
+ 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,
+ "+${lesson.siblingCount}",
+ )
+ block.setViewVisibility(R.id.widget_lesson_sibling_badge, View.VISIBLE)
+ } else {
+ 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)
+ }
+ }
+
+ parent.addView(containerId, block)
+ }
+
+ private fun applyOptionalText(views: RemoteViews, viewId: Int, text: String?) {
+ if (text.isNullOrEmpty()) {
+ views.setViewVisibility(viewId, View.GONE)
+ } else {
+ views.setTextViewText(viewId, text)
+ views.setViewVisibility(viewId, View.VISIBLE)
+ }
+ }
+
+ private fun maybeAddNowIndicator(
+ packageName: String,
+ parent: RemoteViews,
+ containerId: Int,
+ hourHeightDp: Float,
+ anchorDate: Date,
+ periods: List,
+ ) {
+ if (!WidgetDateUtils.isSameDay(anchorDate, Date())) return
+ val now = Calendar.getInstance()
+ val nowMinutes = now.get(Calendar.HOUR_OF_DAY) * 60 + now.get(Calendar.MINUTE)
+ if (periods.isNotEmpty()) {
+ if (nowMinutes < periods.first().startMinutes ||
+ nowMinutes > periods.last().endMinutes
+ ) return
+ }
+ val virtualNow = realMinutesToVirtual(nowMinutes, periods)
+ val topDp = virtualNow * hourHeightDp / 60.0f
+ val indicator = RemoteViews(packageName, R.layout.widget_now_indicator)
+ indicator.setViewLayoutMargin(
+ R.id.widget_now_indicator_root,
+ RemoteViews.MARGIN_TOP,
+ topDp,
+ TypedValue.COMPLEX_UNIT_DIP,
+ )
+ parent.addView(containerId, indicator)
+ }
+
+ /// 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
+ WidgetLessonStatus.ONGOING -> R.drawable.widget_lesson_block_ongoing
+ WidgetLessonStatus.REGULAR -> R.drawable.widget_lesson_block_regular
+ }
+ }
+
+ 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
+
+ private fun dayLabel(context: Context, anchor: Date): String {
+ val today = WidgetDateUtils.startOfDay(Date())
+ val tomorrow = Calendar.getInstance().apply {
+ time = today
+ add(Calendar.DAY_OF_YEAR, 1)
+ }.time
+ val anchorStart = WidgetDateUtils.startOfDay(anchor)
+ return when {
+ anchorStart == today -> context.getString(R.string.widget_today)
+ anchorStart == tomorrow -> context.getString(R.string.widget_tomorrow)
+ else -> SimpleDateFormat("EEEE", Locale.GERMAN).format(anchor)
+ }
+ }
+
+ private fun freshnessLabel(context: Context, fetchedAt: Date): String {
+ val today = WidgetDateUtils.startOfDay(Date())
+ val fetchedDay = WidgetDateUtils.startOfDay(fetchedAt)
+ val yesterday = Calendar.getInstance().apply {
+ time = today
+ add(Calendar.DAY_OF_YEAR, -1)
+ }.time
+ val yesterdayPrefix = context.getString(R.string.widget_yesterday_prefix)
+ return when (fetchedDay) {
+ today -> timeFormat.format(fetchedAt)
+ yesterday -> "$yesterdayPrefix ${timeFormat.format(fetchedAt)}"
+ else -> dateTimeShort.format(fetchedAt)
+ }
+ }
+
+ private fun buildPlaceholder(
+ context: Context,
+ packageName: String,
+ message: String,
+ palette: WidgetPalette,
+ ): RemoteViews {
+ val views = RemoteViews(packageName, R.layout.widget_placeholder)
+ applyChrome(views, palette, null)
+ views.setTextColor(R.id.widget_placeholder_title, palette.textPrimary)
+ views.setTextColor(R.id.widget_placeholder_message, palette.textSecondary)
+ views.setTextViewText(
+ R.id.widget_placeholder_title,
+ context.getString(R.string.widget_placeholder_title),
+ )
+ views.setTextViewText(R.id.widget_placeholder_message, message)
+ views.setOnClickPendingIntent(
+ R.id.widget_placeholder_message,
+ openAppIntent(context),
+ )
+ return views
+ }
+
+ private fun openAppIntent(context: Context): PendingIntent {
+ // ACTION_MAIN + LAUNCHER mirrors a launcher tap; the boolean extra
+ // is consumed by Dart via WidgetNavigation to route to the timetable.
+ val intent = Intent(context, MainActivity::class.java).apply {
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
+ action = Intent.ACTION_MAIN
+ addCategory(Intent.CATEGORY_LAUNCHER)
+ putExtra("widget_open_timetable", true)
+ }
+ return PendingIntent.getActivity(
+ context,
+ 0,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+
+ private fun sharedPrefs(context: Context): SharedPreferences {
+ return context.getSharedPreferences(
+ "HomeWidgetPreferences",
+ Context.MODE_PRIVATE,
+ )
+ }
+
+ const val KEY_DAY_DATA = "widget_data_day_v1"
+ const val KEY_WEEK_DATA = "widget_data_week_v1"
+ const val KEY_LOGGED_IN = "widget_data_logged_in_v1"
+ const val KEY_THEME_MODE = "widget_setting_theme_mode_v1"
+}
diff --git a/android/app/src/main/res/drawable/app_widget_background.xml b/android/app/src/main/res/drawable/app_widget_background.xml
new file mode 100644
index 0000000..4927caa
--- /dev/null
+++ b/android/app/src/main/res/drawable/app_widget_background.xml
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/marianum_m_watermark.xml b/android/app/src/main/res/drawable/marianum_m_watermark.xml
new file mode 100644
index 0000000..20756fa
--- /dev/null
+++ b/android/app/src/main/res/drawable/marianum_m_watermark.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/widget_grid_line.xml b/android/app/src/main/res/drawable/widget_grid_line.xml
new file mode 100644
index 0000000..44d3e41
--- /dev/null
+++ b/android/app/src/main/res/drawable/widget_grid_line.xml
@@ -0,0 +1,5 @@
+
+
+
+
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
new file mode 100644
index 0000000..7427de0
--- /dev/null
+++ b/android/app/src/main/res/drawable/widget_lesson_block_cancelled.xml
@@ -0,0 +1,16 @@
+
+
+ -
+
+
+
+
+
+
+
+
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
new file mode 100644
index 0000000..f50c66a
--- /dev/null
+++ b/android/app/src/main/res/drawable/widget_lesson_block_event_blue.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
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
new file mode 100644
index 0000000..bedb7ac
--- /dev/null
+++ b/android/app/src/main/res/drawable/widget_lesson_block_event_green.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
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
new file mode 100644
index 0000000..1cbb295
--- /dev/null
+++ b/android/app/src/main/res/drawable/widget_lesson_block_event_orange.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/drawable/widget_lesson_block_event_red.xml b/android/app/src/main/res/drawable/widget_lesson_block_event_red.xml
new file mode 100644
index 0000000..9576750
--- /dev/null
+++ b/android/app/src/main/res/drawable/widget_lesson_block_event_red.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
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
new file mode 100644
index 0000000..b9bfdc5
--- /dev/null
+++ b/android/app/src/main/res/drawable/widget_lesson_block_irregular.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
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
new file mode 100644
index 0000000..2bdab1c
--- /dev/null
+++ b/android/app/src/main/res/drawable/widget_lesson_block_ongoing.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
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
new file mode 100644
index 0000000..b25ab7c
--- /dev/null
+++ b/android/app/src/main/res/drawable/widget_lesson_block_past.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
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
new file mode 100644
index 0000000..ca78a54
--- /dev/null
+++ b/android/app/src/main/res/drawable/widget_lesson_block_regular.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
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
new file mode 100644
index 0000000..58cb161
--- /dev/null
+++ b/android/app/src/main/res/drawable/widget_lesson_block_teacher_changed.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
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
new file mode 100644
index 0000000..d396613
--- /dev/null
+++ b/android/app/src/main/res/drawable/widget_lesson_cancelled_x.xml
@@ -0,0 +1,11 @@
+
+
+
+
diff --git a/android/app/src/main/res/drawable/widget_now_indicator.xml b/android/app/src/main/res/drawable/widget_now_indicator.xml
new file mode 100644
index 0000000..48a7f67
--- /dev/null
+++ b/android/app/src/main/res/drawable/widget_now_indicator.xml
@@ -0,0 +1,5 @@
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_break_block.xml b/android/app/src/main/res/layout/widget_break_block.xml
new file mode 100644
index 0000000..8cc7900
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_break_block.xml
@@ -0,0 +1,8 @@
+
+
diff --git a/android/app/src/main/res/layout/widget_day.xml b/android/app/src/main/res/layout/widget_day.xml
new file mode 100644
index 0000000..841d14e
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_day.xml
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_day_preview.xml b/android/app/src/main/res/layout/widget_day_preview.xml
new file mode 100644
index 0000000..1fc7a63
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_day_preview.xml
@@ -0,0 +1,185 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_grid_line.xml b/android/app/src/main/res/layout/widget_grid_line.xml
new file mode 100644
index 0000000..001015c
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_grid_line.xml
@@ -0,0 +1,7 @@
+
+
diff --git a/android/app/src/main/res/layout/widget_lesson_block.xml b/android/app/src/main/res/layout/widget_lesson_block.xml
new file mode 100644
index 0000000..8cdbb39
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_lesson_block.xml
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_now_indicator.xml b/android/app/src/main/res/layout/widget_now_indicator.xml
new file mode 100644
index 0000000..02f77de
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_now_indicator.xml
@@ -0,0 +1,7 @@
+
+
diff --git a/android/app/src/main/res/layout/widget_placeholder.xml b/android/app/src/main/res/layout/widget_placeholder.xml
new file mode 100644
index 0000000..35b4763
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_placeholder.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_time_label.xml b/android/app/src/main/res/layout/widget_time_label.xml
new file mode 100644
index 0000000..43ac8bf
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_time_label.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_week.xml b/android/app/src/main/res/layout/widget_week.xml
new file mode 100644
index 0000000..da2babb
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_week.xml
@@ -0,0 +1,185 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_week_day_header.xml b/android/app/src/main/res/layout/widget_week_day_header.xml
new file mode 100644
index 0000000..2d9e89b
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_week_day_header.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/layout/widget_week_preview.xml b/android/app/src/main/res/layout/widget_week_preview.xml
new file mode 100644
index 0000000..821eaaf
--- /dev/null
+++ b/android/app/src/main/res/layout/widget_week_preview.xml
@@ -0,0 +1,147 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/values-night/widget_colors.xml b/android/app/src/main/res/values-night/widget_colors.xml
new file mode 100644
index 0000000..d1856c7
--- /dev/null
+++ b/android/app/src/main/res/values-night/widget_colors.xml
@@ -0,0 +1,7 @@
+
+
+ #FF1F1716
+ #FFF1F1F1
+ #FFB0B0B0
+ #33FFFFFF
+
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..7d0ae4c
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,17 @@
+
+
+ Marianum · Heute
+ Marianum · Woche
+ Stundenplan und Vertretungen für den anstehenden Schultag.
+ Stundenplan und Vertretungen für die ganze Schulwoche.
+ Keine Stunden
+ Ferien
+ Bitte einloggen, um den Stundenplan zu laden
+ Lade…
+ Stand: %1$s
+ Heute
+ Morgen
+ Marianum Stundenplan
+ KW
+ gestern
+
diff --git a/android/app/src/main/res/values/widget_colors.xml b/android/app/src/main/res/values/widget_colors.xml
new file mode 100644
index 0000000..db5e003
--- /dev/null
+++ b/android/app/src/main/res/values/widget_colors.xml
@@ -0,0 +1,17 @@
+
+
+
+ #FF993333
+ #FFC83333
+ #FF993333
+ #FF000000
+ #FF8F19B3
+ #FF29639B
+ #FF2E7D32
+
+ #FFFCF7F5
+ #FF111111
+ #FF555555
+ #22000000
+
diff --git a/android/app/src/main/res/xml/backup_rules.xml b/android/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..9975e1b
--- /dev/null
+++ b/android/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml
new file mode 100644
index 0000000..76e91c7
--- /dev/null
+++ b/android/app/src/main/res/xml/data_extraction_rules.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/xml/timetable_day_widget_info.xml b/android/app/src/main/res/xml/timetable_day_widget_info.xml
new file mode 100644
index 0000000..81c630c
--- /dev/null
+++ b/android/app/src/main/res/xml/timetable_day_widget_info.xml
@@ -0,0 +1,12 @@
+
+
diff --git a/android/app/src/main/res/xml/timetable_week_widget_info.xml b/android/app/src/main/res/xml/timetable_week_widget_info.xml
new file mode 100644
index 0000000..8673570
--- /dev/null
+++ b/android/app/src/main/res/xml/timetable_week_widget_info.xml
@@ -0,0 +1,12 @@
+
+
diff --git a/assets/img/marianum_m_white.svg b/assets/img/marianum_m_white.svg
new file mode 100644
index 0000000..b7c0c85
--- /dev/null
+++ b/assets/img/marianum_m_white.svg
@@ -0,0 +1,21 @@
+
+
+
+
diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements
index 903def2..3691a2f 100644
--- a/ios/Runner/Runner.entitlements
+++ b/ios/Runner/Runner.entitlements
@@ -4,5 +4,9 @@
aps-environment
development
+ com.apple.security.application-groups
+
+ group.eu.mhsl.marianum.mobile.client.widget
+
diff --git a/ios/TimetableWidgetExtension/Assets.xcassets/Contents.json b/ios/TimetableWidgetExtension/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..73c0059
--- /dev/null
+++ b/ios/TimetableWidgetExtension/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/TimetableWidgetExtension/Assets.xcassets/marianum_m.imageset/Contents.json b/ios/TimetableWidgetExtension/Assets.xcassets/marianum_m.imageset/Contents.json
new file mode 100644
index 0000000..ad043b9
--- /dev/null
+++ b/ios/TimetableWidgetExtension/Assets.xcassets/marianum_m.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "filename" : "marianum_m_white.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/ios/TimetableWidgetExtension/Assets.xcassets/marianum_m.imageset/marianum_m_white.svg b/ios/TimetableWidgetExtension/Assets.xcassets/marianum_m.imageset/marianum_m_white.svg
new file mode 100644
index 0000000..b7c0c85
--- /dev/null
+++ b/ios/TimetableWidgetExtension/Assets.xcassets/marianum_m.imageset/marianum_m_white.svg
@@ -0,0 +1,21 @@
+
+
+
+
diff --git a/ios/TimetableWidgetExtension/Info.plist b/ios/TimetableWidgetExtension/Info.plist
new file mode 100644
index 0000000..c59485a
--- /dev/null
+++ b/ios/TimetableWidgetExtension/Info.plist
@@ -0,0 +1,29 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Marianum Stundenplan
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.widgetkit-extension
+
+
+
diff --git a/ios/TimetableWidgetExtension/MarianumWatermark.swift b/ios/TimetableWidgetExtension/MarianumWatermark.swift
new file mode 100644
index 0000000..ad6e247
--- /dev/null
+++ b/ios/TimetableWidgetExtension/MarianumWatermark.swift
@@ -0,0 +1,28 @@
+import SwiftUI
+
+/// Marianum-M peeking out of the bottom-right corner. Sized to the longer
+/// widget edge so it scales with resize; offset nudges a sliver behind the
+/// edge.
+struct MarianumWatermark: View {
+ @Environment(\.colorScheme) private var colorScheme
+
+ var body: some View {
+ GeometryReader { geo in
+ let markSize = min(400, max(160, max(geo.size.width, geo.size.height) * 0.8))
+ let offsetX = markSize * 0.18
+ let offsetY = markSize * 0.18
+ ZStack(alignment: .bottomTrailing) {
+ Color.clear
+ Image("marianum_m")
+ .resizable()
+ .renderingMode(.template)
+ .aspectRatio(contentMode: .fit)
+ .foregroundStyle(.primary)
+ .frame(width: markSize, height: markSize)
+ .opacity(colorScheme == .dark ? 0.025 : 0.014)
+ .offset(x: offsetX, y: offsetY)
+ }
+ }
+ .clipped()
+ }
+}
diff --git a/ios/TimetableWidgetExtension/SETUP.md b/ios/TimetableWidgetExtension/SETUP.md
new file mode 100644
index 0000000..bcb46c4
--- /dev/null
+++ b/ios/TimetableWidgetExtension/SETUP.md
@@ -0,0 +1,72 @@
+# iOS Widget Extension — Xcode Setup
+
+Die Swift-Quellen unter `ios/TimetableWidgetExtension/` müssen einmalig in Xcode als **Widget Extension Target** verdrahtet werden — ohne diesen Schritt bleibt der Code unkompiliert.
+
+## Schritt 1 — Widget-Extension-Target anlegen
+
+1. `ios/Runner.xcworkspace` in Xcode öffnen.
+2. Projekt-Sidebar → `Runner` (Projekt-Root) → **+ Add Target** unten links.
+3. **iOS → Widget Extension** wählen.
+4. Eigenschaften:
+ - Product Name: `TimetableWidgetExtension`
+ - Bundle Identifier: `eu.mhsl.marianum.mobile.client.TimetableWidgetExtension`
+ - Language: Swift
+ - Include Configuration Intent: **OFF** (StaticConfiguration reicht)
+ - Embed in: Runner
+5. Beim Activate-Scheme-Dialog auf **Cancel** klicken.
+
+## Schritt 2 — Vorhandene Quelldateien ins Target ziehen
+
+Xcode hat zunächst Dummy-Dateien (`TimetableWidgetExtension.swift`, `TimetableWidgetExtensionBundle.swift`) angelegt. Diese **löschen** (Move to Trash). Dann:
+
+1. Sidebar → Rechtsklick auf den Ordner `TimetableWidgetExtension` → **Add Files to "Runner"…**
+2. Im File-Picker zu `ios/TimetableWidgetExtension/` navigieren und alle `.swift`-Dateien, die `Info.plist`, `TimetableWidgetExtension.entitlements` **und den `Assets.xcassets`-Ordner** selektieren (mit `marianum_m`-Asset darin — gleicher Asset-Name wie auf Android-Seite).
+3. **Wichtig**: bei „Add to targets" nur `TimetableWidgetExtension` ankreuzen, **nicht** Runner.
+
+## Schritt 3 — App Group aktivieren
+
+Beide Targets brauchen die App-Group-Berechtigung, damit Hauptapp und Widget über `UserDefaults(suiteName:)` schreiben/lesen können.
+
+1. **Runner**-Target → **Signing & Capabilities** → **+ Capability** → **App Groups**.
+ - Group-ID hinzufügen: `group.eu.mhsl.marianum.mobile.client.widget`
+2. Dasselbe für **TimetableWidgetExtension** — mit derselben Group-ID.
+
+Im Apple-Developer-Portal muss die App-Group bei beiden App-IDs eingetragen sein, sonst schlägt das Provisioning fehl.
+
+## Schritt 4 — Entitlements verlinken
+
+1. **Runner** → Build Settings → `CODE_SIGN_ENTITLEMENTS` sollte bereits auf `Runner/Runner.entitlements` zeigen.
+2. **TimetableWidgetExtension** → Build Settings → `CODE_SIGN_ENTITLEMENTS` → auf `TimetableWidgetExtension/TimetableWidgetExtension.entitlements` setzen.
+
+## Schritt 5 — Info.plist + Deployment Target
+
+1. **TimetableWidgetExtension** → Build Settings → `INFOPLIST_FILE` → auf `TimetableWidgetExtension/Info.plist` setzen.
+2. Build Settings → `IPHONEOS_DEPLOYMENT_TARGET` ≥ 16.0 (Code gated `.containerBackground` mit `if #available(iOS 17, *)`, läuft also auch auf 16).
+
+## Schritt 6 — Build & Run
+
+- Scheme `Runner` (nicht das Widget-Scheme) wählen → Run.
+- Auf Home-Screen langes Drücken → Widget hinzufügen → "Marianum · Heute" / "Marianum · Woche".
+- Widget-Tap öffnet die App im zuletzt sichtbaren Tab. Eine Tab-Navigation auf den Stundenplan ist bewusst nicht implementiert (Android nutzt Intent-Extras, iOS würde dafür ein URL-Scheme oder AppIntent brauchen — beides bewusst ausgespart).
+
+## Troubleshooting
+
+- **Widget zeigt „Lade…"** auch nach Refresh: App-Group greift nicht. Prüfen, ob beide Targets dieselbe Group-ID haben und das Provisioning aktualisiert wurde.
+- **Stale-Daten nach Logout**: `WidgetSync.clear()` schreibt `widget_data_logged_in_v1 = false`; Widget zeigt dann den Login-Placeholder.
+- **Lessons um 1–2 Stunden verschoben**: Date-Parser-Bug. Sollte gefixt sein in `WidgetData.swift::parseDartDate` — verifizieren, dass die ISO-8601-Strings ohne Z-Suffix als `TimeZone.current` geparsed werden.
+- **App-Store-Submit später**: `Runner.entitlements` `aps-environment` von `development` auf `production` umbiegen.
+
+## Was bereits im Repo erledigt ist
+
+- Alle Swift-Quellen, Info.plist, Entitlements liegen unter `ios/TimetableWidgetExtension/`.
+- App-Group-ID konsistent zwischen Dart (`WidgetSync.iosAppGroupId`), Swift (`WidgetDataKey.appGroupId`) und der Entitlements-Datei.
+- `home_widget`-Plugin auf der Dart-Seite konfiguriert; ruft `HomeWidget.setAppGroupId` beim ersten Sync.
+- `containerBackground` für iOS 17+ gegated, fällt auf iOS 16 sauber zurück.
+- Date-Parser fixt das fehlende Z-Suffix (Dart schreibt lokale Zeit ohne TZ-Marker).
+
+## Was am Mac noch zu tun ist
+
+- Schritte 1–5 oben in Xcode durchklicken (10–15 Min).
+- `flutter pub get` + `cd ios && pod install`.
+- Auf physischem Gerät oder iOS-Simulator (≥ 16.0) bauen.
+- Widget aufs Home-Screen ziehen, prüfen dass Lesson-Zeiten korrekt rendern.
diff --git a/ios/TimetableWidgetExtension/TimetableDayView.swift b/ios/TimetableWidgetExtension/TimetableDayView.swift
new file mode 100644
index 0000000..6ece9f7
--- /dev/null
+++ b/ios/TimetableWidgetExtension/TimetableDayView.swift
@@ -0,0 +1,451 @@
+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
+}
+
+let BLOCK_SHOW_ROOM_MIN: CGFloat = 18
+let BLOCK_SHOW_TEACHER_SEPARATE_MIN: CGFloat = 30
+
+let MIN_SUBJECT_FONT: CGFloat = 9
+let MAX_SUBJECT_FONT: CGFloat = 14
+let MIN_SECONDARY_FONT: CGFloat = 7
+
+func subjectFont(forHourHeight hourHeight: CGFloat) -> CGFloat {
+ let t = max(0, min(1, (hourHeight - MIN_HOUR_HEIGHT) / (MAX_HOUR_HEIGHT - MIN_HOUR_HEIGHT)))
+ return MIN_SUBJECT_FONT + t * (MAX_SUBJECT_FONT - MIN_SUBJECT_FONT)
+}
+
+struct TimetableDayView: View {
+ let entry: TimetableEntry
+
+ var body: some View {
+ ZStack {
+ if !entry.isLoggedIn {
+ placeholder("Bitte einloggen, um den Stundenplan zu laden")
+ } else if let data = entry.data {
+ content(data: data)
+ } else {
+ placeholder("Lade…")
+ }
+ }
+ .background(MarianumWatermark())
+ .widgetThemeOverride(entry.themeMode)
+ }
+
+ @ViewBuilder
+ private func content(data: WidgetTimetableData) -> some View {
+ VStack(alignment: .leading, spacing: 6) {
+ header(data: data)
+ if data.isHoliday {
+ emptyState(text: data.holidayName ?? "Ferien")
+ } else if data.lessons.isEmpty {
+ emptyState(text: "Keine Stunden")
+ } else {
+ GeometryReader { geo in
+ let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES)
+ TimeGridView(
+ lessons: data.lessons,
+ periods: data.periods,
+ anchorDate: data.anchorDate,
+ hourHeight: max(
+ MIN_HOUR_HEIGHT,
+ min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60)
+ ),
+ showRoom: true,
+ showTeacher: true,
+ showTimeLabels: true
+ )
+ }
+ }
+ }
+ }
+
+ private func header(data: WidgetTimetableData) -> some View {
+ HStack {
+ Text(dayLabel(for: data.anchorDate))
+ .font(.system(size: 14, weight: .semibold))
+ .foregroundStyle(.primary)
+ Spacer()
+ Text("Stand: \(freshnessLabel(for: data.fetchedAt))")
+ .font(.system(size: 10))
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ private func emptyState(text: String) -> some View {
+ VStack {
+ Spacer()
+ Text(text)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ Spacer()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+ private func placeholder(_ message: String) -> some View {
+ VStack(spacing: 4) {
+ Text("Marianum Stundenplan")
+ .font(.system(size: 14, weight: .semibold))
+ Text(message)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
+
+struct TimeGridView: View {
+ let lessons: [WidgetLesson]
+ let periods: [WidgetPeriod]
+ let anchorDate: Date
+ let hourHeight: CGFloat
+ let showRoom: Bool
+ let showTeacher: Bool
+ let showTimeLabels: Bool
+ /// Week-widget passes 3 for narrow columns; day-widget keeps 7.
+ var horizontalPadding: CGFloat = 7
+
+ private var totalVirtualMinutes: Int {
+ periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
+ }
+
+ private var totalHeight: CGFloat {
+ CGFloat(totalVirtualMinutes) * hourHeight / 60.0
+ }
+
+ /// Below this per-hour height the two-line label collapses to a single
+ /// period number — time + number overlap otherwise.
+ private var compactLabels: Bool { hourHeight < 26 }
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 0) {
+ if showTimeLabels {
+ timeLabelsColumn
+ .frame(width: 32, alignment: .topTrailing)
+ Rectangle()
+ .fill(Color.primary.opacity(0.13))
+ .frame(width: 1)
+ }
+ ZStack(alignment: .top) {
+ gridLines
+ breakBlocks
+ ForEach(lessons.indices, id: \.self) { idx in
+ lessonBlock(lessons[idx])
+ }
+ if Calendar.current.isDate(anchorDate, inSameDayAs: Date()) {
+ nowIndicator
+ }
+ }
+ .frame(maxWidth: .infinity, minHeight: totalHeight, alignment: .top)
+ }
+ }
+
+ private var timeLabelsColumn: some View {
+ ZStack(alignment: .topTrailing) {
+ // Hour rules continue through the time-label column so it reads
+ // as a real table column rather than a free-floating tick list.
+ // Hour rules extend through the time-label column so it reads
+ // as a table column rather than a free-floating tick list.
+ ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
+ Rectangle()
+ .fill(Color.primary.opacity(0.08))
+ .frame(height: 1)
+ .offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
+ }
+ ForEach(periods, id: \.startMinutes) { period in
+ VStack(alignment: .trailing, spacing: -2) {
+ if compactLabels {
+ Text("\(period.name).")
+ .font(.system(size: 9, weight: .bold))
+ .foregroundStyle(.primary)
+ .lineLimit(1)
+ } else {
+ Text(formatHm(period.startMinutes))
+ .font(.system(size: 9))
+ .foregroundStyle(.primary)
+ .lineLimit(1)
+ Text("\(period.name).")
+ .font(.system(size: 7, weight: .bold))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ }
+ .padding(.trailing, 4)
+ .offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
+ }
+ }
+ .frame(height: totalHeight, alignment: .topTrailing)
+ }
+
+ private var gridLines: some View {
+ ZStack(alignment: .top) {
+ // Hour rules continue through the time-label column so it reads
+ // as a real table column rather than a free-floating tick list.
+ ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
+ Rectangle()
+ .fill(Color.primary.opacity(0.08))
+ .frame(height: 1)
+ .offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
+ }
+ }
+ .frame(height: totalHeight)
+ }
+
+ private var breakBlocks: some View {
+ ZStack(alignment: .top) {
+ ForEach(0.. 0 {
+ Rectangle()
+ .fill(Color.primary.opacity(0.03))
+ .frame(height: CGFloat(virtualGap) * hourHeight / 60.0)
+ .padding(.horizontal, 1)
+ .offset(y: CGFloat(curr.virtualEndMinutes) * hourHeight / 60.0)
+ }
+ }
+ }
+ .frame(height: totalHeight)
+ }
+
+ private func formatHm(_ minutes: Int) -> String {
+ String(format: "%02d:%02d", minutes / 60, minutes % 60)
+ }
+
+ @ViewBuilder
+ private func lessonBlock(_ lesson: WidgetLesson) -> some View {
+ let cal = Calendar.current
+ let comps = cal.dateComponents([.hour, .minute], from: lesson.start)
+ let startMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
+ let durationMinutes = max(15, Int(lesson.end.timeIntervalSince(lesson.start) / 60))
+ let virtualStart = realMinutesToVirtual(startMinutes, periods: periods)
+ let virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods: periods)
+
+ if virtualEnd > virtualStart {
+ let top = virtualStart * hourHeight / 60.0 + LESSON_GAP / 2
+ let height = max(
+ MIN_BLOCK_HEIGHT,
+ (virtualEnd - virtualStart) * hourHeight / 60.0 - LESSON_GAP
+ )
+ let subjectSize = subjectFont(forHourHeight: hourHeight)
+ let secondarySize = max(MIN_SECONDARY_FONT, subjectSize - 2)
+ let room = lesson.room
+ let teacher = lesson.teacher ?? lesson.originalTeacher
+ let hasSecondary = (room?.isEmpty == false) || (teacher?.isEmpty == false)
+ HStack(alignment: .top, spacing: 4) {
+ Text(subjectLabel(lesson))
+ .font(.system(size: subjectSize, weight: .semibold))
+ .foregroundStyle(.white)
+ .lineLimit(hasSecondary ? 1 : 2)
+ .minimumScaleFactor(0.5)
+ if hasSecondary {
+ Spacer(minLength: 0)
+ VStack(alignment: .trailing, spacing: -1) {
+ if showRoom && height >= BLOCK_SHOW_ROOM_MIN {
+ if let room, !room.isEmpty {
+ Text(room)
+ .font(.system(size: secondarySize))
+ .foregroundStyle(.white.opacity(0.85))
+ .lineLimit(1)
+ .minimumScaleFactor(0.5)
+ }
+ if showTeacher,
+ height >= BLOCK_SHOW_TEACHER_SEPARATE_MIN,
+ let teacher,
+ !teacher.isEmpty {
+ Text(teacher)
+ .font(.system(size: secondarySize))
+ .foregroundStyle(.white.opacity(0.7))
+ .lineLimit(1)
+ .minimumScaleFactor(0.5)
+ }
+ }
+ }
+ }
+ }
+ .padding(.horizontal, horizontalPadding)
+ .padding(.vertical, 3)
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ .frame(height: height, alignment: .topLeading)
+ .background(blockColor(lesson))
+ .cornerRadius(6)
+ .overlay(alignment: .bottomLeading) {
+ // Separate fixed-size badge so the +N hint stays readable
+ // when the subject autoshrinks on narrow tiles.
+ if let count = lesson.siblingCount, count > 0 {
+ Text("+\(count)")
+ .font(.system(size: 12, weight: .bold))
+ .foregroundStyle(.white)
+ .padding(.leading, horizontalPadding)
+ .padding(.bottom, 2)
+ }
+ }
+ .overlay {
+ // CrossPainter parity: clip cross to the rounded shape so
+ // the diagonals don't bleed past the corners.
+ if lesson.status == .cancelled {
+ ZStack {
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(Color.red.opacity(0.78), lineWidth: 1.5)
+ GeometryReader { geo in
+ Path { p in
+ p.move(to: .zero)
+ p.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height))
+ p.move(to: CGPoint(x: geo.size.width, y: 0))
+ p.addLine(to: CGPoint(x: 0, y: geo.size.height))
+ }
+ .stroke(Color.red.opacity(0.78), lineWidth: 3)
+ }
+ }
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ }
+ }
+ .padding(.horizontal, showRoom ? 2 : 1)
+ .offset(y: top)
+ }
+ }
+
+ private var nowIndicator: some View {
+ let cal = Calendar.current
+ let comps = cal.dateComponents([.hour, .minute], from: Date())
+ let nowMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
+ let inside: Bool
+ if let first = periods.first, let last = periods.last {
+ inside = nowMinutes >= first.startMinutes && nowMinutes <= last.endMinutes
+ } else {
+ inside = true
+ }
+ let top = realMinutesToVirtual(nowMinutes, periods: periods) * hourHeight / 60.0
+ return Group {
+ if inside {
+ Rectangle()
+ .fill(Color.red)
+ .frame(height: 2)
+ .offset(y: top)
+ }
+ }
+ }
+
+ private func subjectLabel(_ lesson: WidgetLesson) -> String {
+ !lesson.subjectShort.isEmpty
+ ? lesson.subjectShort
+ : (lesson.subjectLong ?? "—")
+ }
+
+ /// Mirrors lesson_color.dart + custom_event_colors.dart so the widget
+ /// matches the in-app calendar exactly.
+ private func blockColor(_ lesson: WidgetLesson) -> Color {
+ if lesson.status == .event, let custom = lesson.customColor {
+ switch custom {
+ case "orange": return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0)
+ case "red": return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0)
+ case "green": return Color(red: 76/255.0, green: 175/255.0, blue: 80/255.0)
+ case "blue": return Color(red: 33/255.0, green: 150/255.0, blue: 243/255.0)
+ default: break
+ }
+ }
+ switch lesson.status {
+ case .regular, .past: return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0)
+ case .ongoing: return Color(red: 200/255.0, green: 51/255.0, blue: 51/255.0)
+ case .cancelled: return .black
+ case .irregular: return Color(red: 143/255.0, green: 25/255.0, blue: 179/255.0)
+ case .teacherChanged: return Color(red: 41/255.0, green: 99/255.0, blue: 155/255.0)
+ case .event: return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0)
+ }
+ }
+}
+
+/// Period boundaries deduped: adjacent periods share a line, periods on
+/// either side of a break get their own (bracketing the break block).
+func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] {
+ var seen = Set()
+ var result: [Int] = []
+ for p in periods {
+ for v in [p.virtualStartMinutes, p.virtualEndMinutes] {
+ if seen.insert(v).inserted { result.append(v) }
+ }
+ }
+ return result.sorted()
+}
+
+func dayLabel(for date: Date) -> String {
+ let cal = Calendar.current
+ let today = cal.startOfDay(for: Date())
+ let anchor = cal.startOfDay(for: date)
+ if anchor == today {
+ return "Heute · \(shortDate(date))"
+ }
+ if let tomorrow = cal.date(byAdding: .day, value: 1, to: today), anchor == tomorrow {
+ return "Morgen · \(shortDate(date))"
+ }
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "de_DE")
+ formatter.dateFormat = "EEEE · dd.MM."
+ return formatter.string(from: date)
+}
+
+func shortDate(_ date: Date) -> String {
+ let f = DateFormatter()
+ f.locale = Locale(identifier: "de_DE")
+ f.dateFormat = "dd.MM."
+ return f.string(from: date)
+}
+
+func freshnessLabel(for fetchedAt: Date) -> String {
+ let cal = Calendar.current
+ let today = cal.startOfDay(for: Date())
+ let fetchedDay = cal.startOfDay(for: fetchedAt)
+ let timeFmt = DateFormatter()
+ timeFmt.locale = Locale(identifier: "de_DE")
+ timeFmt.dateFormat = "HH:mm"
+ if fetchedDay == today {
+ return timeFmt.string(from: fetchedAt)
+ }
+ if let yesterday = cal.date(byAdding: .day, value: -1, to: today),
+ fetchedDay == yesterday {
+ return "gestern \(timeFmt.string(from: fetchedAt))"
+ }
+ let dateTimeFmt = DateFormatter()
+ dateTimeFmt.locale = Locale(identifier: "de_DE")
+ dateTimeFmt.dateFormat = "dd.MM. HH:mm"
+ return dateTimeFmt.string(from: fetchedAt)
+}
diff --git a/ios/TimetableWidgetExtension/TimetableWeekView.swift b/ios/TimetableWidgetExtension/TimetableWeekView.swift
new file mode 100644
index 0000000..6173097
--- /dev/null
+++ b/ios/TimetableWidgetExtension/TimetableWeekView.swift
@@ -0,0 +1,161 @@
+import SwiftUI
+import WidgetKit
+
+struct TimetableWeekView: View {
+ let entry: TimetableEntry
+
+ var body: some View {
+ ZStack {
+ if !entry.isLoggedIn {
+ placeholder("Bitte einloggen, um den Stundenplan zu laden")
+ } else if let data = entry.data {
+ content(data: data)
+ } else {
+ placeholder("Lade…")
+ }
+ }
+ .background(MarianumWatermark())
+ .widgetThemeOverride(entry.themeMode)
+ }
+
+ @ViewBuilder
+ private func content(data: WidgetTimetableData) -> some View {
+ VStack(alignment: .leading, spacing: 4) {
+ header(data: data)
+ dayHeaderRow(data: data)
+ 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
+ let subjectOnly = dayColumnWidth < 70
+ HStack(alignment: .top, spacing: 0) {
+ timeLabelsColumn(hourHeight: hourHeight, periods: data.periods)
+ .frame(width: 28, alignment: .topTrailing)
+ columnDivider
+ ForEach(0..<5, id: \.self) { offset in
+ column(
+ data: data,
+ offset: offset,
+ hourHeight: hourHeight,
+ subjectOnly: subjectOnly
+ )
+ .frame(maxWidth: .infinity)
+ if offset < 4 { columnDivider }
+ }
+ }
+ }
+ }
+ }
+
+ private var columnDivider: some View {
+ Rectangle()
+ .fill(Color.primary.opacity(0.13))
+ .frame(width: 1)
+ }
+
+ private func header(data: WidgetTimetableData) -> some 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 {
+ Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))")
+ .font(.system(size: 13, weight: .semibold))
+ .foregroundStyle(.primary)
+ Spacer()
+ Text("Stand: \(freshnessLabel(for: data.fetchedAt))")
+ .font(.system(size: 10))
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ private func dayHeaderRow(data: WidgetTimetableData) -> some View {
+ let cal = Calendar.current
+ return HStack(spacing: 0) {
+ Spacer().frame(width: 28)
+ columnDivider
+ ForEach(0..<5, id: \.self) { offset in
+ let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate
+ VStack(spacing: 0) {
+ Text(weekday(for: day))
+ .font(.system(size: 11, weight: .bold))
+ .foregroundStyle(.primary)
+ Text(shortDate(day))
+ .font(.system(size: 9))
+ .foregroundStyle(.secondary)
+ }
+ .frame(maxWidth: .infinity)
+ if offset < 4 { columnDivider }
+ }
+ }
+ }
+
+ private func timeLabelsColumn(hourHeight: CGFloat, periods: [WidgetPeriod]) -> some View {
+ let totalMin = periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
+ let totalHeight = CGFloat(totalMin) * hourHeight / 60.0
+ return ZStack(alignment: .topTrailing) {
+ ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
+ Rectangle()
+ .fill(Color.primary.opacity(0.08))
+ .frame(height: 1)
+ .offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
+ }
+ ForEach(periods, id: \.startMinutes) { period in
+ VStack(alignment: .trailing, spacing: -2) {
+ Text(String(format: "%02d:%02d", period.startMinutes / 60, period.startMinutes % 60))
+ .font(.system(size: 8))
+ .foregroundStyle(.primary)
+ .lineLimit(1)
+ Text("\(period.name).")
+ .font(.system(size: 6, weight: .bold))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+ .offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
+ }
+ }
+ .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,
+ anchorDate: day,
+ hourHeight: hourHeight,
+ showRoom: !subjectOnly,
+ showTeacher: !subjectOnly,
+ showTimeLabels: false,
+ horizontalPadding: 3
+ )
+ }
+
+ private func weekday(for date: Date) -> String {
+ let f = DateFormatter()
+ f.locale = Locale(identifier: "de_DE")
+ f.dateFormat = "EE"
+ return f.string(from: date)
+ }
+
+ private func placeholder(_ message: String) -> some View {
+ VStack(spacing: 4) {
+ Text("Marianum Stundenplan")
+ .font(.system(size: 14, weight: .semibold))
+ Text(message)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
diff --git a/ios/TimetableWidgetExtension/TimetableWidgetExtension.entitlements b/ios/TimetableWidgetExtension/TimetableWidgetExtension.entitlements
new file mode 100644
index 0000000..cbdd516
--- /dev/null
+++ b/ios/TimetableWidgetExtension/TimetableWidgetExtension.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.eu.mhsl.marianum.mobile.client.widget
+
+
+
diff --git a/ios/TimetableWidgetExtension/TimetableWidgetExtension.swift b/ios/TimetableWidgetExtension/TimetableWidgetExtension.swift
new file mode 100644
index 0000000..b0540c4
--- /dev/null
+++ b/ios/TimetableWidgetExtension/TimetableWidgetExtension.swift
@@ -0,0 +1,138 @@
+import SwiftUI
+import WidgetKit
+
+@main
+struct MarianumWidgetBundle: WidgetBundle {
+ var body: some Widget {
+ TimetableDayWidget()
+ TimetableWeekWidget()
+ }
+}
+
+// MARK: - Day widget
+
+struct TimetableDayWidget: Widget {
+ let kind: String = "TimetableDayWidget"
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: kind, provider: TimetableDayProvider()) { entry in
+ TimetableDayView(entry: entry).widgetContainerBackground()
+ }
+ .configurationDisplayName("Marianum · Heute")
+ .description("Stundenplan und Vertretungen für den anstehenden Schultag.")
+ .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
+ }
+}
+
+struct TimetableDayProvider: TimelineProvider {
+ func placeholder(in context: Context) -> TimetableEntry {
+ TimetableEntry.placeholder()
+ }
+
+ func getSnapshot(in context: Context, completion: @escaping (TimetableEntry) -> Void) {
+ completion(TimetableEntry.current(variant: .day))
+ }
+
+ func getTimeline(
+ in context: Context,
+ completion: @escaping (Timeline) -> Void
+ ) {
+ let entry = TimetableEntry.current(variant: .day)
+ // 30 min mirrors the Dart workmanager cadence. iOS treats this as
+ // advisory; the "Stand:" label tells the user when data is stale.
+ let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
+ completion(Timeline(entries: [entry], policy: .after(next)))
+ }
+}
+
+// MARK: - Week widget
+
+struct TimetableWeekWidget: Widget {
+ let kind: String = "TimetableWeekWidget"
+
+ var body: some WidgetConfiguration {
+ StaticConfiguration(kind: kind, provider: TimetableWeekProvider()) { entry in
+ TimetableWeekView(entry: entry).widgetContainerBackground()
+ }
+ .configurationDisplayName("Marianum · Woche")
+ .description("Stundenplan und Vertretungen für die ganze Schulwoche.")
+ .supportedFamilies([.systemMedium, .systemLarge, .systemExtraLarge])
+ }
+}
+
+struct TimetableWeekProvider: TimelineProvider {
+ func placeholder(in context: Context) -> TimetableEntry {
+ TimetableEntry.placeholder()
+ }
+
+ func getSnapshot(in context: Context, completion: @escaping (TimetableEntry) -> Void) {
+ completion(TimetableEntry.current(variant: .week))
+ }
+
+ func getTimeline(
+ in context: Context,
+ completion: @escaping (Timeline) -> Void
+ ) {
+ let entry = TimetableEntry.current(variant: .week)
+ let next = Calendar.current.date(byAdding: .minute, value: 30, to: Date()) ?? Date()
+ completion(Timeline(entries: [entry], policy: .after(next)))
+ }
+}
+
+// MARK: - Entry
+
+enum TimetableVariant { case day, week }
+
+struct TimetableEntry: TimelineEntry {
+ let date: Date
+ let variant: TimetableVariant
+ let data: WidgetTimetableData?
+ let isLoggedIn: Bool
+ let themeMode: String
+
+ static func placeholder() -> TimetableEntry {
+ TimetableEntry(
+ date: Date(),
+ variant: .day,
+ data: nil,
+ isLoggedIn: true,
+ themeMode: "system"
+ )
+ }
+
+ static func current(variant: TimetableVariant) -> TimetableEntry {
+ let isLoggedIn = WidgetDataLoader.isLoggedIn()
+ let data = isLoggedIn
+ ? (variant == .day ? WidgetDataLoader.loadDay() : WidgetDataLoader.loadWeek())
+ : nil
+ return TimetableEntry(
+ date: Date(),
+ variant: variant,
+ data: data,
+ isLoggedIn: isLoggedIn,
+ themeMode: WidgetDataLoader.themeMode()
+ )
+ }
+}
+
+extension View {
+ @ViewBuilder
+ func widgetThemeOverride(_ mode: String) -> some View {
+ switch mode {
+ case "light": self.environment(\.colorScheme, .light)
+ case "dark": self.environment(\.colorScheme, .dark)
+ default: self
+ }
+ }
+
+ /// `.containerBackground(_:for:)` is iOS 17+. Older iOS uses the
+ /// implicit `.background(...)` model and renders fine without it.
+ @ViewBuilder
+ func widgetContainerBackground() -> some View {
+ if #available(iOS 17.0, *) {
+ self.containerBackground(.fill.tertiary, for: .widget)
+ } else {
+ self
+ }
+ }
+}
diff --git a/ios/TimetableWidgetExtension/WidgetData.swift b/ios/TimetableWidgetExtension/WidgetData.swift
new file mode 100644
index 0000000..49bd54c
--- /dev/null
+++ b/ios/TimetableWidgetExtension/WidgetData.swift
@@ -0,0 +1,128 @@
+import Foundation
+
+/// Mirrors lib/widget_data/widget_data.dart. JSON keys must stay in sync —
+/// the bridge is one-way: Dart writes, Swift reads.
+enum WidgetLessonStatus: String, Codable {
+ case regular
+ case ongoing
+ case past
+ case cancelled
+ case irregular
+ case teacherChanged
+ case event
+}
+
+struct WidgetLesson: Codable {
+ let start: Date
+ let end: Date
+ let subjectShort: String
+ let subjectLong: String?
+ let room: String?
+ let teacher: String?
+ let originalTeacher: String?
+ let status: WidgetLessonStatus
+ let customColor: String?
+ let siblingCount: Int?
+}
+
+struct WidgetPeriod: Codable {
+ let name: String
+ let startMinutes: Int
+ let endMinutes: Int
+ let virtualStartMinutes: Int
+ let virtualEndMinutes: Int
+}
+
+struct WidgetTimetableData: Codable {
+ let fetchedAt: Date
+ let anchorDate: Date
+ let lessons: [WidgetLesson]
+ let periods: [WidgetPeriod]
+ let isHoliday: Bool
+ let holidayName: String?
+}
+
+enum WidgetDataKey {
+ static let appGroupId = "group.eu.mhsl.marianum.mobile.client.widget"
+ static let dayData = "widget_data_day_v1"
+ static let weekData = "widget_data_week_v1"
+ static let loggedIn = "widget_data_logged_in_v1"
+ static let themeMode = "widget_setting_theme_mode_v1"
+}
+
+enum WidgetDataLoader {
+ /// Dart's `DateTime.toIso8601String()` on a non-UTC DateTime drops the
+ /// trailing Z and ships local wall-clock time. ISO8601DateFormatter's
+ /// default treats that as UTC and shifts every lesson by the local TZ
+ /// offset — dispatch by suffix instead, mirroring WidgetDataParser.kt.
+ private static func parseDartDate(_ raw: String) -> Date? {
+ let hasTzSuffix = raw.hasSuffix("Z")
+ || raw.range(of: #"[+-]\d{2}:?\d{2}$"#, options: .regularExpression) != nil
+ if hasTzSuffix {
+ let iso = ISO8601DateFormatter()
+ iso.formatOptions = [.withFullDate, .withFullTime, .withFractionalSeconds]
+ if let d = iso.date(from: raw) { return d }
+ iso.formatOptions = [.withFullDate, .withFullTime]
+ return iso.date(from: raw)
+ }
+ for pattern in [
+ "yyyy-MM-dd'T'HH:mm:ss.SSSSSS",
+ "yyyy-MM-dd'T'HH:mm:ss.SSS",
+ "yyyy-MM-dd'T'HH:mm:ss",
+ ] {
+ let f = DateFormatter()
+ f.dateFormat = pattern
+ f.timeZone = TimeZone.current
+ f.locale = Locale(identifier: "en_US_POSIX")
+ if let d = f.date(from: raw) { return d }
+ }
+ return nil
+ }
+
+ private static func decoder() -> JSONDecoder {
+ let dec = JSONDecoder()
+ dec.dateDecodingStrategy = .custom { decoder in
+ let container = try decoder.singleValueContainer()
+ let raw = try container.decode(String.self)
+ if let d = parseDartDate(raw) { return d }
+ throw DecodingError.dataCorruptedError(
+ in: container,
+ debugDescription: "Unparseable date: \(raw)"
+ )
+ }
+ return dec
+ }
+
+ static func loadDay() -> WidgetTimetableData? {
+ load(key: WidgetDataKey.dayData)
+ }
+
+ static func loadWeek() -> WidgetTimetableData? {
+ load(key: WidgetDataKey.weekData)
+ }
+
+ static func isLoggedIn() -> Bool {
+ let defaults = UserDefaults(suiteName: WidgetDataKey.appGroupId)
+ return defaults?.bool(forKey: WidgetDataKey.loggedIn) ?? false
+ }
+
+ /// "light" / "dark" / "system". The view's `.environment(\.colorScheme)`
+ /// reads this so the App's theme choice wins over the OS-level setting.
+ static func themeMode() -> String {
+ let defaults = UserDefaults(suiteName: WidgetDataKey.appGroupId)
+ return defaults?.string(forKey: WidgetDataKey.themeMode) ?? "system"
+ }
+
+ private static func load(key: String) -> WidgetTimetableData? {
+ guard let defaults = UserDefaults(suiteName: WidgetDataKey.appGroupId),
+ let raw = defaults.string(forKey: key),
+ let data = raw.data(using: .utf8) else {
+ return nil
+ }
+ do {
+ return try decoder().decode(WidgetTimetableData.self, from: data)
+ } catch {
+ return nil
+ }
+ }
+}
diff --git a/lib/app.dart b/lib/app.dart
index 6ca7924..85cea47 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -13,15 +13,19 @@ import 'model/data_cleaner.dart';
import 'notification/notification_controller.dart';
import 'notification/notification_tasks.dart';
import 'notification/notify_updater.dart';
+import 'routing/app_routes.dart';
import 'state/app/modules/app_modules.dart';
import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import 'state/app/modules/settings/bloc/settings_cubit.dart';
import 'state/app/modules/timetable/bloc/timetable_bloc.dart';
+import 'state/app/modules/timetable/bloc/timetable_state.dart';
import 'storage/settings.dart' as model;
import 'utils/debouncer.dart';
import 'view/pages/overhang.dart';
import 'widget/breaker/breaker.dart';
+import 'widget_data/widget_navigation.dart';
+import 'widget_data/widget_publisher.dart';
class App extends StatefulWidget {
const App({super.key});
@@ -33,6 +37,7 @@ class App extends StatefulWidget {
class _AppState extends State with WidgetsBindingObserver {
late Timer _refetchChats;
late Timer _updateTimings;
+ StreamSubscription? _timetableWidgetSync;
// Tracked via the bottom-nav controller's listener so it always reflects the
// user's actual position, even between rapid setting emits where the
// controller hasn't caught up to a scheduled jump yet.
@@ -52,9 +57,16 @@ class _AppState extends State with WidgetsBindingObserver {
log('Refreshing due to LifecycleChange');
NotificationTasks.updateProviders(context);
});
+ _handlePendingWidgetNavigation();
}
}
+ Future _handlePendingWidgetNavigation() async {
+ final pending = await WidgetNavigation.consumePendingTimetableTap();
+ if (!pending || !mounted) return;
+ AppRoutes.goToTab(context, Modules.timetable);
+ }
+
@override
void initState() {
super.initState();
@@ -69,7 +81,37 @@ class _AppState extends State with WidgetsBindingObserver {
// App is freshly mounted on every login (BlocConsumer in main.dart
// swaps it in for Login), so this also covers the post-logout case
// where the bloc was reset to an empty state and needs a fresh fetch.
- context.read().refresh();
+ final timetable = context.read();
+ timetable.refresh();
+ // Push the freshest timetable state into the home-screen widget any
+ // time the BLoC reports new data — without waiting for the periodic
+ // background refresh. This is the "user just opened the app" path:
+ // the widget gets the same data the user is looking at on screen.
+ final settingsCubit = context.read();
+ _timetableWidgetSync?.cancel();
+ _timetableWidgetSync = timetable.stream.listen((state) {
+ final data = state.data;
+ if (data is TimetableState && !state.isLoading) {
+ unawaited(
+ WidgetPublisher.publishFromBlocState(
+ data,
+ settings: settingsCubit.val(),
+ ),
+ );
+ }
+ });
+ // Also publish the current state once, in case data is already loaded
+ // from hydrated storage before the listener attaches.
+ final initialData = timetable.state.data;
+ if (initialData is TimetableState) {
+ unawaited(
+ WidgetPublisher.publishFromBlocState(
+ initialData,
+ settings: settingsCubit.val(),
+ ),
+ );
+ }
+ unawaited(_handlePendingWidgetNavigation());
});
_updateTimings = Timer.periodic(const Duration(seconds: 30), (_) {
@@ -115,6 +157,7 @@ class _AppState extends State with WidgetsBindingObserver {
void dispose() {
_refetchChats.cancel();
_updateTimings.cancel();
+ _timetableWidgetSync?.cancel();
Main.bottomNavigator.removeListener(_onTabControllerChanged);
WidgetsBinding.instance.removeObserver(this);
super.dispose();
diff --git a/lib/background/widget_background_task.dart b/lib/background/widget_background_task.dart
new file mode 100644
index 0000000..e43f13c
--- /dev/null
+++ b/lib/background/widget_background_task.dart
@@ -0,0 +1,178 @@
+import 'dart:async';
+import 'dart:developer';
+
+import 'package:flutter/widgets.dart';
+import 'package:intl/intl.dart';
+import 'package:workmanager/workmanager.dart';
+
+import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart';
+import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart';
+import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
+import '../api/webuntis/queries/authenticate/authenticate.dart';
+import '../api/webuntis/queries/get_holidays/get_holidays.dart';
+import '../api/webuntis/queries/get_holidays/get_holidays_response.dart';
+import '../api/webuntis/queries/get_rooms/get_rooms.dart';
+import '../api/webuntis/queries/get_rooms/get_rooms_response.dart';
+import '../api/webuntis/queries/get_subjects/get_subjects.dart';
+import '../api/webuntis/queries/get_subjects/get_subjects_response.dart';
+import '../api/webuntis/queries/get_timegrid_units/get_timegrid_units.dart';
+import '../api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart';
+import '../api/webuntis/queries/get_timetable/get_timetable.dart';
+import '../api/webuntis/queries/get_timetable/get_timetable_params.dart';
+import '../model/account_data.dart';
+import '../widget_data/widget_data_mapper.dart';
+import '../widget_data/widget_publisher.dart';
+import '../widget_data/widget_sync.dart';
+
+/// Periodic widget refresh in a background Dart isolate. Native HTTP would
+/// mean reimplementing WebUntis JSON-RPC (auth, session-timeout retry -8520,
+/// payload quirks) twice — Dart isolate keeps that logic in one place.
+class WidgetBackgroundTask {
+ static const String periodicTaskName = 'eu.mhsl.marianum.widget.refresh';
+ static const String oneOffTaskName = 'eu.mhsl.marianum.widget.refresh.once';
+
+ static const Duration periodicFrequency = Duration(minutes: 30);
+
+ static Future initialize() async {
+ await Workmanager().initialize(_callbackDispatcher);
+ await Workmanager().registerPeriodicTask(
+ periodicTaskName,
+ periodicTaskName,
+ frequency: periodicFrequency,
+ constraints: Constraints(networkType: NetworkType.connected),
+ existingWorkPolicy: ExistingPeriodicWorkPolicy.keep,
+ backoffPolicy: BackoffPolicy.linear,
+ backoffPolicyDelay: const Duration(minutes: 5),
+ );
+ }
+
+ static Future requestImmediateRefresh() async {
+ await Workmanager().registerOneOffTask(
+ '$oneOffTaskName-${DateTime.now().millisecondsSinceEpoch}',
+ oneOffTaskName,
+ constraints: Constraints(networkType: NetworkType.connected),
+ existingWorkPolicy: ExistingWorkPolicy.append,
+ );
+ }
+
+ static Future cancelAll() async {
+ await Workmanager().cancelAll();
+ }
+}
+
+@pragma('vm:entry-point')
+void _callbackDispatcher() {
+ Workmanager().executeTask((task, inputData) async {
+ try {
+ WidgetsFlutterBinding.ensureInitialized();
+ await AccountData().waitForPopulation();
+ if (!AccountData().isPopulated()) {
+ log('[widget-bg] not logged in, skipping refresh');
+ await WidgetSync.setLoggedIn(false);
+ await WidgetSync.triggerUpdate();
+ return true;
+ }
+ await _refresh();
+ return true;
+ } on Exception catch (e, s) {
+ log('[widget-bg] refresh failed: $e', stackTrace: s);
+ // false → Workmanager retries with backoff. Native side keeps the
+ // last good snapshot so the user still sees something.
+ return false;
+ }
+ });
+}
+
+Future _refresh() async {
+ await WidgetSync.ensureInitialized();
+ await Authenticate.createSession();
+
+ final now = WidgetPublisher.widgetNow();
+ final dateFormat = DateFormat('yyyyMMdd');
+ // 14-day window so the week-widget rolls forward into next Monday's
+ // lessons on Friday evening.
+ final weekStart = _startOfWeek(now);
+ final weekEndExclusive = weekStart.add(const Duration(days: 14));
+ final session = await Authenticate.getSession();
+
+ final timetable = await GetTimetable(
+ GetTimetableParams(
+ options: GetTimetableParamsOptions(
+ element: GetTimetableParamsOptionsElement(
+ id: session.personId,
+ type: session.personType,
+ keyType: GetTimetableParamsOptionsElementKeyType.id,
+ ),
+ startDate: int.parse(dateFormat.format(weekStart)),
+ endDate: int.parse(
+ dateFormat.format(weekEndExclusive.subtract(const Duration(days: 1))),
+ ),
+ teacherFields: GetTimetableParamsOptionsFields.all,
+ subjectFields: GetTimetableParamsOptionsFields.all,
+ roomFields: GetTimetableParamsOptionsFields.all,
+ klasseFields: GetTimetableParamsOptionsFields.all,
+ ),
+ ),
+ ).run();
+
+ // Reference data — failures fall through to null in the mapper rather
+ // than aborting the whole refresh.
+ final subjects = await _runOrNull(() => GetSubjects().run());
+ final rooms = await _runOrNull(() => GetRooms().run());
+ final holidays = await _runOrNull(() => GetHolidays().run());
+ final timegrid = await _runOrNull(
+ () => GetTimegridUnits().run(),
+ );
+ final customEvents = await _runOrNull(
+ () => GetCustomTimetableEvent(
+ GetCustomTimetableEventParams(AccountData().getUserSecret()),
+ ).run(),
+ );
+
+ final lessons = timetable.result;
+
+ final connectDouble = await WidgetSync.getConnectDoubleLessons();
+ final dayData = WidgetDataMapper.buildDayData(
+ now: now,
+ lessons: lessons,
+ subjects: subjects,
+ rooms: rooms,
+ holidays: holidays,
+ timegrid: timegrid,
+ customEvents: customEvents,
+ connectDoubleLessons: connectDouble,
+ );
+ final weekData = WidgetDataMapper.buildWeekData(
+ now: now,
+ lessons: lessons,
+ subjects: subjects,
+ rooms: rooms,
+ holidays: holidays,
+ timegrid: timegrid,
+ customEvents: customEvents,
+ connectDoubleLessons: connectDouble,
+ );
+
+ await WidgetSync.writeDayData(dayData);
+ await WidgetSync.writeWeekData(weekData);
+ await WidgetSync.setLoggedIn(true);
+ await WidgetSync.triggerUpdate();
+ log(
+ '[widget-bg] refreshed: day=${dayData.lessons.length} '
+ 'week=${weekData.lessons.length}',
+ );
+}
+
+DateTime _startOfWeek(DateTime reference) {
+ final monday = reference.subtract(Duration(days: reference.weekday - 1));
+ return DateTime(monday.year, monday.month, monday.day);
+}
+
+Future _runOrNull(Future Function() task) async {
+ try {
+ return await task();
+ } on Exception catch (e) {
+ log('[widget-bg] reference fetch failed: $e');
+ return null;
+ }
+}
diff --git a/lib/main.dart b/lib/main.dart
index 3d02fec..6fb6d0a 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -19,6 +19,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'api/mhsl/breaker/get_breakers/get_breakers_response.dart';
import 'app.dart';
+import 'background/widget_background_task.dart';
import 'firebase_options.dart';
import 'model/account_data.dart';
import 'state/app/modules/account/bloc/account_bloc.dart';
@@ -35,6 +36,7 @@ import 'view/login/login.dart';
import 'widget/app_progress_indicator.dart';
import 'widget/breaker/breaker.dart';
import 'widget/debug/cache_view.dart';
+import 'widget_data/widget_sync.dart';
Future main() async {
log('MarianumMobile started');
@@ -72,6 +74,15 @@ Future main() async {
await Future.wait(initialisationTasks);
log('app initialisation done!');
+ // Wire up the home-screen widget bridge before runApp so any widget render
+ // triggered during startup hits initialised native storage.
+ await WidgetSync.ensureInitialized();
+ unawaited(
+ WidgetBackgroundTask.initialize().onError(
+ (e, _) => log('Workmanager init failed: $e'),
+ ),
+ );
+
unawaited(
FirebaseMessaging.instance.getToken().then(
(token) => log('Firebase token: ${token ?? "Error: no Firebase token!"}'),
@@ -287,6 +298,12 @@ Future _wipeUserState({
await prefs.clear();
await HydratedBloc.storage.clear();
await const CacheView().clear();
+ // Stop the periodic widget refresh job so the background isolate doesn't
+ // wake up every 30 minutes only to write `loggedIn=false`. Re-registers
+ // on the next successful login.
+ await WidgetBackgroundTask.cancelAll();
+ await WidgetSync.clear();
+ await WidgetSync.triggerUpdate();
} catch (e, s) {
log('User state wipe failed: $e', stackTrace: s);
}
diff --git a/lib/view/login/login.dart b/lib/view/login/login.dart
index 879cf0f..80770b2 100644
--- a/lib/view/login/login.dart
+++ b/lib/view/login/login.dart
@@ -1,6 +1,9 @@
+import 'dart:async';
+
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
+import '../../background/widget_background_task.dart';
import '../../state/app/modules/account/bloc/account_bloc.dart';
import '../../state/app/modules/account/bloc/account_state.dart';
import '../../theming/light_app_theme.dart';
@@ -34,6 +37,11 @@ class _LoginState extends State {
void _onLoginSuccess() {
context.read().setStatus(AccountStatus.loggedIn);
+ // Re-register the periodic refresh (cancelAll runs on logout) and kick
+ // off an immediate one-off so the widget populates within seconds
+ // instead of waiting up to 30 minutes for the next periodic slot.
+ unawaited(WidgetBackgroundTask.initialize());
+ unawaited(WidgetBackgroundTask.requestImmediateRefresh());
}
@override
diff --git a/lib/view/login/login_controller.dart b/lib/view/login/login_controller.dart
index 2d7126f..9669522 100644
--- a/lib/view/login/login_controller.dart
+++ b/lib/view/login/login_controller.dart
@@ -7,6 +7,7 @@ import '../../api/errors/error_mapper.dart';
import '../../api/marianumcloud/talk/room/get_room.dart';
import '../../api/marianumcloud/talk/room/get_room_params.dart';
import '../../model/account_data.dart';
+import '../../widget_data/widget_sync.dart';
/// Owns the login flow's transient state (loading, last error) so it can be
/// driven from a thin Stateful view and unit-tested without a widget tree.
@@ -31,6 +32,11 @@ class LoginController extends ChangeNotifier {
final user = username.trim().toLowerCase();
try {
await AccountData().removeData();
+ // Drop any cached widget snapshot from a previous account before the
+ // new credentials populate it — otherwise a re-login with a different
+ // user briefly shows the previous owner's timetable on the home screen.
+ await WidgetSync.clear();
+ await WidgetSync.triggerUpdate();
await AccountData().setData(user, password);
await GetRoom(GetRoomParams(includeStatus: false)).run();
_loading = false;
diff --git a/lib/widget_data/widget_data.dart b/lib/widget_data/widget_data.dart
new file mode 100644
index 0000000..6af2ee2
--- /dev/null
+++ b/lib/widget_data/widget_data.dart
@@ -0,0 +1,76 @@
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'widget_data.freezed.dart';
+part 'widget_data.g.dart';
+
+/// Status mirror of [LessonStatus] in
+/// `lib/view/pages/timetable/data/lesson_status.dart`. Native widget code
+/// switches on the string form, so the JSON name MUST stay stable.
+enum WidgetLessonStatus {
+ regular,
+ ongoing,
+ past,
+ cancelled,
+ irregular,
+ teacherChanged,
+ event,
+}
+
+@freezed
+abstract class WidgetLesson with _$WidgetLesson {
+ const factory WidgetLesson({
+ required DateTime start,
+ required DateTime end,
+ required String subjectShort,
+ String? subjectLong,
+ String? room,
+ String? teacher,
+ String? originalTeacher,
+ required WidgetLessonStatus status,
+ String? customColor,
+ @Default(0) int siblingCount,
+ }) = _WidgetLesson;
+
+ factory WidgetLesson.fromJson(Map json) =>
+ _$WidgetLessonFromJson(json);
+}
+
+@freezed
+abstract class WidgetPeriod with _$WidgetPeriod {
+ const factory WidgetPeriod({
+ /// Webuntis period name — typically the lesson number as string ("1",
+ /// "2", "3", …). Native renderers append a trailing "." for display.
+ required String name,
+ /// Minutes since midnight, e.g. 480 for 08:00. Cheap to read in
+ /// Kotlin/Swift without re-parsing time strings.
+ required int startMinutes,
+ required int endMinutes,
+ /// Position on the **virtual** time axis used by the widget. Small
+ /// between-lesson gaps are squeezed out so periods stack flush; only
+ /// big breaks (> 5 min) remain as visible gaps. Computed by the
+ /// mapper so native renderers don't have to redo the maths.
+ required int virtualStartMinutes,
+ required int virtualEndMinutes,
+ }) = _WidgetPeriod;
+
+ factory WidgetPeriod.fromJson(Map json) =>
+ _$WidgetPeriodFromJson(json);
+}
+
+@freezed
+abstract class WidgetTimetableData with _$WidgetTimetableData {
+ const factory WidgetTimetableData({
+ required DateTime fetchedAt,
+ /// The day this widget snapshot is "about" — display anchor.
+ /// For the day variant: the rendered school day.
+ /// For the week variant: the Monday of the rendered school week.
+ required DateTime anchorDate,
+ required List lessons,
+ @Default([]) List periods,
+ @Default(false) bool isHoliday,
+ String? holidayName,
+ }) = _WidgetTimetableData;
+
+ factory WidgetTimetableData.fromJson(Map json) =>
+ _$WidgetTimetableDataFromJson(json);
+}
diff --git a/lib/widget_data/widget_data.freezed.dart b/lib/widget_data/widget_data.freezed.dart
new file mode 100644
index 0000000..b0faca4
--- /dev/null
+++ b/lib/widget_data/widget_data.freezed.dart
@@ -0,0 +1,891 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+// coverage:ignore-file
+// ignore_for_file: type=lint
+// 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';
+
+// **************************************************************************
+// FreezedGenerator
+// **************************************************************************
+
+// dart format off
+T _$identity(T value) => value;
+
+/// @nodoc
+mixin _$WidgetLesson {
+
+ DateTime get start; DateTime get end; String get subjectShort; String? get subjectLong; String? get room; String? get teacher; String? get originalTeacher; WidgetLessonStatus get status; String? get customColor; int get siblingCount;
+/// Create a copy of WidgetLesson
+/// with the given fields replaced by the non-null parameter values.
+@JsonKey(includeFromJson: false, includeToJson: false)
+@pragma('vm:prefer-inline')
+$WidgetLessonCopyWith get copyWith => _$WidgetLessonCopyWithImpl(this as WidgetLesson, _$identity);
+
+ /// Serializes this WidgetLesson to a JSON map.
+ 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));
+}
+
+@JsonKey(includeFromJson: false, includeToJson: false)
+@override
+int get hashCode => Object.hash(runtimeType,start,end,subjectShort,subjectLong,room,teacher,originalTeacher,status,customColor,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)';
+}
+
+
+}
+
+/// @nodoc
+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
+});
+
+
+
+
+}
+/// @nodoc
+class _$WidgetLessonCopyWithImpl<$Res>
+ implements $WidgetLessonCopyWith<$Res> {
+ _$WidgetLessonCopyWithImpl(this._self, this._then);
+
+ final WidgetLesson _self;
+ final $Res Function(WidgetLesson) _then;
+
+/// 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(
+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
+as String,subjectLong: freezed == subjectLong ? _self.subjectLong : subjectLong // ignore: cast_nullable_to_non_nullable
+as String?,room: freezed == room ? _self.room : room // ignore: cast_nullable_to_non_nullable
+as String?,teacher: freezed == teacher ? _self.teacher : teacher // ignore: cast_nullable_to_non_nullable
+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?,siblingCount: null == siblingCount ? _self.siblingCount : siblingCount // ignore: cast_nullable_to_non_nullable
+as int,
+ ));
+}
+
+}
+
+
+/// Adds pattern-matching-related methods to [WidgetLesson].
+extension WidgetLessonPatterns on WidgetLesson {
+/// A variant of `map` that fallback to returning `orElse`.
+///
+/// It is equivalent to doing:
+/// ```dart
+/// switch (sealedClass) {
+/// case final Subclass value:
+/// return ...;
+/// case _:
+/// return orElse();
+/// }
+/// ```
+
+@optionalTypeArgs TResult maybeMap(TResult Function( _WidgetLesson value)? $default,{required TResult orElse(),}){
+final _that = this;
+switch (_that) {
+case _WidgetLesson() when $default != null:
+return $default(_that);case _:
+ return orElse();
+
+}
+}
+/// A `switch`-like method, using callbacks.
+///
+/// Callbacks receives the raw object, upcasted.
+/// It is equivalent to doing:
+/// ```dart
+/// switch (sealedClass) {
+/// case final Subclass value:
+/// return ...;
+/// case final Subclass2 value:
+/// return ...;
+/// }
+/// ```
+
+@optionalTypeArgs TResult map(TResult Function( _WidgetLesson value) $default,){
+final _that = this;
+switch (_that) {
+case _WidgetLesson():
+return $default(_that);case _:
+ throw StateError('Unexpected subclass');
+
+}
+}
+/// A variant of `map` that fallback to returning `null`.
+///
+/// It is equivalent to doing:
+/// ```dart
+/// switch (sealedClass) {
+/// case final Subclass value:
+/// return ...;
+/// case _:
+/// return null;
+/// }
+/// ```
+
+@optionalTypeArgs TResult? mapOrNull(TResult? Function( _WidgetLesson value)? $default,){
+final _that = this;
+switch (_that) {
+case _WidgetLesson() when $default != null:
+return $default(_that);case _:
+ return null;
+
+}
+}
+/// A variant of `when` that fallback to an `orElse` callback.
+///
+/// It is equivalent to doing:
+/// ```dart
+/// switch (sealedClass) {
+/// case Subclass(:final field):
+/// return ...;
+/// case _:
+/// return orElse();
+/// }
+/// ```
+
+@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;
+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 orElse();
+
+}
+}
+/// A `switch`-like method, using callbacks.
+///
+/// As opposed to `map`, this offers destructuring.
+/// It is equivalent to doing:
+/// ```dart
+/// switch (sealedClass) {
+/// case Subclass(:final field):
+/// return ...;
+/// case Subclass2(:final field2):
+/// return ...;
+/// }
+/// ```
+
+@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;
+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 _:
+ throw StateError('Unexpected subclass');
+
+}
+}
+/// A variant of `when` that fallback to returning `null`
+///
+/// It is equivalent to doing:
+/// ```dart
+/// switch (sealedClass) {
+/// case Subclass(:final field):
+/// return ...;
+/// case _:
+/// return null;
+/// }
+/// ```
+
+@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;
+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 null;
+
+}
+}
+
+}
+
+/// @nodoc
+@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});
+ factory _WidgetLesson.fromJson(Map json) => _$WidgetLessonFromJson(json);
+
+@override final DateTime start;
+@override final DateTime end;
+@override final String subjectShort;
+@override final String? subjectLong;
+@override final String? room;
+@override final String? teacher;
+@override final String? originalTeacher;
+@override final WidgetLessonStatus status;
+@override final String? customColor;
+@override@JsonKey() final int siblingCount;
+
+/// Create a copy of WidgetLesson
+/// with the given fields replaced by the non-null parameter values.
+@override @JsonKey(includeFromJson: false, includeToJson: false)
+@pragma('vm:prefer-inline')
+_$WidgetLessonCopyWith<_WidgetLesson> get copyWith => __$WidgetLessonCopyWithImpl<_WidgetLesson>(this, _$identity);
+
+@override
+Map toJson() {
+ return _$WidgetLessonToJson(this, );
+}
+
+@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));
+}
+
+@JsonKey(includeFromJson: false, includeToJson: false)
+@override
+int get hashCode => Object.hash(runtimeType,start,end,subjectShort,subjectLong,room,teacher,originalTeacher,status,customColor,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)';
+}
+
+
+}
+
+/// @nodoc
+abstract mixin class _$WidgetLessonCopyWith<$Res> implements $WidgetLessonCopyWith<$Res> {
+ 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
+});
+
+
+
+
+}
+/// @nodoc
+class __$WidgetLessonCopyWithImpl<$Res>
+ implements _$WidgetLessonCopyWith<$Res> {
+ __$WidgetLessonCopyWithImpl(this._self, this._then);
+
+ final _WidgetLesson _self;
+ final $Res Function(_WidgetLesson) _then;
+
+/// 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,}) {
+ 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
+as String,subjectLong: freezed == subjectLong ? _self.subjectLong : subjectLong // ignore: cast_nullable_to_non_nullable
+as String?,room: freezed == room ? _self.room : room // ignore: cast_nullable_to_non_nullable
+as String?,teacher: freezed == teacher ? _self.teacher : teacher // ignore: cast_nullable_to_non_nullable
+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?,siblingCount: null == siblingCount ? _self.siblingCount : siblingCount // ignore: cast_nullable_to_non_nullable
+as int,
+ ));
+}
+
+
+}
+
+
+/// @nodoc
+mixin _$WidgetPeriod {
+
+/// Webuntis period name — typically the lesson number as string ("1",
+/// "2", "3", …). Native renderers append a trailing "." for display.
+ String get name;/// Minutes since midnight, e.g. 480 for 08:00. Cheap to read in
+/// Kotlin/Swift without re-parsing time strings.
+ int get startMinutes; int get endMinutes;/// Position on the **virtual** time axis used by the widget. Small
+/// between-lesson gaps are squeezed out so periods stack flush; only
+/// big breaks (> 5 min) remain as visible gaps. Computed by the
+/// mapper so native renderers don't have to redo the maths.
+ int get virtualStartMinutes; int get virtualEndMinutes;
+/// Create a copy of WidgetPeriod
+/// with the given fields replaced by the non-null parameter values.
+@JsonKey(includeFromJson: false, includeToJson: false)
+@pragma('vm:prefer-inline')
+$WidgetPeriodCopyWith get copyWith => _$WidgetPeriodCopyWithImpl(this as WidgetPeriod, _$identity);
+
+ /// Serializes this WidgetPeriod to a JSON map.
+ 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));
+}
+
+@JsonKey(includeFromJson: false, includeToJson: false)
+@override
+int get hashCode => Object.hash(runtimeType,name,startMinutes,endMinutes,virtualStartMinutes,virtualEndMinutes);
+
+@override
+String toString() {
+ return 'WidgetPeriod(name: $name, startMinutes: $startMinutes, endMinutes: $endMinutes, virtualStartMinutes: $virtualStartMinutes, virtualEndMinutes: $virtualEndMinutes)';
+}
+
+
+}
+
+/// @nodoc
+abstract mixin class $WidgetPeriodCopyWith<$Res> {
+ factory $WidgetPeriodCopyWith(WidgetPeriod value, $Res Function(WidgetPeriod) _then) = _$WidgetPeriodCopyWithImpl;
+@useResult
+$Res call({
+ String name, int startMinutes, int endMinutes, int virtualStartMinutes, int virtualEndMinutes
+});
+
+
+
+
+}
+/// @nodoc
+class _$WidgetPeriodCopyWithImpl<$Res>
+ implements $WidgetPeriodCopyWith<$Res> {
+ _$WidgetPeriodCopyWithImpl(this._self, this._then);
+
+ final WidgetPeriod _self;
+ final $Res Function(WidgetPeriod) _then;
+
+/// 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(
+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
+as int,virtualStartMinutes: null == virtualStartMinutes ? _self.virtualStartMinutes : virtualStartMinutes // ignore: cast_nullable_to_non_nullable
+as int,virtualEndMinutes: null == virtualEndMinutes ? _self.virtualEndMinutes : virtualEndMinutes // ignore: cast_nullable_to_non_nullable
+as int,
+ ));
+}
+
+}
+
+
+/// Adds pattern-matching-related methods to [WidgetPeriod].
+extension WidgetPeriodPatterns on WidgetPeriod {
+/// A variant of `map` that fallback to returning `orElse`.
+///
+/// It is equivalent to doing:
+/// ```dart
+/// switch (sealedClass) {
+/// case final Subclass value:
+/// return ...;
+/// case _:
+/// return orElse();
+/// }
+/// ```
+
+@optionalTypeArgs TResult maybeMap(TResult Function( _WidgetPeriod value)? $default,{required TResult orElse(),}){
+final _that = this;
+switch (_that) {
+case _WidgetPeriod() when $default != null:
+return $default(_that);case _:
+ return orElse();
+
+}
+}
+/// A `switch`-like method, using callbacks.
+///
+/// Callbacks receives the raw object, upcasted.
+/// It is equivalent to doing:
+/// ```dart
+/// switch (sealedClass) {
+/// case final Subclass value:
+/// return ...;
+/// case final Subclass2 value:
+/// return ...;
+/// }
+/// ```
+
+@optionalTypeArgs TResult map(TResult Function( _WidgetPeriod value) $default,){
+final _that = this;
+switch (_that) {
+case _WidgetPeriod():
+return $default(_that);case _:
+ throw StateError('Unexpected subclass');
+
+}
+}
+/// A variant of `map` that fallback to returning `null`.
+///
+/// It is equivalent to doing:
+/// ```dart
+/// switch (sealedClass) {
+/// case final Subclass value:
+/// return ...;
+/// case _:
+/// return null;
+/// }
+/// ```
+
+@optionalTypeArgs TResult? mapOrNull(TResult? Function( _WidgetPeriod value)? $default,){
+final _that = this;
+switch (_that) {
+case _WidgetPeriod() when $default != null:
+return $default(_that);case _:
+ return null;
+
+}
+}
+/// A variant of `when` that fallback to an `orElse` callback.
+///
+/// It is equivalent to doing:
+/// ```dart
+/// switch (sealedClass) {
+/// case Subclass(:final field):
+/// return ...;
+/// case _:
+/// return orElse();
+/// }
+/// ```
+
+@optionalTypeArgs TResult maybeWhen(TResult Function( String name, int startMinutes, int endMinutes, int virtualStartMinutes, int virtualEndMinutes)? $default,{required TResult orElse(),}) {final _that = this;
+switch (_that) {
+case _WidgetPeriod() when $default != null:
+return $default(_that.name,_that.startMinutes,_that.endMinutes,_that.virtualStartMinutes,_that.virtualEndMinutes);case _:
+ return orElse();
+
+}
+}
+/// A `switch`-like method, using callbacks.
+///
+/// As opposed to `map`, this offers destructuring.
+/// It is equivalent to doing:
+/// ```dart
+/// switch (sealedClass) {
+/// case Subclass(:final field):
+/// return ...;
+/// case Subclass2(:final field2):
+/// return ...;
+/// }
+/// ```
+
+@optionalTypeArgs TResult when(TResult Function( String name, int startMinutes, int endMinutes, int virtualStartMinutes, int virtualEndMinutes) $default,) {final _that = this;
+switch (_that) {
+case _WidgetPeriod():
+return $default(_that.name,_that.startMinutes,_that.endMinutes,_that.virtualStartMinutes,_that.virtualEndMinutes);case _:
+ throw StateError('Unexpected subclass');
+
+}
+}
+/// A variant of `when` that fallback to returning `null`
+///
+/// It is equivalent to doing:
+/// ```dart
+/// switch (sealedClass) {
+/// case Subclass(:final field):
+/// return ...;
+/// case _:
+/// return null;
+/// }
+/// ```
+
+@optionalTypeArgs TResult? whenOrNull(TResult? Function( String name, int startMinutes, int endMinutes, int virtualStartMinutes, int virtualEndMinutes)? $default,) {final _that = this;
+switch (_that) {
+case _WidgetPeriod() when $default != null:
+return $default(_that.name,_that.startMinutes,_that.endMinutes,_that.virtualStartMinutes,_that.virtualEndMinutes);case _:
+ return null;
+
+}
+}
+
+}
+
+/// @nodoc
+@JsonSerializable()
+
+class _WidgetPeriod implements WidgetPeriod {
+ const _WidgetPeriod({required this.name, required this.startMinutes, required this.endMinutes, required this.virtualStartMinutes, required this.virtualEndMinutes});
+ factory _WidgetPeriod.fromJson(Map json) => _$WidgetPeriodFromJson(json);
+
+/// Webuntis period name — typically the lesson number as string ("1",
+/// "2", "3", …). Native renderers append a trailing "." for display.
+@override final String name;
+/// Minutes since midnight, e.g. 480 for 08:00. Cheap to read in
+/// Kotlin/Swift without re-parsing time strings.
+@override final int startMinutes;
+@override final int endMinutes;
+/// Position on the **virtual** time axis used by the widget. Small
+/// between-lesson gaps are squeezed out so periods stack flush; only
+/// big breaks (> 5 min) remain as visible gaps. Computed by the
+/// mapper so native renderers don't have to redo the maths.
+@override final int virtualStartMinutes;
+@override final int virtualEndMinutes;
+
+/// Create a copy of WidgetPeriod
+/// with the given fields replaced by the non-null parameter values.
+@override @JsonKey(includeFromJson: false, includeToJson: false)
+@pragma('vm:prefer-inline')
+_$WidgetPeriodCopyWith<_WidgetPeriod> get copyWith => __$WidgetPeriodCopyWithImpl<_WidgetPeriod>(this, _$identity);
+
+@override
+Map toJson() {
+ return _$WidgetPeriodToJson(this, );
+}
+
+@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));
+}
+
+@JsonKey(includeFromJson: false, includeToJson: false)
+@override
+int get hashCode => Object.hash(runtimeType,name,startMinutes,endMinutes,virtualStartMinutes,virtualEndMinutes);
+
+@override
+String toString() {
+ return 'WidgetPeriod(name: $name, startMinutes: $startMinutes, endMinutes: $endMinutes, virtualStartMinutes: $virtualStartMinutes, virtualEndMinutes: $virtualEndMinutes)';
+}
+
+
+}
+
+/// @nodoc
+abstract mixin class _$WidgetPeriodCopyWith<$Res> implements $WidgetPeriodCopyWith<$Res> {
+ factory _$WidgetPeriodCopyWith(_WidgetPeriod value, $Res Function(_WidgetPeriod) _then) = __$WidgetPeriodCopyWithImpl;
+@override @useResult
+$Res call({
+ String name, int startMinutes, int endMinutes, int virtualStartMinutes, int virtualEndMinutes
+});
+
+
+
+
+}
+/// @nodoc
+class __$WidgetPeriodCopyWithImpl<$Res>
+ implements _$WidgetPeriodCopyWith<$Res> {
+ __$WidgetPeriodCopyWithImpl(this._self, this._then);
+
+ final _WidgetPeriod _self;
+ final $Res Function(_WidgetPeriod) _then;
+
+/// Create a copy of WidgetPeriod
+/// with the given fields replaced by the non-null parameter values.
+@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? startMinutes = null,Object? endMinutes = null,Object? virtualStartMinutes = null,Object? virtualEndMinutes = null,}) {
+ 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
+as int,virtualStartMinutes: null == virtualStartMinutes ? _self.virtualStartMinutes : virtualStartMinutes // ignore: cast_nullable_to_non_nullable
+as int,virtualEndMinutes: null == virtualEndMinutes ? _self.virtualEndMinutes : virtualEndMinutes // ignore: cast_nullable_to_non_nullable
+as int,
+ ));
+}
+
+
+}
+
+
+/// @nodoc
+mixin _$WidgetTimetableData {
+
+ DateTime get fetchedAt;/// The day this widget snapshot is "about" — display anchor.
+/// For the day variant: the rendered school day.
+/// For the week variant: the Monday of the rendered school week.
+ DateTime get anchorDate; List get lessons; List get periods; bool get isHoliday; String? get holidayName;
+/// Create a copy of WidgetTimetableData
+/// with the given fields replaced by the non-null parameter values.
+@JsonKey(includeFromJson: false, includeToJson: false)
+@pragma('vm:prefer-inline')
+$WidgetTimetableDataCopyWith get copyWith => _$WidgetTimetableDataCopyWithImpl(this as WidgetTimetableData, _$identity);
+
+ /// Serializes this WidgetTimetableData to a JSON map.
+ 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));
+}
+
+@JsonKey(includeFromJson: false, includeToJson: false)
+@override
+int get hashCode => Object.hash(runtimeType,fetchedAt,anchorDate,const DeepCollectionEquality().hash(lessons),const DeepCollectionEquality().hash(periods),isHoliday,holidayName);
+
+@override
+String toString() {
+ return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName)';
+}
+
+
+}
+
+/// @nodoc
+abstract mixin class $WidgetTimetableDataCopyWith<$Res> {
+ factory $WidgetTimetableDataCopyWith(WidgetTimetableData value, $Res Function(WidgetTimetableData) _then) = _$WidgetTimetableDataCopyWithImpl;
+@useResult
+$Res call({
+ DateTime fetchedAt, DateTime anchorDate, List lessons, List periods, bool isHoliday, String? holidayName
+});
+
+
+
+
+}
+/// @nodoc
+class _$WidgetTimetableDataCopyWithImpl<$Res>
+ implements $WidgetTimetableDataCopyWith<$Res> {
+ _$WidgetTimetableDataCopyWithImpl(this._self, this._then);
+
+ final WidgetTimetableData _self;
+ final $Res Function(WidgetTimetableData) _then;
+
+/// 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,}) {
+ return _then(_self.copyWith(
+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
+as List,periods: null == periods ? _self.periods : periods // ignore: cast_nullable_to_non_nullable
+as List,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
+as String?,
+ ));
+}
+
+}
+
+
+/// Adds pattern-matching-related methods to [WidgetTimetableData].
+extension WidgetTimetableDataPatterns on WidgetTimetableData {
+/// A variant of `map` that fallback to returning `orElse`.
+///
+/// It is equivalent to doing:
+/// ```dart
+/// switch (sealedClass) {
+/// case final Subclass value:
+/// return ...;
+/// case _:
+/// return orElse();
+/// }
+/// ```
+
+@optionalTypeArgs TResult maybeMap(TResult Function( _WidgetTimetableData value)? $default,{required TResult orElse(),}){
+final _that = this;
+switch (_that) {
+case _WidgetTimetableData() when $default != null:
+return $default(_that);case _:
+ return orElse();
+
+}
+}
+/// A `switch`-like method, using callbacks.
+///
+/// Callbacks receives the raw object, upcasted.
+/// It is equivalent to doing:
+/// ```dart
+/// switch (sealedClass) {
+/// case final Subclass value:
+/// return ...;
+/// case final Subclass2 value:
+/// return ...;
+/// }
+/// ```
+
+@optionalTypeArgs TResult map(TResult Function( _WidgetTimetableData value) $default,){
+final _that = this;
+switch (_that) {
+case _WidgetTimetableData():
+return $default(_that);case _:
+ throw StateError('Unexpected subclass');
+
+}
+}
+/// A variant of `map` that fallback to returning `null`.
+///
+/// It is equivalent to doing:
+/// ```dart
+/// switch (sealedClass) {
+/// case final Subclass value:
+/// return ...;
+/// case _:
+/// return null;
+/// }
+/// ```
+
+@optionalTypeArgs TResult? mapOrNull(TResult? Function( _WidgetTimetableData value)? $default,){
+final _that = this;
+switch (_that) {
+case _WidgetTimetableData() when $default != null:
+return $default(_that);case _:
+ return null;
+
+}
+}
+/// A variant of `when` that fallback to an `orElse` callback.
+///
+/// It is equivalent to doing:
+/// ```dart
+/// switch (sealedClass) {
+/// case Subclass(:final field):
+/// return ...;
+/// case _:
+/// return orElse();
+/// }
+/// ```
+
+@optionalTypeArgs TResult maybeWhen(TResult Function( DateTime fetchedAt, DateTime anchorDate, List lessons, List periods, bool isHoliday, String? holidayName)? $default,{required TResult orElse(),}) {final _that = this;
+switch (_that) {
+case _WidgetTimetableData() when $default != null:
+return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName);case _:
+ return orElse();
+
+}
+}
+/// A `switch`-like method, using callbacks.
+///
+/// As opposed to `map`, this offers destructuring.
+/// It is equivalent to doing:
+/// ```dart
+/// switch (sealedClass) {
+/// case Subclass(:final field):
+/// return ...;
+/// case Subclass2(:final field2):
+/// return ...;
+/// }
+/// ```
+
+@optionalTypeArgs TResult when(TResult Function( DateTime fetchedAt, DateTime anchorDate, List lessons, List periods, bool isHoliday, String? holidayName) $default,) {final _that = this;
+switch (_that) {
+case _WidgetTimetableData():
+return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName);case _:
+ throw StateError('Unexpected subclass');
+
+}
+}
+/// A variant of `when` that fallback to returning `null`
+///
+/// It is equivalent to doing:
+/// ```dart
+/// switch (sealedClass) {
+/// case Subclass(:final field):
+/// return ...;
+/// case _:
+/// return null;
+/// }
+/// ```
+
+@optionalTypeArgs TResult? whenOrNull(TResult? Function( DateTime fetchedAt, DateTime anchorDate, List lessons, List periods, bool isHoliday, String? holidayName)? $default,) {final _that = this;
+switch (_that) {
+case _WidgetTimetableData() when $default != null:
+return $default(_that.fetchedAt,_that.anchorDate,_that.lessons,_that.periods,_that.isHoliday,_that.holidayName);case _:
+ return null;
+
+}
+}
+
+}
+
+/// @nodoc
+@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}): _lessons = lessons,_periods = periods;
+ factory _WidgetTimetableData.fromJson(Map json) => _$WidgetTimetableDataFromJson(json);
+
+@override final DateTime fetchedAt;
+/// The day this widget snapshot is "about" — display anchor.
+/// For the day variant: the rendered school day.
+/// For the week variant: the Monday of the rendered school week.
+@override final DateTime anchorDate;
+ final List _lessons;
+@override List get lessons {
+ if (_lessons is EqualUnmodifiableListView) return _lessons;
+ // ignore: implicit_dynamic_type
+ return EqualUnmodifiableListView(_lessons);
+}
+
+ final List _periods;
+@override@JsonKey() List get periods {
+ if (_periods is EqualUnmodifiableListView) return _periods;
+ // ignore: implicit_dynamic_type
+ return EqualUnmodifiableListView(_periods);
+}
+
+@override@JsonKey() final bool isHoliday;
+@override final String? holidayName;
+
+/// Create a copy of WidgetTimetableData
+/// with the given fields replaced by the non-null parameter values.
+@override @JsonKey(includeFromJson: false, includeToJson: false)
+@pragma('vm:prefer-inline')
+_$WidgetTimetableDataCopyWith<_WidgetTimetableData> get copyWith => __$WidgetTimetableDataCopyWithImpl<_WidgetTimetableData>(this, _$identity);
+
+@override
+Map toJson() {
+ return _$WidgetTimetableDataToJson(this, );
+}
+
+@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));
+}
+
+@JsonKey(includeFromJson: false, includeToJson: false)
+@override
+int get hashCode => Object.hash(runtimeType,fetchedAt,anchorDate,const DeepCollectionEquality().hash(_lessons),const DeepCollectionEquality().hash(_periods),isHoliday,holidayName);
+
+@override
+String toString() {
+ return 'WidgetTimetableData(fetchedAt: $fetchedAt, anchorDate: $anchorDate, lessons: $lessons, periods: $periods, isHoliday: $isHoliday, holidayName: $holidayName)';
+}
+
+
+}
+
+/// @nodoc
+abstract mixin class _$WidgetTimetableDataCopyWith<$Res> implements $WidgetTimetableDataCopyWith<$Res> {
+ factory _$WidgetTimetableDataCopyWith(_WidgetTimetableData value, $Res Function(_WidgetTimetableData) _then) = __$WidgetTimetableDataCopyWithImpl;
+@override @useResult
+$Res call({
+ DateTime fetchedAt, DateTime anchorDate, List lessons, List periods, bool isHoliday, String? holidayName
+});
+
+
+
+
+}
+/// @nodoc
+class __$WidgetTimetableDataCopyWithImpl<$Res>
+ implements _$WidgetTimetableDataCopyWith<$Res> {
+ __$WidgetTimetableDataCopyWithImpl(this._self, this._then);
+
+ final _WidgetTimetableData _self;
+ final $Res Function(_WidgetTimetableData) _then;
+
+/// Create a copy of WidgetTimetableData
+/// with the given fields replaced by the non-null parameter values.
+@override @pragma('vm:prefer-inline') $Res call({Object? fetchedAt = null,Object? anchorDate = null,Object? lessons = null,Object? periods = null,Object? isHoliday = null,Object? holidayName = freezed,}) {
+ 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
+as List,periods: null == periods ? _self._periods : periods // ignore: cast_nullable_to_non_nullable
+as List,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
+as String?,
+ ));
+}
+
+
+}
+
+// dart format on
diff --git a/lib/widget_data/widget_data.g.dart b/lib/widget_data/widget_data.g.dart
new file mode 100644
index 0000000..8a7afeb
--- /dev/null
+++ b/lib/widget_data/widget_data.g.dart
@@ -0,0 +1,90 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'widget_data.dart';
+
+// **************************************************************************
+// JsonSerializableGenerator
+// **************************************************************************
+
+_WidgetLesson _$WidgetLessonFromJson(Map json) =>
+ _WidgetLesson(
+ start: DateTime.parse(json['start'] as String),
+ end: DateTime.parse(json['end'] as String),
+ subjectShort: json['subjectShort'] as String,
+ subjectLong: json['subjectLong'] as String?,
+ room: json['room'] as String?,
+ teacher: json['teacher'] as String?,
+ originalTeacher: json['originalTeacher'] as String?,
+ status: $enumDecode(_$WidgetLessonStatusEnumMap, json['status']),
+ customColor: json['customColor'] as String?,
+ siblingCount: (json['siblingCount'] as num?)?.toInt() ?? 0,
+ );
+
+Map _$WidgetLessonToJson(_WidgetLesson instance) =>
+ {
+ 'start': instance.start.toIso8601String(),
+ 'end': instance.end.toIso8601String(),
+ 'subjectShort': instance.subjectShort,
+ 'subjectLong': instance.subjectLong,
+ 'room': instance.room,
+ 'teacher': instance.teacher,
+ 'originalTeacher': instance.originalTeacher,
+ 'status': _$WidgetLessonStatusEnumMap[instance.status]!,
+ 'customColor': instance.customColor,
+ 'siblingCount': instance.siblingCount,
+ };
+
+const _$WidgetLessonStatusEnumMap = {
+ WidgetLessonStatus.regular: 'regular',
+ WidgetLessonStatus.ongoing: 'ongoing',
+ WidgetLessonStatus.past: 'past',
+ WidgetLessonStatus.cancelled: 'cancelled',
+ WidgetLessonStatus.irregular: 'irregular',
+ WidgetLessonStatus.teacherChanged: 'teacherChanged',
+ WidgetLessonStatus.event: 'event',
+};
+
+_WidgetPeriod _$WidgetPeriodFromJson(Map json) =>
+ _WidgetPeriod(
+ name: json['name'] as String,
+ startMinutes: (json['startMinutes'] as num).toInt(),
+ endMinutes: (json['endMinutes'] as num).toInt(),
+ virtualStartMinutes: (json['virtualStartMinutes'] as num).toInt(),
+ virtualEndMinutes: (json['virtualEndMinutes'] as num).toInt(),
+ );
+
+Map _$WidgetPeriodToJson(_WidgetPeriod instance) =>
+ {
+ 'name': instance.name,
+ 'startMinutes': instance.startMinutes,
+ 'endMinutes': instance.endMinutes,
+ 'virtualStartMinutes': instance.virtualStartMinutes,
+ 'virtualEndMinutes': instance.virtualEndMinutes,
+ };
+
+_WidgetTimetableData _$WidgetTimetableDataFromJson(Map json) =>
+ _WidgetTimetableData(
+ fetchedAt: DateTime.parse(json['fetchedAt'] as String),
+ anchorDate: DateTime.parse(json['anchorDate'] as String),
+ lessons: (json['lessons'] as List)
+ .map((e) => WidgetLesson.fromJson(e as Map))
+ .toList(),
+ periods:
+ (json['periods'] as List?)
+ ?.map((e) => WidgetPeriod.fromJson(e as Map))
+ .toList() ??
+ const [],
+ isHoliday: json['isHoliday'] as bool? ?? false,
+ holidayName: json['holidayName'] as String?,
+ );
+
+Map _$WidgetTimetableDataToJson(
+ _WidgetTimetableData instance,
+) => {
+ 'fetchedAt': instance.fetchedAt.toIso8601String(),
+ 'anchorDate': instance.anchorDate.toIso8601String(),
+ 'lessons': instance.lessons,
+ 'periods': instance.periods,
+ 'isHoliday': instance.isHoliday,
+ 'holidayName': instance.holidayName,
+};
diff --git a/lib/widget_data/widget_data_mapper.dart b/lib/widget_data/widget_data_mapper.dart
new file mode 100644
index 0000000..812a057
--- /dev/null
+++ b/lib/widget_data/widget_data_mapper.dart
@@ -0,0 +1,472 @@
+import 'dart:developer';
+
+import 'package:rrule/rrule.dart';
+
+import '../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
+import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
+import '../api/webuntis/queries/get_holidays/get_holidays_response.dart';
+import '../api/webuntis/queries/get_rooms/get_rooms_response.dart';
+import '../api/webuntis/queries/get_subjects/get_subjects_response.dart';
+import '../api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart';
+import '../api/webuntis/queries/get_timetable/get_timetable_response.dart';
+import '../view/pages/timetable/data/lesson_period_schedule.dart';
+import '../view/pages/timetable/data/lesson_status.dart';
+import '../view/pages/timetable/data/webuntis_time.dart';
+import 'widget_data.dart';
+
+class WidgetDataMapper {
+ /// After 17:00 the user's question shifts from "what's left today" to
+ /// "what's tomorrow", so the day-widget rolls forward.
+ static const int _dayWidgetCutoffHour = 17;
+
+ static const _weekend = {DateTime.saturday, DateTime.sunday};
+
+ static DateTime resolveDayAnchor(DateTime now) {
+ var candidate = DateTime(now.year, now.month, now.day);
+ final shiftToTomorrow =
+ now.hour >= _dayWidgetCutoffHour || _weekend.contains(now.weekday);
+ if (shiftToTomorrow) {
+ candidate = candidate.add(const Duration(days: 1));
+ }
+ while (_weekend.contains(candidate.weekday)) {
+ candidate = candidate.add(const Duration(days: 1));
+ }
+ return candidate;
+ }
+
+ static DateTime resolveWeekAnchor(DateTime now) {
+ final anchor = resolveDayAnchor(now);
+ final monday = anchor.subtract(Duration(days: anchor.weekday - 1));
+ return DateTime(monday.year, monday.month, monday.day);
+ }
+
+ static WidgetTimetableData buildDayData({
+ required DateTime now,
+ required Iterable lessons,
+ required GetSubjectsResponse? subjects,
+ required GetRoomsResponse? rooms,
+ required GetHolidaysResponse? holidays,
+ GetTimegridUnitsResponse? timegrid,
+ GetCustomTimetableEventResponse? customEvents,
+ bool connectDoubleLessons = true,
+ }) {
+ final anchor = resolveDayAnchor(now);
+ final holiday = _findHoliday(anchor, holidays);
+ final dayStart = anchor;
+ final dayEnd = anchor.add(const Duration(days: 1));
+ final dayLessons = lessons.where((l) => _onSameDay(l, anchor)).toList();
+ final source = connectDoubleLessons
+ ? _mergeAdjacentLessons(dayLessons)
+ : dayLessons;
+ final mapped = [
+ ...source.map((l) => _mapLesson(l, now, subjects, rooms)),
+ ..._expandCustomEvents(customEvents, dayStart, dayEnd),
+ ]..sort((a, b) => a.start.compareTo(b.start));
+ return WidgetTimetableData(
+ fetchedAt: now,
+ anchorDate: anchor,
+ lessons: _resolveCollisions(mapped),
+ periods: _resolvePeriods(timegrid),
+ isHoliday: holiday != null,
+ holidayName: holiday?.longName,
+ );
+ }
+
+ static WidgetTimetableData buildWeekData({
+ required DateTime now,
+ required Iterable lessons,
+ required GetSubjectsResponse? subjects,
+ required GetRoomsResponse? rooms,
+ required GetHolidaysResponse? holidays,
+ GetTimegridUnitsResponse? timegrid,
+ GetCustomTimetableEventResponse? customEvents,
+ bool connectDoubleLessons = true,
+ }) {
+ final anchor = resolveWeekAnchor(now);
+ final endExclusive = anchor.add(const Duration(days: 5));
+ final weekLessons = lessons.where((l) {
+ final dt = WebuntisTime.parse(l.date, l.startTime);
+ return !dt.isBefore(anchor) && dt.isBefore(endExclusive);
+ }).toList();
+ // Per-day merge: otherwise a 4th-period lesson on Mon would collapse with
+ // a 1st-period lesson on Tue if subject/teacher match.
+ final source = connectDoubleLessons
+ ? _mergePerDay(weekLessons)
+ : weekLessons;
+ final mapped = [
+ ...source.map((l) => _mapLesson(l, now, subjects, rooms)),
+ ..._expandCustomEvents(customEvents, anchor, endExclusive),
+ ]..sort((a, b) => a.start.compareTo(b.start));
+ return WidgetTimetableData(
+ fetchedAt: now,
+ anchorDate: anchor,
+ lessons: _resolveCollisions(mapped),
+ periods: _resolvePeriods(timegrid),
+ );
+ }
+
+ /// cancelled (0) < event (1) < regular (2) — events replace cancelled
+ /// lessons but lose to real ones, leaving a `+1` hint on the survivor.
+ static int _priority(WidgetLessonStatus status) => switch (status) {
+ WidgetLessonStatus.cancelled => 0,
+ WidgetLessonStatus.event => 1,
+ _ => 2,
+ };
+
+ static List _resolveCollisions(List lessons) {
+ if (lessons.length <= 1) return lessons;
+
+ bool overlaps(WidgetLesson l, WidgetLesson other) =>
+ l != other && l.start.isBefore(other.end) && l.end.isAfter(other.start);
+
+ // Index-based: a long event covering several regulars must bump *every*
+ // covered lesson, not just the first overlap.
+ final dropped = List.filled(lessons.length, false);
+ final bumps = List.filled(lessons.length, 0);
+ for (var i = 0; i < lessons.length; i++) {
+ final l = lessons[i];
+ final myPrio = _priority(l.status);
+ final overrideIdxs = [];
+ for (var j = 0; j < lessons.length; j++) {
+ if (i == j) continue;
+ if (_priority(lessons[j].status) <= myPrio) continue;
+ if (!overlaps(l, lessons[j])) continue;
+ overrideIdxs.add(j);
+ }
+ if (overrideIdxs.isNotEmpty) {
+ dropped[i] = true;
+ if (l.status == WidgetLessonStatus.event) {
+ for (final idx in overrideIdxs) {
+ bumps[idx] += 1;
+ }
+ }
+ }
+ }
+ final filtered = [];
+ for (var i = 0; i < lessons.length; i++) {
+ if (dropped[i]) continue;
+ final l = lessons[i];
+ filtered.add(
+ bumps[i] > 0
+ ? l.copyWith(siblingCount: l.siblingCount + bumps[i])
+ : l,
+ );
+ }
+ if (filtered.length <= 1) return filtered;
+
+ final groups = >{};
+ for (final l in filtered) {
+ final key =
+ '${l.start.year}-${l.start.month}-${l.start.day}-${l.start.hour}-${l.start.minute}';
+ groups.putIfAbsent(key, () => []).add(l);
+ }
+ final result = [];
+ for (final group in groups.values) {
+ if (group.length == 1) {
+ result.add(group.first);
+ continue;
+ }
+ final active = group
+ .where((l) => l.status != WidgetLessonStatus.cancelled)
+ .toList();
+ if (active.isEmpty) {
+ result.addAll(group);
+ continue;
+ }
+ active.sort((a, b) => a.subjectShort.compareTo(b.subjectShort));
+ // Additive — preserves the event-bump from the priority pass, otherwise
+ // a slot with another regular lesson AND a hidden event would show +1
+ // instead of +2.
+ final keeper = active.first;
+ result.add(
+ keeper.copyWith(
+ siblingCount: keeper.siblingCount + active.length - 1,
+ ),
+ );
+ }
+ return result..sort((a, b) => a.start.compareTo(b.start));
+ }
+
+ /// Gaps below this collapse to zero on the virtual axis so 45-min slots
+ /// stack flush; bigger gaps survive as visible Pause-blocks.
+ static const int _smallBreakThresholdMinutes = 5;
+
+ static List _resolvePeriods(
+ GetTimegridUnitsResponse? timegrid,
+ ) {
+ final schedule =
+ (timegrid != null ? LessonPeriodSchedule.fromApi(timegrid) : null) ??
+ LessonPeriodSchedule.fallback();
+ final raw = schedule.periods
+ .map(
+ (p) => (
+ name: p.name,
+ start: p.start.hour * 60 + p.start.minute,
+ end: p.end.hour * 60 + p.end.minute,
+ ),
+ )
+ .toList()
+ ..sort((a, b) => a.start.compareTo(b.start));
+
+ final result = [];
+ var virtualOffset = 0;
+ int? prevEnd;
+ for (final p in raw) {
+ if (prevEnd != null) {
+ final gap = p.start - prevEnd;
+ if (gap > _smallBreakThresholdMinutes) virtualOffset += gap;
+ }
+ final duration = p.end - p.start;
+ result.add(
+ WidgetPeriod(
+ name: p.name,
+ startMinutes: p.start,
+ endMinutes: p.end,
+ virtualStartMinutes: virtualOffset,
+ virtualEndMinutes: virtualOffset + duration,
+ ),
+ );
+ virtualOffset += duration;
+ prevEnd = p.end;
+ }
+ return result;
+ }
+
+ static List _mergePerDay(
+ List lessons,
+ ) {
+ final byDay = >{};
+ for (final l in lessons) {
+ byDay.putIfAbsent(l.date, () => []).add(l);
+ }
+ return [for (final group in byDay.values) ..._mergeAdjacentLessons(group)];
+ }
+
+ /// Mirrors `TimetableAppointmentFactory._mergeAdjacentLessons` so the
+ /// widget shows the same merged blocks the in-app calendar does.
+ static List _mergeAdjacentLessons(
+ List input, {
+ Duration maxGap = const Duration(minutes: 5),
+ }) {
+ if (input.isEmpty) return const [];
+ final sorted = [...input]..sort(
+ (a, b) => WebuntisTime.parse(
+ a.date,
+ a.startTime,
+ ).compareTo(WebuntisTime.parse(b.date, b.startTime)),
+ );
+ final merged = [];
+ for (final current in sorted) {
+ if (merged.isNotEmpty && _canMerge(merged.last, current, maxGap)) {
+ merged.last.endTime = current.endTime;
+ } else {
+ merged.add(GetTimetableResponseObject.fromJson(current.toJson()));
+ }
+ }
+ return merged;
+ }
+
+ static bool _canMerge(
+ GetTimetableResponseObject a,
+ GetTimetableResponseObject b,
+ Duration maxGap,
+ ) {
+ final aSubject = a.su.firstOrNull?.id;
+ final bSubject = b.su.firstOrNull?.id;
+ if (aSubject == null || bSubject == null || aSubject != bSubject) {
+ return false;
+ }
+ if (a.ro.firstOrNull?.id != b.ro.firstOrNull?.id) return false;
+ if (a.te.firstOrNull?.id != b.te.firstOrNull?.id) return false;
+ if (a.code != b.code) return false;
+ final gap = WebuntisTime.parse(
+ b.date,
+ b.startTime,
+ ).difference(WebuntisTime.parse(a.date, a.endTime));
+ return !gap.isNegative && gap <= maxGap;
+ }
+
+ static WidgetLesson _mapLesson(
+ GetTimetableResponseObject lesson,
+ DateTime now,
+ GetSubjectsResponse? subjects,
+ GetRoomsResponse? rooms,
+ ) {
+ final start = WebuntisTime.parse(lesson.date, lesson.startTime);
+ final end = WebuntisTime.parse(lesson.date, lesson.endTime);
+ final status = _mapStatus(
+ LessonStatusClassifier.classify(lesson, start, end, now),
+ );
+ final subject = lesson.su.firstOrNull;
+ // Webuntis sometimes ships subject-less entries (Wandertag etc.). Fall
+ // back to "Event" so the tile isn't just a dash.
+ final rawSubjectName = subject?.name.trim() ?? '';
+ final subjectShort = rawSubjectName.isEmpty ? 'Event' : rawSubjectName;
+ String? subjectLong;
+ if (subjects != null && subject != null) {
+ final found = subjects.result.where((s) => s.id == subject.id).firstOrNull;
+ subjectLong = found?.longName;
+ }
+ subjectLong ??= subject?.longname;
+ final room = lesson.ro.firstOrNull;
+ var roomName = room?.name;
+ if (rooms != null && room != null) {
+ final resolved =
+ rooms.result.where((r) => r.id == room.id).firstOrNull?.name;
+ roomName = resolved ?? roomName;
+ }
+ final teacher = lesson.te.firstOrNull;
+ final teacherName = teacher?.id == 0 ? null : teacher?.name;
+ final originalTeacher = teacher?.orgname;
+ return WidgetLesson(
+ start: start,
+ end: end,
+ subjectShort: subjectShort,
+ subjectLong: subjectLong,
+ room: roomName,
+ teacher: teacherName,
+ originalTeacher: originalTeacher,
+ status: status,
+ );
+ }
+
+ static WidgetLessonStatus _mapStatus(LessonStatus status) {
+ switch (status) {
+ case LessonStatus.cancelled:
+ return WidgetLessonStatus.cancelled;
+ case LessonStatus.event:
+ return WidgetLessonStatus.event;
+ case LessonStatus.irregular:
+ return WidgetLessonStatus.irregular;
+ case LessonStatus.teacherChanged:
+ return WidgetLessonStatus.teacherChanged;
+ case LessonStatus.past:
+ return WidgetLessonStatus.past;
+ case LessonStatus.ongoing:
+ return WidgetLessonStatus.ongoing;
+ case LessonStatus.regular:
+ return WidgetLessonStatus.regular;
+ }
+ }
+
+ static bool _onSameDay(GetTimetableResponseObject lesson, DateTime day) {
+ final dt = WebuntisTime.parse(lesson.date, lesson.startTime);
+ return dt.year == day.year && dt.month == day.month && dt.day == day.day;
+ }
+
+ static GetHolidaysResponseObject? _findHoliday(
+ DateTime day,
+ GetHolidaysResponse? holidays,
+ ) {
+ if (holidays == null) return null;
+ final asInt = WebuntisTime.formatDate(day);
+ for (final h in holidays.result) {
+ if (asInt >= h.startDate && asInt <= h.endDate) return h;
+ }
+ return null;
+ }
+
+ static Iterable _expandCustomEvents(
+ GetCustomTimetableEventResponse? customEvents,
+ DateTime rangeStart,
+ DateTime rangeEndExclusive,
+ ) sync* {
+ if (customEvents == null) return;
+ final rangeStartUtc = rangeStart.toUtc();
+ final rangeEndUtc = rangeEndExclusive.toUtc();
+ for (final event in customEvents.events) {
+ yield* _expandSingleEvent(event, rangeStartUtc, rangeEndUtc);
+ }
+ }
+
+ static Iterable _expandSingleEvent(
+ CustomTimetableEvent event,
+ DateTime rangeStartUtc,
+ DateTime rangeEndUtc,
+ ) sync* {
+ final rule = event.rrule;
+ final duration = event.endDate.difference(event.startDate);
+
+ if (rule.isEmpty) {
+ final startUtc = event.startDate.toUtc();
+ if (startUtc.isBefore(rangeStartUtc) ||
+ !startUtc.isBefore(rangeEndUtc)) {
+ return;
+ }
+ yield* _customEventToWidgetLessons(event, event.startDate, duration);
+ return;
+ }
+
+ try {
+ final parsed = RecurrenceRule.fromString(rule);
+ final anchorUtc = event.startDate.toUtc();
+ for (final occUtc in parsed.getInstances(start: anchorUtc)) {
+ if (!occUtc.isBefore(rangeEndUtc)) break;
+ if (occUtc.isBefore(rangeStartUtc)) continue;
+ final occLocal = occUtc.toLocal();
+ final occStart = DateTime(
+ occLocal.year,
+ occLocal.month,
+ occLocal.day,
+ event.startDate.hour,
+ event.startDate.minute,
+ );
+ yield* _customEventToWidgetLessons(event, occStart, duration);
+ }
+ } on Exception catch (e) {
+ log('Widget mapper: invalid rrule "$rule" on event ${event.id}: $e');
+ }
+ }
+
+ /// Splits multi-day events into one block per local calendar day, so each
+ /// affected day on the week-widget shows the event. All-day events
+ /// (start = end = midnight) collapse to a single 00:00–23:59 block.
+ static Iterable _customEventToWidgetLessons(
+ CustomTimetableEvent event,
+ DateTime occurrenceStart,
+ Duration duration,
+ ) sync* {
+ final title = event.title.trim();
+ WidgetLesson buildBlock(DateTime start, DateTime end) => WidgetLesson(
+ start: start,
+ end: end,
+ subjectShort: title.isEmpty ? 'Termin' : title,
+ subjectLong: title.isEmpty ? null : title,
+ status: WidgetLessonStatus.event,
+ customColor: event.color,
+ );
+
+ final isAllDay = duration == Duration.zero && _isMidnight(event.startDate);
+ if (isAllDay) {
+ yield buildBlock(
+ occurrenceStart,
+ DateTime(
+ occurrenceStart.year,
+ occurrenceStart.month,
+ occurrenceStart.day,
+ 23,
+ 59,
+ ),
+ );
+ return;
+ }
+
+ final actualEnd = occurrenceStart.add(duration);
+ var segmentStart = occurrenceStart;
+ while (segmentStart.isBefore(actualEnd)) {
+ final nextMidnight = DateTime(
+ segmentStart.year,
+ segmentStart.month,
+ segmentStart.day,
+ ).add(const Duration(days: 1));
+ final segmentEnd = actualEnd.isBefore(nextMidnight)
+ ? actualEnd
+ : nextMidnight.subtract(const Duration(minutes: 1));
+ yield buildBlock(segmentStart, segmentEnd);
+ segmentStart = nextMidnight;
+ }
+ }
+
+ static bool _isMidnight(DateTime d) =>
+ d.hour == 0 && d.minute == 0 && d.second == 0;
+}
diff --git a/lib/widget_data/widget_navigation.dart b/lib/widget_data/widget_navigation.dart
new file mode 100644
index 0000000..bdb8b92
--- /dev/null
+++ b/lib/widget_data/widget_navigation.dart
@@ -0,0 +1,24 @@
+import 'dart:developer';
+
+import 'package:flutter/services.dart';
+
+/// Android-only bridge: MainActivity stashes `widget_open_timetable=true`
+/// from the launch Intent extra when a widget is tapped, Dart polls once
+/// per app-resume to consume and route. iOS widgets simply launch the app
+/// without a navigation hint (no widgetURL set) so this returns `false`
+/// there via MissingPluginException.
+class WidgetNavigation {
+ static const MethodChannel _channel = MethodChannel('eu.mhsl.marianum.widget');
+
+ static Future consumePendingTimetableTap() async {
+ try {
+ final raw = await _channel.invokeMethod('consumePendingNavigation');
+ return raw ?? false;
+ } on MissingPluginException {
+ return false;
+ } on PlatformException catch (e) {
+ log('WidgetNavigation channel error: $e');
+ return false;
+ }
+ }
+}
diff --git a/lib/widget_data/widget_publisher.dart b/lib/widget_data/widget_publisher.dart
new file mode 100644
index 0000000..c022260
--- /dev/null
+++ b/lib/widget_data/widget_publisher.dart
@@ -0,0 +1,77 @@
+import 'dart:developer';
+
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+
+import '../state/app/modules/timetable/bloc/timetable_state.dart';
+import '../storage/settings.dart';
+import 'widget_data_mapper.dart';
+import 'widget_sync.dart';
+
+/// Pushes timetable state to the native widget whenever the foreground bloc
+/// has fresh data, so the widget doesn't have to wait for the next periodic
+/// background fetch.
+class WidgetPublisher {
+ /// Debug-only "now" offset. Gated by [kDebugMode] so a stray non-zero
+ /// value cannot ship in release.
+ static const Duration debugTimeShift = Duration.zero;
+
+ static DateTime widgetNow() =>
+ kDebugMode ? DateTime.now().add(debugTimeShift) : DateTime.now();
+
+ static Future publishFromBlocState(
+ TimetableState state, {
+ Settings? settings,
+ }) async {
+ try {
+ final connectDouble =
+ settings?.timetableSettings.connectDoubleLessons ?? true;
+ // Mirror into widget storage so the background isolate sees the same
+ // value the user just toggled.
+ await WidgetSync.setConnectDoubleLessons(connectDouble);
+ await WidgetSync.setThemeMode(_themeName(settings?.appTheme));
+ final lessons = state.getAllKnownLessons();
+ final now = widgetNow();
+ final dayData = WidgetDataMapper.buildDayData(
+ now: now,
+ lessons: lessons,
+ subjects: state.subjects,
+ rooms: state.rooms,
+ holidays: state.schoolHolidays,
+ timegrid: state.timegrid,
+ customEvents: state.customEvents,
+ connectDoubleLessons: connectDouble,
+ );
+ final weekData = WidgetDataMapper.buildWeekData(
+ now: now,
+ lessons: lessons,
+ subjects: state.subjects,
+ rooms: state.rooms,
+ holidays: state.schoolHolidays,
+ timegrid: state.timegrid,
+ customEvents: state.customEvents,
+ connectDoubleLessons: connectDouble,
+ );
+ await WidgetSync.writeDayData(dayData);
+ await WidgetSync.writeWeekData(weekData);
+ await WidgetSync.setLoggedIn(true);
+ await WidgetSync.triggerUpdate();
+ } on Object catch (e, s) {
+ // Catch Object: non-Exception Errors (RangeError, StateError) from the
+ // bloc layer must not escape into the stream listener.
+ log('WidgetPublisher.publishFromBlocState failed: $e', stackTrace: s);
+ }
+ }
+
+ static String _themeName(ThemeMode? mode) {
+ switch (mode) {
+ case ThemeMode.light:
+ return 'light';
+ case ThemeMode.dark:
+ return 'dark';
+ case ThemeMode.system:
+ case null:
+ return 'system';
+ }
+ }
+}
diff --git a/lib/widget_data/widget_sync.dart b/lib/widget_data/widget_sync.dart
new file mode 100644
index 0000000..d2ab01b
--- /dev/null
+++ b/lib/widget_data/widget_sync.dart
@@ -0,0 +1,109 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:developer';
+
+import 'package:home_widget/home_widget.dart';
+
+import 'widget_data.dart';
+
+/// Bridge to the native widget host. All keys/names live here so the Kotlin
+/// and Swift sides stay in sync.
+class WidgetSync {
+ static const String iosAppGroupId =
+ 'group.eu.mhsl.marianum.mobile.client.widget';
+
+ static const String iosWidgetKind = 'TimetableWidget';
+ static const String androidDayProvider = 'TimetableDayWidget';
+ static const String androidWeekProvider = 'TimetableWeekWidget';
+
+ // `_v1` suffix lets a future schema change invalidate stale snapshots
+ // by bumping the key instead of risking a parse crash.
+ static const String dayDataKey = 'widget_data_day_v1';
+ static const String weekDataKey = 'widget_data_week_v1';
+ static const String fetchedAtKey = 'widget_data_fetched_at_v1';
+ static const String loggedInKey = 'widget_data_logged_in_v1';
+ // Mirrored into widget storage so the background isolate can read it
+ // without reopening HydratedBloc storage.
+ static const String connectDoubleLessonsKey =
+ 'widget_setting_connect_double_lessons_v1';
+ static const String themeModeKey = 'widget_setting_theme_mode_v1';
+
+ static bool _initialised = false;
+
+ static Future ensureInitialized() async {
+ if (_initialised) return;
+ await HomeWidget.setAppGroupId(iosAppGroupId);
+ _initialised = true;
+ }
+
+ static Future writeDayData(WidgetTimetableData data) async {
+ await ensureInitialized();
+ await HomeWidget.saveWidgetData(dayDataKey, jsonEncode(data.toJson()));
+ await HomeWidget.saveWidgetData(
+ fetchedAtKey,
+ data.fetchedAt.toIso8601String(),
+ );
+ }
+
+ static Future writeWeekData(WidgetTimetableData data) async {
+ await ensureInitialized();
+ await HomeWidget.saveWidgetData(
+ weekDataKey,
+ jsonEncode(data.toJson()),
+ );
+ await HomeWidget.saveWidgetData(
+ fetchedAtKey,
+ data.fetchedAt.toIso8601String(),
+ );
+ }
+
+ static Future setLoggedIn(bool loggedIn) async {
+ await ensureInitialized();
+ await HomeWidget.saveWidgetData(loggedInKey, loggedIn);
+ }
+
+ static Future setConnectDoubleLessons(bool value) async {
+ await ensureInitialized();
+ await HomeWidget.saveWidgetData(connectDoubleLessonsKey, value);
+ }
+
+ /// Default `true` matches `default_settings.dart` — fresh install behaves
+ /// like the in-app calendar.
+ static Future getConnectDoubleLessons() async {
+ await ensureInitialized();
+ final value = await HomeWidget.getWidgetData(
+ connectDoubleLessonsKey,
+ defaultValue: true,
+ );
+ return value ?? true;
+ }
+
+ static Future setThemeMode(String mode) async {
+ await ensureInitialized();
+ await HomeWidget.saveWidgetData(themeModeKey, mode);
+ }
+
+ static Future clear() async {
+ await ensureInitialized();
+ await HomeWidget.saveWidgetData(dayDataKey, null);
+ await HomeWidget.saveWidgetData(weekDataKey, null);
+ await HomeWidget.saveWidgetData(fetchedAtKey, null);
+ await HomeWidget.saveWidgetData(loggedInKey, false);
+ }
+
+ static Future triggerUpdate() async {
+ await ensureInitialized();
+ try {
+ await HomeWidget.updateWidget(
+ androidName: androidDayProvider,
+ iOSName: iosWidgetKind,
+ );
+ await HomeWidget.updateWidget(
+ androidName: androidWeekProvider,
+ iOSName: iosWidgetKind,
+ );
+ } on Exception catch (e) {
+ log('WidgetSync.triggerUpdate failed: $e');
+ }
+ }
+}
diff --git a/pubspec.yaml b/pubspec.yaml
index e6cd8c4..24ef739 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -3,7 +3,7 @@ description: Mobile client for Webuntis and Nextcloud with Talk integration
publish_to: 'none'
-version: 0.1.7+46
+version: 1.0.0+47
environment:
sdk: ">=3.8.0 <4.0.0"
@@ -34,6 +34,8 @@ dependencies:
flutter_app_badge: ^2.0.2
flutter_bloc: ^9.0.0
flutter_secure_storage: ^10.0.0
+ home_widget: ^0.7.0
+ workmanager: ^0.9.0+3
intl: ^0.20.2
flutter_linkify: ^6.0.0
flutter_local_notifications: ^21.0.0
diff --git a/test/widget_data/widget_data_mapper_test.dart b/test/widget_data/widget_data_mapper_test.dart
new file mode 100644
index 0000000..c9e2626
--- /dev/null
+++ b/test/widget_data/widget_data_mapper_test.dart
@@ -0,0 +1,385 @@
+import 'package:flutter_test/flutter_test.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';
+import 'package:marianum_mobile/api/webuntis/queries/get_holidays/get_holidays_response.dart';
+import 'package:marianum_mobile/api/webuntis/queries/get_timetable/get_timetable_response.dart';
+import 'package:marianum_mobile/widget_data/widget_data.dart';
+import 'package:marianum_mobile/widget_data/widget_data_mapper.dart';
+
+CustomTimetableEvent _event({
+ required String id,
+ required String title,
+ required DateTime start,
+ required DateTime end,
+}) => CustomTimetableEvent(
+ id: id,
+ title: title,
+ description: '',
+ startDate: start,
+ endDate: end,
+ color: 'orange',
+ rrule: '',
+ createdAt: start,
+ updatedAt: start,
+);
+
+GetTimetableResponseObject _lesson({
+ required int date,
+ required int startTime,
+ required int endTime,
+ String? code,
+ String? subjectName,
+ String? room,
+ int teacherId = 1,
+ String? teacherName,
+ String? teacherOrgname,
+ String? substText,
+}) => GetTimetableResponseObject(
+ id: 1,
+ date: date,
+ startTime: startTime,
+ endTime: endTime,
+ code: code,
+ substText: substText,
+ kl: const [],
+ te: teacherName != null
+ ? [
+ GetTimetableResponseObjectTeacher(
+ teacherId,
+ teacherName,
+ teacherName,
+ teacherOrgname == null ? null : 9,
+ teacherOrgname,
+ null,
+ ),
+ ]
+ : const [],
+ su: subjectName != null
+ ? [
+ GetTimetableResponseObjectSubject(
+ 5,
+ subjectName,
+ subjectName,
+ ),
+ ]
+ : const [],
+ ro: room != null
+ ? [GetTimetableResponseObjectRoom(7, room, room)]
+ : const [],
+);
+
+void main() {
+ group('resolveDayAnchor', () {
+ test('weekday before cutoff stays today', () {
+ final anchor = WidgetDataMapper.resolveDayAnchor(
+ DateTime(2026, 5, 6, 10),
+ );
+ expect(anchor, DateTime(2026, 5, 6));
+ });
+
+ test('weekday after cutoff jumps to next school day', () {
+ final anchor = WidgetDataMapper.resolveDayAnchor(
+ DateTime(2026, 5, 6, 19),
+ );
+ expect(anchor, DateTime(2026, 5, 7));
+ });
+
+ test('Friday after cutoff jumps to Monday', () {
+ final anchor = WidgetDataMapper.resolveDayAnchor(
+ DateTime(2026, 5, 8, 18),
+ );
+ expect(anchor, DateTime(2026, 5, 11));
+ });
+
+ test('Saturday morning jumps to Monday', () {
+ final anchor = WidgetDataMapper.resolveDayAnchor(
+ DateTime(2026, 5, 9, 10),
+ );
+ expect(anchor, DateTime(2026, 5, 11));
+ });
+
+ test('Sunday evening jumps to Monday', () {
+ final anchor = WidgetDataMapper.resolveDayAnchor(
+ DateTime(2026, 5, 10, 22),
+ );
+ expect(anchor, DateTime(2026, 5, 11));
+ });
+ });
+
+ group('resolveWeekAnchor', () {
+ test('Tuesday returns the Monday of that week', () {
+ final anchor = WidgetDataMapper.resolveWeekAnchor(
+ DateTime(2026, 5, 5, 10),
+ );
+ expect(anchor, DateTime(2026, 5, 4));
+ });
+
+ test('Sunday returns next Monday', () {
+ final anchor = WidgetDataMapper.resolveWeekAnchor(
+ DateTime(2026, 5, 10, 10),
+ );
+ expect(anchor, DateTime(2026, 5, 11));
+ });
+ });
+
+ group('buildDayData', () {
+ final now = DateTime(2026, 5, 6, 10);
+
+ test('only includes lessons on the anchor day', () {
+ final lessons = [
+ _lesson(date: 20260506, startTime: 800, endTime: 845, subjectName: 'MA'),
+ _lesson(date: 20260507, startTime: 800, endTime: 845, subjectName: 'EN'),
+ ];
+ final data = WidgetDataMapper.buildDayData(
+ now: now,
+ lessons: lessons,
+ subjects: null,
+ rooms: null,
+ holidays: null,
+ );
+ expect(data.lessons, hasLength(1));
+ expect(data.lessons.first.subjectShort, 'MA');
+ });
+
+ test('classifies cancelled and irregular lessons', () {
+ final lessons = [
+ _lesson(
+ date: 20260506,
+ startTime: 800,
+ endTime: 845,
+ subjectName: 'MA',
+ code: 'cancelled',
+ ),
+ _lesson(
+ date: 20260506,
+ startTime: 900,
+ endTime: 945,
+ subjectName: 'EN',
+ code: 'irregular',
+ ),
+ _lesson(
+ date: 20260506,
+ startTime: 1000,
+ endTime: 1045,
+ subjectName: 'BIO',
+ teacherName: 'Müller',
+ teacherOrgname: 'Schmidt',
+ ),
+ ];
+ final data = WidgetDataMapper.buildDayData(
+ now: now,
+ lessons: lessons,
+ subjects: null,
+ rooms: null,
+ holidays: null,
+ );
+ expect(
+ data.lessons.map((l) => l.status).toList(),
+ [
+ WidgetLessonStatus.cancelled,
+ WidgetLessonStatus.irregular,
+ WidgetLessonStatus.teacherChanged,
+ ],
+ );
+ });
+
+ test('marks day as holiday when in holiday range', () {
+ final holidays = GetHolidaysResponse({
+ GetHolidaysResponseObject(
+ 1,
+ 'Pfingsten',
+ 'Pfingstferien',
+ 20260506,
+ 20260510,
+ ),
+ });
+ final data = WidgetDataMapper.buildDayData(
+ now: now,
+ lessons: const [],
+ subjects: null,
+ rooms: null,
+ holidays: holidays,
+ );
+ expect(data.isHoliday, isTrue);
+ expect(data.holidayName, 'Pfingstferien');
+ });
+
+ test('lessons are sorted by start time', () {
+ final lessons = [
+ _lesson(
+ date: 20260506,
+ startTime: 1000,
+ endTime: 1045,
+ subjectName: 'BIO',
+ ),
+ _lesson(
+ date: 20260506,
+ startTime: 800,
+ endTime: 845,
+ subjectName: 'MA',
+ ),
+ _lesson(
+ date: 20260506,
+ startTime: 900,
+ endTime: 945,
+ subjectName: 'EN',
+ ),
+ ];
+ final data = WidgetDataMapper.buildDayData(
+ now: now,
+ lessons: lessons,
+ subjects: null,
+ rooms: null,
+ holidays: null,
+ );
+ expect(
+ data.lessons.map((l) => l.subjectShort).toList(),
+ ['MA', 'EN', 'BIO'],
+ );
+ });
+ });
+
+ group('event collision bumping', () {
+ final now = DateTime(2026, 5, 6, 10);
+
+ test('long event bumps every regular lesson it covers', () {
+ final lessons = [
+ _lesson(date: 20260506, startTime: 800, endTime: 845, subjectName: 'MA'),
+ _lesson(date: 20260506, startTime: 900, endTime: 945, subjectName: 'EN'),
+ _lesson(date: 20260506, startTime: 1000, endTime: 1045, subjectName: 'BIO'),
+ ];
+ final events = GetCustomTimetableEventResponse([
+ _event(
+ id: 'a',
+ title: 'Wandertag',
+ start: DateTime(2026, 5, 6, 8),
+ end: DateTime(2026, 5, 6, 11),
+ ),
+ ]);
+ final data = WidgetDataMapper.buildDayData(
+ now: now,
+ lessons: lessons,
+ subjects: null,
+ rooms: null,
+ holidays: null,
+ customEvents: events,
+ );
+ expect(data.lessons, hasLength(3));
+ for (final l in data.lessons) {
+ expect(l.siblingCount, 1, reason: '${l.subjectShort} should be bumped');
+ }
+ });
+
+ test('event + same-slot duplicate regular: kept lesson shows +2', () {
+ // User scenario: a long custom event covers the slot, and Webuntis
+ // reports two regular lessons starting at the same time (different
+ // class group). The user wants "+2" — one for the hidden event, one
+ // for the parallel regular lesson — not just "+1".
+ final lessons = [
+ _lesson(date: 20260506, startTime: 900, endTime: 945, subjectName: 'EN'),
+ _lesson(date: 20260506, startTime: 900, endTime: 945, subjectName: 'MA'),
+ ];
+ final events = GetCustomTimetableEventResponse([
+ _event(
+ id: 'long',
+ title: 'Wandertag',
+ start: DateTime(2026, 5, 6, 8),
+ end: DateTime(2026, 5, 6, 12),
+ ),
+ ]);
+ final data = WidgetDataMapper.buildDayData(
+ now: now,
+ lessons: lessons,
+ subjects: null,
+ rooms: null,
+ holidays: null,
+ customEvents: events,
+ );
+ expect(data.lessons, hasLength(1));
+ expect(data.lessons.first.siblingCount, 2);
+ });
+
+ test('multi-day event splits into one block per calendar day', () {
+ final events = GetCustomTimetableEventResponse([
+ _event(
+ id: 'multi',
+ title: 'Klassenfahrt',
+ start: DateTime(2026, 5, 4, 8),
+ end: DateTime(2026, 5, 6, 14),
+ ),
+ ]);
+ final data = WidgetDataMapper.buildWeekData(
+ now: DateTime(2026, 5, 5, 10),
+ lessons: const [],
+ subjects: null,
+ rooms: null,
+ holidays: null,
+ customEvents: events,
+ );
+ expect(data.lessons, hasLength(3));
+ expect(data.lessons[0].start, DateTime(2026, 5, 4, 8));
+ expect(data.lessons[0].end, DateTime(2026, 5, 4, 23, 59));
+ expect(data.lessons[1].start, DateTime(2026, 5, 5, 0));
+ expect(data.lessons[1].end, DateTime(2026, 5, 5, 23, 59));
+ expect(data.lessons[2].start, DateTime(2026, 5, 6, 0));
+ expect(data.lessons[2].end, DateTime(2026, 5, 6, 14));
+ });
+
+ test('two events covering the same regular lesson bump it twice', () {
+ final lessons = [
+ _lesson(date: 20260506, startTime: 900, endTime: 945, subjectName: 'EN'),
+ ];
+ final events = GetCustomTimetableEventResponse([
+ _event(
+ id: 'long',
+ title: 'Termin lang',
+ start: DateTime(2026, 5, 6, 8),
+ end: DateTime(2026, 5, 6, 12),
+ ),
+ _event(
+ id: 'short',
+ title: 'Termin kurz',
+ start: DateTime(2026, 5, 6, 9),
+ end: DateTime(2026, 5, 6, 10),
+ ),
+ ]);
+ final data = WidgetDataMapper.buildDayData(
+ now: now,
+ lessons: lessons,
+ subjects: null,
+ rooms: null,
+ holidays: null,
+ customEvents: events,
+ );
+ expect(data.lessons, hasLength(1));
+ expect(data.lessons.first.subjectShort, 'EN');
+ expect(data.lessons.first.siblingCount, 2);
+ });
+ });
+
+ group('buildWeekData', () {
+ final now = DateTime(2026, 5, 5, 10); // Tuesday
+
+ test('contains lessons across the school week', () {
+ final lessons = [
+ _lesson(date: 20260504, startTime: 800, endTime: 845, subjectName: 'MO'),
+ _lesson(date: 20260506, startTime: 800, endTime: 845, subjectName: 'WE'),
+ _lesson(date: 20260508, startTime: 800, endTime: 845, subjectName: 'FR'),
+ _lesson(date: 20260511, startTime: 800, endTime: 845, subjectName: 'NEXT'),
+ ];
+ final data = WidgetDataMapper.buildWeekData(
+ now: now,
+ lessons: lessons,
+ subjects: null,
+ rooms: null,
+ holidays: null,
+ );
+ // Anchor is Mon 04.05.; week ends Fri 08.05. exclusive of next Mon
+ expect(data.anchorDate, DateTime(2026, 5, 4));
+ expect(
+ data.lessons.map((l) => l.subjectShort).toList(),
+ ['MO', 'WE', 'FR'],
+ );
+ });
+ });
+}