added native features like homescreen-widgets and share intents #97

Merged
MineTec merged 5 commits from develop-native into develop 2026-05-09 19:35:32 +00:00
66 changed files with 5600 additions and 4 deletions
Showing only changes of commit 00664c66a8 - Show all commits
+32 -1
View File
@@ -2,7 +2,9 @@
<application
android:label="Marianum Fulda"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -29,6 +31,32 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- Receiver classes live at the package root (NOT under .widgets) because
the home_widget Flutter plugin resolves them as <app-package>.<name>. -->
<receiver
android:name="eu.mhsl.marianum.mobile.client.TimetableDayWidget"
android:label="@string/widget_day_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/timetable_day_widget_info" />
</receiver>
<receiver
android:name="eu.mhsl.marianum.mobile.client.TimetableWeekWidget"
android:label="@string/widget_week_label"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/timetable_week_widget_info" />
</receiver>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility?hl=en and
@@ -42,4 +70,7 @@
</intent>
</queries>
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Workmanager periodic widget refresh needs to reschedule after device
reboot, otherwise the widget freezes until the user opens the app. -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
</manifest>
@@ -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
}
}
}
@@ -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 `<app-package>.<androidName>`.
*/
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)
}
}
@@ -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)
}
}
@@ -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<WidgetLesson>,
val periods: List<WidgetPeriod>,
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<WidgetLesson>()
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<WidgetPeriod>()
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)
}
}
@@ -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<WidgetPeriod>,
): 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<WidgetPeriod>,
) {
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<WidgetPeriod>,
) {
// 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<Int>()
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<WidgetPeriod>,
) {
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<Int, Int> {
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<WidgetPeriod>,
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<WidgetPeriod>,
) {
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"
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Plain rounded rectangle. We deliberately avoid ?attr/appWidgetRadius
because RemoteViews inflation does not resolve custom theme attributes
reliably across launchers. Hard-coded 20dp matches the system home-screen
radius closely on stock Android. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="20dp" />
<solid android:color="@color/widget_background" />
</shape>
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Direct port of /home/elias/Bilder/marianum_m_white.svg.
- viewport matches the source SVG (70mm × 82.2mm).
- Transforms recreate the SVG's three nested matrices: outer scale+offset,
inner translate, and a final scale+y-flip that pulls the path data into
the visible coordinate space.
- Tinted at runtime via setColorFilter so light/dark themes can share one
drawable.
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="70dp"
android:height="82dp"
android:viewportWidth="70.000168"
android:viewportHeight="82.227348">
<group
android:scaleX="0.26458333"
android:scaleY="0.26458334"
android:translateX="107.44411"
android:translateY="-80.482198">
<group
android:translateX="-749.41293"
android:translateY="290.52252">
<group
android:scaleX="0.13333333"
android:scaleY="-0.13333333"
android:translateX="0"
android:translateY="632.14667">
<path
android:fillColor="#FFFFFF"
android:pathData="M 3499.67,4594.33 c -16.43,-106.19 -29.71,-199.97 -43.79,-293.49 86.83,-19 138.5,-27.61 223.38,-43.82 63.81,-12.18 175.24,-20.4 179.64,-83.23 6.46,-92.69 -124.69,-55.41 -188.38,-43.81 -84.33,15.36 -159.13,28.84 -232.2,43.81 -13.68,-60.72 -26.83,-118.68 -39.43,-179.61 -36.76,-178.32 -73.67,-368.16 -105.11,-551.97 18.09,25.66 30.84,42.72 43.8,65.7 66.7,118.26 140.39,245.04 227.76,354.83 33.49,42.05 76.86,94.81 118.31,113.91 98.42,45.36 166.68,-22.2 170.87,-118.28 3.68,-85.28 -23.09,-181.17 -35.08,-275.99 -12.4,-98.19 -22.89,-194.93 -35.03,-275.98 72.44,102.69 147.93,269.64 240.95,381.12 27.51,33 73.55,80.61 118.27,87.62 218.76,34.33 126.58,-312.17 127.05,-473.13 0.4,-144.9 44.01,-255.37 175.21,-271.59 43.02,-5.31 105.84,11.16 112.7,-26.34 8.67,-47.38 -78.15,-60.52 -125.84,-61.28 -291.34,-4.51 -322.06,262.33 -311.01,573.88 -19.85,-18.57 -35.71,-47.53 -52.57,-74.47 -97.59,-155.88 -203.95,-327.22 -297.92,-503.79 -25.93,-48.79 -53.68,-114.7 -135.8,-83.23 -17.27,6.63 -48.25,44.39 -52.56,56.96 -19.58,57.19 1.55,137.42 8.76,205.89 21.54,203.72 57.81,389.09 78.87,587.01 -26.3,0.51 -43.93,-30.07 -56.96,-48.2 -46.9,-65.27 -86.02,-140.76 -127.04,-214.64 -52.84,-95.15 -108.23,-192.84 -157.71,-293.52 -75.25,-153.09 -188.6,-501.89 -242.12,-678.81 -8.67,-28.67 -17.7,-58.08 -26.3,-87.64 -7.48,-25.72 -10.39,-57.68 -35.05,-74.46 -100.02,18.93 -89.71,104.89 -70.09,205.9 47.35,243.43 170.89,706.45 211.48,946.04 -72.97,-70.97 -153.99,-207.41 -289.14,-236.55 -136.47,-29.44 -217.95,47.68 -271.6,122.66 -17.14,23.96 -41.43,49.54 -26.29,78.84 83.96,35.51 113.37,-65.2 197.15,-74.47 22.65,-2.5 54.56,2.4 74.46,8.78 132.4,42.34 237.57,218.76 297.87,346.07 74.16,156.45 125.32,330.5 148.95,490.64 -65.71,11.4 -142.96,22.15 -219.25,36.52 -109.8,20.72 -158.81,10.75 -201.29,59.86 9.15,41.95 41.41,60.8 70.1,83.24 126.26,-16.84 252.45,-33.77 372.36,-56.97 20.43,89.25 51.98,218.51 74.45,311.05 40.53,26.02 88.88,-8.43 105.17,-35.06 z" />
</group>
</group>
</group>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/widget_divider" />
</shape>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_cancelled" />
<stroke android:width="1.5dp" android:color="#C8FF0000" />
</shape>
</item>
<item
android:left="3dp"
android:top="3dp"
android:right="3dp"
android:bottom="3dp"
android:drawable="@drawable/widget_lesson_cancelled_x" />
</layer-list>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="#FF2196F3" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="#FF4CAF50" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="#FFEF6C00" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="#FF993333" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_irregular" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_ongoing" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_past" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_regular" />
</shape>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="@color/widget_lesson_teacher_changed" />
</shape>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="100dp"
android:height="100dp"
android:viewportWidth="100"
android:viewportHeight="100">
<path
android:strokeColor="#C8FF0000"
android:strokeWidth="5"
android:pathData="M0,0 L100,100 M100,0 L0,100" />
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FFE53935" />
</shape>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_break_block_root"
android:layout_width="match_parent"
android:layout_height="14dp"
android:layout_marginTop="0dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp" />
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- FrameLayout root so the Marianum watermark can sit at bottom|end behind
the actual widget content without disturbing the LinearLayout flow. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/app_widget_background">
<!-- Bottom-leading so the mark looks like it's peeking out of the lower
left corner. Width/height/start+bottom margins are overridden in the
renderer so the mark scales with the widget size. -->
<ImageView
android:id="@+id/widget_watermark"
android:layout_width="160dp"
android:layout_height="160dp"
android:layout_gravity="bottom|end"
android:scaleType="fitCenter"
android:importantForAccessibility="no"
android:contentDescription="@null"
android:alpha="0.025"
android:src="@drawable/marianum_m_watermark" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="10dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/widget_day_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
android:singleLine="true"
android:ellipsize="end"
tools:text="Heute · 08.05." />
<TextView
android:id="@+id/widget_day_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/widget_text_secondary"
tools:text="Stand: 14:32" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="6dp"
android:orientation="horizontal">
<FrameLayout
android:id="@+id/widget_day_time_labels"
android:layout_width="32dp"
android:layout_height="match_parent" />
<FrameLayout
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="@color/widget_divider" />
<FrameLayout
android:id="@+id/widget_day_grid"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
</LinearLayout>
<TextView
android:id="@+id/widget_day_empty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:textSize="13sp"
android:textColor="@color/widget_text_secondary"
android:gravity="center"
android:padding="12dp"
tools:text="Keine Stunden" />
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,185 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/widget_background">
<ImageView
android:layout_width="160dp"
android:layout_height="160dp"
android:layout_gravity="bottom|end"
android:layout_marginEnd="-28dp"
android:layout_marginBottom="-28dp"
android:scaleType="fitCenter"
android:importantForAccessibility="no"
android:contentDescription="@null"
android:alpha="0.018"
android:src="@drawable/marianum_m_watermark" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="10dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
android:singleLine="true"
android:text="Heute · 06.05." />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/widget_text_secondary"
android:text="Stand: 07:50" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal"
android:layout_marginTop="6dp">
<FrameLayout
android:layout_width="32dp"
android:layout_height="match_parent">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="22dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="1." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="07:55" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="50dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="2." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="79dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="3." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="09:30" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="115dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="4." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="10:35" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="142dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="5." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="11:25" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="169dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="6." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="12:15" />
</LinearLayout>
</FrameLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="29dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="56dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="83dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="115dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="143dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="170dp" android:background="@color/widget_divider" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="22dp"
android:layout_marginTop="29dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:padding="3dp"
android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@android:color/white" android:text="MA-LK" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="55dp"
android:layout_marginTop="83dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:padding="3dp"
android:background="@drawable/widget_lesson_block_regular">
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@android:color/white" android:text="DE-GK" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="9sp" android:textColor="#CCFFFFFF" android:text="B11" />
</LinearLayout>
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="22dp"
android:layout_marginTop="143dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:padding="3dp"
android:background="@drawable/widget_lesson_block_cancelled">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@android:color/white" android:text="BIO" />
</FrameLayout>
<FrameLayout
android:layout_width="match_parent"
android:layout_height="22dp"
android:layout_marginTop="170dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:padding="3dp"
android:background="@drawable/widget_lesson_block_teacher_changed">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@android:color/white" android:text="GE" />
</FrameLayout>
</FrameLayout>
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_grid_line_root"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="0dp"
android:background="@drawable/widget_grid_line" />
@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_lesson_block_root"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:layout_marginTop="0dp"
android:paddingStart="7dp"
android:paddingEnd="7dp"
android:paddingTop="3dp"
android:paddingBottom="3dp"
android:background="@drawable/widget_lesson_block_regular">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:gravity="top"
android:baselineAligned="false">
<TextView
android:id="@+id/widget_lesson_subject"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textStyle="bold"
android:textColor="@android:color/white"
android:maxLines="1"
android:ellipsize="end"
android:gravity="top|start"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="5sp"
android:autoSizeMaxTextSize="11sp"
android:autoSizeStepGranularity="1sp"
tools:text="MA-LK" />
<LinearLayout
android:id="@+id/widget_lesson_secondary_stack"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="top|end"
android:layout_marginStart="4dp">
<TextView
android:id="@+id/widget_lesson_room"
android:layout_width="wrap_content"
android:layout_height="12dp"
android:textColor="#CCFFFFFF"
android:maxLines="1"
android:ellipsize="end"
android:gravity="end|top"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="5sp"
android:autoSizeMaxTextSize="11sp"
android:autoSizeStepGranularity="1sp"
tools:text="A12" />
<TextView
android:id="@+id/widget_lesson_teacher"
android:layout_width="wrap_content"
android:layout_height="12dp"
android:textColor="#B3FFFFFF"
android:maxLines="1"
android:ellipsize="end"
android:gravity="end|top"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="5sp"
android:autoSizeMaxTextSize="11sp"
android:autoSizeStepGranularity="1sp"
tools:text="Müller" />
</LinearLayout>
</LinearLayout>
<TextView
android:id="@+id/widget_lesson_sibling_badge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|start"
android:textStyle="bold"
android:textSize="12sp"
android:textColor="@android:color/white"
android:maxLines="1"
android:includeFontPadding="false"
android:visibility="gone"
tools:text="+1" />
</FrameLayout>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_now_indicator_root"
android:layout_width="match_parent"
android:layout_height="2dp"
android:layout_marginTop="0dp"
android:background="@drawable/widget_now_indicator" />
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/app_widget_background">
<ImageView
android:id="@+id/widget_watermark"
android:layout_width="160dp"
android:layout_height="160dp"
android:layout_gravity="bottom|end"
android:scaleType="fitCenter"
android:importantForAccessibility="no"
android:contentDescription="@null"
android:alpha="0.025"
android:src="@drawable/marianum_m_watermark" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:padding="16dp">
<TextView
android:id="@+id/widget_placeholder_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
tools:text="Marianum Vertretungsplan" />
<TextView
android:id="@+id/widget_placeholder_message"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textSize="12sp"
android:layout_marginTop="6dp"
android:textColor="@color/widget_text_secondary"
tools:text="Bitte einloggen, um den Stundenplan zu laden" />
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_time_label_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="0dp"
android:orientation="vertical"
android:gravity="end"
android:paddingEnd="3dp">
<TextView
android:id="@+id/widget_time_label_time"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="9sp"
android:textColor="@color/widget_text_primary"
android:singleLine="true"
android:lineSpacingExtra="-2dp"
tools:text="07:55" />
<TextView
android:id="@+id/widget_time_label_number"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="7sp"
android:textColor="@color/widget_text_secondary"
android:singleLine="true"
android:lineSpacingExtra="-2dp"
tools:text="1." />
</LinearLayout>
@@ -0,0 +1,185 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/app_widget_background">
<ImageView
android:id="@+id/widget_watermark"
android:layout_width="160dp"
android:layout_height="160dp"
android:layout_gravity="bottom|end"
android:scaleType="fitCenter"
android:importantForAccessibility="no"
android:contentDescription="@null"
android:alpha="0.025"
android:src="@drawable/marianum_m_watermark" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="10dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/widget_week_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
android:singleLine="true"
android:ellipsize="end"
tools:text="KW 19 · 06.05.10.05." />
<TextView
android:id="@+id/widget_week_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/widget_text_secondary"
tools:text="Stand: 14:32" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="6dp">
<FrameLayout
android:layout_width="28dp"
android:layout_height="wrap_content" />
<FrameLayout
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="@color/widget_divider" />
<FrameLayout
android:id="@+id/widget_week_header_mon"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
<FrameLayout
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="@color/widget_divider" />
<FrameLayout
android:id="@+id/widget_week_header_tue"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
<FrameLayout
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="@color/widget_divider" />
<FrameLayout
android:id="@+id/widget_week_header_wed"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
<FrameLayout
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="@color/widget_divider" />
<FrameLayout
android:id="@+id/widget_week_header_thu"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
<FrameLayout
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="@color/widget_divider" />
<FrameLayout
android:id="@+id/widget_week_header_fri"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="2dp"
android:orientation="horizontal">
<FrameLayout
android:id="@+id/widget_week_time_labels"
android:layout_width="28dp"
android:layout_height="match_parent" />
<FrameLayout
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="@color/widget_divider" />
<FrameLayout
android:id="@+id/widget_week_col_mon"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<FrameLayout
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="@color/widget_divider" />
<FrameLayout
android:id="@+id/widget_week_col_tue"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<FrameLayout
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="@color/widget_divider" />
<FrameLayout
android:id="@+id/widget_week_col_wed"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<FrameLayout
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="@color/widget_divider" />
<FrameLayout
android:id="@+id/widget_week_col_thu"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
<FrameLayout
android:layout_width="1dp"
android:layout_height="match_parent"
android:background="@color/widget_divider" />
<FrameLayout
android:id="@+id/widget_week_col_fri"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_week_day_header_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center"
android:paddingTop="2dp"
android:paddingBottom="3dp">
<TextView
android:id="@+id/widget_week_day_header_weekday"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="11sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
tools:text="Mo" />
<TextView
android:id="@+id/widget_week_day_header_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="9sp"
android:textColor="@color/widget_text_secondary"
tools:text="06.05." />
</LinearLayout>
@@ -0,0 +1,147 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static preview for the week widget. Five day columns with two short
demo blocks each so the structure is recognisable in the picker. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/widget_background">
<ImageView
android:layout_width="200dp"
android:layout_height="200dp"
android:layout_gravity="bottom|end"
android:layout_marginEnd="-36dp"
android:layout_marginBottom="-36dp"
android:scaleType="fitCenter"
android:importantForAccessibility="no"
android:contentDescription="@null"
android:alpha="0.018"
android:src="@drawable/marianum_m_watermark" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="10dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
android:singleLine="true"
android:text="KW 19 · 06.05.10.05." />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/widget_text_secondary"
android:text="Stand: 07:50" />
</LinearLayout>
<!-- Day-name + date row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="6dp">
<FrameLayout android:layout_width="20dp" android:layout_height="wrap_content" />
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Mo" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="06.05." />
</LinearLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Di" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="07.05." />
</LinearLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Mi" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="08.05." />
</LinearLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Do" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="09.05." />
</LinearLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Fr" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="10.05." />
</LinearLayout>
</LinearLayout>
<!-- Time grid: time-label column + 5 day columns -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal"
android:layout_marginTop="2dp">
<FrameLayout android:layout_width="20dp" android:layout_height="match_parent">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="0dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="08" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="36dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="10" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="72dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="12" />
</FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Mon -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="MA" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="40dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="EN" />
</FrameLayout>
</FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Tue -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="20dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_cancelled">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="BIO" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="60dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="DE" />
</FrameLayout>
</FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Wed -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="32dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="MA" />
</FrameLayout>
</FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Thu -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_teacher_changed">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="GE" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="40dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="PH" />
</FrameLayout>
</FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Fri -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="20dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="EN" />
</FrameLayout>
</FrameLayout>
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="widget_background">#FF1F1716</color>
<color name="widget_text_primary">#FFF1F1F1</color>
<color name="widget_text_secondary">#FFB0B0B0</color>
<color name="widget_divider">#33FFFFFF</color>
</resources>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="widget_day_label">Marianum · Heute</string>
<string name="widget_week_label">Marianum · Woche</string>
<string name="widget_day_description">Stundenplan und Vertretungen für den anstehenden Schultag.</string>
<string name="widget_week_description">Stundenplan und Vertretungen für die ganze Schulwoche.</string>
<string name="widget_no_lessons">Keine Stunden</string>
<string name="widget_holiday">Ferien</string>
<string name="widget_login_required">Bitte einloggen, um den Stundenplan zu laden</string>
<string name="widget_loading">Lade…</string>
<string name="widget_status_label">Stand: %1$s</string>
<string name="widget_today">Heute</string>
<string name="widget_tomorrow">Morgen</string>
<string name="widget_placeholder_title">Marianum Stundenplan</string>
<string name="widget_calendar_week_prefix">KW</string>
<string name="widget_yesterday_prefix">gestern</string>
</resources>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Status colors mirror lib/view/pages/timetable/data/lesson_color.dart
exactly so widget tiles match the in-app calendar. -->
<color name="widget_lesson_regular">#FF993333</color>
<color name="widget_lesson_ongoing">#FFC83333</color>
<color name="widget_lesson_past">#FF993333</color>
<color name="widget_lesson_cancelled">#FF000000</color>
<color name="widget_lesson_irregular">#FF8F19B3</color>
<color name="widget_lesson_teacher_changed">#FF29639B</color>
<color name="widget_lesson_event">#FF2E7D32</color>
<color name="widget_background">#FFFCF7F5</color>
<color name="widget_text_primary">#FF111111</color>
<color name="widget_text_secondary">#FF555555</color>
<color name="widget_divider">#22000000</color>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Auto-Backup rules for Android 11 and below.
Excludes the home_widget plugin's SharedPreferences file
(HomeWidgetPreferences) so the cached timetable — which contains
teacher names, room numbers and personal custom events — is not
uploaded to the user's Google Drive. -->
<full-backup-content>
<exclude domain="sharedpref" path="HomeWidgetPreferences.xml"/>
</full-backup-content>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Backup + device-transfer rules for Android 12+.
Excludes the home_widget plugin's SharedPreferences file
(HomeWidgetPreferences) so the cached timetable — which contains
teacher names, room numbers and personal custom events — is not
uploaded to the user's Google Drive or transferred to a new device.
The widget repopulates from a fresh Webuntis fetch after sign-in. -->
<data-extraction-rules>
<cloud-backup>
<exclude domain="sharedpref" path="HomeWidgetPreferences.xml"/>
</cloud-backup>
<device-transfer>
<exclude domain="sharedpref" path="HomeWidgetPreferences.xml"/>
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_placeholder"
android:minWidth="110dp"
android:minHeight="180dp"
android:targetCellWidth="2"
android:targetCellHeight="5"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:updatePeriodMillis="0"
android:description="@string/widget_day_description"
android:previewLayout="@layout/widget_day_preview" />
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:initialLayout="@layout/widget_placeholder"
android:minWidth="320dp"
android:minHeight="240dp"
android:targetCellWidth="5"
android:targetCellHeight="5"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:updatePeriodMillis="0"
android:description="@string/widget_week_description"
android:previewLayout="@layout/widget_week_preview" />
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="70.000168mm"
height="82.227348mm"
viewBox="0 0 70.000168 82.227348"
version="1.1"
id="svg1"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><g
id="g1"
transform="matrix(0.26458333,0,0,0.26458334,107.44411,-80.482198)"><g
id="group-R5"
transform="translate(-749.41293,290.52252)"><path
id="path3"
d="m 3499.67,4594.33 c -16.43,-106.19 -29.71,-199.97 -43.79,-293.49 86.83,-19 138.5,-27.61 223.38,-43.82 63.81,-12.18 175.24,-20.4 179.64,-83.23 6.46,-92.69 -124.69,-55.41 -188.38,-43.81 -84.33,15.36 -159.13,28.84 -232.2,43.81 -13.68,-60.72 -26.83,-118.68 -39.43,-179.61 -36.76,-178.32 -73.67,-368.16 -105.11,-551.97 18.09,25.66 30.84,42.72 43.8,65.7 66.7,118.26 140.39,245.04 227.76,354.83 33.49,42.05 76.86,94.81 118.31,113.91 98.42,45.36 166.68,-22.2 170.87,-118.28 3.68,-85.28 -23.09,-181.17 -35.08,-275.99 -12.4,-98.19 -22.89,-194.93 -35.03,-275.98 72.44,102.69 147.93,269.64 240.95,381.12 27.51,33 73.55,80.61 118.27,87.62 218.76,34.33 126.58,-312.17 127.05,-473.13 0.4,-144.9 44.01,-255.37 175.21,-271.59 43.02,-5.31 105.84,11.16 112.7,-26.34 8.67,-47.38 -78.15,-60.52 -125.84,-61.28 -291.34,-4.51 -322.06,262.33 -311.01,573.88 -19.85,-18.57 -35.71,-47.53 -52.57,-74.47 -97.59,-155.88 -203.95,-327.22 -297.92,-503.79 -25.93,-48.79 -53.68,-114.7 -135.8,-83.23 -17.27,6.63 -48.25,44.39 -52.56,56.96 -19.58,57.19 1.55,137.42 8.76,205.89 21.54,203.72 57.81,389.09 78.87,587.01 -26.3,0.51 -43.93,-30.07 -56.96,-48.2 -46.9,-65.27 -86.02,-140.76 -127.04,-214.64 -52.84,-95.15 -108.23,-192.84 -157.71,-293.52 -75.25,-153.09 -188.6,-501.89 -242.12,-678.81 -8.67,-28.67 -17.7,-58.08 -26.3,-87.64 -7.48,-25.72 -10.39,-57.68 -35.05,-74.46 -100.02,18.93 -89.71,104.89 -70.09,205.9 47.35,243.43 170.89,706.45 211.48,946.04 -72.97,-70.97 -153.99,-207.41 -289.14,-236.55 -136.47,-29.44 -217.95,47.68 -271.6,122.66 -17.14,23.96 -41.43,49.54 -26.29,78.84 83.96,35.51 113.37,-65.2 197.15,-74.47 22.65,-2.5 54.56,2.4 74.46,8.78 132.4,42.34 237.57,218.76 297.87,346.07 74.16,156.45 125.32,330.5 148.95,490.64 -65.71,11.4 -142.96,22.15 -219.25,36.52 -109.8,20.72 -158.81,10.75 -201.29,59.86 9.15,41.95 41.41,60.8 70.1,83.24 126.26,-16.84 252.45,-33.77 372.36,-56.97 20.43,89.25 51.98,218.51 74.45,311.05 40.53,26.02 88.88,-8.43 105.17,-35.06"
style="fill:#d3d2d2;fill-opacity:1;fill-rule:evenodd;stroke:none"
transform="matrix(0.13333333,0,0,-0.13333333,0,632.14667)" /></g></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+4
View File
@@ -4,5 +4,9 @@
<dict>
<key>aps-environment</key>
<string>development</string>
<key>com.apple.security.application-groups</key>
<array>
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
</array>
</dict>
</plist>
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -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"
}
}
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="70.000168mm"
height="82.227348mm"
viewBox="0 0 70.000168 82.227348"
version="1.1"
id="svg1"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><g
id="g1"
transform="matrix(0.26458333,0,0,0.26458334,107.44411,-80.482198)"><g
id="group-R5"
transform="translate(-749.41293,290.52252)"><path
id="path3"
d="m 3499.67,4594.33 c -16.43,-106.19 -29.71,-199.97 -43.79,-293.49 86.83,-19 138.5,-27.61 223.38,-43.82 63.81,-12.18 175.24,-20.4 179.64,-83.23 6.46,-92.69 -124.69,-55.41 -188.38,-43.81 -84.33,15.36 -159.13,28.84 -232.2,43.81 -13.68,-60.72 -26.83,-118.68 -39.43,-179.61 -36.76,-178.32 -73.67,-368.16 -105.11,-551.97 18.09,25.66 30.84,42.72 43.8,65.7 66.7,118.26 140.39,245.04 227.76,354.83 33.49,42.05 76.86,94.81 118.31,113.91 98.42,45.36 166.68,-22.2 170.87,-118.28 3.68,-85.28 -23.09,-181.17 -35.08,-275.99 -12.4,-98.19 -22.89,-194.93 -35.03,-275.98 72.44,102.69 147.93,269.64 240.95,381.12 27.51,33 73.55,80.61 118.27,87.62 218.76,34.33 126.58,-312.17 127.05,-473.13 0.4,-144.9 44.01,-255.37 175.21,-271.59 43.02,-5.31 105.84,11.16 112.7,-26.34 8.67,-47.38 -78.15,-60.52 -125.84,-61.28 -291.34,-4.51 -322.06,262.33 -311.01,573.88 -19.85,-18.57 -35.71,-47.53 -52.57,-74.47 -97.59,-155.88 -203.95,-327.22 -297.92,-503.79 -25.93,-48.79 -53.68,-114.7 -135.8,-83.23 -17.27,6.63 -48.25,44.39 -52.56,56.96 -19.58,57.19 1.55,137.42 8.76,205.89 21.54,203.72 57.81,389.09 78.87,587.01 -26.3,0.51 -43.93,-30.07 -56.96,-48.2 -46.9,-65.27 -86.02,-140.76 -127.04,-214.64 -52.84,-95.15 -108.23,-192.84 -157.71,-293.52 -75.25,-153.09 -188.6,-501.89 -242.12,-678.81 -8.67,-28.67 -17.7,-58.08 -26.3,-87.64 -7.48,-25.72 -10.39,-57.68 -35.05,-74.46 -100.02,18.93 -89.71,104.89 -70.09,205.9 47.35,243.43 170.89,706.45 211.48,946.04 -72.97,-70.97 -153.99,-207.41 -289.14,-236.55 -136.47,-29.44 -217.95,47.68 -271.6,122.66 -17.14,23.96 -41.43,49.54 -26.29,78.84 83.96,35.51 113.37,-65.2 197.15,-74.47 22.65,-2.5 54.56,2.4 74.46,8.78 132.4,42.34 237.57,218.76 297.87,346.07 74.16,156.45 125.32,330.5 148.95,490.64 -65.71,11.4 -142.96,22.15 -219.25,36.52 -109.8,20.72 -158.81,10.75 -201.29,59.86 9.15,41.95 41.41,60.8 70.1,83.24 126.26,-16.84 252.45,-33.77 372.36,-56.97 20.43,89.25 51.98,218.51 74.45,311.05 40.53,26.02 88.88,-8.43 105.17,-35.06"
style="fill:#d3d2d2;fill-opacity:1;fill-rule:evenodd;stroke:none"
transform="matrix(0.13333333,0,0,-0.13333333,0,632.14667)" /></g></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Marianum Stundenplan</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
</dict>
</plist>
@@ -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()
}
}
+72
View File
@@ -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 12 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 15 oben in Xcode durchklicken (1015 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.
@@ -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..<periods.count {
let curr = periods[i]
if realMin > 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..<max(0, periods.count - 1), id: \.self) { i in
let curr = periods[i]
let next = periods[i + 1]
let virtualGap = next.virtualStartMinutes - curr.virtualEndMinutes
if virtualGap > 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<Int>()
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)
}
@@ -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)
}
}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
</array>
</dict>
</plist>
@@ -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<TimetableEntry>) -> 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<TimetableEntry>) -> 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
}
}
}
@@ -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
}
}
}
+44 -1
View File
@@ -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<App> with WidgetsBindingObserver {
late Timer _refetchChats;
late Timer _updateTimings;
StreamSubscription<dynamic>? _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<App> with WidgetsBindingObserver {
log('Refreshing due to LifecycleChange');
NotificationTasks.updateProviders(context);
});
_handlePendingWidgetNavigation();
}
}
Future<void> _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<App> 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<TimetableBloc>().refresh();
final timetable = context.read<TimetableBloc>();
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<SettingsCubit>();
_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<App> with WidgetsBindingObserver {
void dispose() {
_refetchChats.cancel();
_updateTimings.cancel();
_timetableWidgetSync?.cancel();
Main.bottomNavigator.removeListener(_onTabControllerChanged);
WidgetsBinding.instance.removeObserver(this);
super.dispose();
+178
View File
@@ -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<void> 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<void> requestImmediateRefresh() async {
await Workmanager().registerOneOffTask(
'$oneOffTaskName-${DateTime.now().millisecondsSinceEpoch}',
oneOffTaskName,
constraints: Constraints(networkType: NetworkType.connected),
existingWorkPolicy: ExistingWorkPolicy.append,
);
}
static Future<void> 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<void> _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<GetSubjectsResponse>(() => GetSubjects().run());
final rooms = await _runOrNull<GetRoomsResponse>(() => GetRooms().run());
final holidays = await _runOrNull<GetHolidaysResponse>(() => GetHolidays().run());
final timegrid = await _runOrNull<GetTimegridUnitsResponse>(
() => GetTimegridUnits().run(),
);
final customEvents = await _runOrNull<GetCustomTimetableEventResponse>(
() => 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<T?> _runOrNull<T>(Future<T> Function() task) async {
try {
return await task();
} on Exception catch (e) {
log('[widget-bg] reference fetch failed: $e');
return null;
}
}
+17
View File
@@ -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<void> main() async {
log('MarianumMobile started');
@@ -72,6 +74,15 @@ Future<void> 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<void> _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);
}
+8
View File
@@ -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<Login> {
void _onLoginSuccess() {
context.read<AccountBloc>().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
+6
View File
@@ -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;
+76
View File
@@ -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<String, Object?> 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<String, Object?> 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<WidgetLesson> lessons,
@Default(<WidgetPeriod>[]) List<WidgetPeriod> periods,
@Default(false) bool isHoliday,
String? holidayName,
}) = _WidgetTimetableData;
factory WidgetTimetableData.fromJson(Map<String, Object?> json) =>
_$WidgetTimetableDataFromJson(json);
}
+891
View File
@@ -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>(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<WidgetLesson> get copyWith => _$WidgetLessonCopyWithImpl<WidgetLesson>(this as WidgetLesson, _$identity);
/// Serializes this WidgetLesson to a JSON map.
Map<String, dynamic> 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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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<String, dynamic> 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<String, dynamic> 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<WidgetPeriod> get copyWith => _$WidgetPeriodCopyWithImpl<WidgetPeriod>(this as WidgetPeriod, _$identity);
/// Serializes this WidgetPeriod to a JSON map.
Map<String, dynamic> 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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(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<String, dynamic> 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<String, dynamic> 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<WidgetLesson> get lessons; List<WidgetPeriod> 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<WidgetTimetableData> get copyWith => _$WidgetTimetableDataCopyWithImpl<WidgetTimetableData>(this as WidgetTimetableData, _$identity);
/// Serializes this WidgetTimetableData to a JSON map.
Map<String, dynamic> 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<WidgetLesson> lessons, List<WidgetPeriod> 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<WidgetLesson>,periods: null == periods ? _self.periods : periods // ignore: cast_nullable_to_non_nullable
as List<WidgetPeriod>,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 extends Object?>(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 extends Object?>(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 extends Object?>(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 extends Object?>(TResult Function( DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> 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 extends Object?>(TResult Function( DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> 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 extends Object?>(TResult? Function( DateTime fetchedAt, DateTime anchorDate, List<WidgetLesson> lessons, List<WidgetPeriod> 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<WidgetLesson> lessons, final List<WidgetPeriod> periods = const <WidgetPeriod>[], this.isHoliday = false, this.holidayName}): _lessons = lessons,_periods = periods;
factory _WidgetTimetableData.fromJson(Map<String, dynamic> 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<WidgetLesson> _lessons;
@override List<WidgetLesson> get lessons {
if (_lessons is EqualUnmodifiableListView) return _lessons;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_lessons);
}
final List<WidgetPeriod> _periods;
@override@JsonKey() List<WidgetPeriod> 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<String, dynamic> 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<WidgetLesson> lessons, List<WidgetPeriod> 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<WidgetLesson>,periods: null == periods ? _self._periods : periods // ignore: cast_nullable_to_non_nullable
as List<WidgetPeriod>,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
+90
View File
@@ -0,0 +1,90 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'widget_data.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_WidgetLesson _$WidgetLessonFromJson(Map<String, dynamic> 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<String, dynamic> _$WidgetLessonToJson(_WidgetLesson instance) =>
<String, dynamic>{
'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<String, dynamic> 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<String, dynamic> _$WidgetPeriodToJson(_WidgetPeriod instance) =>
<String, dynamic>{
'name': instance.name,
'startMinutes': instance.startMinutes,
'endMinutes': instance.endMinutes,
'virtualStartMinutes': instance.virtualStartMinutes,
'virtualEndMinutes': instance.virtualEndMinutes,
};
_WidgetTimetableData _$WidgetTimetableDataFromJson(Map<String, dynamic> json) =>
_WidgetTimetableData(
fetchedAt: DateTime.parse(json['fetchedAt'] as String),
anchorDate: DateTime.parse(json['anchorDate'] as String),
lessons: (json['lessons'] as List<dynamic>)
.map((e) => WidgetLesson.fromJson(e as Map<String, dynamic>))
.toList(),
periods:
(json['periods'] as List<dynamic>?)
?.map((e) => WidgetPeriod.fromJson(e as Map<String, dynamic>))
.toList() ??
const <WidgetPeriod>[],
isHoliday: json['isHoliday'] as bool? ?? false,
holidayName: json['holidayName'] as String?,
);
Map<String, dynamic> _$WidgetTimetableDataToJson(
_WidgetTimetableData instance,
) => <String, dynamic>{
'fetchedAt': instance.fetchedAt.toIso8601String(),
'anchorDate': instance.anchorDate.toIso8601String(),
'lessons': instance.lessons,
'periods': instance.periods,
'isHoliday': instance.isHoliday,
'holidayName': instance.holidayName,
};
+472
View File
@@ -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<GetTimetableResponseObject> 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 = <WidgetLesson>[
...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<GetTimetableResponseObject> 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 = <WidgetLesson>[
...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<WidgetLesson> _resolveCollisions(List<WidgetLesson> 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<bool>.filled(lessons.length, false);
final bumps = List<int>.filled(lessons.length, 0);
for (var i = 0; i < lessons.length; i++) {
final l = lessons[i];
final myPrio = _priority(l.status);
final overrideIdxs = <int>[];
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 = <WidgetLesson>[];
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 = <String, List<WidgetLesson>>{};
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 = <WidgetLesson>[];
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<WidgetPeriod> _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 = <WidgetPeriod>[];
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<GetTimetableResponseObject> _mergePerDay(
List<GetTimetableResponseObject> lessons,
) {
final byDay = <int, List<GetTimetableResponseObject>>{};
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<GetTimetableResponseObject> _mergeAdjacentLessons(
List<GetTimetableResponseObject> 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 = <GetTimetableResponseObject>[];
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<WidgetLesson> _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<WidgetLesson> _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:0023:59 block.
static Iterable<WidgetLesson> _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;
}
+24
View File
@@ -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<bool> consumePendingTimetableTap() async {
try {
final raw = await _channel.invokeMethod<bool>('consumePendingNavigation');
return raw ?? false;
} on MissingPluginException {
return false;
} on PlatformException catch (e) {
log('WidgetNavigation channel error: $e');
return false;
}
}
}
+77
View File
@@ -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<void> 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';
}
}
}
+109
View File
@@ -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<void> ensureInitialized() async {
if (_initialised) return;
await HomeWidget.setAppGroupId(iosAppGroupId);
_initialised = true;
}
static Future<void> writeDayData(WidgetTimetableData data) async {
await ensureInitialized();
await HomeWidget.saveWidgetData<String>(dayDataKey, jsonEncode(data.toJson()));
await HomeWidget.saveWidgetData<String>(
fetchedAtKey,
data.fetchedAt.toIso8601String(),
);
}
static Future<void> writeWeekData(WidgetTimetableData data) async {
await ensureInitialized();
await HomeWidget.saveWidgetData<String>(
weekDataKey,
jsonEncode(data.toJson()),
);
await HomeWidget.saveWidgetData<String>(
fetchedAtKey,
data.fetchedAt.toIso8601String(),
);
}
static Future<void> setLoggedIn(bool loggedIn) async {
await ensureInitialized();
await HomeWidget.saveWidgetData<bool>(loggedInKey, loggedIn);
}
static Future<void> setConnectDoubleLessons(bool value) async {
await ensureInitialized();
await HomeWidget.saveWidgetData<bool>(connectDoubleLessonsKey, value);
}
/// Default `true` matches `default_settings.dart` — fresh install behaves
/// like the in-app calendar.
static Future<bool> getConnectDoubleLessons() async {
await ensureInitialized();
final value = await HomeWidget.getWidgetData<bool>(
connectDoubleLessonsKey,
defaultValue: true,
);
return value ?? true;
}
static Future<void> setThemeMode(String mode) async {
await ensureInitialized();
await HomeWidget.saveWidgetData<String>(themeModeKey, mode);
}
static Future<void> clear() async {
await ensureInitialized();
await HomeWidget.saveWidgetData<String>(dayDataKey, null);
await HomeWidget.saveWidgetData<String>(weekDataKey, null);
await HomeWidget.saveWidgetData<String>(fetchedAtKey, null);
await HomeWidget.saveWidgetData<bool>(loggedInKey, false);
}
static Future<void> 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');
}
}
}
+3 -1
View File
@@ -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
@@ -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'],
);
});
});
}