diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 3c24865..3b12681 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/android/build.gradle b/android/build.gradle
index bc157bd..17b32f3 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -9,6 +9,29 @@ rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
+// Pin every Android subproject to JVM 17 so plugins that ship Kotlin sources
+// compiled with a higher target (e.g. receive_sharing_intent at 21) or stale
+// Java compatibility (e.g. home_widget at 1.8) don't break the build under
+// newer Gradle/Kotlin tooling. Registered before evaluationDependsOn so the
+// afterEvaluate fires at the right point in the lifecycle.
+subprojects { sub ->
+ sub.afterEvaluate {
+ if (sub.hasProperty('android')) {
+ sub.android {
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+ }
+ }
+ sub.tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
+ kotlinOptions {
+ jvmTarget = '17'
+ }
+ }
+ }
+}
+
subprojects {
project.evaluationDependsOn(':app')
}
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/Podfile b/ios/Podfile
index b34a8eb..a603498 100644
--- a/ios/Podfile
+++ b/ios/Podfile
@@ -34,6 +34,10 @@ target 'Runner' do
pod 'PhoneNumberKit', '~> 3.7.6'
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
+
+ target 'Share Extension' do
+ inherit! :search_paths
+ end
# target 'RunnerTests' do
# inherit! :search_paths
# end
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index ccbfe9c..ad33a62 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -2,6 +2,19 @@
+ AppGroupId
+ $(CUSTOM_GROUP_ID)
+ CFBundleURLTypes
+
+
+ CFBundleTypeRole
+ Editor
+ CFBundleURLSchemes
+
+ ShareMedia-$(PRODUCT_BUNDLE_IDENTIFIER)
+
+
+
CADisableMinimumFrameDurationOnPhone
CFBundleDevelopmentRegion
diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements
index 903def2..711c2c5 100644
--- a/ios/Runner/Runner.entitlements
+++ b/ios/Runner/Runner.entitlements
@@ -4,5 +4,10 @@
aps-environment
development
+ com.apple.security.application-groups
+
+ group.eu.mhsl.marianum.mobile.client.widget
+ group.eu.mhsl.marianum.mobile.client.share
+
diff --git a/ios/Share Extension/Info.plist b/ios/Share Extension/Info.plist
new file mode 100644
index 0000000..627a72d
--- /dev/null
+++ b/ios/Share Extension/Info.plist
@@ -0,0 +1,54 @@
+
+
+
+
+ AppGroupId
+ $(CUSTOM_GROUP_ID)
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Marianum Fulda
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ NSExtension
+
+ NSExtensionAttributes
+
+ PHSupportedMediaTypes
+
+ Video
+ Image
+
+ NSExtensionActivationRule
+
+ NSExtensionActivationSupportsText
+
+ NSExtensionActivationSupportsWebURLWithMaxCount
+ 1
+ NSExtensionActivationSupportsImageWithMaxCount
+ 10
+ NSExtensionActivationSupportsMovieWithMaxCount
+ 10
+ NSExtensionActivationSupportsFileWithMaxCount
+ 10
+
+
+ NSExtensionMainStoryboard
+ MainInterface
+ NSExtensionPointIdentifier
+ com.apple.share-services
+
+
+
diff --git a/ios/Share Extension/MainInterface.storyboard b/ios/Share Extension/MainInterface.storyboard
new file mode 100644
index 0000000..1746985
--- /dev/null
+++ b/ios/Share Extension/MainInterface.storyboard
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ios/Share Extension/SETUP.md b/ios/Share Extension/SETUP.md
new file mode 100644
index 0000000..6e9031e
--- /dev/null
+++ b/ios/Share Extension/SETUP.md
@@ -0,0 +1,93 @@
+# iOS Share Extension — Xcode Setup
+
+Die Quellen unter `ios/Share Extension/` müssen einmalig in Xcode als **Share Extension Target** verdrahtet werden — analog zur `TimetableWidgetExtension`. Erst danach taucht „Marianum Fulda" im System-Share-Sheet auf.
+
+## Schritt 1 — Share-Extension-Target anlegen
+
+1. `ios/Runner.xcworkspace` in Xcode öffnen.
+2. Projekt-Sidebar → `Runner` (Projekt-Root) → **+ Add Target** unten links.
+3. **iOS → Share Extension** wählen.
+4. Eigenschaften:
+ - Product Name: `Share Extension` (mit Leerzeichen, exakt so — der Ordnername und Podfile-Eintrag matchen).
+ - Bundle Identifier: `eu.mhsl.marianum.mobile.client.Share-Extension`.
+ - Language: Swift.
+ - Embed in: Runner.
+5. Beim Activate-Scheme-Dialog auf **Cancel** klicken.
+6. Deployment Target = mind. iOS 12.0 (Plugin-Mindestanforderung).
+
+## Schritt 2 — Vorhandene Quelldateien ins Target ziehen
+
+Xcode legt Dummy-Dateien an. Diese **löschen** (Move to Trash). Dann:
+
+1. Sidebar → Rechtsklick auf den Ordner `Share Extension` → **Add Files to "Runner"…**
+2. Im File-Picker zu `ios/Share Extension/` navigieren und folgende Dateien selektieren:
+ - `ShareViewController.swift`
+ - `Info.plist`
+ - `MainInterface.storyboard`
+ - `Share Extension.entitlements`
+3. **Wichtig**: bei „Add to targets" nur `Share Extension` ankreuzen, **nicht** Runner.
+
+## Schritt 3 — App Group aktivieren
+
+Beide Targets brauchen die App-Group-Berechtigung, damit die Extension geteilte Dateien für die Hauptapp im gemeinsamen Container ablegen kann.
+
+1. **Runner**-Target → **Signing & Capabilities** → **+ Capability** → **App Groups**.
+ - Group-ID hinzufügen: `group.eu.mhsl.marianum.mobile.client.share` (zusätzlich zur bereits existierenden Widget-Group).
+2. Dasselbe für **Share Extension**-Target — mit derselben Group-ID `group.eu.mhsl.marianum.mobile.client.share`.
+
+Im Apple-Developer-Portal muss diese App-Group bei beiden App-IDs eingetragen sein, sonst schlägt das Provisioning fehl.
+
+## Schritt 4 — User-Defined Build Setting `CUSTOM_GROUP_ID`
+
+Beide Targets brauchen das User-Defined Setting, das in `Runner/Info.plist` und `Share Extension/Info.plist` als `$(CUSTOM_GROUP_ID)` referenziert wird.
+
+1. **Runner** → Build Settings → `+` (oben links) → **Add User-Defined Setting**.
+ - Name: `CUSTOM_GROUP_ID`
+ - Wert: `group.eu.mhsl.marianum.mobile.client.share`
+2. Dasselbe für **Share Extension**-Target.
+
+## Schritt 5 — Entitlements verlinken
+
+1. **Runner** → Build Settings → `CODE_SIGN_ENTITLEMENTS` zeigt bereits auf `Runner/Runner.entitlements` (jetzt mit beiden Groups).
+2. **Share Extension** → Build Settings → `CODE_SIGN_ENTITLEMENTS` → auf `Share Extension/Share Extension.entitlements` setzen.
+
+## Schritt 6 — Info.plist-Pfad
+
+**Share Extension** → Build Settings → `INFOPLIST_FILE` → auf `Share Extension/Info.plist` setzen.
+
+## Schritt 7 — Build Phases reorder
+
+Damit das Plugin-Modul vom Extension-Target gefunden wird:
+
+1. **Runner**-Target → **Build Phases**.
+2. `Embed Foundation Extensions` per Drag-and-Drop **vor** `Thin Binary` ziehen.
+
+## Schritt 8 — Pods installieren
+
+```bash
+cd ios && pod install
+```
+
+Der Podfile-Eintrag (`target 'Share Extension' do inherit! :search_paths end`) ist bereits vorhanden.
+
+## Schritt 9 — Build & Run
+
+1. Scheme `Runner` wählen → Run auf Device oder Simulator (≥ iOS 12).
+2. Foto in der Fotos-App auswählen → Teilen → „Marianum Fulda" sollte erscheinen.
+3. Auswahl → App öffnet sich, ShareTargetPage erscheint.
+
+## Troubleshooting
+
+- **Error: No such module 'receive_sharing_intent'**
+ → Schritt 7 (Build Phases reorder) wurde übersprungen.
+- **Error: ‚Frameworks' not allowed in extension**
+ → In Build Settings der Share Extension `Other Linker Flags` und `Framework Search Paths` leeren (nur die geerbten Pod-Pfade behalten).
+- **Share-Sheet zeigt App nicht an**
+ → `NSExtensionActivationRule`-Limits in `Share Extension/Info.plist` zu klein? Werte testweise erhöhen. Außerdem: App muss **mindestens einmal nach Install** geöffnet worden sein, sonst wird die Extension von iOS nicht registriert.
+- **Files kommen mit `nil` Pfad an**
+ → App-Group nicht konsistent. Prüfen, dass `CUSTOM_GROUP_ID` in beiden Targets identisch ist und die Entitlement-Files dieselbe Group enthalten.
+
+## Was am Mac noch zu tun ist
+
+- Schritte 1–8 oben (~15 Min).
+- Auf physischem iPhone testen — Simulator-Share-Sheet ist eingeschränkt.
diff --git a/ios/Share Extension/Share Extension.entitlements b/ios/Share Extension/Share Extension.entitlements
new file mode 100644
index 0000000..80e2a27
--- /dev/null
+++ b/ios/Share Extension/Share Extension.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.eu.mhsl.marianum.mobile.client.share
+
+
+
diff --git a/ios/Share Extension/ShareViewController.swift b/ios/Share Extension/ShareViewController.swift
new file mode 100644
index 0000000..74b3416
--- /dev/null
+++ b/ios/Share Extension/ShareViewController.swift
@@ -0,0 +1,8 @@
+import UIKit
+import receive_sharing_intent
+
+class ShareViewController: RSIShareViewController {
+ override func shouldAutoRedirect() -> Bool {
+ return true
+ }
+}
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/api/marianumcloud/talk/share_files_to_chat.dart b/lib/api/marianumcloud/talk/share_files_to_chat.dart
new file mode 100644
index 0000000..9409e39
--- /dev/null
+++ b/lib/api/marianumcloud/talk/share_files_to_chat.dart
@@ -0,0 +1,21 @@
+import '../files_sharing/file_sharing_api.dart';
+import '../files_sharing/file_sharing_api_params.dart';
+
+/// WebDAV folder under which Talk-shared files are uploaded before being
+/// linked into a chat.
+const String talkShareFolder = 'MarianumMobile';
+
+/// Posts each already-uploaded WebDAV path as a Talk share (ShareType 10) to
+/// the given conversation token. Calls run concurrently — the server accepts
+/// parallel posts and the picker UI is blocked anyway, so we shouldn't pay
+/// O(n*RTT) latency per share.
+Future shareFilesToChat({
+ required String token,
+ required List remoteFilePaths,
+}) => Future.wait(
+ remoteFilePaths.map(
+ (path) => FileSharingApi().share(
+ FileSharingApiParams(shareType: 10, shareWith: token, path: path),
+ ),
+ ),
+);
diff --git a/lib/app.dart b/lib/app.dart
index 6ca7924..7a9b2f6 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -13,15 +13,20 @@ 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 'share_intent/share_intent_listener.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 +38,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 +58,38 @@ 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;
+ // Routes pushed with `withNavBar: false` (chat views, file viewers, …)
+ // sit on the root navigator above the bottom-nav, so a bare jumpToTab
+ // would swap the tab behind them and leave the user staring at the
+ // previous screen. Reset to the tab root first.
+ final navigator = Navigator.of(context);
+ if (navigator.canPop()) {
+ navigator.popUntil((route) => route.isFirst);
+ }
+ AppRoutes.goToTab(context, Modules.timetable);
+ }
+
+ void _handlePendingShare() {
+ if (!mounted) return;
+ final share = ShareIntentListener.pending.value;
+ if (share == null) return;
+ // A second share arriving while a previous share-flow page is still on
+ // the stack would otherwise leave the old page sitting on top with stale
+ // (already-cleared) file paths. Reset to the tab root before pushing.
+ final navigator = Navigator.of(context);
+ if (navigator.canPop()) {
+ navigator.popUntil((route) => route.isFirst);
+ }
+ AppRoutes.openShareTarget(context, share);
+ }
+
@override
void initState() {
super.initState();
@@ -69,7 +104,40 @@ 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());
+ ShareIntentListener.instance.attach();
+ ShareIntentListener.pending.addListener(_handlePendingShare);
+ _handlePendingShare();
});
_updateTimings = Timer.periodic(const Duration(seconds: 30), (_) {
@@ -115,6 +183,9 @@ class _AppState extends State with WidgetsBindingObserver {
void dispose() {
_refetchChats.cancel();
_updateTimings.cancel();
+ _timetableWidgetSync?.cancel();
+ ShareIntentListener.pending.removeListener(_handlePendingShare);
+ ShareIntentListener.instance.detach();
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..ad7199e 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -19,8 +19,10 @@ 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 'share_intent/share_intent_listener.dart';
import 'state/app/modules/account/bloc/account_bloc.dart';
import 'state/app/modules/account/bloc/account_state.dart';
import 'state/app/modules/breaker/bloc/breaker_bloc.dart';
@@ -35,6 +37,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');
@@ -66,12 +69,22 @@ Future main() async {
HydratedBloc.storage = storage;
}),
AccountData().waitForPopulation(),
+ ShareIntentListener.instance.initialize(),
];
log('starting app initialisation...');
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!"}'),
@@ -198,6 +211,10 @@ class _MainState extends State {
previous.status != current.status,
listener: (context, accountState) {
if (accountState.status != AccountStatus.loggedOut) return;
+ // A pending share would otherwise survive logout and be
+ // re-applied after re-login with file paths the OS may
+ // already have evicted from the cache.
+ ShareIntentListener.instance.clear();
// Routes pushed via AppRoutes (e.g. Settings) live on the
// root navigator and survive the home swap below, so they
// would still cover the Login screen after logout. Pop
@@ -287,6 +304,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/routing/app_routes.dart b/lib/routing/app_routes.dart
index 6f79929..804d94e 100644
--- a/lib/routing/app_routes.dart
+++ b/lib/routing/app_routes.dart
@@ -6,6 +6,8 @@ import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import '../api/marianumcloud/talk/room/get_room_response.dart';
import '../main.dart';
import '../model/account_data.dart';
+import '../share_intent/pending_share.dart';
+import '../share_intent/remote_file_ref.dart';
import '../state/app/modules/app_modules.dart';
import '../state/app/modules/chat/bloc/chat_bloc.dart';
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
@@ -17,6 +19,9 @@ import '../view/pages/more/roomplan/roomplan.dart';
import '../view/pages/more/share/qr_share_view.dart';
import '../view/pages/settings/modules_settings_page.dart';
import '../view/pages/settings/settings.dart';
+import '../view/pages/share_intent/share_chat_picker.dart';
+import '../view/pages/share_intent/share_folder_picker.dart';
+import '../view/pages/share_intent/share_target_page.dart';
import '../view/pages/talk/chat_view.dart';
import '../view/pages/talk/details/message_reactions.dart';
import '../view/pages/talk/talk_navigator.dart';
@@ -42,11 +47,16 @@ class AppRoutes {
BuildContext context,
String localPath, {
bool openExternal = false,
+ RemoteFileRef? remoteFile,
}) {
pushScreen(
context,
withNavBar: false,
- screen: FileViewer(path: localPath, openExternal: openExternal),
+ screen: FileViewer(
+ path: localPath,
+ openExternal: openExternal,
+ remoteFile: remoteFile,
+ ),
);
}
@@ -90,6 +100,64 @@ class AppRoutes {
pushScreen(context, withNavBar: false, screen: const Roomplan());
}
+ static void openShareTarget(BuildContext context, PendingShare share) {
+ pushScreen(
+ context,
+ withNavBar: false,
+ screen: ShareTargetPage(share: share),
+ );
+ }
+
+ static void openShareChatPicker(BuildContext context, PendingShare share) {
+ pushScreen(
+ context,
+ withNavBar: false,
+ screen: ShareChatPicker.forExternalShare(share: share),
+ );
+ }
+
+ static void openShareFolderPicker(BuildContext context, PendingShare share) {
+ pushScreen(
+ context,
+ withNavBar: false,
+ screen: ShareFolderPicker.forExternalShare(share: share),
+ );
+ }
+
+ static void openInternalShareToChat(
+ BuildContext context,
+ RemoteFileRef file,
+ ) {
+ pushScreen(
+ context,
+ withNavBar: false,
+ screen: ShareChatPicker.forInternalShare(file: file),
+ );
+ }
+
+ static void openForwardMessageToChat(
+ BuildContext context, {
+ String? text,
+ RemoteFileRef? file,
+ }) {
+ pushScreen(
+ context,
+ withNavBar: false,
+ screen: ShareChatPicker.forMessageForward(text: text, file: file),
+ );
+ }
+
+ static void openInternalSaveToFolder(
+ BuildContext context,
+ RemoteFileRef file,
+ ) {
+ pushScreen(
+ context,
+ withNavBar: false,
+ screen: ShareFolderPicker.forInternalSave(file: file),
+ );
+ }
+
static void openMessageReactions(
BuildContext context,
String token,
diff --git a/lib/share_intent/internal_share_actions.dart b/lib/share_intent/internal_share_actions.dart
new file mode 100644
index 0000000..f9a5a47
--- /dev/null
+++ b/lib/share_intent/internal_share_actions.dart
@@ -0,0 +1,45 @@
+import 'package:flutter/material.dart';
+import 'package:nextcloud/nextcloud.dart';
+
+import '../api/marianumcloud/webdav/webdav_api.dart';
+import '../widget/confirm_dialog.dart';
+import 'remote_file_ref.dart';
+
+/// Server-side WebDAV copy of [source] into [targetFolderPath]. On a 412
+/// conflict the user is asked whether to overwrite; on confirmation the call
+/// is retried with `overwrite: true`. Returns true when the file ended up at
+/// the target, false when the user cancelled.
+Future copyRemoteFileTo({
+ required BuildContext context,
+ required RemoteFileRef source,
+ required String targetFolderPath,
+}) async {
+ final webdav = await WebdavApi.webdav;
+ final dst = targetFolderPath.isEmpty
+ ? source.name
+ : '${targetFolderPath.replaceAll(RegExp(r'/+$'), '')}/${source.name}';
+ final src = PathUri.parse(source.path);
+ final dstUri = PathUri.parse(dst);
+
+ try {
+ await webdav.copy(src, dstUri);
+ return true;
+ } on DynamiteApiException catch (e) {
+ if (e.statusCode != 412) rethrow;
+ if (!context.mounted) return false;
+ final overwrite = await showDialog(
+ context: context,
+ builder: (ctx) => ConfirmDialog(
+ title: 'Datei existiert bereits',
+ content:
+ '"${source.name}" existiert in /$targetFolderPath. Überschreiben?',
+ confirmButton: 'Überschreiben',
+ cancelButton: 'Abbrechen',
+ onConfirm: () => Navigator.of(ctx).pop(true),
+ ),
+ );
+ if (overwrite != true) return false;
+ await webdav.copy(src, dstUri, overwrite: true);
+ return true;
+ }
+}
diff --git a/lib/share_intent/pending_share.dart b/lib/share_intent/pending_share.dart
new file mode 100644
index 0000000..226b078
--- /dev/null
+++ b/lib/share_intent/pending_share.dart
@@ -0,0 +1,15 @@
+class PendingShare {
+ final List filePaths;
+ final String? text;
+ final DateTime receivedAt;
+
+ const PendingShare({
+ required this.filePaths,
+ required this.text,
+ required this.receivedAt,
+ });
+
+ bool get hasFiles => filePaths.isNotEmpty;
+ bool get hasText => text != null && text!.isNotEmpty;
+ bool get isEmpty => !hasFiles && !hasText;
+}
diff --git a/lib/share_intent/remote_file_ref.dart b/lib/share_intent/remote_file_ref.dart
new file mode 100644
index 0000000..909c410
--- /dev/null
+++ b/lib/share_intent/remote_file_ref.dart
@@ -0,0 +1,20 @@
+import '../api/marianumcloud/talk/chat/get_chat_response.dart';
+import '../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
+
+/// References a file that already lives on the Nextcloud server. Used by the
+/// in-app share/save flows that operate on remote paths instead of local
+/// cache files (no upload needed).
+class RemoteFileRef {
+ final String path;
+ final String name;
+
+ const RemoteFileRef({required this.path, required this.name});
+
+ /// Caller must verify `file.path != null` first — Talk message parameters
+ /// without a path (system events, mentions, polls) are not file refs.
+ factory RemoteFileRef.fromTalk(RichObjectString file) =>
+ RemoteFileRef(path: file.path!, name: file.name);
+
+ factory RemoteFileRef.fromCacheable(CacheableFile file) =>
+ RemoteFileRef(path: file.path, name: file.name);
+}
diff --git a/lib/share_intent/share_intent_listener.dart b/lib/share_intent/share_intent_listener.dart
new file mode 100644
index 0000000..855ec18
--- /dev/null
+++ b/lib/share_intent/share_intent_listener.dart
@@ -0,0 +1,94 @@
+import 'dart:async';
+import 'dart:io';
+
+import 'package:flutter/foundation.dart';
+import 'package:receive_sharing_intent/receive_sharing_intent.dart';
+
+import 'pending_share.dart';
+
+/// Bridges native share intents (Android ACTION_SEND, iOS Share Extension)
+/// into a single [ValueNotifier] that the app routes off of.
+class ShareIntentListener {
+ ShareIntentListener._();
+ static final ShareIntentListener instance = ShareIntentListener._();
+
+ static final ValueNotifier pending = ValueNotifier(null);
+
+ StreamSubscription>? _streamSub;
+ bool _initialized = false;
+
+ /// Reads the cold-start payload exactly once. Call from `main()` before
+ /// `runApp` so the share is queued before the UI mounts.
+ Future initialize() async {
+ if (_initialized) return;
+ _initialized = true;
+ try {
+ final initial = await ReceiveSharingIntent.instance.getInitialMedia();
+ final share = _toPendingShare(initial);
+ if (share != null) pending.value = share;
+ await ReceiveSharingIntent.instance.reset();
+ } catch (e) {
+ debugPrint('ShareIntentListener.initialize failed: $e');
+ }
+ }
+
+ /// Subscribes to warm-share stream events. Safe to call multiple times.
+ void attach() {
+ _streamSub ??= ReceiveSharingIntent.instance.getMediaStream().listen(
+ (items) {
+ final share = _toPendingShare(items);
+ if (share != null) pending.value = share;
+ },
+ onError: (Object e) =>
+ debugPrint('ShareIntentListener stream error: $e'),
+ );
+ }
+
+ /// Cancels the warm-share subscription. The singleton survives, so a
+ /// subsequent [attach] re-subscribes.
+ void detach() {
+ _streamSub?.cancel();
+ _streamSub = null;
+ }
+
+ /// Discards the current share and removes any temp files the plugin copied
+ /// into the app cache. Idempotent.
+ void clear() {
+ final current = pending.value;
+ pending.value = null;
+ if (current != null) {
+ for (final path in current.filePaths) {
+ try {
+ final f = File(path);
+ if (f.existsSync()) f.deleteSync();
+ } catch (_) {
+ // best-effort cleanup; OS will reclaim cache eventually
+ }
+ }
+ }
+ unawaited(ReceiveSharingIntent.instance.reset());
+ }
+
+ PendingShare? _toPendingShare(List items) {
+ if (items.isEmpty) return null;
+ final files = [];
+ final texts = [];
+ for (final item in items) {
+ switch (item.type) {
+ case SharedMediaType.image:
+ case SharedMediaType.video:
+ case SharedMediaType.file:
+ files.add(item.path);
+ case SharedMediaType.text:
+ case SharedMediaType.url:
+ texts.add(item.path);
+ }
+ }
+ if (files.isEmpty && texts.isEmpty) return null;
+ return PendingShare(
+ filePaths: files,
+ text: texts.isEmpty ? null : texts.join('\n'),
+ receivedAt: DateTime.now(),
+ );
+ }
+}
diff --git a/lib/state/app/modules/files/bloc/files_bloc.dart b/lib/state/app/modules/files/bloc/files_bloc.dart
index 27e5f5e..e8753c9 100644
--- a/lib/state/app/modules/files/bloc/files_bloc.dart
+++ b/lib/state/app/modules/files/bloc/files_bloc.dart
@@ -1,3 +1,5 @@
+import 'package:collection/collection.dart';
+
import '../../../../../api/errors/error_mapper.dart';
import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
import '../../../infrastructure/loadable_state/loading_error.dart';
@@ -53,12 +55,24 @@ class FilesBloc
Future _query(List path) async {
final pathString = path.isEmpty ? '/' : path.join('/');
+ // Drop late results when [setPath] has navigated elsewhere or when the
+ // bloc has been disposed (e.g. share-flow picker closed mid-fetch). Both
+ // would otherwise corrupt state or hit "add after close" on the stream.
+ const pathEquality = ListEquality();
+ bool isStale() {
+ if (isClosed) return true;
+ final inner = innerState;
+ if (inner == null) return false;
+ return !pathEquality.equals(inner.currentPath, path);
+ }
+
Object? capturedError;
ListFilesResponse? listing;
try {
listing = await repo.data.listFiles(
pathString,
onCacheData: (cached) {
+ if (isStale()) return;
// Cached payload arrives before the network call settles. Surface it
// immediately via Emit so the listing is visible while isLoading
// stays true and the top loading bar keeps spinning.
@@ -73,6 +87,8 @@ class FilesBloc
capturedError = e;
}
+ if (isStale()) return;
+
if (listing != null) {
listing.files.removeWhere(
(file) => file.name.isEmpty || file.name == path.lastOrNull,
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/view/login/widgets/login_branding.dart b/lib/view/login/widgets/login_branding.dart
index 04649c5..b3aad88 100644
--- a/lib/view/login/widgets/login_branding.dart
+++ b/lib/view/login/widgets/login_branding.dart
@@ -45,7 +45,7 @@ class LoginDisclaimer extends StatelessWidget {
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
- 'Inoffizieller Nextcloud & Webuntis Client. Wird nicht vom Marianum betrieben. Keine Gewähr für Vollständigkeit, Richtigkeit und Aktualität.',
+ 'Inoffizieller Marianum-Cloud & Webuntis Client. Wird nicht vom Marianum betrieben. Keine Gewähr für Vollständigkeit, Richtigkeit und Aktualität.',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.75),
diff --git a/lib/view/pages/files/widgets/add_file_menu.dart b/lib/view/pages/files/widgets/add_file_menu.dart
index 508fe7d..9f8565d 100644
--- a/lib/view/pages/files/widgets/add_file_menu.dart
+++ b/lib/view/pages/files/widgets/add_file_menu.dart
@@ -21,7 +21,7 @@ void showAddFileSheet(
title: const Text('Ordner erstellen'),
onTap: () {
Navigator.of(sheetCtx).pop();
- _showCreateFolderDialog(context, bloc);
+ showCreateFolderDialog(context, bloc);
},
),
ListTile(
@@ -56,7 +56,7 @@ void showAddFileSheet(
);
}
-void _showCreateFolderDialog(BuildContext context, FilesBloc bloc) {
+void showCreateFolderDialog(BuildContext context, FilesBloc bloc) {
final inputController = TextEditingController();
showDialog(
context: context,
diff --git a/lib/view/pages/files/widgets/file_element.dart b/lib/view/pages/files/widgets/file_element.dart
index 0e64864..c573ae8 100644
--- a/lib/view/pages/files/widgets/file_element.dart
+++ b/lib/view/pages/files/widgets/file_element.dart
@@ -7,6 +7,7 @@ import '../../../../api/marianumcloud/webdav/webdav_api.dart';
import '../../../../extensions/date_time.dart';
import '../../../../model/endpoint_data.dart';
import '../../../../routing/app_routes.dart';
+import '../../../../share_intent/remote_file_ref.dart';
import '../../../../utils/download_manager.dart';
import '../../../../utils/file_clipboard.dart';
import '../../../../widget/centered_leading.dart';
@@ -71,7 +72,11 @@ class _FileElementState extends State {
if (status is DownloadDone) {
DownloadManager.instance.clear(widget.file.path);
_detachJob();
- AppRoutes.openFileViewer(context, status.localPath);
+ AppRoutes.openFileViewer(
+ context,
+ status.localPath,
+ remoteFile: RemoteFileRef.fromCacheable(widget.file),
+ );
setState(() {});
} else if (status is DownloadFailed) {
final message = status.message;
@@ -299,6 +304,18 @@ class _FileElementState extends State {
_putOnClipboard(copy: true);
},
),
+ if (!widget.file.isDirectory)
+ ListTile(
+ leading: const CenteredLeading(Icon(Icons.chat_bubble_outline)),
+ title: const Text('Im Talk-Chat teilen'),
+ onTap: () {
+ Navigator.of(sheetCtx).pop();
+ AppRoutes.openInternalShareToChat(
+ context,
+ RemoteFileRef.fromCacheable(widget.file),
+ );
+ },
+ ),
ListTile(
leading: const CenteredLeading(Icon(Icons.delete_outline)),
title: const Text('Löschen'),
diff --git a/lib/view/pages/settings/sections/about_section.dart b/lib/view/pages/settings/sections/about_section.dart
index eeda08b..b2fa812 100644
--- a/lib/view/pages/settings/sections/about_section.dart
+++ b/lib/view/pages/settings/sections/about_section.dart
@@ -69,7 +69,7 @@ class AboutSection extends StatelessWidget {
applicationVersion:
'${appInfo.appName}\n\nPackage: ${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild: ${appInfo.buildNumber}',
applicationLegalese:
- 'Dies ist ein Inoffizieller Nextcloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\n'
+ 'Dies ist ein Inoffizieller Marianum-Cloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\n'
'Keinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!\n\n'
"${kReleaseMode ? "Production" : "Development"} build\n"
'Marianum Fulda 2023-${Jiffy.now().year}\nElias Müller',
@@ -82,7 +82,7 @@ class AboutSection extends StatelessWidget {
ListTile(
leading: const CenteredLeading(Icon(Icons.school_outlined)),
title: const Text('Infos zum Marianum Fulda'),
- subtitle: const Text('Für Talk-Chats und Dateien'),
+ subtitle: const Text('Für Talk-Chats und Cloud-Dateien'),
trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo(
providerText: 'Marianum',
diff --git a/lib/view/pages/settings/sections/talk_section.dart b/lib/view/pages/settings/sections/talk_section.dart
index 575f4cc..2aae4fe 100644
--- a/lib/view/pages/settings/sections/talk_section.dart
+++ b/lib/view/pages/settings/sections/talk_section.dart
@@ -60,7 +60,7 @@ class TalkSection extends StatelessWidget {
context,
"Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n"
'Wenn Push aktiviert wird, werden deine Zugangsdaten und ein Token verschlüsselt an den Betreiber gesendet und von ihm unverschlüsselt gespeichert.\n\n'
- 'Der extene Server verwendet die Zugangsdaten um sich maschinell in Nextcloud Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n'
+ 'Der extene Server verwendet die Zugangsdaten um sich maschinell in Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n'
'Wenn eine neue Nachricht eintrifft wird dein Telefon via FBC-Messaging (Google Firebase Push) vom externen Server benachrichtigt.\n\n'
'Behalte im Hinterkopf, dass deine Zugangsdaten auf einem externen Server gespeichert werden und dies trotz bester Absichten ein Sicherheitsrisiko sein kann!',
title: 'Info über Push',
diff --git a/lib/view/pages/share_intent/share_chat_picker.dart b/lib/view/pages/share_intent/share_chat_picker.dart
new file mode 100644
index 0000000..d61e620
--- /dev/null
+++ b/lib/view/pages/share_intent/share_chat_picker.dart
@@ -0,0 +1,281 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:nextcloud/nextcloud.dart';
+import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
+
+import '../../../api/errors/error_mapper.dart';
+import '../../../api/marianumcloud/talk/room/get_room_response.dart';
+import '../../../api/marianumcloud/talk/send_message/send_message.dart';
+import '../../../api/marianumcloud/talk/send_message/send_message_params.dart';
+import '../../../api/marianumcloud/talk/share_files_to_chat.dart';
+import '../../../api/marianumcloud/webdav/webdav_api.dart';
+import '../../../routing/app_routes.dart';
+import '../../../share_intent/pending_share.dart';
+import '../../../share_intent/remote_file_ref.dart';
+import '../../../share_intent/share_intent_listener.dart';
+import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
+import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
+import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart';
+import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
+import '../../../widget/info_dialog.dart';
+import '../../../widget/placeholder_view.dart';
+import '../files/files_upload_dialog.dart';
+import '../talk/search_chat.dart';
+import '../talk/widgets/chat_tile.dart';
+
+typedef _ChatPickedCallback =
+ Future Function(BuildContext context, GetRoomResponseObject room);
+
+class ShareChatPicker extends StatelessWidget {
+ final _ChatPickedCallback _onPicked;
+
+ const ShareChatPicker._({required _ChatPickedCallback onPicked})
+ : _onPicked = onPicked;
+
+ /// External share-intent flow: uploads local files into the Talk share
+ /// folder, then shares them in the chosen chat. Falls back to a draft-only
+ /// flow when the pending share contains no files.
+ factory ShareChatPicker.forExternalShare({required PendingShare share}) =>
+ ShareChatPicker._(
+ onPicked: (ctx, room) => _externalShareFlow(ctx, room, share),
+ );
+
+ /// In-app share flow: links an already-uploaded server file into the chosen
+ /// chat via FileSharingApi (no upload needed).
+ factory ShareChatPicker.forInternalShare({required RemoteFileRef file}) =>
+ ShareChatPicker._(
+ onPicked: (ctx, room) => _internalShareFlow(ctx, room, file),
+ );
+
+ /// Forward an existing Talk message (text and/or already-uploaded file
+ /// attachment) into another chat. The attachment is re-shared via the same
+ /// FileSharingApi path used for [forInternalShare]; plain text is posted
+ /// with [SendMessage].
+ factory ShareChatPicker.forMessageForward({
+ String? text,
+ RemoteFileRef? file,
+ }) {
+ assert(
+ text != null || file != null,
+ 'forMessageForward requires either text or file',
+ );
+ return ShareChatPicker._(
+ onPicked: (ctx, room) => _forwardMessageFlow(ctx, room, text, file),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final talkSettings = context.watch().val().talkSettings;
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Talk-Chat auswählen'),
+ actions: [
+ Builder(
+ builder: (ctx) => IconButton(
+ icon: const Icon(Icons.search),
+ onPressed: () {
+ final rooms = ctx.read().state.data?.rooms;
+ if (rooms == null) return;
+ showSearch(
+ context: ctx,
+ delegate: SearchChat(
+ rooms.data.where((r) => r.readOnly == 0).toList(),
+ onTapOverride: (room) {
+ Navigator.of(ctx).pop();
+ _onPicked(ctx, room);
+ },
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ body: LoadableStateConsumer(
+ child: (state, _) {
+ final rooms = state.rooms;
+ if (rooms == null) return const SizedBox.shrink();
+ final sorted = rooms
+ .sortBy(
+ lastActivity: true,
+ favoritesToTop: talkSettings.sortFavoritesToTop,
+ unreadToTop: talkSettings.sortUnreadToTop,
+ )
+ // Hide chats the user can't write to (announcement channels,
+ // archived rooms, …) — uploading there would only fail at the
+ // share-API call with 403.
+ .where((r) => r.readOnly == 0)
+ .toList();
+ if (sorted.isEmpty) {
+ return const PlaceholderView(
+ icon: Icons.chat_bubble_outline,
+ text: 'Keine schreibbaren Chats verfügbar',
+ );
+ }
+ return ListView.builder(
+ padding: EdgeInsets.zero,
+ itemCount: sorted.length,
+ itemBuilder: (context, i) => ChatTile(
+ data: sorted[i],
+ disableContextActions: true,
+ onTapOverride: (room) => _onPicked(context, room),
+ ),
+ );
+ },
+ ),
+ );
+ }
+}
+
+Future _externalShareFlow(
+ BuildContext context,
+ GetRoomResponseObject room,
+ PendingShare share,
+) async {
+ if (share.hasFiles) {
+ try {
+ final webdav = await WebdavApi.webdav;
+ await webdav.mkcol(PathUri.parse('/$talkShareFolder'));
+ } catch (_) {
+ // mkcol throws when the folder already exists; ignore.
+ }
+ if (!context.mounted) return;
+ await pushScreen(
+ context,
+ withNavBar: false,
+ screen: FilesUploadDialog(
+ filePaths: share.filePaths,
+ remotePath: talkShareFolder,
+ uniqueNames: true,
+ onUploadFinished: (uploaded) =>
+ _afterExternalFilesUploaded(context, room, uploaded, share),
+ ),
+ );
+ return;
+ }
+ if (share.hasText) {
+ _setExternalDraftAndOpenChat(context, room, share);
+ }
+}
+
+Future _afterExternalFilesUploaded(
+ BuildContext context,
+ GetRoomResponseObject room,
+ List uploadedRemotePaths,
+ PendingShare share,
+) async {
+ unawaited(_showBlockingSpinner(context));
+ try {
+ await shareFilesToChat(
+ token: room.token,
+ remoteFilePaths: uploadedRemotePaths,
+ );
+ } catch (e) {
+ if (context.mounted) Navigator.of(context).pop();
+ if (context.mounted) {
+ InfoDialog.show(
+ context,
+ errorToUserMessage(e),
+ title: 'Fehler',
+ copyable: true,
+ );
+ }
+ return;
+ }
+ if (!context.mounted) return;
+ _setExternalDraftAndOpenChat(context, room, share);
+}
+
+void _setExternalDraftAndOpenChat(
+ BuildContext context,
+ GetRoomResponseObject room,
+ PendingShare share,
+) {
+ if (share.hasText) {
+ final settings = context.read();
+ settings.val(write: true).talkSettings.drafts[room.token] = share.text!;
+ }
+ ShareIntentListener.instance.clear();
+ _finishWithChat(context, room);
+}
+
+/// Closes any picker/spinner pages stacked on top of the current tab and
+/// jumps to the chosen chat. Shared by external + internal share flows.
+void _finishWithChat(BuildContext context, GetRoomResponseObject room) {
+ Navigator.of(context).popUntil((route) => route.isFirst);
+ AppRoutes.openChatByToken(context, room.token);
+}
+
+Future _internalShareFlow(
+ BuildContext context,
+ GetRoomResponseObject room,
+ RemoteFileRef file,
+) async {
+ unawaited(_showBlockingSpinner(context));
+ try {
+ await shareFilesToChat(
+ token: room.token,
+ remoteFilePaths: [file.path],
+ );
+ } catch (e) {
+ if (context.mounted) Navigator.of(context).pop();
+ if (context.mounted) {
+ InfoDialog.show(
+ context,
+ errorToUserMessage(e),
+ title: 'Fehler',
+ copyable: true,
+ );
+ }
+ return;
+ }
+ if (!context.mounted) return;
+ _finishWithChat(context, room);
+}
+
+Future _forwardMessageFlow(
+ BuildContext context,
+ GetRoomResponseObject room,
+ String? text,
+ RemoteFileRef? file,
+) async {
+ unawaited(_showBlockingSpinner(context));
+ try {
+ if (file != null) {
+ await shareFilesToChat(
+ token: room.token,
+ remoteFilePaths: [file.path],
+ );
+ }
+ if (text != null && text.isNotEmpty) {
+ await SendMessage(room.token, SendMessageParams(text)).run();
+ }
+ } catch (e) {
+ if (context.mounted) Navigator.of(context).pop();
+ if (context.mounted) {
+ InfoDialog.show(
+ context,
+ errorToUserMessage(e),
+ title: 'Fehler',
+ copyable: true,
+ );
+ }
+ return;
+ }
+ if (!context.mounted) return;
+ _finishWithChat(context, room);
+}
+
+/// Modal progress overlay shown during share-API roundtrips. The dialog is
+/// popped together with the picker by the subsequent popUntil(isFirst).
+Future _showBlockingSpinner(BuildContext context) => showDialog(
+ context: context,
+ barrierDismissible: false,
+ builder: (_) => const PopScope(
+ canPop: false,
+ child: Center(child: CircularProgressIndicator()),
+ ),
+);
diff --git a/lib/view/pages/share_intent/share_folder_picker.dart b/lib/view/pages/share_intent/share_folder_picker.dart
new file mode 100644
index 0000000..914f156
--- /dev/null
+++ b/lib/view/pages/share_intent/share_folder_picker.dart
@@ -0,0 +1,256 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_bloc/flutter_bloc.dart';
+import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
+
+import '../../../api/errors/error_mapper.dart';
+import '../../../routing/app_routes.dart';
+import '../../../share_intent/internal_share_actions.dart';
+import '../../../share_intent/pending_share.dart';
+import '../../../share_intent/remote_file_ref.dart';
+import '../../../share_intent/share_intent_listener.dart';
+import '../../../state/app/infrastructure/loadable_state/loadable_state.dart';
+import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
+import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
+import '../../../state/app/modules/files/bloc/files_bloc.dart';
+import '../../../state/app/modules/files/bloc/files_state.dart';
+import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
+import '../../../widget/info_dialog.dart';
+import '../../../widget/placeholder_view.dart';
+import '../files/data/sort_options.dart';
+import '../files/files_upload_dialog.dart';
+import '../files/widgets/add_file_menu.dart';
+import '../files/widgets/files_sort_actions.dart';
+
+typedef _FolderConfirmedCallback =
+ Future Function(BuildContext context, List targetPath);
+
+class ShareFolderPicker extends StatelessWidget {
+ final String _fabLabel;
+ final _FolderConfirmedCallback _onConfirm;
+
+ const ShareFolderPicker._({
+ required String fabLabel,
+ required _FolderConfirmedCallback onConfirm,
+ }) : _fabLabel = fabLabel,
+ _onConfirm = onConfirm;
+
+ /// External share-intent flow: upload local files into the chosen folder.
+ factory ShareFolderPicker.forExternalShare({required PendingShare share}) =>
+ ShareFolderPicker._(
+ fabLabel: 'Hier hochladen',
+ onConfirm: (ctx, target) => _externalUploadFlow(ctx, target, share),
+ );
+
+ /// In-app save flow: server-to-server WebDAV-copy of an existing file into
+ /// the chosen folder.
+ factory ShareFolderPicker.forInternalSave({required RemoteFileRef file}) =>
+ ShareFolderPicker._(
+ fabLabel: 'Hierhin kopieren',
+ onConfirm: (ctx, target) => _internalCopyFlow(ctx, target, file),
+ );
+
+ @override
+ Widget build(BuildContext context) =>
+ BlocModule>(
+ create: (_) => FilesBloc(),
+ child: (context, _, _) =>
+ _ShareFolderPickerView(fabLabel: _fabLabel, onConfirm: _onConfirm),
+ );
+}
+
+class _ShareFolderPickerView extends StatefulWidget {
+ final String fabLabel;
+ final _FolderConfirmedCallback onConfirm;
+ const _ShareFolderPickerView({
+ required this.fabLabel,
+ required this.onConfirm,
+ });
+
+ @override
+ State<_ShareFolderPickerView> createState() => _ShareFolderPickerViewState();
+}
+
+class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> {
+ late final SettingsCubit _settings;
+ late SortOption _currentSort;
+ late bool _ascending;
+
+ @override
+ void initState() {
+ super.initState();
+ _settings = context.read();
+ _currentSort = _settings.val().fileSettings.sortBy;
+ _ascending = _settings.val().fileSettings.ascending;
+ }
+
+ void _enter(FilesBloc bloc, List currentPath, String folderName) {
+ bloc.setPath([...currentPath, folderName]);
+ }
+
+ void _goUp(FilesBloc bloc, List currentPath) {
+ if (currentPath.isEmpty) return;
+ bloc.setPath(currentPath.sublist(0, currentPath.length - 1));
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final bloc = context.read();
+ return BlocBuilder>(
+ buildWhen: (a, b) => a.data?.currentPath != b.data?.currentPath,
+ builder: (_, outerState) {
+ final currentPath = outerState.data?.currentPath ?? const [];
+ return PopScope(
+ // Back navigates one level up while inside a sub-folder; only the
+ // root level actually closes the picker. Matches the standard
+ // files-app pattern and keeps the AppBar back-arrow consistent
+ // with the chat picker.
+ canPop: currentPath.isEmpty,
+ onPopInvokedWithResult: (didPop, _) {
+ if (didPop) return;
+ if (currentPath.isNotEmpty) _goUp(bloc, currentPath);
+ },
+ child: _buildScaffold(context, bloc, currentPath),
+ );
+ },
+ );
+ }
+
+ Widget _buildScaffold(
+ BuildContext context,
+ FilesBloc bloc,
+ List currentPath,
+ ) => Scaffold(
+ appBar: AppBar(
+ title: Text(
+ currentPath.isEmpty ? 'Ordner wählen' : '/${currentPath.join('/')}',
+ overflow: TextOverflow.ellipsis,
+ ),
+ actions: [
+ IconButton(
+ icon: const Icon(Icons.create_new_folder_outlined),
+ tooltip: 'Ordner erstellen',
+ onPressed: () => showCreateFolderDialog(context, bloc),
+ ),
+ FilesSortActions(
+ currentSort: _currentSort,
+ ascending: _ascending,
+ onDirectionChanged: (e) {
+ setState(() {
+ _ascending = e;
+ _settings.val(write: true).fileSettings.ascending = e;
+ });
+ },
+ onSortChanged: (e) {
+ setState(() {
+ _currentSort = e;
+ _settings.val(write: true).fileSettings.sortBy = e;
+ });
+ },
+ ),
+ ],
+ ),
+ floatingActionButton: FloatingActionButton.extended(
+ heroTag: 'shareUploadHere',
+ onPressed: () => widget.onConfirm(context, currentPath),
+ icon: const Icon(Icons.upload),
+ label: Text(widget.fabLabel),
+ ),
+ body: LoadableStateConsumer(
+ isReady: (state) => state.listing != null,
+ child: (state, _) {
+ final listing = state.listing!;
+ final entries = listing.sortBy(
+ sortOption: _currentSort,
+ foldersToTop: _settings.val().fileSettings.sortFoldersToTop,
+ reversed: _ascending,
+ );
+
+ if (entries.isEmpty) {
+ return PlaceholderView(
+ icon: Icons.folder_off_rounded,
+ text: state.currentPath.isEmpty
+ ? 'Leer. Du kannst hier direkt hochladen.'
+ : 'Ordner ist leer. Du kannst hier hochladen.',
+ );
+ }
+
+ return ListView.builder(
+ padding: EdgeInsets.zero,
+ itemCount: entries.length,
+ itemBuilder: (context, i) {
+ final entry = entries[i];
+ if (entry.isDirectory) {
+ return ListTile(
+ leading: const Icon(Icons.folder_outlined),
+ title: Text(entry.name),
+ trailing: const Icon(Icons.chevron_right),
+ onTap: () => _enter(bloc, state.currentPath, entry.name),
+ );
+ }
+ return ListTile(
+ enabled: false,
+ leading: const Icon(Icons.description_outlined),
+ title: Text(entry.name),
+ );
+ },
+ );
+ },
+ ),
+ );
+}
+
+Future _externalUploadFlow(
+ BuildContext context,
+ List targetPath,
+ PendingShare share,
+) async {
+ await pushScreen(
+ context,
+ withNavBar: false,
+ screen: FilesUploadDialog(
+ filePaths: share.filePaths,
+ remotePath: targetPath.join('/'),
+ onUploadFinished: (_) => _afterExternalUploaded(context, targetPath),
+ ),
+ );
+}
+
+void _afterExternalUploaded(BuildContext context, List targetPath) {
+ ShareIntentListener.instance.clear();
+ if (!context.mounted) return;
+ _finishWithFolder(context, targetPath);
+}
+
+/// Closes any picker pages stacked on top of the current tab and jumps to
+/// the chosen folder. Shared by external upload + internal copy flows.
+void _finishWithFolder(BuildContext context, List targetPath) {
+ Navigator.of(context).popUntil((route) => route.isFirst);
+ AppRoutes.openFolder(context, targetPath);
+}
+
+Future _internalCopyFlow(
+ BuildContext context,
+ List targetPath,
+ RemoteFileRef file,
+) async {
+ final bool ok;
+ try {
+ ok = await copyRemoteFileTo(
+ context: context,
+ source: file,
+ targetFolderPath: targetPath.join('/'),
+ );
+ } catch (e) {
+ if (context.mounted) {
+ InfoDialog.show(
+ context,
+ errorToUserMessage(e),
+ title: 'Kopieren fehlgeschlagen',
+ copyable: true,
+ );
+ }
+ return;
+ }
+ if (!ok || !context.mounted) return;
+ _finishWithFolder(context, targetPath);
+}
diff --git a/lib/view/pages/share_intent/share_target_page.dart b/lib/view/pages/share_intent/share_target_page.dart
new file mode 100644
index 0000000..17257a7
--- /dev/null
+++ b/lib/view/pages/share_intent/share_target_page.dart
@@ -0,0 +1,210 @@
+import 'dart:io';
+
+import 'package:flutter/material.dart';
+
+import '../../../routing/app_routes.dart';
+import '../../../share_intent/pending_share.dart';
+import '../../../share_intent/share_intent_listener.dart';
+
+class ShareTargetPage extends StatelessWidget {
+ final PendingShare share;
+
+ const ShareTargetPage({super.key, required this.share});
+
+ static const _imageExtensions = {
+ '.jpg',
+ '.jpeg',
+ '.png',
+ '.gif',
+ '.webp',
+ '.heic',
+ '.heif',
+ '.bmp',
+ };
+
+ bool _isImagePath(String path) {
+ final lower = path.toLowerCase();
+ return _imageExtensions.any(lower.endsWith);
+ }
+
+ String _appBarTitle() {
+ if (share.hasFiles && share.hasText) return 'Inhalte teilen';
+ if (share.hasFiles) {
+ return share.filePaths.length == 1
+ ? '1 Datei teilen'
+ : '${share.filePaths.length} Dateien teilen';
+ }
+ return 'Inhalt teilen';
+ }
+
+ @override
+ Widget build(BuildContext context) => PopScope(
+ onPopInvokedWithResult: (didPop, _) {
+ if (didPop) ShareIntentListener.instance.clear();
+ },
+ child: Scaffold(
+ appBar: AppBar(title: Text(_appBarTitle())),
+ body: Column(
+ children: [
+ Expanded(
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ if (share.hasFiles) _buildFilePreview(context),
+ if (share.hasFiles && share.hasText)
+ const SizedBox(height: 12),
+ if (share.hasText) _buildTextPreview(context),
+ ],
+ ),
+ ),
+ ),
+ const Divider(height: 1),
+ SafeArea(
+ top: false,
+ child: Padding(
+ padding: const EdgeInsets.only(top: 12, bottom: 8),
+ child: Column(
+ children: [
+ Padding(
+ padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
+ child: Column(
+ children: [
+ Icon(
+ Icons.ios_share,
+ size: 44,
+ color: Theme.of(context).colorScheme.primary,
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'Wo möchtest du teilen?',
+ textAlign: TextAlign.center,
+ style: Theme.of(context).textTheme.titleLarge
+ ?.copyWith(fontWeight: FontWeight.w600),
+ ),
+ ],
+ ),
+ ),
+ ListTile(
+ leading: const Icon(Icons.chat_bubble_outline),
+ title: const Text('An Talk-Chat senden'),
+ subtitle: const Text(
+ 'Datei oder Text in einem Talk-Chat teilen',
+ ),
+ trailing: const Icon(Icons.chevron_right),
+ onTap: () =>
+ AppRoutes.openShareChatPicker(context, share),
+ ),
+ ListTile(
+ enabled: share.hasFiles,
+ leading: const Icon(Icons.cloud_outlined),
+ title: const Text('In Cloud speichern'),
+ subtitle: Text(
+ share.hasFiles
+ ? 'In einen Cloud-Ordner hochladen'
+ : 'Nur für Dateien verfügbar',
+ ),
+ trailing: const Icon(Icons.chevron_right),
+ onTap: share.hasFiles
+ ? () => AppRoutes.openShareFolderPicker(context, share)
+ : null,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+
+ Widget _buildFilePreview(BuildContext context) {
+ if (share.filePaths.length == 1) {
+ final path = share.filePaths.first;
+ final name = path.split(Platform.pathSeparator).last;
+ return ConstrainedBox(
+ constraints: const BoxConstraints(maxHeight: 320),
+ child: Container(
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surfaceContainer,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ clipBehavior: Clip.antiAlias,
+ child: _isImagePath(path)
+ ? Image.file(
+ File(path),
+ fit: BoxFit.contain,
+ // Decode at most ~1080px so 50-MP gallery photos don't
+ // balloon the decode buffer just to render at <320px high.
+ cacheWidth: 1080,
+ errorBuilder: (_, _, _) => _fileFallbackLarge(name),
+ )
+ : _fileFallbackLarge(name),
+ ),
+ );
+ }
+ return GridView.builder(
+ shrinkWrap: true,
+ physics: const NeverScrollableScrollPhysics(),
+ gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: 2,
+ crossAxisSpacing: 10,
+ mainAxisSpacing: 10,
+ ),
+ itemCount: share.filePaths.length,
+ itemBuilder: (context, i) {
+ final path = share.filePaths[i];
+ final name = path.split(Platform.pathSeparator).last;
+ return Container(
+ decoration: BoxDecoration(
+ color: Theme.of(context).colorScheme.surfaceContainer,
+ borderRadius: BorderRadius.circular(12),
+ ),
+ clipBehavior: Clip.antiAlias,
+ child: _isImagePath(path)
+ ? Image.file(
+ File(path),
+ fit: BoxFit.cover,
+ // Grid tiles are ~half-screen wide; 480px decode is
+ // sharp on 3x displays without blowing up memory when
+ // many files are shared at once.
+ cacheWidth: 480,
+ errorBuilder: (_, _, _) => _fileFallbackLarge(name),
+ )
+ : _fileFallbackLarge(name),
+ );
+ },
+ );
+ }
+
+ Widget _buildTextPreview(BuildContext context) => Card(
+ margin: EdgeInsets.zero,
+ child: Padding(
+ padding: const EdgeInsets.all(12),
+ child: Text(
+ share.text!,
+ maxLines: 6,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ );
+
+ Widget _fileFallbackLarge(String name) => Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Icon(Icons.insert_drive_file_outlined, size: 64),
+ const SizedBox(height: 8),
+ Text(
+ name,
+ maxLines: 3,
+ overflow: TextOverflow.ellipsis,
+ textAlign: TextAlign.center,
+ style: const TextStyle(fontSize: 12),
+ ),
+ ],
+ ),
+ );
+}
diff --git a/lib/view/pages/talk/chat_list.dart b/lib/view/pages/talk/chat_list.dart
index 8939266..0eb72c8 100644
--- a/lib/view/pages/talk/chat_list.dart
+++ b/lib/view/pages/talk/chat_list.dart
@@ -153,10 +153,10 @@ class _ChatListViewState extends State<_ChatListView> {
) {
if (username == null || !context.mounted) return;
ConfirmDialog(
- title: 'Chat starten',
+ title: 'Talk-Chat starten',
content:
- "Möchtest du einen Chat mit Nutzer '$username' starten?",
- confirmButton: 'Chat starten',
+ "Möchtest du einen Talk-Chat mit Nutzer '$username' starten?",
+ confirmButton: 'Talk-Chat starten',
onConfirmAsync: () => bloc.createDirectChat(username),
).asDialog(context);
});
diff --git a/lib/view/pages/talk/search_chat.dart b/lib/view/pages/talk/search_chat.dart
index 9f36bf7..768daba 100644
--- a/lib/view/pages/talk/search_chat.dart
+++ b/lib/view/pages/talk/search_chat.dart
@@ -5,8 +5,9 @@ import 'widgets/chat_tile.dart';
class SearchChat extends SearchDelegate {
List chats;
+ final void Function(GetRoomResponseObject room)? onTapOverride;
- SearchChat(this.chats);
+ SearchChat(this.chats, {this.onTapOverride});
@override
List? buildActions(BuildContext context) => [
@@ -34,7 +35,11 @@ class SearchChat extends SearchDelegate {
itemCount: items.length,
itemBuilder: (context, index) {
var item = items.elementAt(index);
- return ChatTile(data: item, disableContextActions: true);
+ return ChatTile(
+ data: item,
+ disableContextActions: true,
+ onTapOverride: onTapOverride,
+ );
},
);
}
diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart
index 7e50a9e..9e5079f 100644
--- a/lib/view/pages/talk/widgets/chat_bubble.dart
+++ b/lib/view/pages/talk/widgets/chat_bubble.dart
@@ -6,6 +6,7 @@ import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../extensions/date_time.dart';
import '../../../../extensions/text.dart';
import '../../../../routing/app_routes.dart';
+import '../../../../share_intent/remote_file_ref.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../utils/download_manager.dart';
import '../../../../widget/confirm_dialog.dart';
@@ -90,7 +91,14 @@ class _ChatBubbleState extends State
if (status is DownloadDone) {
DownloadManager.instance.clear(job.remotePath);
_detachJob();
- AppRoutes.openFileViewer(context, status.localPath);
+ final talkFile = message.file;
+ AppRoutes.openFileViewer(
+ context,
+ status.localPath,
+ remoteFile: talkFile != null
+ ? RemoteFileRef.fromTalk(talkFile)
+ : null,
+ );
setState(() {});
} else if (status is DownloadFailed) {
final message = status.message;
diff --git a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart
index 227490b..1435914 100644
--- a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart
+++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart
@@ -1,5 +1,4 @@
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart' as emojis;
-import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -9,15 +8,28 @@ import '../../../../api/marianumcloud/talk/react_message/react_message.dart';
import '../../../../api/marianumcloud/talk/react_message/react_message_params.dart';
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../routing/app_routes.dart';
+import '../../../../share_intent/remote_file_ref.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
+import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../../utils/clipboard_helper.dart';
import '../../../../widget/app_progress_indicator.dart';
import '../../../../widget/async_action_button.dart';
+import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/details_bottom_sheet.dart';
const _commonReactions = ['👍', '👎', '😆', '❤️', '👀'];
+RichObjectString? _attachedFile(GetChatResponseObject bubbleData) {
+ final file = bubbleData.messageParameters?['file'];
+ if (file == null ||
+ file.path == null ||
+ file.type != RichObjectStringObjectType.file) {
+ return null;
+ }
+ return file;
+}
+
/// Long-press / double-tap options dialog for a single chat message bubble.
/// The hosting [ChatBubble] keeps responsibility for rendering the bubble;
/// this file owns the modal interactions (react, reply, copy, delete, ...).
@@ -36,6 +48,7 @@ void showChatMessageOptionsDialog(
DateTime.fromMillisecondsSinceEpoch(
bubbleData.timestamp * 1000,
).add(const Duration(hours: 6)).isAfter(DateTime.now());
+ final attachedFile = _attachedFile(bubbleData);
showDetailsBottomSheet(
context,
@@ -79,13 +92,52 @@ void showChatMessageOptionsDialog(
Navigator.of(sheetCtx).pop();
},
),
- if (!kReleaseMode &&
+ if (attachedFile != null)
+ ListTile(
+ leading: const Icon(Icons.cloud_outlined),
+ title: const Text('In Cloud speichern'),
+ onTap: () {
+ Navigator.of(sheetCtx).pop();
+ if (!parentContext.mounted) return;
+ AppRoutes.openInternalSaveToFolder(
+ parentContext,
+ RemoteFileRef.fromTalk(attachedFile),
+ );
+ },
+ ),
+ if (canReact && (bubbleData.message != '{file}' || attachedFile != null))
+ ListTile(
+ leading: const Icon(Icons.forward_outlined),
+ title: const Text('Weiterleiten'),
+ onTap: () {
+ Navigator.of(sheetCtx).pop();
+ if (!parentContext.mounted) return;
+ AppRoutes.openForwardMessageToChat(
+ parentContext,
+ text: bubbleData.message == '{file}' ? null : bubbleData.message,
+ file: attachedFile != null
+ ? RemoteFileRef.fromTalk(attachedFile)
+ : null,
+ );
+ },
+ ),
+ if (canReact &&
!isSender &&
- chatData.type != GetRoomResponseObjectConversationType.oneToOne)
+ chatData.type != GetRoomResponseObjectConversationType.oneToOne &&
+ bubbleData.actorType ==
+ GetRoomResponseObjectMessageActorType.user)
ListTile(
leading: const Icon(Icons.sms_outlined),
- title: Text("Private Nachricht an '${bubbleData.actorDisplayName}'"),
- onTap: () => Navigator.of(sheetCtx).pop(),
+ title: Text('Private Nachricht an ${bubbleData.actorDisplayName}'),
+ onTap: () {
+ Navigator.of(sheetCtx).pop();
+ if (!parentContext.mounted) return;
+ _openOrCreateDirectChat(
+ parentContext,
+ actorId: bubbleData.actorId,
+ actorDisplayName: bubbleData.actorDisplayName,
+ );
+ },
),
if (canDelete)
AsyncListTile(
@@ -101,6 +153,60 @@ void showChatMessageOptionsDialog(
);
}
+void _openOrCreateDirectChat(
+ BuildContext context, {
+ required String actorId,
+ required String actorDisplayName,
+}) {
+ final chatListBloc = context.read();
+
+ GetRoomResponseObject? findExisting() {
+ final rooms = chatListBloc.state.data?.rooms;
+ if (rooms == null) return null;
+ for (final room in rooms.data) {
+ if (room.type == GetRoomResponseObjectConversationType.oneToOne &&
+ room.name == actorId) {
+ return room;
+ }
+ }
+ return null;
+ }
+
+ void switchToChat(GetRoomResponseObject room) {
+ // Pop the current ChatView before swapping the global ChatBloc token —
+ // otherwise the previous group chat stays mounted in the back-stack and
+ // would render empty after a back-swipe (currentToken no longer matches).
+ Navigator.of(context).popUntil((route) => route.isFirst);
+ AppRoutes.openChatByToken(context, room.token);
+ }
+
+ final existing = findExisting();
+ if (existing != null) {
+ switchToChat(existing);
+ return;
+ }
+
+ ConfirmDialog(
+ title: 'Privatchat starten?',
+ content:
+ 'Es existiert noch kein Privatchat mit $actorDisplayName. '
+ 'Soll einer erstellt werden?',
+ confirmButton: 'Erstellen',
+ onConfirmAsync: () async {
+ await chatListBloc.createDirectChat(actorId);
+ final created = findExisting();
+ if (created == null) {
+ throw Exception(
+ 'Privatchat konnte nach dem Erstellen nicht gefunden werden.',
+ );
+ }
+ if (context.mounted) {
+ switchToChat(created);
+ }
+ },
+ ).asDialog(context);
+}
+
class _ReactionsRow extends StatefulWidget {
final String chatToken;
final int messageId;
diff --git a/lib/view/pages/talk/widgets/chat_textfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart
index f4067b8..26dd9db 100644
--- a/lib/view/pages/talk/widgets/chat_textfield.dart
+++ b/lib/view/pages/talk/widgets/chat_textfield.dart
@@ -1,15 +1,13 @@
import 'dart:async';
-import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:nextcloud/nextcloud.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
-import '../../../../api/marianumcloud/files_sharing/file_sharing_api.dart';
-import '../../../../api/marianumcloud/files_sharing/file_sharing_api_params.dart';
import '../../../../api/marianumcloud/talk/send_message/send_message.dart';
import '../../../../api/marianumcloud/talk/send_message/send_message_params.dart';
+import '../../../../api/marianumcloud/talk/share_files_to_chat.dart';
import '../../../../api/marianumcloud/webdav/webdav_api.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
@@ -36,30 +34,21 @@ class _ChatTextfieldState extends State {
final AsyncActionController _sendController = AsyncActionController();
String? _sendError;
- void share(String shareFolder, List filePaths) {
- for (final element in filePaths) {
- final fileName = element.split(Platform.pathSeparator).last;
- FileSharingApi()
- .share(
- FileSharingApiParams(
- shareType: 10,
- shareWith: widget.sendToToken,
- path: '$shareFolder/$fileName',
- ),
- )
- .then((_) {
- if (mounted) context.read().refresh();
- });
- }
+ void share(List uploadedRemotePaths) {
+ shareFilesToChat(
+ token: widget.sendToToken,
+ remoteFilePaths: uploadedRemotePaths,
+ ).then((_) {
+ if (mounted) context.read().refresh();
+ });
}
Future mediaUpload(List? paths) async {
if (paths == null) return;
- const shareFolder = 'MarianumMobile';
unawaited(
WebdavApi.webdav.then(
- (webdav) => webdav.mkcol(PathUri.parse('/$shareFolder')),
+ (webdav) => webdav.mkcol(PathUri.parse('/$talkShareFolder')),
),
);
@@ -70,8 +59,8 @@ class _ChatTextfieldState extends State {
withNavBar: false,
screen: FilesUploadDialog(
filePaths: paths,
- remotePath: shareFolder,
- onUploadFinished: (uploaded) => share(shareFolder, uploaded),
+ remotePath: talkShareFolder,
+ onUploadFinished: share,
uniqueNames: true,
),
),
diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart
index ddb0127..bea3098 100644
--- a/lib/view/pages/talk/widgets/chat_tile.dart
+++ b/lib/view/pages/talk/widgets/chat_tile.dart
@@ -25,11 +25,17 @@ class ChatTile extends StatefulWidget {
final bool disableContextActions;
final bool hasDraft;
+ /// When set, replaces the default tap-into-chat behaviour. Used by the
+ /// share-intent picker to surface the room selection without opening the
+ /// chat view itself.
+ final void Function(GetRoomResponseObject room)? onTapOverride;
+
const ChatTile({
super.key,
required this.data,
this.disableContextActions = false,
this.hasDraft = false,
+ this.onTapOverride,
});
@override
@@ -143,6 +149,10 @@ class _ChatTileState extends State {
),
),
onTap: () {
+ if (widget.onTapOverride != null) {
+ widget.onTapOverride!(widget.data);
+ return;
+ }
if (selfUsername == null) return;
unawaited(_setCurrentAsRead());
final view = ChatView(
@@ -197,11 +207,11 @@ class _ChatTileState extends State {
),
ListTile(
leading: const Icon(Icons.delete_outline),
- title: const Text('Konversation verlassen'),
+ title: const Text('Talk-Chat verlassen'),
onTap: () {
Navigator.of(sheetCtx).pop();
ConfirmDialog(
- title: 'Chat verlassen',
+ title: 'Talk-Chat verlassen',
content:
'Du benötigst ggf. eine Einladung um erneut beizutreten.',
confirmButton: 'Verlassen',
diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart
index 6440bad..9d3ab72 100644
--- a/lib/widget/file_viewer.dart
+++ b/lib/widget/file_viewer.dart
@@ -11,6 +11,7 @@ import 'package:share_plus/share_plus.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import '../routing/app_routes.dart';
+import '../share_intent/remote_file_ref.dart';
import '../state/app/modules/settings/bloc/settings_cubit.dart';
import 'info_dialog.dart';
import 'placeholder_view.dart';
@@ -19,13 +20,24 @@ import 'share_position_origin.dart';
class FileViewer extends StatefulWidget {
final String path;
final bool openExternal;
- const FileViewer({super.key, required this.path, this.openExternal = false});
+
+ /// When set, enables the in-app actions "An Chat senden" and "In Dateien
+ /// speichern" — these need a server-side reference, not the local cache
+ /// path. Aufrufer reichen die Referenz durch (siehe AppRoutes.openFileViewer).
+ final RemoteFileRef? remoteFile;
+
+ const FileViewer({
+ super.key,
+ required this.path,
+ this.openExternal = false,
+ this.remoteFile,
+ });
@override
State createState() => _FileViewerState();
}
-enum FileViewingActions { openExternal, share, save }
+enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud }
/// Workaround for a Syncfusion PDF viewer race: SfPdfViewer's internal
/// LayoutBuilder calls `localToGlobal` during build, which asserts when an
@@ -110,6 +122,16 @@ class _FileViewerState extends State {
context,
widget.path,
openExternal: true,
+ remoteFile: widget.remoteFile,
+ );
+ break;
+ case FileViewingActions.sendToChat:
+ AppRoutes.openInternalShareToChat(context, widget.remoteFile!);
+ break;
+ case FileViewingActions.saveToCloud:
+ AppRoutes.openInternalSaveToFolder(
+ context,
+ widget.remoteFile!,
);
break;
case FileViewingActions.share:
@@ -154,6 +176,24 @@ class _FileViewerState extends State {
dense: true,
),
),
+ if (widget.remoteFile != null) ...[
+ const PopupMenuItem(
+ value: FileViewingActions.sendToChat,
+ child: ListTile(
+ leading: Icon(Icons.chat_bubble_outline),
+ title: Text('An Talk-Chat senden'),
+ dense: true,
+ ),
+ ),
+ const PopupMenuItem(
+ value: FileViewingActions.saveToCloud,
+ child: ListTile(
+ leading: Icon(Icons.cloud_outlined),
+ title: Text('In Cloud speichern'),
+ dense: true,
+ ),
+ ),
+ ],
const PopupMenuItem(
value: FileViewingActions.share,
child: ListTile(
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..98ddb1e 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
@@ -69,6 +71,7 @@ dependencies:
time_range_picker: ^2.3.0
url_launcher: ^6.3.1
enough_icalendar: ^0.17.0
+ receive_sharing_intent: ^1.8.1
dev_dependencies:
flutter_test:
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'],
+ );
+ });
+ });
+}