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