added base homescreen-widget setup, working on Android, iOS in progress
This commit is contained in:
@@ -2,7 +2,9 @@
|
|||||||
<application
|
<application
|
||||||
android:label="Marianum Fulda"
|
android:label="Marianum Fulda"
|
||||||
android:name="${applicationName}"
|
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
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -29,6 +31,32 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
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>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility?hl=en and
|
https://developer.android.com/training/package-visibility?hl=en and
|
||||||
@@ -42,4 +70,7 @@
|
|||||||
</intent>
|
</intent>
|
||||||
</queries>
|
</queries>
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<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>
|
</manifest>
|
||||||
|
|||||||
@@ -1,5 +1,42 @@
|
|||||||
package eu.mhsl.marianum.mobile.client
|
package eu.mhsl.marianum.mobile.client
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
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" />
|
||||||
@@ -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,5 +4,9 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>aps-environment</key>
|
<key>aps-environment</key>
|
||||||
<string>development</string>
|
<string>development</string>
|
||||||
|
<key>com.apple.security.application-groups</key>
|
||||||
|
<array>
|
||||||
|
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -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 |
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# iOS Widget Extension — Xcode Setup
|
||||||
|
|
||||||
|
Die Swift-Quellen unter `ios/TimetableWidgetExtension/` müssen einmalig in Xcode als **Widget Extension Target** verdrahtet werden — ohne diesen Schritt bleibt der Code unkompiliert.
|
||||||
|
|
||||||
|
## Schritt 1 — Widget-Extension-Target anlegen
|
||||||
|
|
||||||
|
1. `ios/Runner.xcworkspace` in Xcode öffnen.
|
||||||
|
2. Projekt-Sidebar → `Runner` (Projekt-Root) → **+ Add Target** unten links.
|
||||||
|
3. **iOS → Widget Extension** wählen.
|
||||||
|
4. Eigenschaften:
|
||||||
|
- Product Name: `TimetableWidgetExtension`
|
||||||
|
- Bundle Identifier: `eu.mhsl.marianum.mobile.client.TimetableWidgetExtension`
|
||||||
|
- Language: Swift
|
||||||
|
- Include Configuration Intent: **OFF** (StaticConfiguration reicht)
|
||||||
|
- Embed in: Runner
|
||||||
|
5. Beim Activate-Scheme-Dialog auf **Cancel** klicken.
|
||||||
|
|
||||||
|
## Schritt 2 — Vorhandene Quelldateien ins Target ziehen
|
||||||
|
|
||||||
|
Xcode hat zunächst Dummy-Dateien (`TimetableWidgetExtension.swift`, `TimetableWidgetExtensionBundle.swift`) angelegt. Diese **löschen** (Move to Trash). Dann:
|
||||||
|
|
||||||
|
1. Sidebar → Rechtsklick auf den Ordner `TimetableWidgetExtension` → **Add Files to "Runner"…**
|
||||||
|
2. Im File-Picker zu `ios/TimetableWidgetExtension/` navigieren und alle `.swift`-Dateien, die `Info.plist`, `TimetableWidgetExtension.entitlements` **und den `Assets.xcassets`-Ordner** selektieren (mit `marianum_m`-Asset darin — gleicher Asset-Name wie auf Android-Seite).
|
||||||
|
3. **Wichtig**: bei „Add to targets" nur `TimetableWidgetExtension` ankreuzen, **nicht** Runner.
|
||||||
|
|
||||||
|
## Schritt 3 — App Group aktivieren
|
||||||
|
|
||||||
|
Beide Targets brauchen die App-Group-Berechtigung, damit Hauptapp und Widget über `UserDefaults(suiteName:)` schreiben/lesen können.
|
||||||
|
|
||||||
|
1. **Runner**-Target → **Signing & Capabilities** → **+ Capability** → **App Groups**.
|
||||||
|
- Group-ID hinzufügen: `group.eu.mhsl.marianum.mobile.client.widget`
|
||||||
|
2. Dasselbe für **TimetableWidgetExtension** — mit derselben Group-ID.
|
||||||
|
|
||||||
|
Im Apple-Developer-Portal muss die App-Group bei beiden App-IDs eingetragen sein, sonst schlägt das Provisioning fehl.
|
||||||
|
|
||||||
|
## Schritt 4 — Entitlements verlinken
|
||||||
|
|
||||||
|
1. **Runner** → Build Settings → `CODE_SIGN_ENTITLEMENTS` sollte bereits auf `Runner/Runner.entitlements` zeigen.
|
||||||
|
2. **TimetableWidgetExtension** → Build Settings → `CODE_SIGN_ENTITLEMENTS` → auf `TimetableWidgetExtension/TimetableWidgetExtension.entitlements` setzen.
|
||||||
|
|
||||||
|
## Schritt 5 — Info.plist + Deployment Target
|
||||||
|
|
||||||
|
1. **TimetableWidgetExtension** → Build Settings → `INFOPLIST_FILE` → auf `TimetableWidgetExtension/Info.plist` setzen.
|
||||||
|
2. Build Settings → `IPHONEOS_DEPLOYMENT_TARGET` ≥ 16.0 (Code gated `.containerBackground` mit `if #available(iOS 17, *)`, läuft also auch auf 16).
|
||||||
|
|
||||||
|
## Schritt 6 — Build & Run
|
||||||
|
|
||||||
|
- Scheme `Runner` (nicht das Widget-Scheme) wählen → Run.
|
||||||
|
- Auf Home-Screen langes Drücken → Widget hinzufügen → "Marianum · Heute" / "Marianum · Woche".
|
||||||
|
- Widget-Tap öffnet die App im zuletzt sichtbaren Tab. Eine Tab-Navigation auf den Stundenplan ist bewusst nicht implementiert (Android nutzt Intent-Extras, iOS würde dafür ein URL-Scheme oder AppIntent brauchen — beides bewusst ausgespart).
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **Widget zeigt „Lade…"** auch nach Refresh: App-Group greift nicht. Prüfen, ob beide Targets dieselbe Group-ID haben und das Provisioning aktualisiert wurde.
|
||||||
|
- **Stale-Daten nach Logout**: `WidgetSync.clear()` schreibt `widget_data_logged_in_v1 = false`; Widget zeigt dann den Login-Placeholder.
|
||||||
|
- **Lessons um 1–2 Stunden verschoben**: Date-Parser-Bug. Sollte gefixt sein in `WidgetData.swift::parseDartDate` — verifizieren, dass die ISO-8601-Strings ohne Z-Suffix als `TimeZone.current` geparsed werden.
|
||||||
|
- **App-Store-Submit später**: `Runner.entitlements` `aps-environment` von `development` auf `production` umbiegen.
|
||||||
|
|
||||||
|
## Was bereits im Repo erledigt ist
|
||||||
|
|
||||||
|
- Alle Swift-Quellen, Info.plist, Entitlements liegen unter `ios/TimetableWidgetExtension/`.
|
||||||
|
- App-Group-ID konsistent zwischen Dart (`WidgetSync.iosAppGroupId`), Swift (`WidgetDataKey.appGroupId`) und der Entitlements-Datei.
|
||||||
|
- `home_widget`-Plugin auf der Dart-Seite konfiguriert; ruft `HomeWidget.setAppGroupId` beim ersten Sync.
|
||||||
|
- `containerBackground` für iOS 17+ gegated, fällt auf iOS 16 sauber zurück.
|
||||||
|
- Date-Parser fixt das fehlende Z-Suffix (Dart schreibt lokale Zeit ohne TZ-Marker).
|
||||||
|
|
||||||
|
## Was am Mac noch zu tun ist
|
||||||
|
|
||||||
|
- Schritte 1–5 oben in Xcode durchklicken (10–15 Min).
|
||||||
|
- `flutter pub get` + `cd ios && pod install`.
|
||||||
|
- Auf physischem Gerät oder iOS-Simulator (≥ 16.0) bauen.
|
||||||
|
- Widget aufs Home-Screen ziehen, prüfen dass Lesson-Zeiten korrekt rendern.
|
||||||
@@ -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
@@ -13,15 +13,19 @@ import 'model/data_cleaner.dart';
|
|||||||
import 'notification/notification_controller.dart';
|
import 'notification/notification_controller.dart';
|
||||||
import 'notification/notification_tasks.dart';
|
import 'notification/notification_tasks.dart';
|
||||||
import 'notification/notify_updater.dart';
|
import 'notification/notify_updater.dart';
|
||||||
|
import 'routing/app_routes.dart';
|
||||||
import 'state/app/modules/app_modules.dart';
|
import 'state/app/modules/app_modules.dart';
|
||||||
import 'state/app/modules/breaker/bloc/breaker_bloc.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/chat_list/bloc/chat_list_bloc.dart';
|
||||||
import 'state/app/modules/settings/bloc/settings_cubit.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_bloc.dart';
|
||||||
|
import 'state/app/modules/timetable/bloc/timetable_state.dart';
|
||||||
import 'storage/settings.dart' as model;
|
import 'storage/settings.dart' as model;
|
||||||
import 'utils/debouncer.dart';
|
import 'utils/debouncer.dart';
|
||||||
import 'view/pages/overhang.dart';
|
import 'view/pages/overhang.dart';
|
||||||
import 'widget/breaker/breaker.dart';
|
import 'widget/breaker/breaker.dart';
|
||||||
|
import 'widget_data/widget_navigation.dart';
|
||||||
|
import 'widget_data/widget_publisher.dart';
|
||||||
|
|
||||||
class App extends StatefulWidget {
|
class App extends StatefulWidget {
|
||||||
const App({super.key});
|
const App({super.key});
|
||||||
@@ -33,6 +37,7 @@ class App extends StatefulWidget {
|
|||||||
class _AppState extends State<App> with WidgetsBindingObserver {
|
class _AppState extends State<App> with WidgetsBindingObserver {
|
||||||
late Timer _refetchChats;
|
late Timer _refetchChats;
|
||||||
late Timer _updateTimings;
|
late Timer _updateTimings;
|
||||||
|
StreamSubscription<dynamic>? _timetableWidgetSync;
|
||||||
// Tracked via the bottom-nav controller's listener so it always reflects the
|
// Tracked via the bottom-nav controller's listener so it always reflects the
|
||||||
// user's actual position, even between rapid setting emits where the
|
// user's actual position, even between rapid setting emits where the
|
||||||
// controller hasn't caught up to a scheduled jump yet.
|
// 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');
|
log('Refreshing due to LifecycleChange');
|
||||||
NotificationTasks.updateProviders(context);
|
NotificationTasks.updateProviders(context);
|
||||||
});
|
});
|
||||||
|
_handlePendingWidgetNavigation();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _handlePendingWidgetNavigation() async {
|
||||||
|
final pending = await WidgetNavigation.consumePendingTimetableTap();
|
||||||
|
if (!pending || !mounted) return;
|
||||||
|
AppRoutes.goToTab(context, Modules.timetable);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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
|
// App is freshly mounted on every login (BlocConsumer in main.dart
|
||||||
// swaps it in for Login), so this also covers the post-logout case
|
// 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.
|
// 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), (_) {
|
_updateTimings = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||||
@@ -115,6 +157,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_refetchChats.cancel();
|
_refetchChats.cancel();
|
||||||
_updateTimings.cancel();
|
_updateTimings.cancel();
|
||||||
|
_timetableWidgetSync?.cancel();
|
||||||
Main.bottomNavigator.removeListener(_onTabControllerChanged);
|
Main.bottomNavigator.removeListener(_onTabControllerChanged);
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
|
|
||||||
import 'api/mhsl/breaker/get_breakers/get_breakers_response.dart';
|
import 'api/mhsl/breaker/get_breakers/get_breakers_response.dart';
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
import 'background/widget_background_task.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
import 'model/account_data.dart';
|
import 'model/account_data.dart';
|
||||||
import 'state/app/modules/account/bloc/account_bloc.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/app_progress_indicator.dart';
|
||||||
import 'widget/breaker/breaker.dart';
|
import 'widget/breaker/breaker.dart';
|
||||||
import 'widget/debug/cache_view.dart';
|
import 'widget/debug/cache_view.dart';
|
||||||
|
import 'widget_data/widget_sync.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
log('MarianumMobile started');
|
log('MarianumMobile started');
|
||||||
@@ -72,6 +74,15 @@ Future<void> main() async {
|
|||||||
await Future.wait(initialisationTasks);
|
await Future.wait(initialisationTasks);
|
||||||
log('app initialisation done!');
|
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(
|
unawaited(
|
||||||
FirebaseMessaging.instance.getToken().then(
|
FirebaseMessaging.instance.getToken().then(
|
||||||
(token) => log('Firebase token: ${token ?? "Error: no Firebase token!"}'),
|
(token) => log('Firebase token: ${token ?? "Error: no Firebase token!"}'),
|
||||||
@@ -287,6 +298,12 @@ Future<void> _wipeUserState({
|
|||||||
await prefs.clear();
|
await prefs.clear();
|
||||||
await HydratedBloc.storage.clear();
|
await HydratedBloc.storage.clear();
|
||||||
await const CacheView().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) {
|
} catch (e, s) {
|
||||||
log('User state wipe failed: $e', stackTrace: s);
|
log('User state wipe failed: $e', stackTrace: s);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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_bloc.dart';
|
||||||
import '../../state/app/modules/account/bloc/account_state.dart';
|
import '../../state/app/modules/account/bloc/account_state.dart';
|
||||||
import '../../theming/light_app_theme.dart';
|
import '../../theming/light_app_theme.dart';
|
||||||
@@ -34,6 +37,11 @@ class _LoginState extends State<Login> {
|
|||||||
|
|
||||||
void _onLoginSuccess() {
|
void _onLoginSuccess() {
|
||||||
context.read<AccountBloc>().setStatus(AccountStatus.loggedIn);
|
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
|
@override
|
||||||
|
|||||||
@@ -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.dart';
|
||||||
import '../../api/marianumcloud/talk/room/get_room_params.dart';
|
import '../../api/marianumcloud/talk/room/get_room_params.dart';
|
||||||
import '../../model/account_data.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
|
/// 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.
|
/// 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();
|
final user = username.trim().toLowerCase();
|
||||||
try {
|
try {
|
||||||
await AccountData().removeData();
|
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 AccountData().setData(user, password);
|
||||||
await GetRoom(GetRoomParams(includeStatus: false)).run();
|
await GetRoom(GetRoomParams(includeStatus: false)).run();
|
||||||
_loading = false;
|
_loading = false;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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:00–23: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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -3,7 +3,7 @@ description: Mobile client for Webuntis and Nextcloud with Talk integration
|
|||||||
|
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 0.1.7+46
|
version: 1.0.0+47
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.8.0 <4.0.0"
|
sdk: ">=3.8.0 <4.0.0"
|
||||||
|
|
||||||
@@ -34,6 +34,8 @@ dependencies:
|
|||||||
flutter_app_badge: ^2.0.2
|
flutter_app_badge: ^2.0.2
|
||||||
flutter_bloc: ^9.0.0
|
flutter_bloc: ^9.0.0
|
||||||
flutter_secure_storage: ^10.0.0
|
flutter_secure_storage: ^10.0.0
|
||||||
|
home_widget: ^0.7.0
|
||||||
|
workmanager: ^0.9.0+3
|
||||||
intl: ^0.20.2
|
intl: ^0.20.2
|
||||||
flutter_linkify: ^6.0.0
|
flutter_linkify: ^6.0.0
|
||||||
flutter_local_notifications: ^21.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'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user