merged develop

This commit is contained in:
2026-09-27 14:58:07 +02:00
188 changed files with 7474 additions and 2558 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ lib/
**Session:** Die aktive Sitzung liegt in `SessionManager().current` (`lib/session/`). Nextcloud-Zugriffe nur über `SessionManager().requireNextcloud()` – Eltern-Sessions haben keine Nextcloud-Identität. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE). **Session:** Die aktive Sitzung liegt in `SessionManager().current` (`lib/session/`). Nextcloud-Zugriffe nur über `SessionManager().requireNextcloud()` – Eltern-Sessions haben keine Nextcloud-Identität. Abmelden ausschließlich über `SessionLifecycle.signOut()`. Die Keychain-Keys in `SessionKeys` sind eingefroren (Bestandsinstallationen, iOS-NSE).
**Mehrere Konten:** Die `SessionKeys`-Slots, der MC-Token und die Group-Keychain spiegeln immer das *aktive* Konto; inaktive Konten liegen im Tresor (`accounts_index` + `account_vault_<id>`, `lib/session/account_codec.dart`). Wechseln/Hinzufügen/Entfernen nur über `SessionLifecycle` (`switchTo`, `beginAddAccount`/`finishLogin`/`cancelAddAccount`, `removeInactive`), danach `AccountBloc.activated(id)`. Lokale Daten sind pro Konto getrennt (`AccountStorage`: eigene HydratedBloc-Box + Localstore-Collection); nur die Settings liegen in der globalen Box. Alle Pro-Konto-Blocs gehören in `AccountScope` – er wird per Konto-ID neu aufgebaut (samt `MaterialApp`/Navigator). Push ist nur für das aktive Konto registriert. **Mehrere Konten:** Die `SessionKeys`-Slots, der MC-Token und die Group-Keychain spiegeln immer das *aktive* Konto; inaktive Konten liegen im Tresor (`accounts_index` + `account_vault_<id>`, `lib/session/account_codec.dart`). Wechseln/Hinzufügen/Entfernen nur über `SessionLifecycle` (`switchTo`, `beginAddAccount`/`finishLogin`/`cancelAddAccount`, `removeInactive`), danach `AccountBloc.activated(id)`. Lokale Daten sind pro Konto getrennt (`AccountStorage`: eigene HydratedBloc-Box; der Request-Cache `CacheStore` folgt dem aktiven Konto mit eigenem Verzeichnis); nur die Settings liegen in der globalen Box. `SessionManager().sessionEpoch` steigt bei jedem Kontowechsel – asynchrone Arbeit prüft damit, ob ihr Ergebnis noch zum aktiven Konto gehört. Was ein Konto außerhalb seines Speichers hinterlässt (Tray, Push-Schlüssel, Downloads), räumt `SessionWipe` auf. Alle Pro-Konto-Blocs gehören in `AccountScope` – er wird per Konto-ID neu aufgebaut (samt `MaterialApp`/Navigator). Push ist nur für das aktive Konto registriert.
**Rollen & Zuschnitt:** Views verzweigen nie auf Rollen. Module und Settings-Sections deklarieren `AccessRequirement`s (`nextcloud`, `guardian`; `AppModule.requirements`, `Settings._sections`); Views bekommen ein Subjekt + eine Policy, die an genau einer Stelle als pure Funktion aufgelöst wird (Vorbild: `TimetableSubject` + `TimetablePolicy.resolve`, `AbsenceFormPolicy`). `UserRole` nur für Anzeige/Policy-Ableitung. **Rollen & Zuschnitt:** Views verzweigen nie auf Rollen. Module und Settings-Sections deklarieren `AccessRequirement`s (`nextcloud`, `guardian`; `AppModule.requirements`, `Settings._sections`); Views bekommen ein Subjekt + eine Policy, die an genau einer Stelle als pure Funktion aufgelöst wird (Vorbild: `TimetableSubject` + `TimetablePolicy.resolve`, `AbsenceFormPolicy`). `UserRole` nur für Anzeige/Policy-Ableitung.
+3 -1
View File
@@ -9,7 +9,7 @@
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop" android:launchMode="singleTask"
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
@@ -98,6 +98,7 @@
the home_widget Flutter plugin resolves them as <app-package>.<name>. --> the home_widget Flutter plugin resolves them as <app-package>.<name>. -->
<receiver <receiver
android:name="eu.mhsl.marianum.mobile.client.TimetableDayWidget" android:name="eu.mhsl.marianum.mobile.client.TimetableDayWidget"
android:enabled="@bool/widget_supported"
android:label="@string/widget_day_label" android:label="@string/widget_day_label"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
@@ -110,6 +111,7 @@
<receiver <receiver
android:name="eu.mhsl.marianum.mobile.client.TimetableWeekWidget" android:name="eu.mhsl.marianum.mobile.client.TimetableWeekWidget"
android:enabled="@bool/widget_supported"
android:label="@string/widget_week_label" android:label="@string/widget_week_label"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
@@ -1,15 +1,38 @@
package eu.mhsl.marianum.mobile.client package eu.mhsl.marianum.mobile.client
import android.content.Intent import android.content.Intent
import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() { class MainActivity : FlutterActivity() {
private val widgetChannel = "eu.mhsl.marianum.widget" private val widgetChannel = "eu.mhsl.marianum.widget"
private val shareChannel = "eu.mhsl.marianum.share"
/// Last seen widget tap target. Cleared by Dart via `consumePendingNavigation` /// Last seen widget tap target. Cleared by Dart via `consumePendingNavigation`
/// so the same intent isn't replayed on every resume. /// so the same intent isn't replayed on every resume.
private var pendingTimetableTap: Boolean = false private var pendingTimetableTap: Boolean = false
private val shareActions = setOf(Intent.ACTION_SEND, Intent.ACTION_SEND_MULTIPLE)
/// receive_sharing_intent only emits warm shares to a live Dart listener and
/// does not buffer them. After a relaunch into an existing task the share
/// arrives via onNewIntent before Dart subscribed, so it is held here until
/// Dart reports `listenerReady`.
private var shareListenerReady = false
private var bufferedShare: Intent? = null
override fun onCreate(savedInstanceState: Bundle?) {
// Android replays the original launch intent when the activity is
// recreated from recents or after process death. A share or widget tap
// that was already handled would otherwise run again — for shares with
// temp files that are long gone.
val replayed = savedInstanceState != null ||
(intent.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0
if (replayed && (intent.action in shareActions || isWidgetTap(intent))) {
intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
}
super.onCreate(savedInstanceState)
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
@@ -26,17 +49,35 @@ class MainActivity : FlutterActivity() {
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
shareChannel
).setMethodCallHandler { call, result ->
when (call.method) {
"listenerReady" -> {
shareListenerReady = true
bufferedShare?.let { flutterEngine.activityControlSurface.onNewIntent(it) }
bufferedShare = null
result.success(null)
}
else -> result.notImplemented()
}
}
consumeIntentData(intent) consumeIntentData(intent)
} }
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
if (!shareListenerReady && intent.action in shareActions) {
bufferedShare = intent
}
consumeIntentData(intent) consumeIntentData(intent)
} }
private fun isWidgetTap(intent: Intent?): Boolean =
intent?.getBooleanExtra("widget_open_timetable", false) == true
private fun consumeIntentData(intent: Intent?) { private fun consumeIntentData(intent: Intent?) {
if (intent?.getBooleanExtra("widget_open_timetable", false) == true) { if (isWidgetTap(intent)) pendingTimetableTap = true
pendingTimetableTap = true
}
} }
} }
@@ -11,7 +11,7 @@ import java.util.TimeZone
// Mirror of lib/widget_data/widget_data.dart — JSON keys + enum names // Mirror of lib/widget_data/widget_data.dart — JSON keys + enum names
// must stay in sync. // must stay in sync.
enum class WidgetLessonStatus { enum class WidgetLessonStatus {
REGULAR, ONGOING, PAST, CANCELLED, IRREGULAR, TEACHER_CHANGED, EVENT; REGULAR, ONGOING, PAST, CANCELLED, IRREGULAR, TEACHER_CHANGED, EVENT, DUTY;
companion object { companion object {
fun fromWire(raw: String?): WidgetLessonStatus = when (raw) { fun fromWire(raw: String?): WidgetLessonStatus = when (raw) {
@@ -22,6 +22,7 @@ enum class WidgetLessonStatus {
"irregular" -> IRREGULAR "irregular" -> IRREGULAR
"teacherChanged" -> TEACHER_CHANGED "teacherChanged" -> TEACHER_CHANGED
"event" -> EVENT "event" -> EVENT
"duty" -> DUTY
else -> REGULAR else -> REGULAR
} }
} }
@@ -39,6 +40,9 @@ data class WidgetLesson(
val originalTeacher: String?, val originalTeacher: String?,
val status: WidgetLessonStatus, val status: WidgetLessonStatus,
val customColor: String?, val customColor: String?,
// Tile colour resolved in Dart (subject colours, palettes); null only for
// payloads written before the field existed.
val color: Int?,
val siblingCount: Int, val siblingCount: Int,
) )
@@ -50,6 +54,12 @@ data class WidgetPeriod(
val virtualEndMinutes: Int, val virtualEndMinutes: Int,
) )
data class WidgetDayInfo(
val date: Date,
val isHoliday: Boolean,
val holidayName: String?,
)
data class WidgetTimetableData( data class WidgetTimetableData(
val fetchedAt: Date, val fetchedAt: Date,
val anchorDate: Date, val anchorDate: Date,
@@ -57,6 +67,8 @@ data class WidgetTimetableData(
val periods: List<WidgetPeriod>, val periods: List<WidgetPeriod>,
val isHoliday: Boolean, val isHoliday: Boolean,
val holidayName: String?, val holidayName: String?,
// Week payload only; empty for the day payload.
val days: List<WidgetDayInfo>,
) )
object WidgetDataParser { object WidgetDataParser {
@@ -81,6 +93,11 @@ object WidgetDataParser {
} }
} }
private fun parseColor(raw: String?): Int? {
if (raw == null || !Regex("#[0-9A-Fa-f]{6}").matches(raw)) return null
return 0xFF000000.toInt() or raw.substring(1).toInt(16)
}
fun parse(json: String?): WidgetTimetableData? { fun parse(json: String?): WidgetTimetableData? {
if (json.isNullOrEmpty()) return null if (json.isNullOrEmpty()) return null
return try { return try {
@@ -102,6 +119,7 @@ object WidgetDataParser {
originalTeacher = obj.stringOrNull("originalTeacher"), originalTeacher = obj.stringOrNull("originalTeacher"),
status = WidgetLessonStatus.fromWire(obj.stringOrNull("status")), status = WidgetLessonStatus.fromWire(obj.stringOrNull("status")),
customColor = obj.stringOrNull("customColor"), customColor = obj.stringOrNull("customColor"),
color = parseColor(obj.stringOrNull("color")),
siblingCount = obj.optInt("siblingCount", 0), siblingCount = obj.optInt("siblingCount", 0),
) )
} }
@@ -120,6 +138,18 @@ object WidgetDataParser {
) )
} }
} }
val daysArray = root.optJSONArray("days")
val days = mutableListOf<WidgetDayInfo>()
if (daysArray != null) {
for (i in 0 until daysArray.length()) {
val obj = daysArray.optJSONObject(i) ?: continue
days += WidgetDayInfo(
date = parseDate(obj.stringOrNull("date")) ?: continue,
isHoliday = obj.optBoolean("isHoliday", false),
holidayName = obj.stringOrNull("holidayName"),
)
}
}
WidgetTimetableData( WidgetTimetableData(
fetchedAt = parseDate(root.stringOrNull("fetchedAt")) ?: Date(), fetchedAt = parseDate(root.stringOrNull("fetchedAt")) ?: Date(),
anchorDate = parseDate(root.stringOrNull("anchorDate")) ?: Date(), anchorDate = parseDate(root.stringOrNull("anchorDate")) ?: Date(),
@@ -127,6 +157,7 @@ object WidgetDataParser {
periods = periods, periods = periods,
isHoliday = root.optBoolean("isHoliday", false), isHoliday = root.optBoolean("isHoliday", false),
holidayName = root.stringOrNull("holidayName"), holidayName = root.stringOrNull("holidayName"),
days = days,
) )
} catch (_: JSONException) { } catch (_: JSONException) {
null null
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Icons "coffee_outlined", matches the in-app period ruler. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M18.52 3H6C4.92 3 3.98 3.89 3.98 5.02V10.69C3.98 14.53 6.94 17.91 10.78 18C14.72 18.14 18 14.95 18 11.02V9.98H18.52C20.44 9.98 21.98 8.44 21.98 6.52C21.98 4.55 20.44 3 18.52 3ZM15.98 5.02V8.02H6V5.02H15.98ZM15.98 9.98V11.02C15.98 13.78 13.78 15.98 11.02 15.98C8.25 15.98 6 13.78 6 11.02V9.98ZM18.52 8.02H18V5.02H18.52C19.31 5.02 20.02 5.67 20.02 6.52C20.02 7.31 19.31 8.02 18.52 8.02ZM3.98 18.98H20.02V21H3.98V18.98Z" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Icons "event_outlined", matches the in-app holiday region. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M18.98 3.98H18V2.02H15.98V3.98H8.02V2.02H6V3.98H5.02C3.89 3.98 3 4.92 3 6V20.02C3 21.09 3.89 21.98 5.02 21.98H18.98C20.11 21.98 21 21.09 21 20.02V6C21 4.92 20.11 3.98 18.98 3.98ZM18.98 20.02H5.02V9.98H18.98V20.02ZM18.98 8.02H5.02V6H18.98V8.02ZM12 12.98H17.02V18H12V12.98Z" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Material Icons "restaurant", matches the in-app break region. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M11.02 9H9V2.02H6.98V9H5.02V2.02H3V9C3 11.11 4.64 12.84 6.75 12.98V21.98H9.23V12.98C11.34 12.84 12.98 11.11 12.98 9V2.02H11.02V9ZM15.98 6V14.02H18.52V21.98H21V2.02C18.23 2.02 15.98 4.22 15.98 6Z" />
</vector>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Tinted at runtime with the tile colour resolved in Dart. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="7dp" />
<solid android:color="@android:color/white" />
</shape>
@@ -1,16 +0,0 @@
<?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>
@@ -1,6 +0,0 @@
<?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>
@@ -1,6 +0,0 @@
<?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>
@@ -1,6 +0,0 @@
<?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>
@@ -1,6 +0,0 @@
<?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>
@@ -1,6 +0,0 @@
<?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>
@@ -1,6 +0,0 @@
<?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>
@@ -1,6 +0,0 @@
<?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>
@@ -1,6 +0,0 @@
<?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,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="7dp" />
<solid android:color="@android:color/transparent" />
<stroke android:width="2dp" android:color="#C8F44336" />
</shape>
</item>
<item
android:left="2dp"
android:top="2dp"
android:right="2dp"
android:bottom="2dp"
android:drawable="@drawable/widget_lesson_cancelled_x" />
</layer-list>
@@ -5,7 +5,7 @@
android:viewportWidth="100" android:viewportWidth="100"
android:viewportHeight="100"> android:viewportHeight="100">
<path <path
android:strokeColor="#C8FF0000" android:strokeColor="#C8F44336"
android:strokeWidth="5" android:strokeWidth="2.5"
android:pathData="M0,0 L100,100 M100,0 L0,100" /> android:pathData="M0,0 L100,100 M100,0 L0,100" />
</vector> </vector>
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" <shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle"> android:shape="oval">
<corners android:radius="6dp" /> <solid android:color="@android:color/white" />
<solid android:color="#FF993333" />
</shape> </shape>
@@ -3,6 +3,14 @@
android:id="@+id/widget_break_block_root" android:id="@+id/widget_break_block_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="14dp" android:layout_height="14dp"
android:layout_marginTop="0dp" android:layout_marginTop="0dp">
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp" /> <ImageView
android:id="@+id/widget_break_block_icon"
android:layout_width="12dp"
android:layout_height="12dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:contentDescription="@null"
android:src="@drawable/widget_icon_restaurant" />
</FrameLayout>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_break_label_root"
android:layout_width="match_parent"
android:layout_height="14dp"
android:layout_marginTop="0dp">
<ImageView
android:id="@+id/widget_break_label_icon"
android:layout_width="10dp"
android:layout_height="10dp"
android:layout_gravity="center"
android:importantForAccessibility="no"
android:contentDescription="@null"
android:src="@drawable/widget_icon_coffee" />
</FrameLayout>
+40 -13
View File
@@ -1,16 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- FrameLayout root so the Marianum watermark can sit at bottom|end behind <!-- FrameLayout root so the Marianum watermark can sit at bottom|end behind
the actual widget content without disturbing the LinearLayout flow. --> the actual widget content without disturbing the LinearLayout flow.
Text sizes and chrome heights are dp: WidgetRenderer derives the grid
height from them, so they must not follow the system font scale. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_root" android:id="@+id/widget_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@drawable/app_widget_background"> android:background="@drawable/app_widget_background"
tools:ignore="SpUsage,SmallSp">
<!-- Bottom-leading so the mark looks like it's peeking out of the lower <!-- Width/height/end+bottom margins are overridden in the renderer so
left corner. Width/height/start+bottom margins are overridden in the the mark scales with the widget size. -->
renderer so the mark scales with the widget size. -->
<ImageView <ImageView
android:id="@+id/widget_watermark" android:id="@+id/widget_watermark"
android:layout_width="160dp" android:layout_width="160dp"
@@ -26,43 +28,68 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:padding="10dp"> android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="8dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="20dp"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical"> android:gravity="center_vertical">
<TextView <TextView
android:id="@+id/widget_day_title" android:id="@+id/widget_day_title"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_weight="1" android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:textColor="@color/widget_text_primary"
android:singleLine="true" android:maxLines="1"
android:ellipsize="end" android:ellipsize="end"
android:gravity="start|center_vertical"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="9dp"
android:autoSizeMaxTextSize="14dp"
android:autoSizeStepGranularity="0.5dp"
tools:text="Heute · 08.05." /> tools:text="Heute · 08.05." />
<TextView <TextView
android:id="@+id/widget_day_subtitle" android:id="@+id/widget_day_subtitle"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="10sp" android:layout_marginStart="6dp"
android:textSize="10dp"
android:textColor="@color/widget_text_secondary" android:textColor="@color/widget_text_secondary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="Stand: 14:32" /> tools:text="Stand: 14:32" />
</LinearLayout> </LinearLayout>
<!-- Narrow day widgets can't fit title and freshness side by side —
the renderer moves the freshness label down here instead. -->
<TextView
android:id="@+id/widget_day_subtitle_below"
android:layout_width="match_parent"
android:layout_height="12dp"
android:visibility="gone"
android:gravity="start|center_vertical"
android:textSize="9dp"
android:textColor="@color/widget_text_secondary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="Stand: 14:32" />
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" android:layout_weight="1"
android:layout_marginTop="6dp" android:layout_marginTop="4dp"
android:orientation="horizontal"> android:orientation="horizontal">
<FrameLayout <FrameLayout
android:id="@+id/widget_day_time_labels" android:id="@+id/widget_day_time_labels"
android:layout_width="32dp" android:layout_width="30dp"
android:layout_height="match_parent" /> android:layout_height="match_parent" />
<FrameLayout <FrameLayout
@@ -1,8 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Static picker preview of the day widget, laid out like WidgetRenderer's output. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@color/widget_background"> android:background="@color/widget_background"
tools:ignore="SpUsage,SmallSp,HardcodedText,UseAppTint">
<ImageView <ImageView
android:layout_width="160dp" android:layout_width="160dp"
@@ -20,164 +23,126 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:padding="10dp"> android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView <TextView
android:layout_width="0dp" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="20dp"
android:layout_weight="1" android:gravity="start|center_vertical"
android:textSize="14sp" android:includeFontPadding="false"
android:textSize="14dp"
android:textStyle="bold" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:textColor="@color/widget_text_primary"
android:singleLine="true" android:maxLines="1"
android:text="Heute · 06.05." /> 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 <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" android:layout_weight="1"
android:orientation="horizontal" android:layout_marginTop="4dp"
android:layout_marginTop="6dp"> android:orientation="horizontal">
<FrameLayout <FrameLayout android:layout_width="30dp" android:layout_height="match_parent">
android:layout_width="32dp" <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
android:layout_height="match_parent"> <FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="0dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="07:55" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="1" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="36dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="2" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="09:25" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="72dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="09:30" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="3" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="10:15" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp">
<ImageView android:layout_width="10dp" android:layout_height="10dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_text_secondary" android:src="@drawable/widget_icon_coffee" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="122dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="10:35" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="4" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="11:20" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="158dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="11:25" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="5" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="12:10" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="194dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="12:15" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="6" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:00" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="230dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:05" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="7" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:50" />
</FrameLayout>
</FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" <FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp" <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
android:layout_marginTop="22dp"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
android:textSize="11sp" android:textStyle="bold" <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
android:textColor="@color/widget_text_primary" android:text="1." /> <FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" <ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="07:55" /> </FrameLayout>
</LinearLayout> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp" <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
android:layout_marginTop="50dp"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" <FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#5C6BC0">
android:textSize="11sp" android:textStyle="bold" <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
android:textColor="@color/widget_text_primary" android:text="2." /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MA-LK" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="B11" />
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="08:40" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MUE" />
</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> </LinearLayout>
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
<FrameLayout <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
android:layout_width="0dp" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="DE" />
android:layout_height="match_parent" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A204" />
android:layout_weight="1"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SCHN" />
<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> </LinearLayout>
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#0097A7">
<FrameLayout <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
android:layout_width="match_parent" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EN" />
android:layout_height="22dp" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="F2.04" />
android:layout_marginTop="143dp" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="KRA" />
android:layout_marginStart="2dp" </LinearLayout>
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>
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#000000">
<FrameLayout <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
android:layout_width="match_parent" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="BIO" />
android:layout_height="22dp" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N103" />
android:layout_marginTop="170dp" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="WEB" />
android:layout_marginStart="2dp" </LinearLayout>
android:layout_marginEnd="2dp" <FrameLayout android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/widget_lesson_cancelled_overlay" />
android:padding="3dp" </FrameLayout>
android:background="@drawable/widget_lesson_block_teacher_changed"> <FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="230.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#29639B">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
android:textSize="11sp" android:textStyle="bold" <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="GE" />
android:textColor="@android:color/white" android:text="GE" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A110" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BER" />
</LinearLayout>
</FrameLayout> </FrameLayout>
</FrameLayout> </FrameLayout>
</LinearLayout> </LinearLayout>
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Mirrors the in-app holiday region: icon and "Schulfrei" on top, the
holiday name rotated along the column. The name's width is set by the
renderer — after the rotation it is the length along the column. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_holiday_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:ignore="SpUsage,SmallSp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="vertical"
android:gravity="center_horizontal">
<ImageView
android:id="@+id/widget_holiday_icon"
android:layout_width="14dp"
android:layout_height="14dp"
android:importantForAccessibility="no"
android:contentDescription="@null"
android:src="@drawable/widget_icon_event" />
<TextView
android:id="@+id/widget_holiday_label"
android:layout_width="match_parent"
android:layout_height="12dp"
android:layout_marginTop="2dp"
android:paddingStart="2dp"
android:paddingEnd="2dp"
android:gravity="center"
android:text="@string/widget_school_free"
android:textColor="@color/widget_text_primary"
android:maxLines="1"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="6dp"
android:autoSizeMaxTextSize="9dp"
android:autoSizeStepGranularity="0.5dp" />
</LinearLayout>
<TextView
android:id="@+id/widget_holiday_name"
android:layout_width="120dp"
android:layout_height="14dp"
android:layout_gravity="center"
android:rotation="90"
android:gravity="center"
android:textStyle="bold"
android:letterSpacing="0.1"
android:textColor="@color/widget_text_primary"
android:maxLines="1"
android:ellipsize="end"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="7dp"
android:autoSizeMaxTextSize="11dp"
android:autoSizeStepGranularity="0.5dp"
tools:text="Herbstferien" />
</FrameLayout>
@@ -1,90 +1,113 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Mirrors AppointmentTile: title on top, room and teacher stacked below,
each line shrinking on its own to fit the tile width. Text sizes are dp
(not sp) because the line boxes are sized in dp by the renderer. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_lesson_block_root" android:id="@+id/widget_lesson_block_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="40dp" android:layout_height="40dp"
android:layout_marginStart="2dp" android:layout_marginStart="1dp"
android:layout_marginEnd="2dp" android:layout_marginEnd="1dp"
android:layout_marginTop="0dp" android:layout_marginTop="0dp"
android:paddingStart="7dp" android:background="@drawable/widget_lesson_block"
android:paddingEnd="7dp" tools:ignore="SpUsage,SmallSp">
android:paddingTop="3dp"
android:paddingBottom="3dp"
android:background="@drawable/widget_lesson_block_regular">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical"
android:paddingStart="4dp"
android:paddingEnd="4dp"
android:paddingTop="2dp"
android:paddingBottom="2dp">
<LinearLayout
android:id="@+id/widget_lesson_title_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="top"
android:baselineAligned="false"> android:baselineAligned="false">
<TextView <TextView
android:id="@+id/widget_lesson_subject" android:id="@+id/widget_lesson_subject"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="14dp"
android:layout_weight="1" android:layout_weight="1"
android:textStyle="bold" android:fontFamily="sans-serif-medium"
android:textColor="@android:color/white" android:textColor="@android:color/white"
android:maxLines="1" android:maxLines="1"
android:ellipsize="end" android:ellipsize="end"
android:gravity="top|start" android:gravity="start|center_vertical"
android:includeFontPadding="false" android:includeFontPadding="false"
android:autoSizeTextType="uniform" android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="5sp" android:autoSizeMinTextSize="6.5dp"
android:autoSizeMaxTextSize="11sp" android:autoSizeMaxTextSize="15dp"
android:autoSizeStepGranularity="1sp" android:autoSizeStepGranularity="0.5dp"
tools:text="MA-LK" /> tools:text="MA-LK" />
<LinearLayout <!-- Only for tiles too short for a body line below the title. -->
android:id="@+id/widget_lesson_secondary_stack" <TextView
android:layout_width="0dp" android:id="@+id/widget_lesson_inline"
android:layout_height="match_parent" android:layout_width="wrap_content"
android:layout_weight="1" android:layout_height="wrap_content"
android:orientation="vertical" android:layout_gravity="center_vertical"
android:gravity="top|end" android:layout_marginStart="3dp"
android:layout_marginStart="4dp"> android:textColor="@android:color/white"
android:textSize="7dp"
android:maxLines="1"
android:includeFontPadding="false"
android:visibility="gone"
tools:text="B11" />
</LinearLayout>
<TextView <TextView
android:id="@+id/widget_lesson_room" android:id="@+id/widget_lesson_room"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="12dp" android:layout_height="10dp"
android:textColor="#CCFFFFFF" android:textColor="@android:color/white"
android:maxLines="1" android:maxLines="1"
android:ellipsize="end" android:ellipsize="end"
android:gravity="end|top" android:gravity="start|center_vertical"
android:includeFontPadding="false" android:includeFontPadding="false"
android:autoSizeTextType="uniform" android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="5sp" android:autoSizeMinTextSize="5.5dp"
android:autoSizeMaxTextSize="11sp" android:autoSizeMaxTextSize="10dp"
android:autoSizeStepGranularity="1sp" android:autoSizeStepGranularity="0.5dp"
tools:text="A12" /> tools:text="A12" />
<TextView <TextView
android:id="@+id/widget_lesson_teacher" android:id="@+id/widget_lesson_teacher"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="12dp" android:layout_height="10dp"
android:textColor="#B3FFFFFF" android:textColor="@android:color/white"
android:maxLines="1" android:maxLines="1"
android:ellipsize="end" android:ellipsize="end"
android:gravity="end|top" android:gravity="start|center_vertical"
android:includeFontPadding="false" android:includeFontPadding="false"
android:autoSizeTextType="uniform" android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="5sp" android:autoSizeMinTextSize="5.5dp"
android:autoSizeMaxTextSize="11sp" android:autoSizeMaxTextSize="10dp"
android:autoSizeStepGranularity="1sp" android:autoSizeStepGranularity="0.5dp"
tools:text="Müller" /> tools:text="Müller" />
</LinearLayout> </LinearLayout>
</LinearLayout>
<FrameLayout
android:id="@+id/widget_lesson_cancelled"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/widget_lesson_cancelled_overlay"
android:visibility="gone" />
<TextView <TextView
android:id="@+id/widget_lesson_sibling_badge" android:id="@+id/widget_lesson_sibling_badge"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_gravity="bottom|start" android:layout_gravity="bottom|end"
android:layout_marginEnd="4dp"
android:layout_marginBottom="2dp"
android:textStyle="bold" android:textStyle="bold"
android:textSize="12sp" android:textSize="9dp"
android:textColor="@android:color/white" android:textColor="@android:color/white"
android:maxLines="1" android:maxLines="1"
android:includeFontPadding="false" android:includeFontPadding="false"
@@ -1,31 +1,50 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <!-- One period cell of the ruler, mirroring the in-app _PeriodLabel: number
centred, start time above, end time below. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_time_label_root" android:id="@+id/widget_time_label_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="36dp"
android:layout_marginTop="0dp" android:layout_marginTop="0dp"
android:orientation="vertical" tools:ignore="SpUsage,SmallSp">
android:gravity="end"
android:paddingEnd="3dp">
<TextView <TextView
android:id="@+id/widget_time_label_time" android:id="@+id/widget_time_label_start"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="9sp" android:layout_gravity="top"
android:textColor="@color/widget_text_primary" android:layout_marginTop="2dp"
android:singleLine="true" android:gravity="center_horizontal"
android:lineSpacingExtra="-2dp" android:textSize="7dp"
android:textColor="@color/widget_text_secondary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="07:55" /> tools:text="07:55" />
<TextView <TextView
android:id="@+id/widget_time_label_number" android:id="@+id/widget_time_label_number"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:layout_gravity="center_vertical"
android:gravity="center_horizontal"
android:fontFamily="sans-serif-medium"
android:textSize="11dp"
android:textColor="@color/widget_text_primary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="1" />
<TextView
android:id="@+id/widget_time_label_end"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:layout_marginBottom="2dp"
android:gravity="center_horizontal"
android:textSize="7dp"
android:textColor="@color/widget_text_secondary" android:textColor="@color/widget_text_secondary"
android:singleLine="true" android:maxLines="1"
android:lineSpacingExtra="-2dp" android:includeFontPadding="false"
tools:text="1." /> tools:text="08:40" />
</LinearLayout> </FrameLayout>
+36 -18
View File
@@ -1,11 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?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.
Text sizes and chrome heights are dp: WidgetRenderer derives the grid
height from them, so they must not follow the system font scale. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_root" android:id="@+id/widget_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@drawable/app_widget_background"> android:background="@drawable/app_widget_background"
tools:ignore="SpUsage,SmallSp">
<!-- Width/height/end+bottom margins are overridden in the renderer so
the mark scales with the widget size. -->
<ImageView <ImageView
android:id="@+id/widget_watermark" android:id="@+id/widget_watermark"
android:layout_width="160dp" android:layout_width="160dp"
@@ -21,42 +28,53 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:padding="10dp"> android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="8dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="20dp"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical"> android:gravity="center_vertical">
<TextView <TextView
android:id="@+id/widget_week_title" android:id="@+id/widget_week_title"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_weight="1" android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:textColor="@color/widget_text_primary"
android:singleLine="true" android:maxLines="1"
android:ellipsize="end" android:ellipsize="end"
android:gravity="start|center_vertical"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="9dp"
android:autoSizeMaxTextSize="14dp"
android:autoSizeStepGranularity="0.5dp"
tools:text="KW 19 · 06.05.–10.05." /> tools:text="KW 19 · 06.05.–10.05." />
<TextView <TextView
android:id="@+id/widget_week_subtitle" android:id="@+id/widget_week_subtitle"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="10sp" android:layout_marginStart="6dp"
android:textSize="10dp"
android:textColor="@color/widget_text_secondary" android:textColor="@color/widget_text_secondary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="Stand: 14:32" /> tools:text="Stand: 14:32" />
</LinearLayout> </LinearLayout>
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="32dp"
android:orientation="horizontal" android:layout_marginTop="4dp"
android:layout_marginTop="6dp"> android:orientation="horizontal">
<FrameLayout <FrameLayout
android:layout_width="28dp" android:layout_width="30dp"
android:layout_height="wrap_content" /> android:layout_height="match_parent" />
<FrameLayout <FrameLayout
android:layout_width="1dp" android:layout_width="1dp"
@@ -66,7 +84,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_header_mon" android:id="@+id/widget_week_header_mon"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_weight="1" /> android:layout_weight="1" />
<FrameLayout <FrameLayout
@@ -77,7 +95,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_header_tue" android:id="@+id/widget_week_header_tue"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_weight="1" /> android:layout_weight="1" />
<FrameLayout <FrameLayout
@@ -88,7 +106,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_header_wed" android:id="@+id/widget_week_header_wed"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_weight="1" /> android:layout_weight="1" />
<FrameLayout <FrameLayout
@@ -99,7 +117,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_header_thu" android:id="@+id/widget_week_header_thu"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_weight="1" /> android:layout_weight="1" />
<FrameLayout <FrameLayout
@@ -110,7 +128,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_header_fri" android:id="@+id/widget_week_header_fri"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:layout_weight="1" /> android:layout_weight="1" />
</LinearLayout> </LinearLayout>
@@ -123,7 +141,7 @@
<FrameLayout <FrameLayout
android:id="@+id/widget_week_time_labels" android:id="@+id/widget_week_time_labels"
android:layout_width="28dp" android:layout_width="30dp"
android:layout_height="match_parent" /> android:layout_height="match_parent" />
<FrameLayout <FrameLayout
@@ -1,28 +1,35 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Mirrors the in-app _DayHeaderCell: weekday in caps, day number below,
today inside an accent circle. -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_week_day_header_root" android:id="@+id/widget_week_day_header_root"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:gravity="center" android:gravity="center"
android:paddingTop="2dp" tools:ignore="SpUsage,SmallSp">
android:paddingBottom="3dp">
<TextView <TextView
android:id="@+id/widget_week_day_header_weekday" android:id="@+id/widget_week_day_header_weekday"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:fontFamily="sans-serif-medium"
android:textStyle="bold" android:textSize="9dp"
android:textColor="@color/widget_text_primary" android:textColor="@color/widget_text_secondary"
tools:text="Mo" /> android:maxLines="1"
android:includeFontPadding="false"
tools:text="MO." />
<TextView <TextView
android:id="@+id/widget_week_day_header_date" android:id="@+id/widget_week_day_header_date"
android:layout_width="wrap_content" android:layout_width="18dp"
android:layout_height="wrap_content" android:layout_height="18dp"
android:textSize="9sp" android:layout_marginTop="1dp"
android:textColor="@color/widget_text_secondary" android:gravity="center"
tools:text="06.05." /> android:textSize="11dp"
android:textColor="@color/widget_text_primary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="6" />
</LinearLayout> </LinearLayout>
@@ -1,10 +1,11 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Static preview for the week widget. Five day columns with two short <!-- Static picker preview of the week widget, laid out like WidgetRenderer's output. -->
demo blocks each so the structure is recognisable in the picker. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:background="@color/widget_background"> android:background="@color/widget_background"
tools:ignore="SpUsage,SmallSp,HardcodedText,UseAppTint">
<ImageView <ImageView
android:layout_width="200dp" android:layout_width="200dp"
@@ -22,124 +23,312 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:padding="10dp"> android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="8dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView <TextView
android:layout_width="0dp" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="20dp"
android:layout_weight="1" android:gravity="start|center_vertical"
android:textSize="14sp" android:includeFontPadding="false"
android:textSize="14dp"
android:textStyle="bold" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:textColor="@color/widget_text_primary"
android:singleLine="true" android:maxLines="1"
android:text="KW 19 · 06.05.–10.05." /> android:text="KW 19 · 04.05.–08.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 <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="32dp"
android:orientation="horizontal" android:layout_marginTop="4dp"
android:layout_marginTop="6dp"> android:orientation="horizontal">
<FrameLayout android:layout_width="20dp" android:layout_height="wrap_content" /> <FrameLayout android:layout_width="30dp" android:layout_height="match_parent" />
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <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"> <LinearLayout android:layout_width="0dp" android:layout_height="match_parent" 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:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" 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." /> <TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="4" />
</LinearLayout> </LinearLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <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"> <LinearLayout android:layout_width="0dp" android:layout_height="match_parent" 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:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" 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." /> <TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="5" />
</LinearLayout> </LinearLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <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"> <LinearLayout android:layout_width="0dp" android:layout_height="match_parent" 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:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" 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." /> <TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="6" />
</LinearLayout> </LinearLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <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"> <LinearLayout android:layout_width="0dp" android:layout_height="match_parent" 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:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" 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." /> <TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="7" />
</LinearLayout> </LinearLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <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"> <LinearLayout android:layout_width="0dp" android:layout_height="match_parent" 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:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" 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." /> <TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="8" />
</LinearLayout> </LinearLayout>
</LinearLayout> </LinearLayout>
<!-- Time grid: time-label column + 5 day columns -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" android:layout_weight="1"
android:orientation="horizontal" android:layout_marginTop="2dp"
android:layout_marginTop="2dp"> android:orientation="horizontal">
<FrameLayout android:layout_width="20dp" android:layout_height="match_parent"> <FrameLayout android:layout_width="30dp" 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" /> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
<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" /> <FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="0dp">
<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" /> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="07:55" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="1" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="36dp">
<!-- Mon --> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1"> <TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="2" />
<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="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="09:25" />
<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="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"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<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 android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="72dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="09:30" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="3" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="10:15" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp">
<ImageView android:layout_width="10dp" android:layout_height="10dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_text_secondary" android:src="@drawable/widget_icon_coffee" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="122dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="10:35" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="4" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="11:20" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="158dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="11:25" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="5" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="12:10" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="194dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="12:15" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="6" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:00" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="230dp">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:05" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:gravity="center_horizontal" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="7" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom" android:layout_marginBottom="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="13:50" />
</FrameLayout> </FrameLayout>
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <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="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"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
<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 android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
</FrameLayout> </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"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<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 android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#5C6BC0">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MA-LK" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="B11" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MUE" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="DE" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A204" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SCHN" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#0097A7">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EN" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="F2.04" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="KRA" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#000000">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="BIO" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N103" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="WEB" />
</LinearLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/widget_lesson_cancelled_overlay" />
</FrameLayout> </FrameLayout>
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <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="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"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
<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 android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#26A69A">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="SPO" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="TH1" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BAU" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="36.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#8F19B3">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="PoWi" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A105" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="HOF" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="84.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#29639B">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="PH" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N201" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="LEH" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#9E9D24">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="GE" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A110" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BER" />
</LinearLayout>
</FrameLayout> </FrameLayout>
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <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="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"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
<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 android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
</FrameLayout> </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"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<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 android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#E91E63">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="CH" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N305" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MEI" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#5C6BC0">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MA-LK" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="B11" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MUE" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="DE" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A204" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SCHN" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="194.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#0097A7">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EN" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="F2.04" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="KRA" />
</LinearLayout>
</FrameLayout> </FrameLayout>
</FrameLayout> </FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" /> <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="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"> <FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
<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 android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#78909C">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EK" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A301" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="LAN" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="36.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="BIO" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N103" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="WEB" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#7E57C2">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MU" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="M1" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SIN" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#F4511E">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="KU" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="K2" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MAL" />
</LinearLayout>
</FrameLayout>
</FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<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="0dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="PH" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N201" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="LEH" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#FFA000">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="LA" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A108" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="CIC" />
</LinearLayout>
</FrameLayout>
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#9E9D24">
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="GE" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A110" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BER" />
</LinearLayout>
</FrameLayout> </FrameLayout>
</FrameLayout> </FrameLayout>
</LinearLayout> </LinearLayout>
@@ -4,4 +4,6 @@
<color name="widget_text_primary">#FFF1F1F1</color> <color name="widget_text_primary">#FFF1F1F1</color>
<color name="widget_text_secondary">#FFB0B0B0</color> <color name="widget_text_secondary">#FFB0B0B0</color>
<color name="widget_divider">#33FFFFFF</color> <color name="widget_divider">#33FFFFFF</color>
<color name="widget_preview_accent">#FFFFB3AF</color>
<color name="widget_preview_break">#32FFB3AF</color>
</resources> </resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<bool name="widget_supported">true</bool>
</resources>
@@ -6,6 +6,7 @@
<string name="widget_week_description">Stundenplan und Vertretungen für die ganze Schulwoche.</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_no_lessons">Keine Stunden</string>
<string name="widget_holiday">Ferien</string> <string name="widget_holiday">Ferien</string>
<string name="widget_school_free">Schulfrei</string>
<string name="widget_login_required">Bitte einloggen, um den Stundenplan zu laden</string> <string name="widget_login_required">Bitte einloggen, um den Stundenplan zu laden</string>
<string name="widget_loading">Lade…</string> <string name="widget_loading">Lade…</string>
<string name="widget_status_label">Stand: %1$s</string> <string name="widget_status_label">Stand: %1$s</string>
@@ -1,17 +1,9 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <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_background">#FFFCF7F5</color>
<color name="widget_text_primary">#FF111111</color> <color name="widget_text_primary">#FF111111</color>
<color name="widget_text_secondary">#FF555555</color> <color name="widget_text_secondary">#FF555555</color>
<color name="widget_divider">#22000000</color> <color name="widget_divider">#22000000</color>
<color name="widget_preview_accent">#FF904A47</color>
<color name="widget_preview_break">#32904A47</color>
</resources> </resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- WidgetRenderer positions every tile with RemoteViews calls that only
exist from Android 12 (API 31). Below that the widgets are not
offered at all rather than showing up empty. -->
<bool name="widget_supported">false</bool>
</resources>
+2 -2
View File
@@ -180,11 +180,11 @@ AppDelegate) exakt so ab: `GenericPassword` + `kSecAttrAccount = <key>` +
| `push_server_public_key_pem` | **Server**-Public-Key (per User), SPKI-PEM | `push_registration_store.dart` | **NSE** (Signatur prüfen) | | `push_server_public_key_pem` | **Server**-Public-Key (per User), SPKI-PEM | `push_registration_store.dart` | **NSE** (Signatur prüfen) |
| `push_device_identifier` | NC Device-Identifier | `push_registration_store.dart` | Dart | | `push_device_identifier` | NC Device-Identifier | `push_registration_store.dart` | Dart |
| `push_registered_fcm_token` | FCM-Token der Registrierung | `push_registration_store.dart` | Dart | | `push_registered_fcm_token` | FCM-Token der Registrierung | `push_registration_store.dart` | Dart |
| `nextcloud_app_password` | NC App-Password | `account_data.dart` | **AppDelegate** (Basic-Auth) | | `nextcloud_app_password` | NC App-Password | `session_manager.dart` | **AppDelegate** (Basic-Auth) |
| `nextcloud_username` | NC Username | `push_registration_store.dart` (**neu**) | **AppDelegate** (Basic-Auth) | | `nextcloud_username` | NC Username | `push_registration_store.dart` (**neu**) | **AppDelegate** (Basic-Auth) |
| `nextcloud_base_url` | z.B. `https://cloud.marianum-fulda.de` | `push_registration_store.dart` (**neu**) | **AppDelegate** (OCS-URL) | | `nextcloud_base_url` | z.B. `https://cloud.marianum-fulda.de` | `push_registration_store.dart` (**neu**) | **AppDelegate** (OCS-URL) |
> `username` und `password` (Realpasswort) liegen in AccountDatas **Default**-Storage > `username` und `password` (Realpasswort) liegen im **Default**-Storage des SessionManagers
> **ohne** `groupId` → nur im primären Access-Group der App, **für die NSE > **ohne** `groupId` → nur im primären Access-Group der App, **für die NSE
> unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei > unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei
> jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das > jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das
+25 -2
View File
@@ -13,21 +13,44 @@ class SceneDelegate: FlutterSceneDelegate {
options connectionOptions: UIScene.ConnectionOptions options connectionOptions: UIScene.ConnectionOptions
) { ) {
super.scene(scene, willConnectTo: session, options: connectionOptions) super.scene(scene, willConnectTo: session, options: connectionOptions)
for context in connectionOptions.urlContexts { var handledShare = false
for context in connectionOptions.urlContexts where isShareUrl(context.url) {
_ = ReceiveSharingIntentPlugin.instance.application( _ = ReceiveSharingIntentPlugin.instance.application(
UIApplication.shared, UIApplication.shared,
didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url] didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url]
) )
handledShare = true
} }
if handledShare { clearSharedPayload() }
} }
override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) { override func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
for context in URLContexts { let shares = URLContexts.filter { isShareUrl($0.url) }
for context in shares {
_ = ReceiveSharingIntentPlugin.instance.application( _ = ReceiveSharingIntentPlugin.instance.application(
UIApplication.shared, UIApplication.shared,
open: context.url, open: context.url,
options: [:] options: [:]
) )
} }
if !shares.isEmpty { clearSharedPayload() }
let others = URLContexts.subtracting(shares)
if !others.isEmpty { super.scene(scene, openURLContexts: others) }
}
private func isShareUrl(_ url: URL) -> Bool {
ReceiveSharingIntentPlugin.instance.hasMatchingSchemePrefix(url: url)
}
// The plugin keeps the payload in memory once read but never removes it
// from the App Group defaults. The Share Extension opens the app twice per
// share and a scene reconnect replays the URL, so every later delivery
// would publish the finished share again.
private func clearSharedPayload() {
let groupId = Bundle.main.object(forInfoDictionaryKey: "AppGroupId") as? String
let defaults = UserDefaults(
suiteName: groupId ?? "group.\(Bundle.main.bundleIdentifier ?? "")")
defaults?.removeObject(forKey: "ShareKey")
defaults?.removeObject(forKey: "ShareMessageKey")
} }
} }
+45 -9
View File
@@ -152,17 +152,54 @@ final class ShareViewController: UIViewController {
} }
} }
private func copyIntoAppGroup(src: URL) -> URL? { // loadItem-Callbacks laufen parallel; Ordneranlage und Namenswahl seriell.
private let fileQueue = DispatchQueue(label: "share.files")
// Ein Ordner pro Share: Gleichnamige Dateien (z. B. bearbeitete Fotos, alle
// "FullSizeRender.jpg") überschrieben sich sonst gegenseitig, auch über
// zwei Shares hinweg. Die App löscht den Ordner, sobald der Share erledigt ist.
private lazy var shareDir: URL? = {
guard let container = FileManager.default guard let container = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else { .containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else {
return nil return nil
} }
let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent let root = container.appendingPathComponent("share_intent", isDirectory: true)
let dst = container.appendingPathComponent(name) sweepStaleShares(in: root)
let dir = root.appendingPathComponent(UUID().uuidString, isDirectory: true)
do { do {
if FileManager.default.fileExists(atPath: dst.path) { try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try FileManager.default.removeItem(at: dst) return dir
} catch {
return nil
} }
}()
// Shares, deren App-Flow nie abgeschlossen wurde (Prozess beendet o. ä.),
// räumt sonst niemand weg — der App-Group-Container wird nie geleert.
private func sweepStaleShares(in root: URL) {
let fm = FileManager.default
guard let entries = try? fm.contentsOfDirectory(
at: root, includingPropertiesForKeys: [.creationDateKey]) else { return }
let cutoff = Date().addingTimeInterval(-24 * 60 * 60)
for entry in entries {
let created = (try? entry.resourceValues(forKeys: [.creationDateKey]))?.creationDate
if let created, created < cutoff { try? fm.removeItem(at: entry) }
}
}
private func copyIntoAppGroup(src: URL) -> URL? {
fileQueue.sync { copyIntoShareDir(src: src) }
}
private func copyIntoShareDir(src: URL) -> URL? {
guard let dir = shareDir else { return nil }
let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent
var dst = dir.appendingPathComponent(name)
// Mehrfachauswahl mit gleichem Namen innerhalb eines Shares.
if FileManager.default.fileExists(atPath: dst.path) {
dst = dir.appendingPathComponent("\(UUID().uuidString.prefix(8))-\(name)")
}
do {
try FileManager.default.copyItem(at: src, to: dst) try FileManager.default.copyItem(at: src, to: dst)
return dst return dst
} catch { } catch {
@@ -171,10 +208,9 @@ final class ShareViewController: UIViewController {
} }
private func writePng(image: UIImage) -> URL? { private func writePng(image: UIImage) -> URL? {
guard let container = FileManager.default guard let data = image.pngData(),
.containerURL(forSecurityApplicationGroupIdentifier: appGroupId), let dir = fileQueue.sync(execute: { shareDir }) else { return nil }
let data = image.pngData() else { return nil } let dst = dir.appendingPathComponent("\(UUID().uuidString).png")
let dst = container.appendingPathComponent("\(UUID().uuidString).png")
return (try? data.write(to: dst)) != nil ? dst : nil return (try? data.write(to: dst)) != nil ? dst : nil
} }
@@ -1,59 +1,6 @@
import SwiftUI import SwiftUI
import WidgetKit 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
}
// Below these tile heights the secondary stack collapses progressively:
// shorter than ROOM_MIN drops everything, between ROOM_MIN and
// TEACHER_SEPARATE_MIN keeps the room but drops the teacher.
let BLOCK_SHOW_ROOM_MIN: CGFloat = 13
let BLOCK_SHOW_TEACHER_SEPARATE_MIN: CGFloat = 20
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 { struct TimetableDayView: View {
let entry: TimetableEntry let entry: TimetableEntry
@Environment(\.widgetPalette) private var palette @Environment(\.widgetPalette) private var palette
@@ -73,7 +20,7 @@ struct TimetableDayView: View {
@ViewBuilder @ViewBuilder
private func content(data: WidgetTimetableData) -> some View { private func content(data: WidgetTimetableData) -> some View {
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: GRID_MARGIN_TOP) {
header(data: data) header(data: data)
if data.isHoliday { if data.isHoliday {
emptyState(text: data.holidayName ?? "Ferien") emptyState(text: data.holidayName ?? "Ferien")
@@ -81,37 +28,41 @@ struct TimetableDayView: View {
emptyState(text: "Keine Stunden") emptyState(text: "Keine Stunden")
} else { } else {
GeometryReader { geo in GeometryReader { geo in
let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES) let periods = trimTrailingPeriods(data.periods, lessons: data.lessons)
HStack(alignment: .top, spacing: 0) {
PeriodRuler(
periods: periods,
hourHeight: resolveHourHeight(forGridHeight: geo.size.height, periods: periods)
)
Rectangle()
.fill(palette.divider)
.frame(width: 1)
TimeGridView( TimeGridView(
lessons: data.lessons, lessons: data.lessons,
periods: data.periods, periods: periods,
hourHeight: max( hourHeight: resolveHourHeight(forGridHeight: geo.size.height, periods: periods)
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 { private func header(data: WidgetTimetableData) -> some View {
HStack(spacing: 4) { HStack(spacing: 6) {
Text(dayLabel(for: data.anchorDate, relativeTo: entry.date)) Text(dayLabel(for: data.anchorDate, relativeTo: entry.date))
.font(.system(size: 13, weight: .semibold)) .font(.system(size: 14, weight: .bold))
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.7) .minimumScaleFactor(0.65)
Spacer(minLength: 4) .frame(maxWidth: .infinity, alignment: .leading)
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))") Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
.font(.system(size: 9)) .font(.system(size: 10))
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.7) .fixedSize()
} }
.frame(height: TITLE_ROW_HEIGHT)
} }
private func emptyState(text: String) -> some View { private func emptyState(text: String) -> some View {
@@ -139,54 +90,23 @@ struct TimetableDayView: View {
} }
} }
struct TimeGridView: View { /// Mirrors the in-app _PeriodLabel: period number centred, start time above,
let lessons: [WidgetLesson] /// end time below, a cup in the breaks.
struct PeriodRuler: View {
let periods: [WidgetPeriod] let periods: [WidgetPeriod]
let hourHeight: CGFloat 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
@Environment(\.widgetPalette) private var palette @Environment(\.widgetPalette) private var palette
private var totalVirtualMinutes: Int {
periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
}
private var totalHeight: CGFloat { private var totalHeight: CGFloat {
CGFloat(totalVirtualMinutes) * hourHeight / 60.0 CGFloat(totalVirtualMinutes(periods)) * 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 { var body: some View {
HStack(alignment: .top, spacing: 0) { // Every child has layout-y 0 (`.offset` only shifts the rendering),
if showTimeLabels { // so both the ZStack and its frame need `.top` — the default
// 28pt matches the Week widget's time-label column so the // `.center` would anchor the whole ruler at the column's midpoint.
// visual rhythm is identical across both widgets.
timeLabelsColumn
.frame(width: 28, alignment: .topTrailing)
Rectangle()
.fill(palette.divider)
.frame(width: 1)
}
ZStack(alignment: .top) { ZStack(alignment: .top) {
gridLines
breakBlocks
ForEach(lessons.indices, id: \.self) { idx in
lessonBlock(lessons[idx])
}
}
.frame(maxWidth: .infinity, minHeight: totalHeight, alignment: .top)
}
}
private var timeLabelsColumn: some View {
ZStack(alignment: .topTrailing) {
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
Rectangle() Rectangle()
.fill(palette.divider) .fill(palette.divider)
@@ -194,39 +114,91 @@ struct TimeGridView: View {
.offset(y: CGFloat(virtualMin) * hourHeight / 60.0) .offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
} }
ForEach(periods, id: \.startMinutes) { period in ForEach(periods, id: \.startMinutes) { period in
VStack(alignment: .trailing, spacing: -2) { cell(period)
if compactLabels { }
Text("\(period.name).") ForEach(periodBreaks(periods), id: \.self) { pause in
.font(.system(size: 9, weight: .bold)) let height = CGFloat(pause.virtualMinutes) * hourHeight / 60.0
if height >= BREAK_ICON_MIN_HEIGHT {
Image(systemName: "cup.and.saucer")
.font(.system(size: 8))
.foregroundStyle(palette.textSecondary)
.frame(width: RULER_WIDTH, height: height)
.offset(y: CGFloat(pause.virtualStart) * hourHeight / 60.0)
}
}
}
.frame(width: RULER_WIDTH, height: totalHeight, alignment: .top)
}
private func cell(_ period: WidgetPeriod) -> some View {
let height = CGFloat(period.virtualEndMinutes - period.virtualStartMinutes) * hourHeight / 60.0
let timeFont = max(6, min(8.5, height * 0.19))
return ZStack {
Text(period.name)
.font(.system(size: max(8, min(13, height * 0.3)), weight: .medium))
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
.lineLimit(1) .lineLimit(1)
} else { if height >= RULER_TIMES_MIN_CELL {
VStack(spacing: 0) {
Text(formatHm(period.startMinutes)) Text(formatHm(period.startMinutes))
.font(.system(size: 9)) Spacer(minLength: 0)
.foregroundStyle(palette.textPrimary) Text(formatHm(period.endMinutes))
.lineLimit(1) }
Text("\(period.name).") .font(.system(size: timeFont))
.font(.system(size: 7, weight: .bold))
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.lineLimit(1) .lineLimit(1)
.padding(.vertical, 2)
} }
} }
.padding(.trailing, 4) .frame(width: RULER_WIDTH, height: height)
.offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0) .offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
} }
private func formatHm(_ minutes: Int) -> String {
String(format: "%02d:%02d", minutes / 60, minutes % 60)
} }
.frame(height: totalHeight, alignment: .topTrailing) }
/// One day column: grid lines, break bands and the lesson tiles. Shared by
/// the day widget and each of the week widget's five columns.
struct TimeGridView: View {
let lessons: [WidgetLesson]
let periods: [WidgetPeriod]
let hourHeight: CGFloat
var holiday: WidgetDayInfo? = nil
var isToday: Bool = false
@Environment(\.widgetPalette) private var palette
private var totalHeight: CGFloat {
CGFloat(totalVirtualMinutes(periods)) * hourHeight / 60.0
}
var body: some View {
GeometryReader { geo in
let metrics = TileMetrics(periods: periods, hourHeight: hourHeight)
let innerWidth = geo.size.width - 2 * (TILE_MARGIN_H + TILE_PADDING_H)
ZStack(alignment: .top) {
if let holiday {
holidayColumn(holiday)
} else {
if isToday {
palette.todayTint.frame(height: totalHeight)
}
gridLines
breakBlocks
}
ForEach(lessons.indices, id: \.self) { idx in
lessonBlock(lessons[idx], metrics: metrics, innerWidth: innerWidth)
}
}
.frame(width: geo.size.width, height: totalHeight, alignment: .top)
}
.frame(height: totalHeight)
} }
private var gridLines: some View { private var gridLines: some View {
// `.frame(height: totalHeight, alignment: .top)` — without the // `.top` on both the ZStack and the frame — see PeriodRuler.
// explicit `.top`, SwiftUI's frame modifier defaults to `.center`.
// Every Rectangle here has layout-y=0 (the `.offset()`s shift the
// rendered position only, not layout), so the inner ZStack's
// natural height is just 1pt; a 1pt ZStack centred in a 300pt
// frame anchors at the middle, and every line then renders
// relative to that midpoint — the grid visually starts halfway
// down the column and the later periods clip past the bottom.
ZStack(alignment: .top) { ZStack(alignment: .top) {
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
Rectangle() Rectangle()
@@ -239,103 +211,136 @@ struct TimeGridView: View {
} }
private var breakBlocks: some View { private var breakBlocks: some View {
// Same `.center`-vs-`.top` bug as gridLines — see comment above.
ZStack(alignment: .top) { ZStack(alignment: .top) {
ForEach(0..<max(0, periods.count - 1), id: \.self) { i in ForEach(periodBreaks(periods), id: \.self) { pause in
let curr = periods[i] let height = CGFloat(pause.virtualMinutes) * hourHeight / 60.0
let next = periods[i + 1] ZStack {
let virtualGap = next.virtualStartMinutes - curr.virtualEndMinutes palette.breakBlock
if virtualGap > 0 { if height >= BREAK_ICON_MIN_HEIGHT {
Rectangle() Image(systemName: "fork.knife")
.fill(palette.breakBlock) .font(.system(size: 9))
.frame(height: CGFloat(virtualGap) * hourHeight / 60.0) .foregroundStyle(palette.accent)
.padding(.horizontal, 1)
.offset(y: CGFloat(curr.virtualEndMinutes) * hourHeight / 60.0)
} }
} }
.frame(height: height)
.offset(y: CGFloat(pause.virtualStart) * hourHeight / 60.0)
}
} }
.frame(height: totalHeight, alignment: .top) .frame(height: totalHeight, alignment: .top)
} }
private func formatHm(_ minutes: Int) -> String { /// Mirrors the in-app holiday region: icon and "Schulfrei" on top, the
String(format: "%02d:%02d", minutes / 60, minutes % 60) /// holiday name rotated along the column.
private func holidayColumn(_ holiday: WidgetDayInfo) -> some View {
let nameLength = max(40, totalHeight - 2 * HOLIDAY_NAME_INSET)
return ZStack {
palette.holidayTint
VStack(spacing: 2) {
Image(systemName: "calendar")
.font(.system(size: 12))
Text("Schulfrei")
.font(.system(size: 9))
.lineLimit(1)
.minimumScaleFactor(0.6)
}
.padding(.top, 8)
.padding(.horizontal, 2)
.frame(maxHeight: .infinity, alignment: .top)
// The outer frame carries the rotated footprint: rotationEffect
// alone leaves the layout box at its unrotated size.
Text(holiday.holidayName ?? "")
.font(.system(size: 11, weight: .bold))
.kerning(1)
.lineLimit(1)
.minimumScaleFactor(0.6)
.frame(width: nameLength, height: 14)
.rotationEffect(.degrees(90))
.frame(width: 14, height: nameLength)
}
.foregroundStyle(palette.textPrimary)
.frame(height: totalHeight)
} }
@ViewBuilder @ViewBuilder
private func lessonBlock(_ lesson: WidgetLesson) -> some View { private func lessonBlock(
let cal = Calendar.current _ lesson: WidgetLesson,
let comps = cal.dateComponents([.hour, .minute], from: lesson.start) metrics: TileMetrics,
let startMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0) innerWidth: CGFloat
) -> some View {
let startMinutes = minutesOfDay(lesson.start)
let durationMinutes = max(15, Int(lesson.end.timeIntervalSince(lesson.start) / 60)) let durationMinutes = max(15, Int(lesson.end.timeIntervalSince(lesson.start) / 60))
let virtualStart = realMinutesToVirtual(startMinutes, periods: periods) let virtualStart = realMinutesToVirtual(startMinutes, periods: periods)
let virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods: periods) let virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods: periods)
if virtualEnd > virtualStart { if virtualEnd > virtualStart {
// Half the gap above + half below so the grid line under the
// tile stays visible.
let top = virtualStart * hourHeight / 60.0 + LESSON_GAP / 2 let top = virtualStart * hourHeight / 60.0 + LESSON_GAP / 2
let height = max( let height = max(
MIN_BLOCK_HEIGHT, MIN_BLOCK_HEIGHT,
(virtualEnd - virtualStart) * hourHeight / 60.0 - LESSON_GAP (virtualEnd - virtualStart) * hourHeight / 60.0 - LESSON_GAP
) )
let subjectSize = subjectFont(forHourHeight: hourHeight) LessonTile(
let secondarySize = max(MIN_SECONDARY_FONT, subjectSize - 2) lesson: lesson,
let room = lesson.room height: height,
let teacher = lesson.teacher ?? lesson.originalTeacher metrics: metrics,
let hasSecondary = (room?.isEmpty == false) || (teacher?.isEmpty == false) layout: TileTextLayout(
HStack(alignment: .top, spacing: 4) { lesson: lesson,
Text(subjectLabel(lesson)) innerHeight: height - 2 * TILE_PADDING_V,
.font(.system(size: subjectSize, weight: .semibold)) innerWidth: innerWidth,
metrics: metrics
)
)
.offset(y: top)
}
}
}
/// Mirrors the in-app AppointmentTile: title on top, room and teacher
/// stacked below, each line shrinking on its own to fit the tile width.
private struct LessonTile: View {
let lesson: WidgetLesson
let height: CGFloat
let metrics: TileMetrics
let layout: TileTextLayout
private static let cancelledRed =
Color(red: 244 / 255, green: 67 / 255, blue: 54 / 255).opacity(200 / 255)
var body: some View {
let shape = RoundedRectangle(cornerRadius: TILE_CORNER_RADIUS)
VStack(alignment: .leading, spacing: 0) {
titleRow
ForEach(Array(layout.body.enumerated()), id: \.offset) { index, line in
Text(line)
.font(.system(size: metrics.bodyFont))
.lineLimit(1)
.minimumScaleFactor(MIN_BODY_LINE_FONT / metrics.bodyFont)
.frame(maxWidth: .infinity, alignment: .leading)
.frame(height: metrics.bodyBox)
.padding(.trailing, badgeInset(line: index + 1))
}
}
.foregroundStyle(.white) .foregroundStyle(.white)
.lineLimit(hasSecondary ? 1 : 2) .padding(.horizontal, TILE_PADDING_H)
.minimumScaleFactor(0.5) .padding(.vertical, TILE_PADDING_V)
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(maxWidth: .infinity, alignment: .topLeading)
.frame(height: height, alignment: .topLeading) .frame(height: height, alignment: .topLeading)
.background(blockColor(lesson)) .background(lesson.tileColor)
.cornerRadius(6) .clipShape(shape)
.overlay(alignment: .bottomLeading) { .overlay(alignment: .bottomTrailing) {
// Separate fixed-size badge so the +N hint stays readable
// when the subject autoshrinks on narrow tiles.
if let count = lesson.siblingCount, count > 0 { if let count = lesson.siblingCount, count > 0 {
Text("+\(count)") Text("+\(count)")
.font(.system(size: 12, weight: .bold)) .font(.system(size: 9, weight: .bold))
.foregroundStyle(.white) .foregroundStyle(.white)
.padding(.leading, horizontalPadding) .padding(.trailing, TILE_PADDING_H)
.padding(.bottom, 2) .padding(.bottom, TILE_PADDING_V)
} }
} }
.overlay { .overlay {
// CrossPainter parity: clip cross to the rounded shape so
// the diagonals don't bleed past the corners.
if lesson.status == .cancelled { if lesson.status == .cancelled {
ZStack { ZStack {
RoundedRectangle(cornerRadius: 6) shape.strokeBorder(LessonTile.cancelledRed, lineWidth: 2)
.stroke(Color.red.opacity(0.78), lineWidth: 1.5)
GeometryReader { geo in GeometryReader { geo in
Path { p in Path { p in
p.move(to: .zero) p.move(to: .zero)
@@ -343,62 +348,36 @@ struct TimeGridView: View {
p.move(to: CGPoint(x: geo.size.width, y: 0)) p.move(to: CGPoint(x: geo.size.width, y: 0))
p.addLine(to: CGPoint(x: 0, y: geo.size.height)) p.addLine(to: CGPoint(x: 0, y: geo.size.height))
} }
.stroke(Color.red.opacity(0.78), lineWidth: 3) .stroke(LessonTile.cancelledRed, lineWidth: 2)
} }
} }
.clipShape(RoundedRectangle(cornerRadius: 6)) .clipShape(shape)
} }
} }
.padding(.horizontal, showRoom ? 2 : 1) .padding(.horizontal, TILE_MARGIN_H)
.offset(y: top)
}
} }
private func subjectLabel(_ lesson: WidgetLesson) -> String { private var titleRow: some View {
!lesson.subjectShort.isEmpty HStack(alignment: .center, spacing: 3) {
? lesson.subjectShort Text(layout.title)
: (lesson.subjectLong ?? "—") .font(.system(size: layout.titleFont, weight: .medium))
.lineLimit(layout.titleLines)
.minimumScaleFactor(min(1, MIN_TITLE_FONT / layout.titleFont))
.frame(maxWidth: .infinity, alignment: .leading)
if let inline = layout.inline {
Text(inline)
.font(.system(size: metrics.bodyFont))
.lineLimit(1)
.fixedSize()
}
}
.frame(height: layout.titleBox)
.padding(.trailing, badgeInset(line: 0))
} }
/// Mirrors lesson_color.dart + custom_event_colors.dart so the widget private func badgeInset(line: Int) -> CGFloat {
/// matches the in-app calendar exactly. layout.badgeLine == line ? BADGE_WIDTH : 0
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 {
// `.ongoing` deliberately collapses into the regular red — the
// widget timeline only ticks every ~30min, so highlighting the
// "current" lesson would be stale most of the day and misleads
// more than it helps.
case .regular, .past, .ongoing:
return Color(red: 153/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)
case .duty: return Color(red: 0/255.0, green: 121/255.0, blue: 107/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()
} }
/// Fixed-locale formatters cached once — DateFormatter setup is the /// Fixed-locale formatters cached once — DateFormatter setup is the
@@ -0,0 +1,279 @@
import SwiftUI
import UIKit
// Layout constants and rules — must mirror WidgetRenderer.kt on Android,
// otherwise the platforms drift apart on the same widget size.
let FALLBACK_VIRTUAL_MINUTES = 11 * 60
let FALLBACK_SLOT_MINUTES = 45
/// Trailing periods without lessons are cut off so the tiles get more
/// height — but never below this many periods, or a short day would blow up
/// to a handful of oversized tiles.
let MIN_VISIBLE_PERIODS = 6
let MIN_HOUR_HEIGHT: CGFloat = 18
let MAX_HOUR_HEIGHT: CGFloat = 72
let MIN_BLOCK_HEIGHT: CGFloat = 16
let LESSON_GAP: CGFloat = 1.5
let TITLE_ROW_HEIGHT: CGFloat = 20
let GRID_MARGIN_TOP: CGFloat = 4
let WEEK_HEADER_HEIGHT: CGFloat = 32
let RULER_WIDTH: CGFloat = 30
let TILE_MARGIN_H: CGFloat = 1
let TILE_PADDING_H: CGFloat = 4
let TILE_PADDING_V: CGFloat = 2
let TILE_CORNER_RADIUS: CGFloat = 7
// In-app tile: 15sp title over 10sp body lines. The widget keeps that ratio
// and scales both so title + room + teacher fill one period.
let TITLE_TO_BODY_RATIO: CGFloat = 1.5
let MAX_BODY_FONT: CGFloat = 10
let MIN_BODY_FONT: CGFloat = 6.5
let MIN_TITLE_FONT: CGFloat = 6.5
let MIN_BODY_LINE_FONT: CGFloat = 5.5
let LINE_BOX_PER_FONT: CGFloat = 1.2
/// If stacking room and teacher would push the body font below this, both
/// share one line instead so the text stays readable.
let STACKED_BODY_MIN_FONT: CGFloat = 7.5
/// A title that needs to shrink below this to fit on one line wraps onto a
/// second line instead, if the tile has the height for it.
let TITLE_WRAP_BELOW_FONT: CGFloat = 8
// Footprint of the "+N" badge in the tile's bottom trailing corner.
let BADGE_WIDTH: CGFloat = 16
let BADGE_HEIGHT: CGFloat = 11
let RULER_TIMES_MIN_CELL: CGFloat = 30
let BREAK_ICON_MIN_HEIGHT: CGFloat = 12
/// Space the holiday column keeps free above and below the rotated name for
/// the icon and the "Schulfrei" label.
let HOLIDAY_NAME_INSET: CGFloat = 44
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
}
func minutesOfDay(_ date: Date) -> Int {
let comps = Calendar.current.dateComponents([.hour, .minute], from: date)
return (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
}
func trimTrailingPeriods(_ periods: [WidgetPeriod], lessons: [WidgetLesson]) -> [WidgetPeriod] {
guard let latestEnd = lessons.map({ minutesOfDay($0.end) }).max() else { return periods }
let lastUsed = periods.lastIndex { $0.startMinutes < latestEnd } ?? -1
return Array(periods.prefix(max(lastUsed + 1, MIN_VISIBLE_PERIODS)))
}
func totalVirtualMinutes(_ periods: [WidgetPeriod]) -> Int {
periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
}
func resolveHourHeight(forGridHeight height: CGFloat, periods: [WidgetPeriod]) -> CGFloat {
let virtualHours = max(CGFloat(totalVirtualMinutes(periods)) / 60, 1)
return max(MIN_HOUR_HEIGHT, min(MAX_HOUR_HEIGHT, height / virtualHours))
}
/// 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()
}
struct PeriodBreak: Hashable {
let virtualStart: Int
let virtualMinutes: Int
}
/// Only big breaks (Hofpause, Mittagspause) survive the mapper's small-break
/// collapse as a virtual gap between two periods.
func periodBreaks(_ periods: [WidgetPeriod]) -> [PeriodBreak] {
guard periods.count > 1 else { return [] }
return (0..<periods.count - 1).compactMap { i in
let gap = periods[i + 1].virtualStartMinutes - periods[i].virtualEndMinutes
return gap > 0
? PeriodBreak(virtualStart: periods[i].virtualEndMinutes, virtualMinutes: gap)
: nil
}
}
/// Font and line-box sizes shared by every tile of one widget, derived from
/// the height of a single period.
struct TileMetrics {
let maxBodyLines: Int
let bodyFont: CGFloat
let titleBox: CGFloat
let bodyBox: CGFloat
init(periods: [WidgetPeriod], hourHeight: CGFloat) {
let slotMinutes = periods.first.map { $0.virtualEndMinutes - $0.virtualStartMinutes }
?? FALLBACK_SLOT_MINUTES
let slotHeight = CGFloat(slotMinutes) * hourHeight / 60
let inner = slotHeight - LESSON_GAP - 2 * TILE_PADDING_V
func font(lines: CGFloat) -> CGFloat {
inner / (LINE_BOX_PER_FONT * (TITLE_TO_BODY_RATIO + lines))
}
maxBodyLines = font(lines: 2) < STACKED_BODY_MIN_FONT ? 1 : 2
bodyFont = max(MIN_BODY_FONT, min(MAX_BODY_FONT, font(lines: CGFloat(maxBodyLines))))
titleBox = bodyFont * TITLE_TO_BODY_RATIO * LINE_BOX_PER_FONT
bodyBox = bodyFont * LINE_BOX_PER_FONT
}
}
/// Distributes the tile height like the in-app _TileContent: the title comes
/// first, then as many body lines as fit.
struct TileTextLayout {
let title: String
let titleBox: CGFloat
let titleFont: CGFloat
let titleLines: Int
let body: [String]
/// Set when there is no room for a line below the title: the room moves
/// next to it instead.
let inline: String?
/// Index of the line (0 = title row) that has to stay clear of the "+N"
/// badge, or nil when the text ends above it.
let badgeLine: Int?
init(lesson: WidgetLesson, innerHeight: CGFloat, innerWidth: CGFloat, metrics: TileMetrics) {
title = !lesson.subjectShort.isEmpty ? lesson.subjectShort : (lesson.subjectLong ?? "—")
let candidates = [lesson.room, lesson.teacher ?? lesson.originalTeacher]
.compactMap { $0 }
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
let singleTitleBox = min(metrics.titleBox, innerHeight)
let capacity = max(
0,
min(metrics.maxBodyLines, Int((innerHeight - singleTitleBox) / metrics.bodyBox + 0.05))
)
if capacity >= candidates.count {
body = candidates
} else if capacity == 1 {
// Rather share one line than drop the teacher entirely.
body = [candidates.joined(separator: " · ")]
} else {
body = []
}
if capacity == 0, let first = candidates.first, !title.contains(first) {
inline = first
} else {
inline = nil
}
let spare = innerHeight - singleTitleBox - CGFloat(body.count) * metrics.bodyBox
if spare >= singleTitleBox,
let wrappedLine = TileTextLayout.wrappedTitleLineBox(
title, innerWidth: innerWidth, metrics: metrics
) {
titleBox = 2 * wrappedLine
titleFont = wrappedLine / LINE_BOX_PER_FONT
titleLines = 2
} else {
titleBox = singleTitleBox
titleFont = singleTitleBox / LINE_BOX_PER_FONT
titleLines = 1
}
let content = titleBox + CGFloat(body.count) * metrics.bodyBox
let hasBadge = (lesson.siblingCount ?? 0) > 0
badgeLine = hasBadge && innerHeight - content < BADGE_HEIGHT ? body.count : nil
}
/// Line box for a title wrapped onto two lines, or nil when it should
/// stay on one: it fits there at a readable size, or it has no word
/// boundary to break at. The box is capped so the longest word still
/// fits the tile width.
private static func wrappedTitleLineBox(
_ title: String,
innerWidth: CGFloat,
metrics: TileMetrics
) -> CGFloat? {
func widthPerFontPoint(_ text: String) -> CGFloat {
let font = UIFont.systemFont(ofSize: 100, weight: .medium)
return (text as NSString).size(withAttributes: [.font: font]).width / 100
}
if widthPerFontPoint(title) * TITLE_WRAP_BELOW_FONT <= innerWidth { return nil }
let words = title
.replacingOccurrences(of: "-", with: "- ")
.split(separator: " ")
.map(String.init)
guard words.count > 1, let widest = words.map(widthPerFontPoint).max(), widest > 0 else {
return nil
}
return min(metrics.titleBox, innerWidth / widest * LINE_BOX_PER_FONT)
}
}
extension Color {
/// Parses the `#RRGGBB` tile colour resolved in Dart.
init?(widgetHex hex: String?) {
guard let hex, hex.count == 7, hex.hasPrefix("#"),
let value = UInt32(hex.dropFirst(), radix: 16) else {
return nil
}
self.init(
red: Double((value >> 16) & 0xFF) / 255,
green: Double((value >> 8) & 0xFF) / 255,
blue: Double(value & 0xFF) / 255
)
}
}
extension WidgetLesson {
var tileColor: Color {
if let resolved = Color(widgetHex: color) { return resolved }
// Only reached for payloads written before Dart shipped the resolved
// tile colour; mirrors lesson_color.dart.
switch status {
// No time-based highlight: the widget timeline only ticks every
// ~30min, so "current" and "past" would be stale most of the day.
case .regular, .past, .ongoing:
return Color(red: 153 / 255, green: 51 / 255, blue: 51 / 255)
case .cancelled: return .black
case .irregular: return Color(red: 143 / 255, green: 25 / 255, blue: 179 / 255)
case .teacherChanged: return Color(red: 41 / 255, green: 99 / 255, blue: 155 / 255)
case .duty: return Color(red: 0, green: 121 / 255, blue: 107 / 255)
case .event:
return customColor != nil
? Color(red: 1, green: 160 / 255, blue: 0)
: Color(red: 46 / 255, green: 125 / 255, blue: 50 / 255)
}
}
}
@@ -20,36 +20,44 @@ struct TimetableWeekView: View {
@ViewBuilder @ViewBuilder
private func content(data: WidgetTimetableData) -> some View { private func content(data: WidgetTimetableData) -> some View {
let cal = Calendar.current
let days = (0..<5).map { offset in
cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate
}
let weekLessons = data.lessons.filter { lesson in
days.contains { cal.isDate(lesson.start, inSameDayAs: $0) }
}
let periods = trimTrailingPeriods(data.periods, lessons: weekLessons)
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
header(data: data) header(data: data)
dayHeaderRow(data: data) dayHeaderRow(days: days)
.padding(.top, GRID_MARGIN_TOP)
GeometryReader { geo in GeometryReader { geo in
let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES) let height = resolveHourHeight(forGridHeight: geo.size.height, periods: periods)
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
// 45pt is the minimum where a 7pt-font room (e.g. "F2.04")
// plus the subject fits without `minimumScaleFactor`
// shrinking the subject into illegibility.
let subjectOnly = dayColumnWidth < 45
HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, spacing: 0) {
timeLabelsColumn(hourHeight: hourHeight, periods: data.periods) PeriodRuler(periods: periods, hourHeight: height)
.frame(width: 28, alignment: .topTrailing)
columnDivider columnDivider
ForEach(0..<5, id: \.self) { offset in ForEach(days.indices, id: \.self) { index in
column( TimeGridView(
data: data, lessons: weekLessons.filter {
offset: offset, cal.isDate($0.start, inSameDayAs: days[index])
hourHeight: hourHeight, },
subjectOnly: subjectOnly periods: periods,
hourHeight: height,
holiday: holiday(on: days[index], data: data),
isToday: cal.isDate(days[index], inSameDayAs: entry.date)
) )
.frame(maxWidth: .infinity) if index < 4 { columnDivider }
if offset < 4 { columnDivider }
} }
} }
} }
.padding(.top, 2)
}
}
private func holiday(on day: Date, data: WidgetTimetableData) -> WidgetDayInfo? {
data.days?.first {
$0.isHoliday && Calendar.current.isDate($0.date, inSameDayAs: day)
} }
} }
@@ -63,113 +71,56 @@ struct TimetableWeekView: View {
let cal = Calendar.current let cal = Calendar.current
let week = cal.component(.weekOfYear, from: data.anchorDate) let week = cal.component(.weekOfYear, from: data.anchorDate)
let endDate = cal.date(byAdding: .day, value: 4, to: data.anchorDate) ?? data.anchorDate let endDate = cal.date(byAdding: .day, value: 4, to: data.anchorDate) ?? data.anchorDate
return HStack { return HStack(spacing: 6) {
Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))") Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))")
.font(.system(size: 12, weight: .semibold)) .font(.system(size: 14, weight: .bold))
.foregroundStyle(palette.textPrimary) .foregroundStyle(palette.textPrimary)
.lineLimit(1) .lineLimit(1)
.minimumScaleFactor(0.8) .minimumScaleFactor(0.65)
Spacer() .frame(maxWidth: .infinity, alignment: .leading)
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))") Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
.font(.system(size: 9)) .font(.system(size: 10))
.foregroundStyle(palette.textSecondary) .foregroundStyle(palette.textSecondary)
.lineLimit(1) .lineLimit(1)
.fixedSize()
} }
.frame(height: TITLE_ROW_HEIGHT)
} }
private func dayHeaderRow(data: WidgetTimetableData) -> some View { /// Mirrors the in-app _DayHeaderCell: weekday in caps, day number below,
let cal = Calendar.current /// today inside an accent circle.
// `.frame(maxWidth: .infinity)` is critical: without an explicit private func dayHeaderRow(days: [Date]) -> some View {
// greedy width (or a true `Spacer()` inside), this HStack collapses
// to the sum of its fixed-width children in a `.leading` VStack
// and the 5 day-columns end up sharing ~5pt of width — the labels
// crunch to the left and stop aligning with the grid columns below.
// The fixed height keeps `columnDivider`'s vertically-flexible // The fixed height keeps `columnDivider`'s vertically-flexible
// Rectangle from stealing space from the GeometryReader. // Rectangle from stealing space from the GeometryReader below.
return HStack(spacing: 0) { HStack(spacing: 0) {
Spacer().frame(width: 28) Spacer().frame(width: RULER_WIDTH)
columnDivider columnDivider
ForEach(0..<5, id: \.self) { offset in ForEach(days.indices, id: \.self) { index in
let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate dayHeaderCell(days[index])
VStack(spacing: -1) {
Text(weekday(for: day))
.font(.system(size: 10, weight: .bold))
.foregroundStyle(palette.textPrimary)
Text(shortDate(day))
.font(.system(size: 8))
.foregroundStyle(palette.textSecondary)
}
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
if offset < 4 { columnDivider } if index < 4 { columnDivider }
} }
} }
// 20pt fits 10pt weekday + 8pt date with tight -1pt spacing. .frame(maxWidth: .infinity, minHeight: WEEK_HEADER_HEIGHT, maxHeight: WEEK_HEADER_HEIGHT)
.frame(maxWidth: .infinity, minHeight: 20, maxHeight: 20)
} }
private func timeLabelsColumn(hourHeight: CGFloat, periods: [WidgetPeriod]) -> some View { private func dayHeaderCell(_ day: Date) -> some View {
let totalMin = periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES // `entry.date`, not `Date()` — WidgetKit archives entries ahead of
let totalHeight = CGFloat(totalMin) * hourHeight / 60.0 // time, the midnight entry has to mark its own day.
// Mirrors TimeGridView.compactLabels — below 26pt/hour a stacked let isToday = Calendar.current.isDate(day, inSameDayAs: entry.date)
// time+name no longer fits in one period slot and the two labels return VStack(spacing: 1) {
// visually clip into the next slot. Drop to a single bold period Text(WidgetDateFormatters.weekdayShort.string(from: day).uppercased())
// number until there's room to read both lines. .font(.system(size: 9, weight: .medium))
let compact = hourHeight < 26 .foregroundStyle(isToday ? palette.accent : palette.textSecondary)
return ZStack(alignment: .topTrailing) {
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
Rectangle()
.fill(palette.divider)
.frame(height: 1)
.offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
}
ForEach(periods, id: \.startMinutes) { period in
VStack(alignment: .trailing, spacing: -2) {
if compact {
Text("\(period.name).")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(palette.textPrimary)
.lineLimit(1) .lineLimit(1)
} else { Text("\(Calendar.current.component(.day, from: day))")
Text(String(format: "%02d:%02d", period.startMinutes / 60, period.startMinutes % 60)) .font(.system(size: 11, weight: isToday ? .bold : .regular))
.font(.system(size: 9)) .foregroundStyle(isToday ? palette.onAccent : palette.textPrimary)
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
Text("\(period.name).")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(palette.textSecondary)
.lineLimit(1) .lineLimit(1)
.frame(width: 18, height: 18)
.background(Circle().fill(isToday ? palette.accent : Color.clear))
} }
} }
.padding(.trailing, 4)
.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,
hourHeight: hourHeight,
showRoom: !subjectOnly,
showTeacher: !subjectOnly,
showTimeLabels: false,
horizontalPadding: 3
)
}
private func weekday(for date: Date) -> String {
WidgetDateFormatters.weekdayShort.string(from: date)
}
private func placeholder(_ message: String) -> some View { private func placeholder(_ message: String) -> some View {
VStack(spacing: 4) { VStack(spacing: 4) {
@@ -33,6 +33,9 @@ struct WidgetLesson: Codable {
let originalTeacher: String? let originalTeacher: String?
let status: WidgetLessonStatus let status: WidgetLessonStatus
let customColor: String? let customColor: String?
// Tile colour resolved in Dart as "#RRGGBB" (subject colours, palettes);
// nil only for payloads written before the field existed.
let color: String?
let siblingCount: Int? let siblingCount: Int?
} }
@@ -9,15 +9,27 @@ struct WidgetPalette {
let textPrimary: Color let textPrimary: Color
let textSecondary: Color let textSecondary: Color
let divider: Color let divider: Color
let accent: Color
let onAccent: Color
let breakBlock: Color let breakBlock: Color
let todayTint: Color
let holidayTint: Color
let watermarkOpacity: Double let watermarkOpacity: Double
// ColorScheme.fromSeed(#993333).primary of the app's light / dark theme.
private static let lightAccent = Color(red: 0x90 / 255, green: 0x4A / 255, blue: 0x47 / 255)
private static let darkAccent = Color(red: 0xFF / 255, green: 0xB3 / 255, blue: 0xAF / 255)
static let light = WidgetPalette( static let light = WidgetPalette(
background: Color(red: 0xFC / 255, green: 0xF7 / 255, blue: 0xF5 / 255), background: Color(red: 0xFC / 255, green: 0xF7 / 255, blue: 0xF5 / 255),
textPrimary: Color(red: 0x11 / 255, green: 0x11 / 255, blue: 0x11 / 255), textPrimary: Color(red: 0x11 / 255, green: 0x11 / 255, blue: 0x11 / 255),
textSecondary: Color(red: 0x55 / 255, green: 0x55 / 255, blue: 0x55 / 255), textSecondary: Color(red: 0x55 / 255, green: 0x55 / 255, blue: 0x55 / 255),
divider: Color.black.opacity(0x22 / 255.0), divider: Color.black.opacity(0x22 / 255.0),
breakBlock: Color.black.opacity(0x0C / 255.0), accent: lightAccent,
onAccent: .white,
breakBlock: lightAccent.opacity(0x32 / 255.0),
todayTint: lightAccent.opacity(0x0E / 255.0),
holidayTint: Color.black.opacity(0x32 / 255.0),
watermarkOpacity: 0.014 watermarkOpacity: 0.014
) )
@@ -26,7 +38,11 @@ struct WidgetPalette {
textPrimary: Color(red: 0xF1 / 255, green: 0xF1 / 255, blue: 0xF1 / 255), textPrimary: Color(red: 0xF1 / 255, green: 0xF1 / 255, blue: 0xF1 / 255),
textSecondary: Color(red: 0xB0 / 255, green: 0xB0 / 255, blue: 0xB0 / 255), textSecondary: Color(red: 0xB0 / 255, green: 0xB0 / 255, blue: 0xB0 / 255),
divider: Color.white.opacity(0x33 / 255.0), divider: Color.white.opacity(0x33 / 255.0),
breakBlock: Color.white.opacity(0x14 / 255.0), accent: darkAccent,
onAccent: Color(red: 0x57 / 255, green: 0x1D / 255, blue: 0x1D / 255),
breakBlock: darkAccent.opacity(0x32 / 255.0),
todayTint: darkAccent.opacity(0x0E / 255.0),
holidayTint: Color.white.opacity(0x32 / 255.0),
watermarkOpacity: 0.025 watermarkOpacity: 0.025
) )
+219
View File
@@ -0,0 +1,219 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import '../session/session_manager.dart';
import '../utils/directory_size.dart';
/// One cached response: the raw JSON payload and when it was stored.
class CacheEntry {
final String json;
final int lastUpdate;
const CacheEntry({required this.json, required this.lastUpdate});
}
/// File-per-key store behind [RequestCache] and friends.
///
/// Replaces `localstore` for response caching: localstore reads and decodes
/// the entire collection synchronously on first access, keeps every document
/// in memory for the whole session and JSON-encodes payloads a second time.
/// Here every access is async and touches exactly one file, and the payload is
/// stored verbatim behind a one-line header (`<lastUpdate>\n<json>`).
///
/// Every account has its own directory; all access goes to the one of the
/// active account, in every isolate.
class CacheStore {
CacheStore._();
static final CacheStore instance = CacheStore._();
/// Payloads above this size are decoded on a background isolate.
static const int isolateDecodeThreshold = 64 * 1024;
static const _directoryPrefix = 'request_cache';
final Map<String, Future<Directory>> _dirs = {};
final Map<String, Future<void>> _writes = {};
/// Directory name for an account's storage [namespace]; null while no
/// account is active. The account from before multi-account support
/// (namespace '') keeps the original directory.
@visibleForTesting
static String directoryName(String? namespace) => switch (namespace) {
null => '$_directoryPrefix-signed-out',
'' => _directoryPrefix,
_ => '$_directoryPrefix-$namespace',
};
Future<Directory> _directory() {
final name = directoryName(SessionManager().activeAccount?.namespace);
return _dirs[name] ??= () async {
final base = await getApplicationCacheDirectory();
final dir = Directory('${base.path}/$name');
await dir.create(recursive: true);
return dir;
}();
}
Future<File> _file(String key) async =>
File('${(await _directory()).path}/${_safeName(key)}');
/// Keys are internal ids (`nc-chat-<token>`, `wd-folder-<md5>`); only path
/// separators would be a problem in a file name.
static String _safeName(String key) => key.replaceAll(RegExp(r'[/\\]'), '_');
Future<CacheEntry?> read(String key) async {
try {
final file = await _file(key);
await _writes[file.path];
return parse(await file.readAsString());
} on Object {
// Missing (PathNotFoundException) or unreadable: a cache miss.
return null;
}
}
/// All entries whose key starts with [prefix], read concurrently.
Future<Map<String, CacheEntry>> readAll({String prefix = ''}) async {
final keys = await this.keys(prefix: prefix);
final entries = await Future.wait(keys.map(read));
return {for (var i = 0; i < keys.length; i++) keys[i]: ?entries[i]};
}
Future<void> write(String key, String json) async {
// Resolved up front: a queued write must not follow an account switch
// into the next account's directory.
final file = await _file(key);
final path = file.path;
final previous = _writes[path] ?? Future<void>.value();
late final Future<void> current;
current = previous.then((_) => _write(file, json)).whenComplete(() {
if (identical(_writes[path], current)) _writes.remove(path);
});
return _writes[path] = current;
}
Future<void> _write(File file, String json) async {
try {
// Write-then-rename so a reader (or the widget background isolate)
// never sees a half-written file.
final tmp = File('${file.path}.tmp');
await tmp.writeAsString(
'${DateTime.now().millisecondsSinceEpoch}\n$json',
flush: true,
);
await tmp.rename(file.path);
} on Object catch (e) {
debugPrint('CacheStore.write(${file.path}) failed: $e');
}
}
Future<void> delete(String key) async {
try {
final file = await _file(key);
await _writes[file.path];
await file.delete();
} on Object {
// A missing or locked file is as good as deleted for a cache.
}
}
/// Empties the active account's cache.
Future<void> clear() async {
try {
final dir = await _directory();
if (dir.existsSync()) await dir.delete(recursive: true);
} on Object catch (e) {
debugPrint('CacheStore.clear failed: $e');
}
_dirs.clear();
}
/// Removes the cache of the account with [namespace], active or not.
Future<void> deleteNamespace(String namespace) async {
final name = directoryName(namespace);
try {
final base = await getApplicationCacheDirectory();
final dir = Directory('${base.path}/$name');
if (dir.existsSync()) await dir.delete(recursive: true);
} on Object catch (e) {
debugPrint('CacheStore.deleteNamespace failed: $e');
}
_dirs.removeWhere((key, _) => key == name);
}
/// All stored keys, optionally filtered by [prefix].
Future<List<String>> keys({String prefix = ''}) async {
try {
final dir = await _directory();
return [
await for (final entity in dir.list())
if (entity is File && !entity.path.endsWith('.tmp'))
if (entity.uri.pathSegments.last case final name
when name.startsWith(prefix))
name,
];
} on Object {
return const [];
}
}
/// Removes entries not written for [maxAge] from every account's cache,
/// judged by file mtime so nothing has to be read or decoded.
Future<void> deleteOlderThan(Duration maxAge) async {
try {
final base = await getApplicationCacheDirectory();
final cutoff = DateTime.now().subtract(maxAge);
await for (final dir in base.list()) {
if (dir is! Directory) continue;
final name = dir.uri.pathSegments.lastWhere((s) => s.isNotEmpty);
if (!name.startsWith(_directoryPrefix)) continue;
await for (final entity in dir.list()) {
if (entity is! File) continue;
// ignore: avoid_slow_async_io
final modified = (await entity.stat()).modified;
if (modified.isBefore(cutoff)) await entity.delete();
}
}
} on Object catch (e) {
debugPrint('CacheStore.deleteOlderThan failed: $e');
}
}
/// Total size of all entries in bytes.
Future<int> totalSize() async => directorySize(await _directory());
/// Parses the stored file format; public for tests.
static CacheEntry? parse(String raw) {
final newline = raw.indexOf('\n');
if (newline < 0) return null;
final lastUpdate = int.tryParse(raw.substring(0, newline));
if (lastUpdate == null) return null;
return CacheEntry(json: raw.substring(newline + 1), lastUpdate: lastUpdate);
}
/// Decodes a cached payload, off the UI isolate when it is large.
static Future<Map<String, dynamic>> decode(String json) =>
json.length > isolateDecodeThreshold
? compute(_decodeMap, json)
: Future.value(_decodeMap(json));
static Map<String, dynamic> _decodeMap(String json) =>
jsonDecode(json) as Map<String, dynamic>;
/// Deletes the directory the old localstore-based cache lived in. It is
/// never read again and would otherwise linger with up to 200 days of data.
static Future<void> deleteLegacyLocalstoreCache() async {
try {
final docs = await getApplicationDocumentsDirectory();
final legacy = Directory('${docs.path}/MarianumMobile');
if (legacy.existsSync()) await legacy.delete(recursive: true);
} on Object catch (e) {
debugPrint('Legacy cache cleanup failed: $e');
}
}
}
@@ -0,0 +1,11 @@
import 'app_exception.dart';
/// A request finished after the account it was started for signed out. Its
/// result must not reach the state or cache of whoever is signed in now.
class StaleSessionException extends AppException {
const StaleSessionException()
: super(
userMessage: 'Die Anmeldung hat sich geändert. Bitte lade neu.',
technicalDetails: 'result of a previous session discarded',
);
}
@@ -4,6 +4,7 @@ import 'package:http/http.dart' as http;
import '../../../model/endpoint_data.dart'; import '../../../model/endpoint_data.dart';
import '../../errors/server_exception.dart'; import '../../errors/server_exception.dart';
import '../../http_errors.dart';
import '../nextcloud_ocs.dart'; import '../nextcloud_ocs.dart';
import 'autocomplete_response.dart'; import 'autocomplete_response.dart';
@@ -30,7 +31,12 @@ class AutocompleteApi {
'limit': '10', 'limit': '10',
}, },
); );
final response = await http.get(uri, headers: NextcloudOcs.headers()); final response = (await sendGuarded(
'Autocomplete $uri',
() => http
.get(uri, headers: NextcloudOcs.headers())
.timeout(const Duration(seconds: 20)),
))!;
if (response.statusCode != HttpStatus.ok) { if (response.statusCode != HttpStatus.ok) {
throw ServerException( throw ServerException(
statusCode: response.statusCode, statusCode: response.statusCode,
@@ -4,6 +4,7 @@ import 'package:http/http.dart' as http;
import '../../../api_params.dart'; import '../../../api_params.dart';
import '../../../api_response.dart'; import '../../../api_response.dart';
import '../room/get_room_response.dart';
import '../talk_api.dart'; import '../talk_api.dart';
/// Small POST/DELETE-only Talk endpoints that have no response payload. /// Small POST/DELETE-only Talk endpoints that have no response payload.
@@ -105,3 +106,21 @@ class DeleteRoomAvatar extends TalkApi {
Map<String, String>? headers, Map<String, String>? headers,
) => http.delete(uri, headers: headers); ) => http.delete(uri, headers: headers);
} }
class SetNotificationLevel extends TalkApi {
final String chatToken;
final GetRoomResponseObjectParticipantNotificationLevel level;
SetNotificationLevel(this.chatToken, this.level)
: super('v4/room/$chatToken/notify', null);
@override
ApiResponse? assemble(String raw) => null;
@override
Future<http.Response> request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) => http.post(uri, headers: headers, body: {'level': '${level.apiValue}'});
}
@@ -17,7 +17,7 @@ class GetChatCache extends SimpleCache<GetChatResponse> {
lookIntoFuture: GetChatParamsSwitch.off, lookIntoFuture: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.on, setReadMarker: GetChatParamsSwitch.on,
// Small initial page; also the per-chat offline snapshot written to // Small initial page; also the per-chat offline snapshot written to
// localstore. Older messages are paged in on scroll-up via // the request cache. Older messages are paged in on scroll-up via
// GetChatHistory. Keep in sync with ChatBloc's _kInitialPageSize. // GetChatHistory. Keep in sync with ChatBloc's _kInitialPageSize.
limit: 50, limit: 50,
), ),
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../../errors/server_exception.dart'; import '../../../errors/server_exception.dart';
@@ -45,8 +46,10 @@ class GetChatHistory {
final status = response.statusCode; final status = response.statusCode;
if (status == 304) return null; if (status == 304) return null;
if (status >= 200 && status < 300) { if (status >= 200 && status < 300) {
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body)) // A page holds up to 200 messages and lands while the user is scrolling;
..headers = response.headers; // decoding it on the UI isolate stalls the fling.
final parsed = await compute(_parseChatResponse, response.body);
return parsed..headers = response.headers;
} }
throw ServerException( throw ServerException(
statusCode: status, statusCode: status,
@@ -54,3 +57,6 @@ class GetChatHistory {
); );
} }
} }
GetChatResponse _parseChatResponse(String body) =>
GetChatResponse.fromJson(NextcloudOcs.decode(body));
+10 -2
View File
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
@@ -10,8 +11,12 @@ class GetRoom extends TalkApi<GetRoomResponse> {
GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson()); GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson());
@override @override
GetRoomResponse assemble(String raw) => GetRoomResponse assemble(String raw) => _parseRooms(raw);
GetRoomResponse.fromJson(NextcloudOcs.decode(raw));
// The room list with status is 100–250 KB and polled every 15–60 s.
@override
Future<GetRoomResponse> assembleAsync(String raw) =>
compute(_parseRooms, raw);
@override @override
Future<http.Response> request( Future<http.Response> request(
@@ -20,3 +25,6 @@ class GetRoom extends TalkApi<GetRoomResponse> {
Map<String, String>? headers, Map<String, String>? headers,
) => http.get(uri, headers: headers); ) => http.get(uri, headers: headers);
} }
GetRoomResponse _parseRooms(String raw) =>
GetRoomResponse.fromJson(NextcloudOcs.decode(raw));
@@ -130,6 +130,12 @@ enum GetRoomResponseObjectParticipantNotificationLevel {
neverNotify, neverNotify,
} }
extension NotificationLevelApiValue
on GetRoomResponseObjectParticipantNotificationLevel {
int get apiValue =>
_$GetRoomResponseObjectParticipantNotificationLevelEnumMap[this]!;
}
enum GetRoomResponseObjectMessageActorType { enum GetRoomResponseObjectMessageActorType {
@JsonValue('deleted_users') @JsonValue('deleted_users')
deletedUsers, deletedUsers,
@@ -0,0 +1,27 @@
import 'get_room_response.dart';
typedef NotificationLevel = GetRoomResponseObjectParticipantNotificationLevel;
/// The levels a user can pick in the UI; [NotificationLevel.defaultLevel]
/// is only ever reported by the server, never chosen.
const selectableNotificationLevels = [
NotificationLevel.alwaysNotify,
NotificationLevel.notifyOnMention,
NotificationLevel.neverNotify,
];
extension RoomNotificationLevel on GetRoomResponseObject {
/// Resolves "default" the way Spreed does: 1:1 chats notify on every
/// message, group chats only on mentions (`default_group_notification`).
NotificationLevel get effectiveNotificationLevel {
if (notificationLevel != NotificationLevel.defaultLevel) {
return notificationLevel;
}
return type == GetRoomResponseObjectConversationType.oneToOne
? NotificationLevel.alwaysNotify
: NotificationLevel.notifyOnMention;
}
bool get isMuted =>
effectiveNotificationLevel == NotificationLevel.neverNotify;
}
@@ -17,6 +17,10 @@ Future<void>? _shareFolderReady;
Future<void> ensureTalkShareFolder() => Future<void> ensureTalkShareFolder() =>
_shareFolderReady ??= _createTalkShareFolder(); _shareFolderReady ??= _createTalkShareFolder();
/// The folder is per user; after a sign-out the next account has to check
/// again.
void resetTalkShareFolderCache() => _shareFolderReady = null;
Future<void> _createTalkShareFolder() async { Future<void> _createTalkShareFolder() async {
try { try {
final webdav = await WebdavApi.webdav; final webdav = await WebdavApi.webdav;
+10 -2
View File
@@ -12,6 +12,10 @@ import '../nextcloud_ocs.dart';
import 'talk_error.dart'; import 'talk_error.dart';
abstract class TalkApi<T extends ApiResponse?> { abstract class TalkApi<T extends ApiResponse?> {
// package:http has no timeout of its own; a request stuck on a dead
// connection (e.g. one started right before a suspend) would spin forever.
static const Duration _timeout = Duration(seconds: 30);
String path; String path;
ApiParams? body; ApiParams? body;
Map<String, String>? headers; Map<String, String>? headers;
@@ -26,6 +30,10 @@ abstract class TalkApi<T extends ApiResponse?> {
); );
T assemble(String raw); T assemble(String raw);
/// Override to parse large payloads off the UI isolate (e.g. via `compute`
/// with a top-level parser); defaults to the synchronous [assemble].
Future<T> assembleAsync(String raw) async => assemble(raw);
Future<T> run() async { Future<T> run() async {
final endpoint = NextcloudOcs.uri( final endpoint = NextcloudOcs.uri(
'apps/spreed/api/$path', 'apps/spreed/api/$path',
@@ -35,7 +43,7 @@ abstract class TalkApi<T extends ApiResponse?> {
final data = await sendGuarded( final data = await sendGuarded(
'Talk $endpoint', 'Talk $endpoint',
() => request(endpoint, body, mergedHeaders), () => request(endpoint, body, mergedHeaders)?.timeout(_timeout),
); );
if (data == null) { if (data == null) {
throw const NetworkException( throw const NetworkException(
@@ -65,7 +73,7 @@ abstract class TalkApi<T extends ApiResponse?> {
} }
try { try {
final assembled = assemble(data.body); final assembled = await assembleAsync(data.body);
assembled?.headers = data.headers; assembled?.headers = data.headers;
return assembled; return assembled;
} catch (e) { } catch (e) {
@@ -29,6 +29,19 @@ class CacheableFile {
/// file/folder in the list. Nullable so older cached entries decode fine. /// file/folder in the list. Nullable so older cached entries decode fine.
bool? isSharedWithMe; bool? isSharedWithMe;
/// Lower-cased [name] for sorting/filtering, cached because comparators
/// call it for every comparison (name is mutable, hence the identity check).
String get lowerName {
if (!identical(_lowerNameFor, name)) {
_lowerNameFor = name;
_lowerName = name.toLowerCase();
}
return _lowerName;
}
String? _lowerNameFor;
String _lowerName = '';
CacheableFile({ CacheableFile({
required this.path, required this.path,
required this.isDirectory, required this.isDirectory,
@@ -1,9 +1,9 @@
import 'dart:convert'; import 'dart:convert';
import 'package:crypto/crypto.dart'; import 'package:crypto/crypto.dart';
import 'package:localstore/localstore.dart';
import '../../../../../utils/cache_invalidation_bus.dart'; import '../../../../../utils/cache_invalidation_bus.dart';
import '../../../../cache_store.dart';
import '../../../../request_cache.dart'; import '../../../../request_cache.dart';
import 'list_files.dart'; import 'list_files.dart';
import 'list_files_params.dart'; import 'list_files_params.dart';
@@ -43,14 +43,11 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
/// (slow) root listing is already populated by the time the user /// (slow) root listing is already populated by the time the user
/// navigates to the Files module. /// navigates to the Files module.
/// ///
/// No-ops when a cached root payload is already present in localstore — /// No-ops when a cached root payload is already present in the cache —
/// the regular TTL handling in [RequestCache] takes over from there. /// the regular TTL handling in [RequestCache] takes over from there.
static Future<void> prefetchRootListing() async { static Future<void> prefetchRootListing() async {
const rootPath = ''; const rootPath = '';
final cached = await Localstore.instance final cached = await CacheStore.instance.read(_documentId(rootPath));
.collection(RequestCache.collection)
.doc(_documentId(rootPath))
.get();
if (cached != null) return; if (cached != null) return;
// Drive the same code path as a regular fetch so the result lands in // Drive the same code path as a regular fetch so the result lands in
// the cache; we don't care about the in-memory callback here. // the cache; we don't care about the in-memory callback here.
@@ -69,10 +66,7 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
/// `_FilesView` for that path via [CacheInvalidationBus] so it refetches /// `_FilesView` for that path via [CacheInvalidationBus] so it refetches
/// even while it is sitting in the background of the navigation stack. /// even while it is sitting in the background of the navigation stack.
static Future<void> invalidate(String path) async { static Future<void> invalidate(String path) async {
await Localstore.instance await CacheStore.instance.delete(_documentId(path));
.collection(RequestCache.collection)
.doc(_documentId(path))
.delete();
CacheInvalidationBus.notifyListFiles(path); CacheInvalidationBus.notifyListFiles(path);
} }
} }
@@ -18,6 +18,8 @@ abstract class WebdavApi<T> {
/// changes (app password minted/renewed, account switch) so it never keeps /// changes (app password minted/renewed, account switch) so it never keeps
/// authenticating with stale credentials. /// authenticating with stale credentials.
static Future<WebDavClient> get webdav { static Future<WebDavClient> get webdav {
// Keyed by user too: two accounts may share a password (no app password
// minted), and the client would keep the previous login name.
final nextcloud = SessionManager().requireNextcloud(); final nextcloud = SessionManager().requireNextcloud();
final secret = '${nextcloud.username}:${nextcloud.secret}'; final secret = '${nextcloud.username}:${nextcloud.secret}';
if (_webdav == null || _webdavSecret != secret) { if (_webdav == null || _webdavSecret != secret) {
@@ -105,17 +105,29 @@ class MarianumConnectAuthInterceptor extends Interceptor {
Future<bool> _performReLogin() async { Future<bool> _performReLogin() async {
final session = SessionManager().current; final session = SessionManager().current;
if (session is! CredentialSession) return false; if (session is! CredentialSession) return false;
final username = session.username;
// A background engine (widget task) keeps the account it loaded. When
// the app signed that account out, its revoked token answers 401 — a
// re-login would mint a fresh token for it into the shared keystore.
if (await SessionManager().readStoredUsername() != username) return false;
try { try {
await _loginClient.run( await _loginClient.run(
username: session.username, username: username,
password: session.password, password: session.password,
tokenName: await DeviceTokenName.resolve(), tokenName: await DeviceTokenName.resolve(),
); );
return true;
} catch (_) { } catch (_) {
if (await SessionManager().readStoredUsername() == username) {
await _tokenStorage.clear(); await _tokenStorage.clear();
}
return false; return false;
} }
final stored = await SessionManager().readStoredUsername();
if (stored == username) return true;
// Signed out during the login: drop the orphaned token, unless another
// account already stored its own.
if (stored == null) await _tokenStorage.clear();
return false;
} }
Future<Response<dynamic>> _retryWithFreshToken( Future<Response<dynamic>> _retryWithFreshToken(
@@ -19,6 +19,7 @@ class SessionValidator {
// The probes use their own dio (bypassing the demo interceptor), so a demo // The probes use their own dio (bypassing the demo interceptor), so a demo
// session must be skipped or its missing token would 401 into a logout. // session must be skipped or its missing token would 401 into a logout.
if (session == null || session.isDemo) return; if (session == null || session.isDemo) return;
final epoch = SessionManager().sessionEpoch;
try { try {
switch (session) { switch (session) {
case CredentialSession(:final username, :final password): case CredentialSession(:final username, :final password):
@@ -28,6 +29,9 @@ class SessionValidator {
} }
} on AuthException catch (e) { } on AuthException catch (e) {
if (e.statusCode != 401) return; if (e.statusCode != 401) return;
// The probed account is no longer the active one; the 401 must not
// sign out whoever took over meanwhile.
if (!SessionManager().isCurrentSession(epoch)) return;
log('MC: stored session rejected — forcing re-login'); log('MC: stored session rejected — forcing re-login');
final next = await SessionLifecycle.signOut( final next = await SessionLifecycle.signOut(
notice: switch (session) { notice: switch (session) {
@@ -16,6 +16,16 @@ class MarianumConnectApi {
static Dio dio() => _instance; static Dio dio() => _instance;
/// Drops the pooled keep-alive sockets. After a suspend the OS or a NAT
/// has usually killed them already, but dart:io only notices once a request
/// is written into one — which then hangs until [_receiveTimeout].
/// In-flight requests finish on the old adapter.
static void resetConnections() {
final stale = _instance.httpClientAdapter;
_instance.httpClientAdapter = HttpClientAdapter();
stale.close();
}
/// A fresh dio with the standard JSON options but no interceptors — used by /// A fresh dio with the standard JSON options but no interceptors — used by
/// the auth queries (login/verify) that must bypass the bearer/demo /// the auth queries (login/verify) that must bypass the bearer/demo
/// interceptors to avoid a re-auth loop. /// interceptors to avoid a re-auth loop.
@@ -3,41 +3,71 @@ import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import '../../../../push/push_registration.dart';
import '../../../../push/push_registration_store.dart'; import '../../../../push/push_registration_store.dart';
import '../../../../push/push_registration_type.dart'; import '../../../../push/push_registration_type.dart';
import '../../marianumconnect_query.dart'; import '../../marianumconnect_query.dart';
import 'telemetry_device_id.dart'; import 'telemetry_device_id.dart';
typedef _PushState = ({
bool enabled,
AuthorizationStatus? permission,
String? pushDeviceIdentifier,
});
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) — /// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) —
/// upserts the stable install id, platform, app version and device info. Sent /// upserts the stable install id, platform, app version and device info. Sent
/// on app start, again once push registration completes that session (so a /// on app start, on resume and once push registration completes, throttled to
/// fresh registration isn't under-reported until the next launch), and on /// one per 15 minutes unless the push state changed in between.
/// resume after the app spent more than 15 minutes in the background.
/// Bearer-authenticated via the shared dio interceptor. Replaces the legacy /// Bearer-authenticated via the shared dio interceptor. Replaces the legacy
/// mhsl.eu `server/userIndex/update` call. /// mhsl.eu `server/userIndex/update` call.
class TelemetryHeartbeat extends MarianumConnectQuery { class TelemetryHeartbeat extends MarianumConnectQuery {
TelemetryHeartbeat({super.dio}); TelemetryHeartbeat({super.dio});
static const Duration _interval = Duration(minutes: 15);
// Wall-clock throttle rather than Debouncer.throttle: a Timer does not tick
// reliably while the app is suspended, so the window would still be open on
// the resume it is supposed to let through.
static DateTime? _lastSentAt;
static _PushState? _lastPushState;
/// Fire-and-forget: schedules a heartbeat and swallows any error, so a failed /// Fire-and-forget: schedules a heartbeat and swallows any error, so a failed
/// send never disrupts app start. Used from the app shell's initState and /// send never disrupts app start. A changed push state (toggle, OS
/// lifecycle handler, and re-emitted once push registration completes (see /// permission — e.g. back from the system settings — or a fresh
/// `_MainState._syncPush`). /// registration) bypasses the throttle: the server picks visible vs. silent
/// delivery from it, so it must not wait for the next window.
static void report({required bool notificationsEnabled}) { static void report({required bool notificationsEnabled}) {
unawaited( unawaited(_report(notificationsEnabled).catchError((Object _) {}));
TelemetryHeartbeat()
.send(notificationsEnabled: notificationsEnabled)
.catchError((Object _) {}),
);
} }
Future<void> send({required bool notificationsEnabled}) => guard(() async { static Future<void> _report(bool enabled) async {
final pushState = (
enabled: enabled,
permission: await PushRegistration.osPermissionStatus(),
pushDeviceIdentifier: await const PushRegistrationStore()
.deviceIdentifier(PushRegistrationType.general),
);
final now = DateTime.now();
final last = _lastSentAt;
if (last != null &&
now.difference(last) < _interval &&
pushState == _lastPushState) {
return;
}
// Claimed before sending so overlapping triggers don't both go out.
_lastSentAt = now;
_lastPushState = pushState;
await TelemetryHeartbeat()._send(pushState);
}
Future<void> _send(_PushState push) => guard(() async {
final info = DeviceInfoPlugin(); final info = DeviceInfoPlugin();
final package = await PackageInfo.fromPlatform(); final package = await PackageInfo.fromPlatform();
final deviceIdentifier = await TelemetryDeviceId.resolve(); final deviceIdentifier = await TelemetryDeviceId.resolve();
final pushDeviceIdentifier = await const PushRegistrationStore()
.deviceIdentifier(PushRegistrationType.general);
var platform = 'unknown'; var platform = 'unknown';
String? deviceModel; String? deviceModel;
@@ -62,11 +92,12 @@ class TelemetryHeartbeat extends MarianumConnectQuery {
data: { data: {
'deviceIdentifier': deviceIdentifier, 'deviceIdentifier': deviceIdentifier,
// `pushDeviceIdentifier` reflects a *completed* registration and is // `pushDeviceIdentifier` reflects a *completed* registration and is
// absent until it lands; `pushEnabled` carries the user's intent // absent until it lands; `pushEnabled` carries the user's intent (the
// (the notification toggle) so the backend can tell "user wants push" // toggle). The server combines it with `osPermission` into the
// apart from "registration not finished yet". // visible-vs-silent delivery decision.
'pushDeviceIdentifier': ?pushDeviceIdentifier, 'pushDeviceIdentifier': ?push.pushDeviceIdentifier,
'pushEnabled': notificationsEnabled, 'pushEnabled': push.enabled,
'osPermission': ?osPermissionWireValue(push.permission),
'platform': platform, 'platform': platform,
'appVersion': package.version, 'appVersion': package.version,
'appBuild': int.tryParse(package.buildNumber), 'appBuild': int.tryParse(package.buildNumber),
@@ -77,3 +108,13 @@ class TelemetryHeartbeat extends MarianumConnectQuery {
); );
}); });
} }
/// Wire value of the OS notification permission for the heartbeat.
String? osPermissionWireValue(AuthorizationStatus? status) => switch (status) {
AuthorizationStatus.authorized => 'granted',
AuthorizationStatus.provisional => 'provisional',
AuthorizationStatus.denied ||
AuthorizationStatus.deniedPermanently => 'denied',
AuthorizationStatus.notDetermined => 'notDetermined',
null => null,
};
@@ -1,6 +1,6 @@
import 'dart:developer'; import 'dart:developer';
import 'package:localstore/localstore.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../../../../session/session.dart'; import '../../../../session/session.dart';
import '../../../../session/session_manager.dart'; import '../../../../session/session_manager.dart';
@@ -21,9 +21,7 @@ import 'timetable_custom_events_add.dart';
/// failure only sees the events that were not yet moved, so nothing is /// failure only sees the events that were not yet moved, so nothing is
/// duplicated. The flag is set only once MHSL reports no remaining events. /// duplicated. The flag is set only once MHSL reports no remaining events.
class CustomEventsMigration { class CustomEventsMigration {
static const String _collection = 'MarianumMobile'; static const String _doneKey = 'customEventsMigratedToMc';
static const String _document = 'customEventsMigration';
static const String _doneKey = 'migratedToMc';
const CustomEventsMigration._(); const CustomEventsMigration._();
@@ -34,18 +32,23 @@ class CustomEventsMigration {
if (session is! CredentialSession || session.isDemo) return; if (session is! CredentialSession || session.isDemo) return;
if (await _isDone()) return; if (await _isDone()) return;
final epoch = SessionManager().sessionEpoch;
try { try {
final response = await GetCustomTimetableEvent( final response = await GetCustomTimetableEvent(
GetCustomTimetableEventParams(session.legacyUserSecret), GetCustomTimetableEventParams(session.legacyUserSecret),
).run(); ).run();
for (final event in response.events) { for (final event in response.events) {
// The POST authenticates with whoever is signed in now; after a
// sign-out the previous account's events would land in the next one.
if (!SessionManager().isCurrentSession(epoch)) return;
await TimetableCustomEventsAdd().run(event); await TimetableCustomEventsAdd().run(event);
await RemoveCustomTimetableEvent( await RemoveCustomTimetableEvent(
RemoveCustomTimetableEventParams(event.id), RemoveCustomTimetableEventParams(event.id),
).run(); ).run();
} }
if (!SessionManager().isCurrentSession(epoch)) return;
await _markDone(); await _markDone();
log( log(
'Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.', 'Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.',
@@ -57,17 +60,11 @@ class CustomEventsMigration {
} }
} }
static Future<bool> _isDone() async { // SharedPreferences, like the old cache document, is cleared on sign-out,
final data = await Localstore.instance // so the next account runs its own migration.
.collection(_collection) static Future<bool> _isDone() async =>
.doc(_document) (await SharedPreferences.getInstance()).getBool(_doneKey) ?? false;
.get();
return data != null && data[_doneKey] == true;
}
static Future<void> _markDone() async { static Future<void> _markDone() async =>
await Localstore.instance.collection(_collection).doc(_document).set({ (await SharedPreferences.getInstance()).setBool(_doneKey, true);
_doneKey: true,
});
}
} }
@@ -10,12 +10,17 @@ class McTimetableTeacher {
final String displayName; final String displayName;
final String? originalShortName; final String? originalShortName;
final String? originalDisplayName; final String? originalDisplayName;
// Webuntis element ids; null from servers that predate them.
final int? id;
final int? originalId;
McTimetableTeacher({ McTimetableTeacher({
required this.shortName, required this.shortName,
required this.displayName, required this.displayName,
this.originalShortName, this.originalShortName,
this.originalDisplayName, this.originalDisplayName,
this.id,
this.originalId,
}); });
factory McTimetableTeacher.fromJson(Map<String, dynamic> json) => factory McTimetableTeacher.fromJson(Map<String, dynamic> json) =>
@@ -45,6 +50,8 @@ class McTimetableEntry {
final String? substitutionText; final String? substitutionText;
final String? lessonText; final String? lessonText;
final String? infoText; final String? infoText;
// Index-aligned with [classNames]; null from servers that predate it.
final List<int>? classIds;
McTimetableEntry({ McTimetableEntry({
required this.id, required this.id,
@@ -60,6 +67,7 @@ class McTimetableEntry {
required this.substitutionText, required this.substitutionText,
required this.lessonText, required this.lessonText,
required this.infoText, required this.infoText,
this.classIds,
}); });
factory McTimetableEntry.fromJson(Map<String, dynamic> json) => factory McTimetableEntry.fromJson(Map<String, dynamic> json) =>
@@ -68,10 +76,17 @@ class McTimetableEntry {
/// Combines the calendar date with the hour/minute portion of [startTime] /// Combines the calendar date with the hour/minute portion of [startTime]
/// (which carries a 1970 placeholder date) into a real DateTime. /// (which carries a 1970 placeholder date) into a real DateTime.
DateTime get startDateTime => DateTime get startDateTime => _startDateTime;
DateTime get endDateTime => _endDateTime;
// Cached (private, so json_serializable ignores them): sorting and lesson
// merging read these per comparison, and every local DateTime construction
// does a timezone lookup.
late final DateTime _startDateTime =
DateTime(date.year, date.month, date.day, startTime.hour, startTime.minute); DateTime(date.year, date.month, date.day, startTime.hour, startTime.minute);
DateTime get endDateTime => late final DateTime _endDateTime =
DateTime(date.year, date.month, date.day, endTime.hour, endTime.minute); DateTime(date.year, date.month, date.day, endTime.hour, endTime.minute);
static DateTime _dateFromJson(String raw) => DateTime.parse(raw); static DateTime _dateFromJson(String raw) => DateTime.parse(raw);
@@ -12,6 +12,8 @@ McTimetableTeacher _$McTimetableTeacherFromJson(Map<String, dynamic> json) =>
displayName: json['displayName'] as String, displayName: json['displayName'] as String,
originalShortName: json['originalShortName'] as String?, originalShortName: json['originalShortName'] as String?,
originalDisplayName: json['originalDisplayName'] as String?, originalDisplayName: json['originalDisplayName'] as String?,
id: (json['id'] as num?)?.toInt(),
originalId: (json['originalId'] as num?)?.toInt(),
); );
Map<String, dynamic> _$McTimetableTeacherToJson(McTimetableTeacher instance) => Map<String, dynamic> _$McTimetableTeacherToJson(McTimetableTeacher instance) =>
@@ -20,6 +22,8 @@ Map<String, dynamic> _$McTimetableTeacherToJson(McTimetableTeacher instance) =>
'displayName': instance.displayName, 'displayName': instance.displayName,
'originalShortName': instance.originalShortName, 'originalShortName': instance.originalShortName,
'originalDisplayName': instance.originalDisplayName, 'originalDisplayName': instance.originalDisplayName,
'id': instance.id,
'originalId': instance.originalId,
}; };
McTimetableEntry _$McTimetableEntryFromJson(Map<String, dynamic> json) => McTimetableEntry _$McTimetableEntryFromJson(Map<String, dynamic> json) =>
@@ -43,6 +47,9 @@ McTimetableEntry _$McTimetableEntryFromJson(Map<String, dynamic> json) =>
substitutionText: json['substitutionText'] as String?, substitutionText: json['substitutionText'] as String?,
lessonText: json['lessonText'] as String?, lessonText: json['lessonText'] as String?,
infoText: json['infoText'] as String?, infoText: json['infoText'] as String?,
classIds: (json['classIds'] as List<dynamic>?)
?.map((e) => (e as num).toInt())
.toList(),
); );
Map<String, dynamic> _$McTimetableEntryToJson(McTimetableEntry instance) => Map<String, dynamic> _$McTimetableEntryToJson(McTimetableEntry instance) =>
@@ -60,6 +67,7 @@ Map<String, dynamic> _$McTimetableEntryToJson(McTimetableEntry instance) =>
'substitutionText': instance.substitutionText, 'substitutionText': instance.substitutionText,
'lessonText': instance.lessonText, 'lessonText': instance.lessonText,
'infoText': instance.infoText, 'infoText': instance.infoText,
'classIds': instance.classIds,
}; };
TimetableGetWeekResponse _$TimetableGetWeekResponseFromJson( TimetableGetWeekResponse _$TimetableGetWeekResponseFromJson(
+40 -25
View File
@@ -1,10 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:localstore/localstore.dart'; import '../session/session_manager.dart';
import 'api_response.dart'; import 'api_response.dart';
import 'cache_store.dart';
import 'errors/parse_exception.dart'; import 'errors/parse_exception.dart';
import 'errors/stale_session_exception.dart';
abstract class RequestCache<T extends ApiResponse?> { abstract class RequestCache<T extends ApiResponse?> {
static const int cacheNothing = 0; static const int cacheNothing = 0;
@@ -12,12 +13,10 @@ abstract class RequestCache<T extends ApiResponse?> {
static const int cacheHour = 60 * 60; static const int cacheHour = 60 * 60;
static const int cacheDay = 60 * 60 * 24; static const int cacheDay = 60 * 60 * 24;
static String collection = 'MarianumMobile';
int maxCacheTime; int maxCacheTime;
void Function(T)? onUpdate; void Function(T)? onUpdate;
/// Called only when [start] finds a cached payload in localstore. Use this /// Called only when [start] finds a cached payload in the [CacheStore]. Use this
/// (instead of [onUpdate]) when callers need to distinguish stale-but-fast /// (instead of [onUpdate]) when callers need to distinguish stale-but-fast
/// cache hits from authoritative network responses. /// cache hits from authoritative network responses.
void Function(T)? onCacheData; void Function(T)? onCacheData;
@@ -48,18 +47,35 @@ abstract class RequestCache<T extends ApiResponse?> {
static void ignore(Exception e) {} static void ignore(Exception e) {}
Future<void> start(String document) async { Future<void> start(String document) async {
final epoch = SessionManager().sessionEpoch;
try { try {
final tableData = await Localstore.instance final entry = await CacheStore.instance.read(document);
.collection(collection) var lastUpdate = entry?.lastUpdate ?? 0;
.doc(document) T? cached;
.get(); if (entry != null) {
if (tableData != null) { try {
final cached = onLocalData(tableData['json'] as String); cached = fromCacheJson(await CacheStore.decode(entry.json));
} on Object {
// A payload from an older model version: treat it as a miss.
await CacheStore.instance.delete(document);
lastUpdate = 0;
}
}
if (cached != null) {
onUpdate?.call(cached); onUpdate?.call(cached);
onCacheData?.call(cached); onCacheData?.call(cached);
} }
await _load(document, epoch, lastUpdate: lastUpdate);
} finally {
if (!_ready.isCompleted) _ready.complete();
}
}
final lastUpdate = (tableData?['lastupdate'] as num?) ?? 0; Future<void> _load(
String document,
int epoch, {
required int lastUpdate,
}) async {
if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) < if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) <
lastUpdate) { lastUpdate) {
if (renew == null || !renew!) return; if (renew == null || !renew!) return;
@@ -67,23 +83,21 @@ abstract class RequestCache<T extends ApiResponse?> {
try { try {
final newValue = await onLoad(); final newValue = await onLoad();
// The cache is shared, so a late response of a signed-out
// account would otherwise be cached for the next one.
if (!SessionManager().isCurrentSession(epoch)) {
onError(const StaleSessionException());
return;
}
onUpdate?.call(newValue); onUpdate?.call(newValue);
onNetworkData?.call(newValue); onNetworkData?.call(newValue);
unawaited( unawaited(CacheStore.instance.write(document, jsonEncode(newValue)));
Localstore.instance.collection(collection).doc(document).set({
'json': jsonEncode(newValue),
'lastupdate': DateTime.now().millisecondsSinceEpoch,
}),
);
} on Exception catch (e) { } on Exception catch (e) {
onError(e); onError(e);
} }
} finally {
if (!_ready.isCompleted) _ready.complete();
}
} }
T onLocalData(String json); T fromCacheJson(Map<String, dynamic> json);
Future<T> onLoad(); Future<T> onLoad();
} }
@@ -117,8 +131,7 @@ class SimpleCache<T extends ApiResponse?> extends RequestCache<T> {
Future<T> onLoad() => _loader(); Future<T> onLoad() => _loader();
@override @override
T onLocalData(String json) => T fromCacheJson(Map<String, dynamic> json) => _fromJson(json);
_fromJson(jsonDecode(json) as Map<String, dynamic>);
} }
/// Captures the latest cache payload (cached or network) and rethrows the /// Captures the latest cache payload (cached or network) and rethrows the
@@ -141,8 +154,10 @@ Future<T> resolveFromCache<T extends ApiResponse?>(
onError?.call(e); onError?.call(e);
}); });
await cache.ready; await cache.ready;
if (latest != null) return latest as T;
final err = capturedError; final err = capturedError;
// `latest` may still hold the cache hit read before the sign-out.
if (err is StaleSessionException) throw err;
if (latest != null) return latest as T;
if (err != null) throw err; if (err != null) throw err;
throw ParseException( throw ParseException(
technicalDetails: operationName != null technicalDetails: operationName != null
+34 -24
View File
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import 'api/marianumconnect/marianumconnect_api.dart';
import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart'; import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart'; import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart';
import 'main.dart'; import 'main.dart';
@@ -45,18 +46,15 @@ class App extends StatefulWidget {
} }
class _AppState extends State<App> with WidgetsBindingObserver { class _AppState extends State<App> with WidgetsBindingObserver {
late Timer _updateTimings;
StreamSubscription<RemoteMessage>? _onMessageSub; StreamSubscription<RemoteMessage>? _onMessageSub;
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub; StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
StreamSubscription<String>? _fcmTokenRefreshSub; StreamSubscription<String>? _fcmTokenRefreshSub;
int _knownTotalTabs = 1; int _knownTotalTabs = 1;
int _lastTabIndex = 0; int _lastTabIndex = 0;
bool _userOnLastTab = false; bool _userOnLastTab = false;
DateTime? _lastTelemetryAt;
static const Duration _chatListActiveInterval = Duration(seconds: 15); static const Duration _chatListActiveInterval = Duration(seconds: 15);
static const Duration _chatListIdleInterval = Duration(seconds: 60); static const Duration _chatListIdleInterval = Duration(seconds: 60);
static const Duration _telemetryInterval = Duration(minutes: 15);
void _onTabControllerChanged() { void _onTabControllerChanged() {
final newIndex = Main.bottomNavigator.index; final newIndex = Main.bottomNavigator.index;
@@ -78,18 +76,15 @@ class _AppState extends State<App> with WidgetsBindingObserver {
bloc.setAutoRefreshInterval( bloc.setAutoRefreshInterval(
talkIsActive ? _chatListActiveInterval : _chatListIdleInterval, talkIsActive ? _chatListActiveInterval : _chatListIdleInterval,
); );
if (talkIsActive && refresh) bloc.refresh(); // The poll keeps the list current; switching back to the tab shortly
// after a fetch doesn't need another one mid tab transition.
if (talkIsActive && refresh) {
bloc.refreshIfOlderThan(_chatListActiveInterval);
}
} }
// Wall-clock throttle rather than Debouncer.throttle: a Timer does not tick
// reliably while the app is suspended, so the window would still be open on
// the resume it is supposed to let through.
void _reportTelemetry() { void _reportTelemetry() {
if (!mounted) return; if (!mounted) return;
final now = DateTime.now();
final last = _lastTelemetryAt;
if (last != null && now.difference(last) < _telemetryInterval) return;
_lastTelemetryAt = now;
TelemetryHeartbeat.report( TelemetryHeartbeat.report(
notificationsEnabled: context notificationsEnabled: context
.read<SettingsCubit>() .read<SettingsCubit>()
@@ -103,6 +98,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
log('AppLifecycle: $state'); log('AppLifecycle: $state');
if (state == AppLifecycleState.resumed) { if (state == AppLifecycleState.resumed) {
MarianumConnectApi.resetConnections();
_reportTelemetry(); _reportTelemetry();
Debouncer.throttle('appLifecycleState', const Duration(seconds: 10), () { Debouncer.throttle('appLifecycleState', const Duration(seconds: 10), () {
if (!mounted) return; if (!mounted) return;
@@ -113,6 +109,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
_syncChatListPolling(refresh: false); _syncChatListPolling(refresh: false);
_handlePendingWidgetNavigation(); _handlePendingWidgetNavigation();
} else if (mounted) { } else if (mounted) {
context.read<SettingsCubit>().flushSilentSave();
// Stop polling while backgrounded: a silent refresh failing in the // Stop polling while backgrounded: a silent refresh failing in the
// background would otherwise leave an error that flashes on the next // background would otherwise leave an error that flashes on the next
// resume before the foreground refetch replaces it. // resume before the foreground refetch replaces it.
@@ -162,7 +159,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
void _handlePendingShare() { void _handlePendingShare() {
if (!mounted) return; if (!mounted) return;
final share = ShareIntentListener.pending.value; final share = ShareIntentListener.pending.value;
if (share == null) return; if (share == null || ShareIntentListener.instance.isShown(share)) return;
// A second share would otherwise leave the previous share-flow page // A second share would otherwise leave the previous share-flow page
// on top with stale (already-cleared) file paths. // on top with stale (already-cleared) file paths.
// Sharing targets Talk chats and Files folders only. // Sharing targets Talk chats and Files folders only.
@@ -181,6 +178,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (navigator.canPop()) { if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst || route is PopupRoute); navigator.popUntil((route) => route.isFirst || route is PopupRoute);
} }
ShareIntentListener.instance.markShown(share);
AppRoutes.openShareTarget(context, share); AppRoutes.openShareTarget(context, share);
} }
@@ -198,19 +196,18 @@ class _AppState extends State<App> with WidgetsBindingObserver {
context.read<BreakerBloc>().refresh(); context.read<BreakerBloc>().refresh();
context.read<ChatListBloc>().refresh(); context.read<ChatListBloc>().refresh();
// Initial publish in case hydrated storage already has data. No refresh // Initial publish in case hydrated storage already has data. No refresh
// needed: PrimaryTimetableScope hands out a freshly loading bloc. // needed: PrimaryTimetableScope hands out a freshly loading bloc. The
_publishWidget(context.read<TimetableBloc>()); // widget still shows its last snapshot, so this waits until the
// cold-start frames are done (a bloc emit in the meantime supersedes it).
Debouncer.debounce('widgetPublish', const Duration(seconds: 3), () {
if (mounted) _publishWidget(context.read<TimetableBloc>());
});
unawaited(_handlePendingWidgetNavigation()); unawaited(_handlePendingWidgetNavigation());
ShareIntentListener.instance.attach();
ShareIntentListener.pending.addListener(_handlePendingShare); ShareIntentListener.pending.addListener(_handlePendingShare);
_handlePendingShare(); _handlePendingShare();
_syncChatListPolling(); _syncChatListPolling();
}); });
_updateTimings = Timer.periodic(const Duration(seconds: 30), (_) {
if (mounted) setState(() {});
});
_reportTelemetry(); _reportTelemetry();
// A refreshed FCM token invalidates the existing push subscription — the // A refreshed FCM token invalidates the existing push subscription — the
@@ -253,18 +250,20 @@ class _AppState extends State<App> with WidgetsBindingObserver {
NotificationController.onAppOpenedByNotification(message, context); NotificationController.onAppOpenedByNotification(message, context);
}); });
DataCleaner.cleanOldCache(); // Housekeeping only; kept out of the cold-start window.
Future<void>.delayed(
const Duration(seconds: 20),
DataCleaner.cleanOldCache,
);
} }
@override @override
void dispose() { void dispose() {
_updateTimings.cancel();
_onMessageSub?.cancel(); _onMessageSub?.cancel();
_onMessageOpenedAppSub?.cancel(); _onMessageOpenedAppSub?.cancel();
_fcmTokenRefreshSub?.cancel(); _fcmTokenRefreshSub?.cancel();
PushTapRouter.pendingTarget.removeListener(_onPushTargetPending); PushTapRouter.pendingTarget.removeListener(_onPushTargetPending);
ShareIntentListener.pending.removeListener(_handlePendingShare); ShareIntentListener.pending.removeListener(_handlePendingShare);
ShareIntentListener.instance.detach();
Main.bottomNavigator.removeListener(_onTabControllerChanged); Main.bottomNavigator.removeListener(_onTabControllerChanged);
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
super.dispose(); super.dispose();
@@ -280,7 +279,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
// one widget reload for the burst is enough. // one widget reload for the burst is enough.
listener: (_, _) => Debouncer.debounce( listener: (_, _) => Debouncer.debounce(
'widgetPublish', 'widgetPublish',
const Duration(milliseconds: 500), const Duration(seconds: 1),
() { () {
if (mounted) _publishWidget(context.read<TimetableBloc>()); if (mounted) _publishWidget(context.read<TimetableBloc>());
}, },
@@ -290,7 +289,18 @@ class _AppState extends State<App> with WidgetsBindingObserver {
Widget _buildShell( Widget _buildShell(
BuildContext context, BuildContext context,
) => BlocBuilder<SettingsCubit, model.Settings>( ) => BlocSelector<SettingsCubit, model.Settings, Object>(
// Only the module layout shapes the shell; copied values because the
// settings object is mutated in place.
selector: (settings) {
final m = settings.modulesSettings;
return (
m.moduleOrder.join(','),
m.hiddenModules.join(','),
m.autoFillBottomBar,
m.fixedBottomBarSlots,
);
},
builder: (context, _) { builder: (context, _) {
final bottomBarModules = AppModule.getBottomBarModules(context); final bottomBarModules = AppModule.getBottomBarModules(context);
final totalTabs = bottomBarModules.length + 1; final totalTabs = bottomBarModules.length + 1;
+5
View File
@@ -36,6 +36,11 @@ extension CalendarDayArithmetic on DateTime {
); );
DateTime subtractDays(int days) => addDays(-days); DateTime subtractDays(int days) => addDays(-days);
DateTime get dateOnly => DateTime(year, month, day);
/// Monday 00:00 of this date's week.
DateTime get mondayOfWeek => subtractDays(weekday - 1).dateOnly;
} }
/// Formatting helpers backed by Jiffy. Centralises the patterns that previously /// Formatting helpers backed by Jiffy. Centralises the patterns that previously
+17 -4
View File
@@ -1,16 +1,29 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
extension TextExt on Text { extension TextExt on Text {
Size get size { /// Single-line width as rendered in [context] (honours the text scaler).
final textPainter = TextPainter( /// Memoised: chat bubbles measure the same few names and times over and
/// over, and every measurement is a full paragraph layout.
double measuredWidth(BuildContext context) {
final scaler = MediaQuery.textScalerOf(context);
final key = (data, style, scaler);
final cached = _widthCache[key];
if (cached != null) return cached;
final painter = TextPainter(
text: TextSpan(text: data, style: style), text: TextSpan(text: data, style: style),
maxLines: 1, maxLines: 1,
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
)..layout(minWidth: 0, maxWidth: double.infinity); textScaler: scaler,
return textPainter.size; )..layout();
final width = painter.width;
painter.dispose();
if (_widthCache.length >= 512) _widthCache.clear();
return _widthCache[key] = width;
} }
} }
final Map<(String?, TextStyle?, TextScaler), double> _widthCache = {};
/// Returns the first non-empty (after trim) entry, or '' if none match. /// Returns the first non-empty (after trim) entry, or '' if none match.
String firstNonEmpty(List<String?> values) { String firstNonEmpty(List<String?> values) {
for (final v in values) { for (final v in values) {
+111 -66
View File
@@ -7,6 +7,7 @@ import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart' show timeDilation;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
@@ -14,8 +15,8 @@ import 'package:jiffy/jiffy.dart';
import 'package:loader_overlay/loader_overlay.dart'; import 'package:loader_overlay/loader_overlay.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'access/user_role.dart';
import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; import 'api/marianumcloud/webdav/queries/list_files/list_files_cache.dart';
import 'api/marianumconnect/auth/session_validator.dart'; import 'api/marianumconnect/auth/session_validator.dart';
import 'api/marianumconnect/marianumconnect_endpoint.dart'; import 'api/marianumconnect/marianumconnect_endpoint.dart';
@@ -26,6 +27,7 @@ import 'app.dart';
import 'auth_link/guardian_link_listener.dart'; import 'auth_link/guardian_link_listener.dart';
import 'background/widget_background_task.dart'; import 'background/widget_background_task.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
import 'model/session_wipe.dart';
import 'notification/notification_service.dart'; import 'notification/notification_service.dart';
import 'push/notification_permission_prompt.dart'; import 'push/notification_permission_prompt.dart';
import 'push/push_message_handler.dart'; import 'push/push_message_handler.dart';
@@ -40,6 +42,7 @@ import 'share_intent/share_intent_listener.dart';
import 'state/app/modules/account/account_scope.dart'; import 'state/app/modules/account/account_scope.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 'state/app/modules/app_modules.dart';
import 'state/app/modules/capabilities/bloc/capabilities_cubit.dart'; import 'state/app/modules/capabilities/bloc/capabilities_cubit.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/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart'; import 'state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
@@ -57,6 +60,7 @@ import 'view/login/login.dart';
import 'view/login/post_login_splash.dart'; import 'view/login/post_login_splash.dart';
import 'widget/avatar_disk_cache.dart'; import 'widget/avatar_disk_cache.dart';
import 'widget/breaker/breaker.dart'; import 'widget/breaker/breaker.dart';
import 'widget/debug/debug_stats_overlay.dart';
import 'widget/downloads/download_tray.dart'; import 'widget/downloads/download_tray.dart';
import 'widget/emergency/emergency_notice_gate.dart'; import 'widget/emergency/emergency_notice_gate.dart';
import 'widget/info_dialog.dart'; import 'widget/info_dialog.dart';
@@ -120,6 +124,7 @@ void _installErrorHandlers() {
Future<void> main() async { Future<void> main() async {
log('MarianumMobile started'); log('MarianumMobile started');
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
SessionManager().markUiEngine();
// Before any initialisation so startup failures reach the backend too. // Before any initialisation so startup failures reach the backend too.
_installErrorHandlers(); _installErrorHandlers();
@@ -179,6 +184,12 @@ Future<void> main() async {
); );
log('app initialisation done!'); log('app initialisation done!');
// Independent of the notification setup below, so it runs alongside it.
final widgetSyncInit = _startupStep(
'widget sync',
WidgetSync.ensureInitialized,
);
// Local notifications: init the plugin (with tap/action callbacks) and the // Local notifications: init the plugin (with tap/action callbacks) and the
// Android channels, then register the FCM background isolate handler that // Android channels, then register the FCM background isolate handler that
// decrypts and renders Nextcloud pushes while the app is not in foreground. // decrypts and renders Nextcloud pushes while the app is not in foreground.
@@ -198,9 +209,9 @@ Future<void> main() async {
), ),
); );
// Wire up the home-screen widget bridge before runApp so any widget render // The home-screen widget bridge must be ready before runApp so any widget
// triggered during startup hits initialised native storage. // render triggered during startup hits initialised native storage.
await _startupStep('widget sync', WidgetSync.ensureInitialized); await widgetSyncInit;
unawaited( unawaited(
WidgetBackgroundTask.initialize().onError( WidgetBackgroundTask.initialize().onError(
(e, _) => log('Workmanager init failed: $e'), (e, _) => log('Workmanager init failed: $e'),
@@ -297,13 +308,19 @@ class Main extends StatefulWidget {
} }
class _MainState extends State<Main> { class _MainState extends State<Main> {
final List<NavigatorObserver> _navigatorObservers = [
AppRoutes.chatRouteObserver,
DownloadRouteObserver(),
];
bool _showPostLoginSplash = false; bool _showPostLoginSplash = false;
bool _appMounted = true; bool _appMounted = true;
late AccountStatus _lastStatus;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
Jiffy.setLocale('de'); Jiffy.setLocale('de');
_lastStatus = context.read<AccountBloc>().state.status;
SessionManager().unauthorizedSignal.addListener(_onUnauthorized); SessionManager().unauthorizedSignal.addListener(_onUnauthorized);
GuardianLinkListener.pending.addListener(_onGuardianLink); GuardianLinkListener.pending.addListener(_onGuardianLink);
@@ -332,8 +349,10 @@ class _MainState extends State<Main> {
final capabilitiesCubit = scopeContext.read<CapabilitiesCubit>(); final capabilitiesCubit = scopeContext.read<CapabilitiesCubit>();
unawaited( unawaited(
capabilitiesCubit.load().then((_) { capabilitiesCubit.load().then((_) {
if (!mounted) return; // Closed: the account was switched away from while loading.
if (!mounted || capabilitiesCubit.isClosed) return;
_syncPush(settingsCubit, capabilitiesCubit); _syncPush(settingsCubit, capabilitiesCubit);
_applyRoleDefaults(settingsCubit, capabilitiesCubit);
_promptGuardianNotifications(); _promptGuardianNotifications();
}), }),
); );
@@ -393,6 +412,19 @@ class _MainState extends State<Main> {
); );
} }
/// userType is only known once capabilities are loaded.
void _applyRoleDefaults(
SettingsCubit settings,
CapabilitiesCubit capabilities,
) {
if (AppModule.applyTeacherDefaults(
settings.val().modulesSettings,
isTeacher: capabilities.state.role == UserRole.teacher,
)) {
settings.val(write: true);
}
}
@override @override
void dispose() { void dispose() {
SessionManager().unauthorizedSignal.removeListener(_onUnauthorized); SessionManager().unauthorizedSignal.removeListener(_onUnauthorized);
@@ -444,55 +476,72 @@ class _MainState extends State<Main> {
if (mounted) setState(() => _appMounted = true); if (mounted) setState(() => _appMounted = true);
}); });
} }
if (accountState.status == AccountStatus.addingAccount) { final wasLoggedIn = _lastStatus == AccountStatus.loggedIn;
// Routes pushed on the root navigator (Settings) would otherwise keep _lastStatus = accountState.status;
// covering the login screen that replaces the home route. // A cold start that merely resolves as signed out has nothing to wipe,
AppRoutes.rootNavigatorKey.currentState?.popUntil( // and a share waiting for the login must survive it.
(route) => route.isFirst, if (accountState.status != AccountStatus.loggedOut || !wasLoggedIn) return;
);
}
if (accountState.status != AccountStatus.loggedOut) return;
// A pending share would otherwise survive logout and be re-applied after
// re-login with file paths the OS may already have evicted from the cache.
ShareIntentListener.instance.clear();
// Deferred until the account-scoped tree is torn down. // Deferred until the account-scoped tree is torn down.
WidgetsBinding.instance.addPostFrameCallback( WidgetsBinding.instance.addPostFrameCallback(
(_) => unawaited(_wipeDeviceState()), (_) => unawaited(SessionWipe.deviceLeft()),
); );
} }
String? _scopeAccountId; /// The account-scoped tree only lives while its account is the one in use:
/// adding another account parks it, so its blocs go as well.
static Object _scopeOf(AccountState state) => (
state.accountId,
state.status == AccountStatus.addingAccount,
);
Object? _scope;
@override @override
Widget build(BuildContext context) => Directionality( Widget build(BuildContext context) => Directionality(
textDirection: TextDirection.ltr, textDirection: TextDirection.ltr,
child: BlocBuilder<SettingsCubit, Settings>( // Selects only what the root uses: Settings is mutated in place and
builder: (context, settings) { // re-emitted as a fresh instance on every write, so a plain BlocBuilder
// would rebuild MaterialApp (and every route) for unrelated settings.
child: BlocSelector<SettingsCubit, Settings, _RootSettings>(
selector: (settings) => (
appTheme: settings.appTheme,
notificationsEnabled: settings.notificationSettings.enabled,
showPerformanceOverlay:
settings.devToolsSettings.showPerformanceOverlay,
showSemanticsDebugger: settings.devToolsSettings.showSemanticsDebugger,
showFrameStats: settings.devToolsSettings.showFrameStats,
showMemoryStats: settings.devToolsSettings.showMemoryStats,
slowAnimations: settings.devToolsSettings.slowAnimations,
textScaleOverride: settings.devToolsSettings.textScaleOverride,
),
builder: (context, root) {
// Mirror the notification toggle into group-scoped storage so the FCM // Mirror the notification toggle into group-scoped storage so the FCM
// background isolate and the iOS NSE can suppress rendering when off. // background isolate and the iOS NSE can suppress rendering when off.
unawaited( unawaited(
const PushRegistrationStore().setNotificationsEnabled( const PushRegistrationStore().setNotificationsEnabled(
settings.notificationSettings.enabled, root.notificationsEnabled,
), ),
); );
timeDilation = root.slowAnimations ? 5.0 : 1.0;
return BlocConsumer<AccountBloc, AccountState>( return BlocConsumer<AccountBloc, AccountState>(
listenWhen: (previous, current) => listenWhen: (previous, current) =>
previous.status != current.status || previous.status != current.status ||
previous.accountId != current.accountId, previous.accountId != current.accountId,
listener: _onAccountChanged, listener: _onAccountChanged,
buildWhen: (previous, current) => buildWhen: (previous, current) =>
previous.accountId != current.accountId, _scopeOf(previous) != _scopeOf(current),
builder: (context, account) { builder: (context, account) {
if (account.accountId != _scopeAccountId) { final scope = _scopeOf(account);
_scopeAccountId = account.accountId; if (scope != _scope) {
_scope = scope;
// The old navigator (and every route on it) goes with the old // The old navigator (and every route on it) goes with the old
// account; a shared GlobalKey would carry it over instead. // account; a shared GlobalKey would carry it over instead.
AppRoutes.rootNavigatorKey = GlobalKey<NavigatorState>(); AppRoutes.rootNavigatorKey = GlobalKey<NavigatorState>();
} }
return AccountScope( return AccountScope(
key: ValueKey(account.accountId), key: ValueKey(scope),
onActive: _onSessionActive, onActive: _onSessionActive,
child: _buildApp(settings), child: _buildApp(root),
); );
}, },
); );
@@ -500,22 +549,15 @@ class _MainState extends State<Main> {
), ),
); );
Widget _buildApp(Settings settings) { Widget _buildApp(_RootSettings root) => MaterialApp(
final devToolsSettings = settings.devToolsSettings; showPerformanceOverlay: root.showPerformanceOverlay,
return MaterialApp( showSemanticsDebugger: root.showSemanticsDebugger,
showPerformanceOverlay: devToolsSettings.showPerformanceOverlay,
checkerboardOffscreenLayers: devToolsSettings.checkerboardOffscreenLayers,
checkerboardRasterCacheImages:
devToolsSettings.checkerboardRasterCacheImages,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
navigatorKey: AppRoutes.rootNavigatorKey, navigatorKey: AppRoutes.rootNavigatorKey,
// Used by ChatView.didPopNext to reclaim the global ChatBloc. // Used by ChatView.didPopNext to reclaim the global ChatBloc.
// DownloadRouteObserver tracks full-page navigations so the downloads // DownloadRouteObserver tracks full-page navigations so the downloads
// chip only surfaces once the user leaves the screen they started on. // chip only surfaces once the user leaves the screen they started on.
navigatorObservers: [ navigatorObservers: _navigatorObservers,
AppRoutes.chatRouteObserver,
DownloadRouteObserver(),
],
localizationsDelegates: const [ localizationsDelegates: const [
...GlobalMaterialLocalizations.delegates, ...GlobalMaterialLocalizations.delegates,
GlobalWidgetsLocalizations.delegate, GlobalWidgetsLocalizations.delegate,
@@ -523,7 +565,7 @@ class _MainState extends State<Main> {
supportedLocales: const [Locale('de'), Locale('en')], supportedLocales: const [Locale('de'), Locale('en')],
locale: const Locale('de'), locale: const Locale('de'),
title: 'Marianum Fulda', title: 'Marianum Fulda',
themeMode: settings.appTheme, themeMode: root.appTheme,
theme: LightAppTheme.theme, theme: LightAppTheme.theme,
darkTheme: DarkAppTheme.theme, darkTheme: DarkAppTheme.theme,
// Brand-colored backdrop behind every route. During the logout // Brand-colored backdrop behind every route. During the logout
@@ -531,20 +573,34 @@ class _MainState extends State<Main> {
// expose the layer below the topmost Scaffold; without this // expose the layer below the topmost Scaffold; without this
// the dark Material default shows through and the user sees a // the dark Material default shows through and the user sees a
// black flash. // black flash.
builder: (context, child) => ColoredBox( builder: (context, child) {
Widget app = ColoredBox(
color: LightAppTheme.marianumRed, color: LightAppTheme.marianumRed,
// Downloads tray mounted ABOVE the navigator so its chip floats over // Downloads tray mounted ABOVE the navigator so its chip floats
// every route (folder views, chat, viewer are full-page pushes that // over every route (folder views, chat, viewer are full-page
// would otherwise cover it). // pushes that would otherwise cover it).
child: DownloadTrayHost(child: child ?? const SizedBox.shrink()), child: DownloadTrayHost(child: child ?? const SizedBox.shrink()),
), );
final scale = root.textScaleOverride;
if (scale > 0) {
app = MediaQuery.withClampedTextScaling(
minScaleFactor: scale,
maxScaleFactor: scale,
child: app,
);
}
return DebugStatsOverlay(
showFrameStats: root.showFrameStats,
showMemoryStats: root.showMemoryStats,
child: app,
);
},
home: EmergencyNoticeGate( home: EmergencyNoticeGate(
child: LoaderOverlay( child: LoaderOverlay(
child: Breaker( child: Breaker(
breaker: BreakerArea.global, breaker: BreakerArea.global,
child: BlocBuilder<AccountBloc, AccountState>( child: BlocBuilder<AccountBloc, AccountState>(
buildWhen: (previous, current) => buildWhen: (previous, current) => previous.status != current.status,
previous.status != current.status,
builder: (context, accountState) { builder: (context, accountState) {
switch (accountState.status) { switch (accountState.status) {
case AccountStatus.loggedIn: case AccountStatus.loggedIn:
@@ -575,26 +631,15 @@ class _MainState extends State<Main> {
), ),
), ),
); );
}
} }
/// Wipes what outlives accounts once the last one signed out. Account data typedef _RootSettings = ({
/// itself is removed by [SessionLifecycle.signOut]. ThemeMode appTheme,
Future<void> _wipeDeviceState() async { bool notificationsEnabled,
try { bool showPerformanceOverlay,
final prefs = await SharedPreferences.getInstance(); bool showSemanticsDebugger,
await prefs.clear(); bool showFrameStats,
// The chat background image lives outside HydratedStorage, so clear it too bool showMemoryStats,
// (best-effort) to avoid orphaning the previous user's wallpaper. bool slowAnimations,
final backgroundImage = File(AppPaths.chatBackgroundImage); double textScaleOverride,
if (backgroundImage.existsSync()) backgroundImage.deleteSync(); });
// Stop the periodic widget refresh job so the background isolate doesn't
// wake up every 30 minutes only to write `loggedIn=false`. Re-registers
// on the next successful login.
await WidgetBackgroundTask.cancelAll();
await WidgetSync.clear();
await WidgetSync.triggerUpdate();
} catch (e, s) {
log('Device state wipe failed: $e', stackTrace: s);
}
}
+3 -19
View File
@@ -1,24 +1,8 @@
import 'package:localstore/localstore.dart'; import '../api/cache_store.dart';
import '../api/request_cache.dart';
class DataCleaner { class DataCleaner {
static Future<void> cleanOldCache() async { static Future<void> cleanOldCache() async {
final cacheData = await Localstore.instance await CacheStore.deleteLegacyLocalstoreCache();
.collection(RequestCache.collection) await CacheStore.instance.deleteOlderThan(const Duration(days: 200));
.get();
cacheData?.forEach((key, value) async {
final lastUpdate = DateTime.fromMillisecondsSinceEpoch(
((value['lastupdate'] as num?) ?? 0).toInt(),
);
if (DateTime.now()
.subtract(const Duration(days: 200))
.isAfter(lastUpdate)) {
await Localstore.instance
.collection(RequestCache.collection)
.doc(key.split('/').last)
.delete();
}
});
} }
} }
+122
View File
@@ -0,0 +1,122 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_app_badge/flutter_app_badge.dart';
import 'package:path_provider/path_provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../api/marianumcloud/talk/share_files_to_chat.dart';
import '../background/widget_background_task.dart';
import '../notification/notification_service.dart';
import '../push/chat_thread_store.dart';
import '../push/nid_store.dart';
import '../push/push_keypair.dart';
import '../push/push_tap_router.dart';
import '../routing/app_routes.dart';
import '../share_intent/share_intent_listener.dart';
import '../utils/app_paths.dart';
import '../utils/downloads/download_manager.dart';
import '../utils/file_clipboard.dart';
import '../widget_data/widget_sync.dart';
/// Removes what an account leaves on the device outside its own storage (see
/// `AccountStorage` for that). Every step is isolated: one failing step used
/// to skip all later ones and leave the previous account's data behind.
abstract final class SessionWipe {
static Future<void>? _running;
/// Completes once a running wipe finished. A login awaits it, otherwise the
/// wipe could clear what the next account just stored (widget job, caches).
static Future<void> get done => _running ?? Future.value();
/// State that must be gone before the next frame (a login screen or another
/// account can be reached right away): pending navigation and in-memory
/// singletons.
static void clearImmediate() {
_step('share intents', ShareIntentListener.instance.clearAll);
_step('share folder cache', resetTalkShareFolderCache);
_step('pending navigation', () {
AppRoutes.pendingChatToken.value = null;
PushTapRouter.pendingTarget.value = null;
});
_step('file clipboard', FileClipboard.instance.clear);
_step('downloads', DownloadManager.instance.clearAll);
}
/// Runs whenever the active account stops being active (sign-out or
/// switch). The tray and its bookkeeping belong to that account — a tap or
/// inline reply there would otherwise act as the next one. A new keypair
/// and FCM token make pushes still addressed to the previous registration
/// undecryptable and undeliverable (an offline sign-out or switch could not
/// unregister it).
static Future<void> accountLeft() async {
await _stepAsync('download files', () async {
final dir = Directory('${(await getTemporaryDirectory()).path}/downloads');
if (dir.existsSync()) await dir.delete(recursive: true);
});
await _stepAsync(
'notification tray',
NotificationService().flutterLocalNotificationsPlugin.cancelAll,
);
await _stepAsync('app badge', () => FlutterAppBadge.count(0));
await _stepAsync('push nid store', NidStore().clear);
await _stepAsync('push thread store', ChatThreadStore().clearAll);
await _stepAsync('push keypair', const PushKeypair().clear);
// Bounded: the next account waits for this, also without a network.
await _stepAsync(
'fcm token',
() => FirebaseMessaging.instance.deleteToken().timeout(
const Duration(seconds: 5),
),
);
}
/// Wipes what outlives accounts, once the last one signed out.
static Future<void> deviceLeft() {
final wipe = _deviceLeft();
_running = wipe;
return wipe.whenComplete(() {
if (identical(_running, wipe)) _running = null;
});
}
static Future<void> _deviceLeft() async {
// SettingsCubit is deliberately left alone: its selector wraps
// MaterialApp, and emitting a fresh state here tore down the freshly
// mounted Login tree (blank screen until the next interaction).
await _stepAsync('shared preferences', () async {
await (await SharedPreferences.getInstance()).clear();
});
await _stepAsync('chat background', () async {
final image = File(AppPaths.chatBackgroundImage);
if (image.existsSync()) await image.delete();
});
// 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 _stepAsync('widget task', WidgetBackgroundTask.cancelAll);
await _stepAsync('widget data', WidgetSync.clear);
await _stepAsync('widget update', WidgetSync.triggerUpdate);
}
static void _step(String name, void Function() step) {
try {
step();
} catch (e, s) {
log('Session wipe step "$name" failed: $e', stackTrace: s);
}
}
static Future<void> _stepAsync(
String name,
Future<void> Function() step,
) async {
try {
await step();
} catch (e, s) {
log('Session wipe step "$name" failed: $e', stackTrace: s);
}
}
}
+10 -1
View File
@@ -7,6 +7,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../push/push_message_handler.dart'; import '../push/push_message_handler.dart';
import '../push/push_target.dart'; import '../push/push_target.dart';
import '../state/app/modules/chat/bloc/chat_bloc.dart'; import '../state/app/modules/chat/bloc/chat_bloc.dart';
import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../utils/debouncer.dart';
import '../widget/debug/debug_tile.dart'; import '../widget/debug/debug_tile.dart';
import '../widget/debug/json_viewer.dart'; import '../widget/debug/json_viewer.dart';
import '../widget/info_dialog.dart'; import '../widget/info_dialog.dart';
@@ -35,7 +37,14 @@ class NotificationController {
); );
await NotificationTasks.refreshBadge(); await NotificationTasks.refreshBadge();
if (!context.mounted) return; if (!context.mounted) return;
NotificationTasks.updateProviders(context); // A busy group chat delivers pushes in bursts; one silent refresh after
// the burst replaces a full room-list fetch per message.
final chatList = context.read<ChatListBloc>();
Debouncer.debounce(
'pushChatListRefresh',
const Duration(seconds: 1),
() => chatList.refresh(silent: true),
);
} }
static Future<void> onAppOpenedByNotification( static Future<void> onAppOpenedByNotification(
@@ -25,7 +25,12 @@ class NotificationService {
// iOS Talk category mirrors the Android inline reply + mark-as-read actions // iOS Talk category mirrors the Android inline reply + mark-as-read actions
// so both platforms expose the same quick actions. The actual delivery of // so both platforms expose the same quick actions. The actual delivery of
// these while the app is terminated is handled by the (Phase 3) NSE. // these while the app is terminated is handled by the (Phase 3) NSE.
// The plugin defaults to prompting here, which would block startup on the
// OS dialog; the prompt belongs to maybePromptTalkNotifications instead.
final iosSettings = DarwinInitializationSettings( final iosSettings = DarwinInitializationSettings(
requestAlertPermission: false,
requestSoundPermission: false,
requestBadgePermission: false,
notificationCategories: [ notificationCategories: [
DarwinNotificationCategory( DarwinNotificationCategory(
PushRenderer.iosTalkCategory, PushRenderer.iosTalkCategory,
+4
View File
@@ -60,6 +60,10 @@ class PushActions {
// auth header, the Talk POST never happens and the RemoteInput spinner // auth header, the Talk POST never happens and the RemoteInput spinner
// runs forever. // runs forever.
DartPluginRegistrant.ensureInitialized(); DartPluginRegistrant.ensureInitialized();
// The action engine lives as long as the process: without a reload a
// reply would be sent with the account that was signed in when the
// engine first started.
await SessionManager().reloadFromStorage();
_plog( _plog(
'action=${response.actionId} payload=${response.payload} ' 'action=${response.actionId} payload=${response.payload} '
+19 -1
View File
@@ -1,7 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:typed_data';
import 'package:crypton/crypton.dart'; import 'package:crypton/crypton.dart';
import 'package:flutter/foundation.dart';
import 'package:pointycastle/export.dart' as pc; import 'package:pointycastle/export.dart' as pc;
import 'push_subject.dart'; import 'push_subject.dart';
@@ -25,6 +25,14 @@ class PushDecryptor {
const PushDecryptor({required this.devicePrivateKey, this.serverPublicKey}); const PushDecryptor({required this.devicePrivateKey, this.serverPublicKey});
/// [verify] + [decrypt] on a background isolate: pure-Dart RSA-2048 (with
/// the OAEP → PKCS#1 fallback) takes tens of ms up to >100 ms on older
/// phones, which would otherwise land on the UI thread for every push.
Future<({bool verified, PushSubject? subject})> verifyAndDecryptInBackground(
String subjectBase64,
String signatureBase64,
) => compute(_verifyAndDecrypt, (this, subjectBase64, signatureBase64));
/// Returns true when [signatureBase64] is a valid server signature over the /// Returns true when [signatureBase64] is a valid server signature over the
/// encrypted subject. Returns true when no server key is configured (the /// encrypted subject. Returns true when no server key is configured (the
/// proxy already verified the signature before forwarding). /// proxy already verified the signature before forwarding).
@@ -75,3 +83,13 @@ class PushDecryptor {
} }
} }
} }
({bool verified, PushSubject? subject}) _verifyAndDecrypt(
(PushDecryptor, String, String) args,
) {
final (decryptor, subject, signature) = args;
if (!decryptor.verify(subject, signature)) {
return (verified: false, subject: null);
}
return (verified: true, subject: decryptor.decrypt(subject));
}
+9 -2
View File
@@ -5,6 +5,7 @@ import 'package:firebase_messaging/firebase_messaging.dart';
import '../background/widget_background_task.dart'; import '../background/widget_background_task.dart';
import '../notification/notification_service.dart'; import '../notification/notification_service.dart';
import '../session/session_manager.dart';
import 'chat_thread_store.dart'; import 'chat_thread_store.dart';
import 'nid_store.dart'; import 'nid_store.dart';
import 'push_decryptor.dart'; import 'push_decryptor.dart';
@@ -54,6 +55,8 @@ PushKind classifyPush(Map<String, dynamic> data) {
/// "must be annotated"), so a plain function is the reliable form. /// "must be annotated"), so a plain function is the reliable form.
@pragma('vm:entry-point') @pragma('vm:entry-point')
Future<void> pushOnBackgroundMessage(RemoteMessage message) async { Future<void> pushOnBackgroundMessage(RemoteMessage message) async {
// This engine outlives sign-outs and logins in the app.
await SessionManager().reloadFromStorage();
await NotificationService().initializeNotifications(); await NotificationService().initializeNotifications();
await PushRenderer.ensureChannels(); await PushRenderer.ensureChannels();
await PushMessageHandler().handle(message); await PushMessageHandler().handle(message);
@@ -175,11 +178,15 @@ class PushMessageHandler {
devicePrivateKey: privateKey, devicePrivateKey: privateKey,
serverPublicKey: serverPublicKey, serverPublicKey: serverPublicKey,
); );
if (!decryptor.verify(subjectBase64, signatureBase64)) { final result = await decryptor.verifyAndDecryptInBackground(
subjectBase64,
signatureBase64,
);
if (!result.verified) {
log('Push: signature verification failed'); log('Push: signature verification failed');
return; return;
} }
final subject = decryptor.decrypt(subjectBase64); final subject = result.subject;
if (subject == null) { if (subject == null) {
log('Push: could not decrypt subject'); log('Push: could not decrypt subject');
return; return;
+32 -34
View File
@@ -118,6 +118,7 @@ class PushRegistration {
Future<bool> register() async { Future<bool> register() async {
if (DemoMode.active) return false; if (DemoMode.active) return false;
if (_nextcloudOrNull == null) return _direct.register(); if (_nextcloudOrNull == null) return _direct.register();
final epoch = SessionManager().sessionEpoch;
final String? fcmToken; final String? fcmToken;
try { try {
fcmToken = await FirebaseMessaging.instance.getToken(); fcmToken = await FirebaseMessaging.instance.getToken();
@@ -167,6 +168,7 @@ class PushRegistration {
fcmToken: fcmToken, fcmToken: fcmToken,
pems: pems, pems: pems,
appVersion: appVersion, appVersion: appVersion,
epoch: epoch,
); );
allOk = allOk && ok; allOk = allOk && ok;
} }
@@ -178,6 +180,7 @@ class PushRegistration {
required String fcmToken, required String fcmToken,
required PushKeypairPems pems, required PushKeypairPems pems,
required String? appVersion, required String? appVersion,
required int epoch,
}) async { }) async {
try { try {
final proxyServer = currentProxyServer; final proxyServer = currentProxyServer;
@@ -193,6 +196,11 @@ class PushRegistration {
userAgent: isTalk ? _talkUserAgent : null, userAgent: isTalk ? _talkUserAgent : null,
); );
// Signed out while registering: persisting or announcing this
// registration would keep delivering the previous account's pushes,
// and logoutCleanup already ran so nothing would unregister it.
if (!SessionManager().isCurrentSession(epoch)) return false;
await _store.save( await _store.save(
type: type, type: type,
deviceIdentifier: registration.deviceIdentifier, deviceIdentifier: registration.deviceIdentifier,
@@ -355,9 +363,8 @@ class PushRegistration {
status != AuthorizationStatus.deniedPermanently; status != AuthorizationStatus.deniedPermanently;
/// Requests the OS notification permission (covers iOS + Android 13) and /// Requests the OS notification permission (covers iOS + Android 13) and
/// returns whether registration should proceed. Errors from the plugin are /// returns whether it is usable (not explicitly denied). Errors from the
/// treated as usable — better a possibly-idle registration than silently /// plugin are treated as usable so a transient failure never nags the user.
/// losing push over a transient failure.
static Future<bool> requestOsPermission() async { static Future<bool> requestOsPermission() async {
try { try {
final settings = await FirebaseMessaging.instance.requestPermission(); final settings = await FirebaseMessaging.instance.requestPermission();
@@ -368,56 +375,47 @@ class PushRegistration {
} }
} }
/// True when the user has explicitly denied the OS notification permission. /// Current OS notification permission, or null when the plugin can't tell.
/// Read-only (no prompt) — used by the settings UI to surface the state. /// Read-only — never triggers the OS prompt.
static Future<bool> isOsPermissionDenied() async { static Future<AuthorizationStatus?> osPermissionStatus() async {
try { try {
final settings = await FirebaseMessaging.instance final settings = await FirebaseMessaging.instance
.getNotificationSettings(); .getNotificationSettings();
return !isPermissionUsable(settings.authorizationStatus); return settings.authorizationStatus;
} on Object { } on Object {
return false; return null;
} }
} }
/// True when the OS notification permission is already granted /// True when the OS notification permission is already granted
/// (`authorized`/`provisional`). Read-only — never triggers the OS prompt. /// (`authorized`/`provisional`). Read-only — never triggers the OS prompt.
/// Used by the cold-start/self-heal path so it registers only for devices
/// that already opted in, leaving the actual prompt to the first Talk visit.
static Future<bool> isOsPermissionGranted() async { static Future<bool> isOsPermissionGranted() async {
try { final status = await osPermissionStatus();
final settings = await FirebaseMessaging.instance return status != null && canDisplay(status);
.getNotificationSettings();
return settings.authorizationStatus == AuthorizationStatus.authorized ||
settings.authorizationStatus == AuthorizationStatus.provisional;
} on Object {
return false;
}
} }
/// Whether the OS will actually show a visible notification in [status].
/// Stricter than [isPermissionUsable]: `notDetermined` shows nothing yet.
static bool canDisplay(AuthorizationStatus status) =>
status == AuthorizationStatus.authorized ||
status == AuthorizationStatus.provisional;
/// Registers this device whenever the backend advertises the push capability. /// Registers this device whenever the backend advertises the push capability.
/// Deliberately independent of the in-app notification toggle: a user who /// Deliberately independent of both the in-app notification toggle and the
/// turned notifications off stays registered so silent sync pushes keep /// OS notification permission: silent sync pushes (deletes, chat/badge
/// flowing — the display is suppressed downstream via the mirrored flag (see /// refresh, widget refresh) need no permission — data-only FCM on Android,
/// [PushRegistrationStore.notificationsEnabled]). Only registers when the OS /// `content-available` on iOS — so every device stays in sync. Whether a
/// notification permission is *already* granted — it never triggers the OS /// visible notification is wanted is reported separately via the telemetry
/// prompt itself. Requesting the permission is the job of the first Talk visit /// heartbeat (toggle + OS permission), from which the server picks silent
/// (see `maybePromptTalkNotifications`), which keeps the prompt out of the /// pushes on iOS. Never triggers the OS prompt. Safe to call on every
/// cold-start path. Safe to call on every start — Nextcloud dedups an /// start — Nextcloud dedups an unchanged registration — which also
/// unchanged registration — which also self-heals a device whose registration /// self-heals a device whose registration was lost.
/// was lost.
/// Returns whether registration was actually *attempted* (all gates passed). /// Returns whether registration was actually *attempted* (all gates passed).
/// Even a partial success persists the `general` device identifier, so the /// Even a partial success persists the `general` device identifier, so the
/// caller re-emits telemetry on `true` to reflect the fresh registration in /// caller re-emits telemetry on `true` to reflect the fresh registration in
/// the same session instead of lagging until the next launch. /// the same session instead of lagging until the next launch.
static Future<bool> syncSubscription({required bool capable}) async { static Future<bool> syncSubscription({required bool capable}) async {
if (!capable) return false; if (!capable) return false;
if (!await isOsPermissionGranted()) {
log(
'Push: OS notification permission not granted, skipping registration',
);
return false;
}
final registration = PushRegistration(); final registration = PushRegistration();
// register() below refreshes an unchanged subscription anyway; the check // register() below refreshes an unchanged subscription anyway; the check
// only surfaces the endpoint switch in the log for diagnosability. // only surfaces the endpoint switch in the log for diagnosability.
+4 -15
View File
@@ -1,4 +1,3 @@
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../session/session_manager.dart'; import '../session/session_manager.dart';
@@ -142,21 +141,11 @@ Future<PushStatusReport> collectPushStatus({
} }
Future<PushCheck> _osPermission() async { Future<PushCheck> _osPermission() async {
try { final status = await PushRegistration.osPermissionStatus();
final settings = await FirebaseMessaging.instance.getNotificationSettings(); if (status == null) return PushCheck.unknown;
switch (settings.authorizationStatus) { if (PushRegistration.canDisplay(status)) return PushCheck.ok;
case AuthorizationStatus.authorized: if (!PushRegistration.isPermissionUsable(status)) return PushCheck.fail;
case AuthorizationStatus.provisional:
return PushCheck.ok;
case AuthorizationStatus.denied:
case AuthorizationStatus.deniedPermanently:
return PushCheck.fail;
case AuthorizationStatus.notDetermined:
return PushCheck.unknown; return PushCheck.unknown;
}
} on Object {
return PushCheck.unknown;
}
} }
/// One line in the status checklist. /// One line in the status checklist.
+22 -1
View File
@@ -28,6 +28,7 @@ import '../view/pages/more/roomplan/roomplan.dart';
import '../view/pages/more/share/qr_share_view.dart'; import '../view/pages/more/share/qr_share_view.dart';
import '../view/pages/parent_letters/parent_letter_view.dart'; import '../view/pages/parent_letters/parent_letter_view.dart';
import '../view/pages/settings/chat_background_settings_page.dart'; import '../view/pages/settings/chat_background_settings_page.dart';
import '../view/pages/settings/diagnostics_page.dart';
import '../view/pages/settings/modules_settings_page.dart'; import '../view/pages/settings/modules_settings_page.dart';
import '../view/pages/settings/settings.dart'; import '../view/pages/settings/settings.dart';
import '../view/pages/share_intent/share_chat_picker.dart'; import '../view/pages/share_intent/share_chat_picker.dart';
@@ -40,7 +41,9 @@ import '../view/pages/ticker/ticker_page_view.dart';
import '../view/pages/timetable/custom_events/custom_events_view.dart'; import '../view/pages/timetable/custom_events/custom_events_view.dart';
import '../view/pages/timetable/subject_colors/subject_colors_view.dart'; import '../view/pages/timetable/subject_colors/subject_colors_view.dart';
import '../widget/avatar_crop_page.dart'; import '../widget/avatar_crop_page.dart';
import '../widget/debug/bloc_storage_view.dart';
import '../widget/debug/cache_view.dart'; import '../widget/debug/cache_view.dart';
import '../widget/debug/json_viewer.dart';
import '../widget/file_viewer.dart'; import '../widget/file_viewer.dart';
import '../widget/large_profile_picture_view.dart'; import '../widget/large_profile_picture_view.dart';
import '../widget/user_avatar.dart'; import '../widget/user_avatar.dart';
@@ -294,8 +297,26 @@ class AppRoutes {
pushScreen(context, withNavBar: false, screen: const FeedbackDialog()); pushScreen(context, withNavBar: false, screen: const FeedbackDialog());
} }
static void openCacheView(BuildContext context) { static Future<void> openCacheView(BuildContext context) =>
pushScreen(context, withNavBar: false, screen: const CacheView()); pushScreen(context, withNavBar: false, screen: const CacheView());
static Future<void> openBlocStorageView(BuildContext context) =>
pushScreen(context, withNavBar: false, screen: const BlocStorageView());
static void openDiagnostics(BuildContext context) {
pushScreen(context, withNavBar: false, screen: const DiagnosticsPage());
}
static void openJsonViewer(
BuildContext context,
Object? data, {
String? title,
}) {
pushScreen(
context,
withNavBar: false,
screen: JsonViewer(data: data, title: title ?? 'Rohdaten'),
);
} }
static void openRoomplan(BuildContext context) { static void openRoomplan(BuildContext context) {
+19 -16
View File
@@ -10,8 +10,7 @@ import '../api/marianumconnect/queries/auth_logout/auth_logout.dart';
import '../api/marianumconnect/queries/auth_me/auth_me.dart'; import '../api/marianumconnect/queries/auth_me/auth_me.dart';
import '../auth_link/guardian_link_listener.dart'; import '../auth_link/guardian_link_listener.dart';
import '../background/widget_background_task.dart'; import '../background/widget_background_task.dart';
import '../push/chat_thread_store.dart'; import '../model/session_wipe.dart';
import '../push/nid_store.dart';
import '../push/push_registration.dart'; import '../push/push_registration.dart';
import '../storage/account_storage.dart'; import '../storage/account_storage.dart';
import '../widget_data/widget_sync.dart'; import '../widget_data/widget_sync.dart';
@@ -41,10 +40,11 @@ abstract final class SessionLifecycle {
} }
await AuthLogout().run(); await AuthLogout().run();
final removed = await SessionManager().signOut(); final removed = await SessionManager().signOut();
SessionWipe.clearImmediate();
// A login link that arrived while signed in must not be replayed on the // A login link that arrived while signed in must not be replayed on the
// login screen that follows. // login screen that follows.
GuardianLinkListener.clear(); GuardianLinkListener.clear();
await _clearPushStores(); await SessionWipe.accountLeft();
if (removed != null) await AccountStorage.delete(removed.namespace); if (removed != null) await AccountStorage.delete(removed.namespace);
for ( for (
@@ -78,14 +78,22 @@ abstract final class SessionLifecycle {
log('Switch: push deactivation failed: $e'); log('Switch: push deactivation failed: $e');
} }
await SessionManager().stashActive(); await SessionManager().stashActive();
SessionWipe.clearImmediate();
var restored = false;
try { try {
await SessionManager().activate(id); await SessionManager().activate(id);
} on Object { } on Object {
if (previous != null) await SessionManager().activate(previous.id); if (previous != null) {
await SessionManager().activate(previous.id);
restored = true;
}
rethrow; rethrow;
} finally { } finally {
await _clearPushStores(); await SessionWipe.accountLeft();
await AccountStorage.activate(SessionManager().activeAccount); await AccountStorage.activate(SessionManager().activeAccount);
// Nothing remounts for the account that stays, so its push would
// remain unregistered until the next start.
if (restored) unawaited(PushRegistration().register());
} }
await _resetWidget(); await _resetWidget();
} }
@@ -94,6 +102,10 @@ abstract final class SessionLifecycle {
static Future<void> beginAddAccount() async { static Future<void> beginAddAccount() async {
await SessionManager().stashActive(); await SessionManager().stashActive();
_addReturnId = SessionManager().activeAccount?.id; _addReturnId = SessionManager().activeAccount?.id;
SessionWipe.clearImmediate();
// Whatever runs behind the login screen must not write into the parked
// account's data.
await AccountStorage.activate(null);
} }
static bool get isAddingAccount => _addReturnId != null; static bool get isAddingAccount => _addReturnId != null;
@@ -109,6 +121,7 @@ abstract final class SessionLifecycle {
await SessionManager().signOut(); await SessionManager().signOut();
} }
await SessionManager().activate(returnId); await SessionManager().activate(returnId);
await AccountStorage.activate(SessionManager().activeAccount);
} }
/// Called once a login finished. After "add account" the previous account's /// Called once a login finished. After "add account" the previous account's
@@ -127,7 +140,7 @@ abstract final class SessionLifecycle {
log('Add account: push deactivation failed: $e'); log('Add account: push deactivation failed: $e');
} }
await SessionManager().activate(added.id); await SessionManager().activate(added.id);
await _clearPushStores(); await SessionWipe.accountLeft();
} }
await AccountStorage.activate(SessionManager().activeAccount); await AccountStorage.activate(SessionManager().activeAccount);
return added.id; return added.id;
@@ -191,14 +204,4 @@ abstract final class SessionLifecycle {
log('Widget reset failed: $e'); log('Widget reset failed: $e');
} }
} }
// Tray bookkeeping of the previous account's pushes.
static Future<void> _clearPushStores() async {
try {
await NidStore().clear();
await ChatThreadStore().clearAll();
} on Object catch (e) {
log('Push store cleanup failed: $e');
}
}
} }
+59 -4
View File
@@ -58,6 +58,21 @@ class SessionManager {
Session? get current => _current; Session? get current => _current;
int _sessionEpoch = 0;
/// Bumped whenever the active account changes (sign-out, switch, another
/// account signing in). Async work captures it when it starts and drops its
/// result when it changed meanwhile, so a request of the previous account
/// cannot land in the next account's state or cache.
int get sessionEpoch => _sessionEpoch;
bool isCurrentSession(int epoch) => epoch == _sessionEpoch;
bool _isUiEngine = false;
/// Called from `main()`; background entry points never run it.
void markUiEngine() => _isUiEngine = true;
/// Every signed-in account and which one is active. /// Every signed-in account and which one is active.
final ValueNotifier<AccountIndex> accounts = ValueNotifier( final ValueNotifier<AccountIndex> accounts = ValueNotifier(
AccountIndex.empty, AccountIndex.empty,
@@ -115,6 +130,7 @@ class SessionManager {
} }
Future<void> _writeActive(Session session) async { Future<void> _writeActive(Session session) async {
if (!_isSameAccount(_current, session)) _sessionEpoch++;
await Future.wait([ await Future.wait([
for (final MapEntry(:key, :value) in encodeSessionFields(session).entries) for (final MapEntry(:key, :value) in encodeSessionFields(session).entries)
_writeSecret(key, value), _writeSecret(key, value),
@@ -134,6 +150,7 @@ class SessionManager {
/// stay in their vaults; see [activate]. /// stay in their vaults; see [activate].
Future<AccountEntry?> signOut() async { Future<AccountEntry?> signOut() async {
final removed = activeAccount; final removed = activeAccount;
_sessionEpoch++;
_loaded = Completer(); _loaded = Completer();
_current = null; _current = null;
await Future.wait([ await Future.wait([
@@ -193,6 +210,11 @@ class SessionManager {
await _saveIndex(accounts.value.remove(id)); await _saveIndex(accounts.value.remove(id));
} }
static bool _isSameAccount(Session? a, Session? b) {
if (a == null || b == null) return a == b;
return identityOf(a) == identityOf(b) && a.isDemo == b.isDemo;
}
Future<void> _saveIndex(AccountIndex index) async { Future<void> _saveIndex(AccountIndex index) async {
accounts.value = index; accounts.value = index;
await _secureStorage.write(key: _indexKey, value: index.encode()); await _secureStorage.write(key: _indexKey, value: index.encode());
@@ -280,8 +302,15 @@ class SessionManager {
Future<void> _migrateAndLoad() async { Future<void> _migrateAndLoad() async {
await _migrateFromLegacyStorage(); await _migrateFromLegacyStorage();
await _migrateKeychainAccessibility(); await _migrateKeychainAccessibility();
// On the startup critical path (and every background wake): read in _current = await _readActive();
// parallel instead of one keychain round-trip after the other. await _loadIndex();
if (!_loaded.isCompleted) _loaded.complete();
}
/// The session in the active slots. On the startup critical path (and every
/// background wake): read in parallel instead of one keychain round-trip
/// after the other.
Future<Session?> _readActive() async {
final values = await Future.wait( final values = await Future.wait(
_sessionFields.map((field) => _secureStorage.read(key: field)), _sessionFields.map((field) => _secureStorage.read(key: field)),
); );
@@ -296,9 +325,35 @@ class SessionManager {
} on Object { } on Object {
// Group keystore unavailable: fall back to the real password. // Group keystore unavailable: fall back to the real password.
} }
_current = decodeSession(raw); return decodeSession(raw);
await _loadIndex(); }
/// Username currently in the active slots. Other engines (widget task, push
/// isolates) sign out, in or switch without this instance noticing.
Future<String?> readStoredUsername() =>
_secureStorage.read(key: SessionKeys.username);
/// Re-reads the session for long-lived background engines: they load once
/// and would otherwise keep acting with an account that signed out or was
/// switched away from in the app meanwhile. Keeps the known state when the
/// keystore is unreadable.
Future<void> reloadFromStorage() async {
// The UI engine performs sign-in, sign-out and switches itself, so its
// state is current; re-reading in between could resurrect the removed
// account.
if (_isUiEngine) return;
try {
final session = await _readActive();
final index = AccountIndex.decode(
await _secureStorage.read(key: _indexKey),
);
if (!_isSameAccount(_current, session)) _sessionEpoch++;
_current = session;
accounts.value = index;
if (!_loaded.isCompleted) _loaded.complete(); if (!_loaded.isCompleted) _loaded.complete();
} on Object catch (e) {
log('Session reload failed, keeping loaded state: $e');
}
} }
Future<void> _loadIndex() async { Future<void> _loadIndex() async {
+8 -2
View File
@@ -5,11 +5,17 @@ class PendingShare {
final String? text; final String? text;
final DateTime receivedAt; final DateTime receivedAt;
/// Paths as delivered by the platform, before the listener moved them into
/// a per-share folder. Duplicate detection compares these, since the
/// relocated [filePaths] differ for every delivery.
final List<String> sourcePaths;
const PendingShare({ const PendingShare({
required this.filePaths, required this.filePaths,
required this.text, required this.text,
required this.receivedAt, required this.receivedAt,
}); List<String>? sourcePaths,
}) : sourcePaths = sourcePaths ?? filePaths;
bool get hasFiles => filePaths.isNotEmpty; bool get hasFiles => filePaths.isNotEmpty;
bool get hasText => text != null && text!.isNotEmpty; bool get hasText => text != null && text!.isNotEmpty;
@@ -20,5 +26,5 @@ class PendingShare {
/// the same share can arrive twice on the media stream — receivedAt is /// the same share can arrive twice on the media stream — receivedAt is
/// deliberately ignored here so such duplicates compare equal. /// deliberately ignored here so such duplicates compare equal.
bool contentEquals(PendingShare other) => bool contentEquals(PendingShare other) =>
text == other.text && listEquals(filePaths, other.filePaths); text == other.text && listEquals(sourcePaths, other.sourcePaths);
} }
+172 -47
View File
@@ -2,6 +2,8 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import '../auth_link/guardian_login_link.dart'; import '../auth_link/guardian_login_link.dart';
@@ -15,55 +17,63 @@ class ShareIntentListener {
static final ValueNotifier<PendingShare?> pending = ValueNotifier(null); static final ValueNotifier<PendingShare?> pending = ValueNotifier(null);
StreamSubscription<List<SharedMediaFile>>? _streamSub; static const _androidChannel = MethodChannel('eu.mhsl.marianum.share');
bool _initialized = false;
/// Reads the cold-start payload exactly once. Call from `main()` before /// Per-share folders live below this directory name — on Android in the
/// `runApp` so the share is queued before the UI mounts. /// cache dir (created here), on iOS in the App Group container (created by
/// the Share Extension).
static const _shareDirName = 'share_intent';
bool _initialized = false;
String? _cacheDir;
int _flowDepth = 0;
PendingShare? _queued;
PendingShare? _shown;
/// Subscribes to warm shares and reads the cold-start payload. Call from
/// `main()` before `runApp`. The subscription stays for the process
/// lifetime: the plugin drops shares that arrive while nobody listens
/// (login screen, app remount), and [pending] buffers until the UI is ready.
Future<void> initialize() async { Future<void> initialize() async {
if (_initialized) return; if (_initialized) return;
_initialized = true; _initialized = true;
try { try {
final initial = await ReceiveSharingIntent.instance.getInitialMedia(); if (Platform.isAndroid) {
final share = _toPendingShare(initial); _cacheDir = (await getTemporaryDirectory()).path;
if (share != null) _publish(share); _sweepStaleAndroidShares();
}
ReceiveSharingIntent.instance.getMediaStream().listen(
_onItems,
onError: (Object e) => debugPrint('ShareIntentListener stream: $e'),
);
_onItems(await ReceiveSharingIntent.instance.getInitialMedia());
await ReceiveSharingIntent.instance.reset(); await ReceiveSharingIntent.instance.reset();
if (Platform.isAndroid) {
await _androidChannel.invokeMethod<void>('listenerReady');
}
} catch (e) { } catch (e) {
debugPrint('ShareIntentListener.initialize failed: $e'); debugPrint('ShareIntentListener.initialize failed: $e');
} }
} }
/// Subscribes to warm-share stream events. Safe to call multiple times. /// Whether the share flow for [share] is already on screen. Re-running the
void attach() { /// navigation for it would pop its own page and thereby clear it.
_streamSub ??= ReceiveSharingIntent.instance.getMediaStream().listen( bool isShown(PendingShare share) => identical(share, _shown);
(items) {
final share = _toPendingShare(items); void markShown(PendingShare share) => _shown = share;
if (share != null) _publish(share);
}, /// Marks a share flow step that must not be interrupted (upload, share API
onError: (Object e) => debugPrint('ShareIntentListener stream error: $e'), /// call). Shares arriving meanwhile are queued instead of replacing the
); /// current one, whose files are still in use. Pair with [endFlow].
void beginFlow() => _flowDepth++;
void endFlow() {
if (_flowDepth > 0) _flowDepth--;
if (_flowDepth == 0) _drainQueue();
} }
/// The iOS Share Extension fires two `open(url)` requests per share, so the /// Discards the current share and deletes its temp files. Idempotent.
/// same payload can arrive twice in quick succession. Publishing the
/// duplicate would re-trigger the share-flow navigation, pop the already
/// open ShareTargetPage and thereby delete the temp files of the share that
/// is still in flight — swallow it instead.
void _publish(PendingShare share) {
final current = pending.value;
if (current != null && current.contentEquals(share)) return;
pending.value = share;
}
/// Cancels the warm-share subscription. The singleton survives, so a
/// subsequent [attach] re-subscribes.
void detach() {
_streamSub?.cancel();
_streamSub = null;
}
/// Discards the current share and removes any temp files the plugin copied
/// into the app cache. Idempotent.
/// ///
/// Pass [ifCurrent] from UI that owns a specific share (e.g. the /// Pass [ifCurrent] from UI that owns a specific share (e.g. the
/// ShareTargetPage pop handler): the call then only acts while that share /// ShareTargetPage pop handler): the call then only acts while that share
@@ -73,18 +83,58 @@ class ShareIntentListener {
void clear({PendingShare? ifCurrent}) { void clear({PendingShare? ifCurrent}) {
final current = pending.value; final current = pending.value;
if (ifCurrent != null && !identical(current, ifCurrent)) return; if (ifCurrent != null && !identical(current, ifCurrent)) return;
_shown = null;
pending.value = null; pending.value = null;
if (current != null) { if (current != null) _deleteFiles(current);
for (final path in current.filePaths) {
try {
final f = File(path);
if (f.existsSync()) f.deleteSync();
} catch (_) {
// best-effort cleanup; OS will reclaim cache eventually
}
}
}
unawaited(ReceiveSharingIntent.instance.reset()); unawaited(ReceiveSharingIntent.instance.reset());
if (_flowDepth == 0) _drainQueue();
}
/// Drops every share, including a queued one — for sign-out.
void clearAll() {
final queued = _queued;
_queued = null;
_flowDepth = 0;
if (queued != null) _deleteFiles(queued);
clear();
}
void _onItems(List<SharedMediaFile> items) {
final share = _toPendingShare(items);
if (share == null) return;
// The iOS Share Extension opens the app twice per share, so the same
// payload can arrive again while it is still pending. Publishing it would
// re-run the navigation and pop the share that is in flight.
for (final live in [pending.value, _queued]) {
if (live != null && live.contentEquals(share)) {
_deleteFiles(share, keep: live.filePaths);
return;
}
}
final claimed = _claim(share);
if (_flowDepth > 0) {
final replaced = _queued;
_queued = claimed;
if (replaced != null) _deleteFiles(replaced);
return;
}
_replaceCurrent(claimed);
}
void _drainQueue() {
final queued = _queued;
if (queued == null) return;
_queued = null;
_replaceCurrent(queued);
}
// No flow is running, so nothing uses the previous share's files anymore;
// its pages are popped when the new share is routed.
void _replaceCurrent(PendingShare share) {
final previous = pending.value;
_shown = null;
pending.value = share;
if (previous != null) _deleteFiles(previous);
} }
/// Android hands an opened App Link (guardian login mail) to the share /// Android hands an opened App Link (guardian login mail) to the share
@@ -106,7 +156,9 @@ class ShareIntentListener {
case SharedMediaType.image: case SharedMediaType.image:
case SharedMediaType.video: case SharedMediaType.video:
case SharedMediaType.file: case SharedMediaType.file:
files.add(item.path); // A replayed share can point at temp copies deleted after the
// original share completed.
if (File(item.path).existsSync()) files.add(item.path);
case SharedMediaType.text: case SharedMediaType.text:
case SharedMediaType.url: case SharedMediaType.url:
if (!isAppLink(item.path)) texts.add(item.path); if (!isAppLink(item.path)) texts.add(item.path);
@@ -119,4 +171,77 @@ class ShareIntentListener {
receivedAt: DateTime.now(), receivedAt: DateTime.now(),
); );
} }
/// The Android plugin copies shared content to `cacheDir/<display name>`,
/// so a second share with an equally named file would overwrite the first
/// one's copy. Move owned copies into a folder of their own. iOS shares
/// already arrive in a per-share folder.
PendingShare _claim(PendingShare share) {
final cacheDir = _cacheDir;
if (!Platform.isAndroid || cacheDir == null || !share.hasFiles) {
return share;
}
final dir = Directory(
'$cacheDir/$_shareDirName/${share.receivedAt.microsecondsSinceEpoch}',
);
final claimed = <String>[];
for (final (i, path) in share.filePaths.indexed) {
if (!_isOwned(path)) {
claimed.add(path);
continue;
}
try {
dir.createSync(recursive: true);
final name = path.split(Platform.pathSeparator).last;
var target = '${dir.path}/$name';
if (File(target).existsSync()) target = '${dir.path}/$i-$name';
claimed.add(File(path).renameSync(target).path);
} catch (_) {
claimed.add(path);
}
}
return PendingShare(
filePaths: claimed,
text: share.text,
receivedAt: share.receivedAt,
sourcePaths: share.sourcePaths,
);
}
/// Only temp copies are deleted: on Android the plugin hands out the real
/// path for some providers (e.g. a file in Downloads), which must survive.
bool _isOwned(String path) {
if (Platform.isIOS) return true;
final cacheDir = _cacheDir;
return cacheDir != null && path.startsWith('$cacheDir/');
}
void _deleteFiles(PendingShare share, {List<String> keep = const []}) {
for (final path in share.filePaths) {
if (keep.contains(path) || !_isOwned(path)) continue;
try {
final file = File(path);
if (file.existsSync()) file.deleteSync();
final dir = file.parent;
if (dir.parent.path.endsWith('/$_shareDirName') &&
dir.listSync().isEmpty) {
dir.deleteSync();
}
} catch (_) {
// best-effort cleanup; OS will reclaim cache eventually
}
}
}
// Shares whose flow never finished (process killed) leave their folder
// behind; the pending one of this process is not claimed yet.
void _sweepStaleAndroidShares() {
try {
final root = Directory('$_cacheDir/$_shareDirName');
if (!root.existsSync()) return;
root.deleteSync(recursive: true);
} catch (_) {
// best-effort
}
}
} }
@@ -20,10 +20,15 @@ class LoadableStateBloc extends Bloc<LoadableStateEvent, LoadableStateState>
LoadableStateBloc() : super(const LoadableStateState(connections: null)) { LoadableStateBloc() : super(const LoadableStateState(connections: null)) {
on<ConnectivityChanged>((event, emit) { on<ConnectivityChanged>((event, emit) {
// Only a real reconnect (or a resume) warrants a refetch: the initial
// status after mount and Wi-Fi ↔ mobile handovers would otherwise
// reload an already loaded page for nothing.
final wasOffline = connectivityStatusKnown() && !isConnected();
emit(event.state); emit(event.state);
if (connectivityStatusKnown() && isConnected()) { if ((wasOffline || event.fromResume) &&
if (reFetch == null) return; connectivityStatusKnown() &&
reFetch!(); isConnected()) {
reFetch?.call();
} }
}); });
@@ -54,7 +59,12 @@ class LoadableStateBloc extends Bloc<LoadableStateEvent, LoadableStateState>
unawaited( unawaited(
Connectivity().checkConnectivity().then((result) { Connectivity().checkConnectivity().then((result) {
if (isClosed) return; if (isClosed) return;
add(ConnectivityChanged(LoadableStateState(connections: result))); add(
ConnectivityChanged(
LoadableStateState(connections: result),
fromResume: true,
),
);
}), }),
); );
} }
@@ -4,5 +4,10 @@ sealed class LoadableStateEvent {}
final class ConnectivityChanged extends LoadableStateEvent { final class ConnectivityChanged extends LoadableStateEvent {
final LoadableStateState state; final LoadableStateState state;
ConnectivityChanged(this.state);
/// Re-check after the app came back to the foreground: refetches whenever
/// online, not only on an offline → online transition.
final bool fromResume;
ConnectivityChanged(this.state, {this.fromResume = false});
} }
@@ -69,7 +69,7 @@ class LoadableStateConsumer<
// null mid-refetch, and toggling the RefreshIndicator on that signal would // null mid-refetch, and toggling the RefreshIndicator on that signal would
// rebuild the tree under the ListView and reset its scroll position. // rebuild the tree under the ListView and reset its scroll position.
final content = SizedBox( final content = SizedBox(
height: MediaQuery.of(context).size.height, height: MediaQuery.sizeOf(context).height,
child: hasContent child: hasContent
? child(typedData as TState, isLoading) ? child(typedData as TState, isLoading)
: const SizedBox.shrink(), : const SizedBox.shrink(),
@@ -95,10 +95,12 @@ class _LoadableStateErrorBarTextState extends State<LoadableStateErrorBarText> {
late Timer _rebuildTimer; late Timer _rebuildTimer;
@override @override
void initState() { void initState() {
_rebuildTimer = Timer.periodic( // Only refresh the relative "last updated" text while this page is the
const Duration(seconds: 10), // visible one; offstage tabs and covered routes have tickers disabled.
(timer) => setState(() {}), _rebuildTimer = Timer.periodic(const Duration(seconds: 10), (timer) {
); if (!mounted) return;
if (TickerMode.getValuesNotifier(context).value.enabled) setState(() {});
});
super.initState(); super.initState();
} }
@@ -33,6 +33,9 @@ class _LoadableStatePrimaryLoadingState
extends State<LoadableStatePrimaryLoading> { extends State<LoadableStatePrimaryLoading> {
Timer? _slowHintTimer; Timer? _slowHintTimer;
bool _showSlowHint = false; bool _showSlowHint = false;
// An indeterminate spinner ticks every frame even at opacity 0, so it is
// unmounted once the fade-out finished instead of just being hidden.
late bool _spinnerMounted = widget.visible;
@override @override
void initState() { void initState() {
@@ -44,6 +47,7 @@ class _LoadableStatePrimaryLoadingState
void didUpdateWidget(covariant LoadableStatePrimaryLoading oldWidget) { void didUpdateWidget(covariant LoadableStatePrimaryLoading oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (widget.visible != oldWidget.visible) _restartSlowHintTimer(); if (widget.visible != oldWidget.visible) _restartSlowHintTimer();
if (widget.visible) _spinnerMounted = true;
} }
void _restartSlowHintTimer() { void _restartSlowHintTimer() {
@@ -62,16 +66,23 @@ class _LoadableStatePrimaryLoadingState
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) => AnimatedOpacity(
final status =
widget.statusText ??
(_showSlowHint ? LoadableStatePrimaryLoading.slowHintText : null);
return AnimatedOpacity(
opacity: widget.visible ? 1.0 : 0.0, opacity: widget.visible ? 1.0 : 0.0,
duration: LoadableStateConsumer.animationDuration, duration: LoadableStateConsumer.animationDuration,
curve: Curves.easeInOut, curve: Curves.easeInOut,
child: Center( onEnd: () {
if (!widget.visible && _spinnerMounted) {
setState(() => _spinnerMounted = false);
}
},
child: _spinnerMounted ? _spinner(context) : const SizedBox.shrink(),
);
Widget _spinner(BuildContext context) {
final status =
widget.statusText ??
(_showSlowHint ? LoadableStatePrimaryLoading.slowHintText : null);
return Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -99,7 +110,6 @@ class _LoadableStatePrimaryLoadingState
), ),
], ],
), ),
),
); );
} }
} }
@@ -1,10 +1,13 @@
import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../access/access_requirement.dart'; import '../../../../../access/access_requirement.dart';
import '../../../../../api/errors/error_mapper.dart'; import '../../../../../api/errors/error_mapper.dart';
import '../../../../../api/errors/stale_session_exception.dart';
import '../../../../../session/session_manager.dart'; import '../../../../../session/session_manager.dart';
import '../../../../../utils/session_single_flight.dart';
import '../../loadable_state/loadable_state.dart'; import '../../loadable_state/loadable_state.dart';
import '../../loadable_state/loading_error.dart'; import '../../loadable_state/loading_error.dart';
import '../../repository/repository.dart'; import '../../repository/repository.dart';
@@ -19,6 +22,12 @@ abstract class LoadableHydratedBloc<
extends extends
HydratedBloc<LoadableHydratedBlocEvent<TState>, LoadableState<TState>> { HydratedBloc<LoadableHydratedBlocEvent<TState>, LoadableState<TState>> {
late TRepository _repository; late TRepository _repository;
// HydratedBloc serialises and writes the full state on every emit; loading
// flags, status texts and errors re-emit the very same data, so those
// writes are skipped (see [persistenceKey]).
Object? _lastPersistedKey;
int? _lastPersistedFetch;
LoadableHydratedBloc() LoadableHydratedBloc()
: super( : super(
const LoadableState( const LoadableState(
@@ -112,6 +121,8 @@ abstract class LoadableHydratedBloc<
/// fresh [fetch] (e.g. via [retry] or page-specific refresh) once the user /// fresh [fetch] (e.g. via [retry] or page-specific refresh) once the user
/// is authenticated again, otherwise the UI would stay blank. /// is authenticated again, otherwise the UI would stay blank.
Future<void> reset() async { Future<void> reset() async {
_lastPersistedKey = null;
_lastPersistedFetch = null;
await clear(); await clear();
add(Reset<TState>()); add(Reset<TState>());
} }
@@ -123,6 +134,30 @@ abstract class LoadableHydratedBloc<
bool get requirementsMet => requirements.areMetBy(SessionManager().current); bool get requirementsMet => requirements.areMetBy(SessionManager().current);
// Blocs are rebuilt per account (see AccountScope). One that briefly
// outlives its account would otherwise load with the next account's
// credentials and persist the result into its own storage.
final int _ownSession = SessionManager().sessionEpoch;
/// Whether the account this bloc was created for is still the active one.
bool get ownsSession => SessionManager().isCurrentSession(_ownSession);
static const _sessionKey = #loadableSessionEpoch;
/// Runs [body] tagged with the current session: events it adds, also from
/// its async continuations, are dropped once the account signed out, so a
/// late response of the previous account cannot refill the reset bloc.
R runInSession<R>(R Function() body) =>
runZoned(body, zoneValues: {_sessionKey: SessionManager().sessionEpoch});
@override
void add(LoadableHydratedBlocEvent<TState> event) {
if (!ownsSession) return;
final epoch = Zone.current[_sessionKey];
if (epoch is int && !SessionManager().isCurrentSession(epoch)) return;
super.add(event);
}
TState? get innerState => state.data; TState? get innerState => state.data;
TRepository get repo => _repository; TRepository get repo => _repository;
@@ -135,7 +170,10 @@ abstract class LoadableHydratedBloc<
/// Maps [e] through the shared error mapper and emits it as an [Error] event. /// Maps [e] through the shared error mapper and emits it as an [Error] event.
/// Does not guard [isClosed] — callers decide whether a late error still /// Does not guard [isClosed] — callers decide whether a late error still
/// applies. /// applies.
void addLoadingError(Object e) => add( void addLoadingError(Object e) {
// Belongs to a signed-out account; the current one loads on its own.
if (e is StaleSessionException) return;
add(
Error( Error(
LoadingError( LoadingError(
message: errorToUserMessage(e), message: errorToUserMessage(e),
@@ -144,21 +182,35 @@ abstract class LoadableHydratedBloc<
), ),
), ),
); );
}
// The constructor, the app shell and resume/reconnect handlers can all ask
// at once; each parallel gather would re-parse, re-emit and re-persist the
// same data.
final SessionSingleFlight _fetchFlight = SessionSingleFlight();
void fetch() { void fetch() {
if (!requirementsMet) return; if (!requirementsMet || !ownsSession) return;
unawaited(
_fetchFlight.run(() {
log('Fetching data for ${TState.toString()}'); log('Fetching data for ${TState.toString()}');
gatherData() return runInSession(
() => gatherData()
.catchError((Object e) { .catchError((Object e) {
log('Error while fetching ${TState.toString()}: ${e.toString()}'); log(
// The bloc may have been closed before this async error landed; 'Error while fetching ${TState.toString()}: ${e.toString()}',
// adding to a closed bloc throws, so swallow that case. );
// The bloc may have been closed before this async error
// landed; adding to a closed bloc throws, so swallow that case.
if (isClosed) return; if (isClosed) return;
addLoadingError(e); addLoadingError(e);
}) })
.then((value) { .then((value) {
log('Fetch for ${TState.toString()} completed!'); log('Fetch for ${TState.toString()} completed!');
}); }),
);
}),
);
} }
@override @override
@@ -175,13 +227,27 @@ abstract class LoadableHydratedBloc<
@override @override
Map<String, dynamic>? toJson(LoadableState<TState> state) { Map<String, dynamic>? toJson(LoadableState<TState> state) {
final stateData = state.data;
final key = stateData is TState ? persistenceKey(stateData) : null;
if (key != null &&
// Identity for plain data: a freezed `==` would deep-compare it.
(identical(key, _lastPersistedKey) ||
(key is Record && key == _lastPersistedKey)) &&
state.lastFetch == _lastPersistedFetch) {
return null;
}
_lastPersistedKey = key;
_lastPersistedFetch = state.lastFetch;
Map<String, dynamic>? data; Map<String, dynamic>? data;
try { try {
final stateData = state.data;
data = stateData is TState ? toStorage(stateData) : null; data = stateData is TState ? toStorage(stateData) : null;
} catch (e) { } catch (e) {
log('Failed to save state ${TState.toString()}: ${e.toString()}'); log('Failed to save state ${TState.toString()}: ${e.toString()}');
} }
// Blocs that keep nothing on disk return null from toStorage; writing an
// empty wrapper per emit would be pure overhead.
if (stateData != null && data == null) return null;
return LoadableSaveContext.wrap( return LoadableSaveContext.wrap(
data, data,
@@ -189,6 +255,13 @@ abstract class LoadableHydratedBloc<
); );
} }
/// What has to change for the state to be written to disk again. Defaults
/// to the data instance; blocs whose state also carries transient UI flags
/// can return a record of just the persisted parts (records compare with
/// `==`, so use fields that compare cheaply, e.g. by identity).
Object? persistenceKey(TState data) => data;
Future<void> gatherData(); Future<void> gatherData();
TRepository repository(); TRepository repository();
+21
View File
@@ -199,6 +199,27 @@ class AppModule {
return order; return order;
} }
/// Modules that are of no use to teachers and start out hidden for them.
static const Set<Modules> hiddenForTeachersByDefault = {
Modules.absenceReport,
};
/// Hides [hiddenForTeachersByDefault] once for teacher accounts; the teacher
/// can re-enable them in the module settings. Returns whether [settings]
/// changed.
static bool applyTeacherDefaults(
ModulesSettings settings, {
required bool isTeacher,
}) {
if (!isTeacher || settings.teacherDefaultsApplied) return false;
settings.hiddenModules = {
...settings.hiddenModules,
...hiddenForTeachersByDefault,
}.toList();
settings.teacherDefaultsApplied = true;
return true;
}
// The settings list displays a capability-filtered subset, so reorder // The settings list displays a capability-filtered subset, so reorder
// indices refer to that subset; the move is applied there and merged back // indices refer to that subset; the move is applied there and merged back
// into the full order (non-displayed modules keep their relative slots). // into the full order (non-displayed modules keep their relative slots).
@@ -37,8 +37,12 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
); );
return; return;
} }
final epoch = SessionManager().sessionEpoch;
try { try {
final response = await GetCapabilities().run(); final response = await GetCapabilities().run();
// A slow answer for the previous account must not decide the modules
// of the next one.
if (!SessionManager().isCurrentSession(epoch)) return;
emit( emit(
CapabilitiesState( CapabilitiesState(
viewForeignTimetables: response.viewForeignTimetables, viewForeignTimetables: response.viewForeignTimetables,
@@ -56,7 +60,7 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
} }
} }
void reset() => emit(const CapabilitiesState()); Future<void> reset() async => emit(const CapabilitiesState());
@override @override
CapabilitiesState fromJson(Map<String, dynamic> json) { CapabilitiesState fromJson(Map<String, dynamic> json) {
+25 -5
View File
@@ -23,6 +23,9 @@ class ChatBloc
final ChatListBloc? _chatListBloc; final ChatListBloc? _chatListBloc;
String? _pollingToken; String? _pollingToken;
// A loop parked in its request survives stop+start for the same token
// (pause/resume); the generation retires it so only one loop polls.
int _pollGeneration = 0;
int _backoffMs = 0; int _backoffMs = 0;
int _lastKnownMessageId = 0; int _lastKnownMessageId = 0;
bool _appResumed = true; bool _appResumed = true;
@@ -56,6 +59,12 @@ class ChatBloc
@override @override
ChatState fromStorage(Map<String, dynamic> json) => ChatState.fromJson(json); ChatState fromStorage(Map<String, dynamic> json) => ChatState.fromJson(json);
// Loading-older and reply-reference flips must not re-persist up to 500
// messages; GetChatResponse has no `==`, so it compares by identity here.
@override
Object? persistenceKey(ChatState data) =>
(data.currentToken, data.chatResponse, data.hasMoreOld);
@override @override
Map<String, dynamic>? toStorage(ChatState state) { Map<String, dynamic>? toStorage(ChatState state) {
final response = state.chatResponse; final response = state.chatResponse;
@@ -123,6 +132,15 @@ class ChatBloc
/// No-op when the bloc has already moved on to a different token: when /// No-op when the bloc has already moved on to a different token: when
/// popping a stacked chat (B over A), A's didPopNext runs setToken(A) /// popping a stacked chat (B over A), A's didPopNext runs setToken(A)
/// before B's dispose fires. /// before B's dispose fires.
/// The chat view may still be popping when the sign-out resets this bloc,
/// so leaveChat would find no token and the long-poll would keep running.
@override
Future<void> reset() {
_chatViewActive = false;
_stopLongPoll();
return super.reset();
}
void leaveChat(String fromToken) { void leaveChat(String fromToken) {
if ((innerState?.currentToken ?? '') != fromToken) return; if ((innerState?.currentToken ?? '') != fromToken) return;
_chatViewActive = false; _chatViewActive = false;
@@ -256,23 +274,25 @@ class ChatBloc
_pollingToken = token; _pollingToken = token;
_backoffMs = 0; _backoffMs = 0;
_lastKnownMessageId = _maxMessageId(innerState?.chatResponse); _lastKnownMessageId = _maxMessageId(innerState?.chatResponse);
unawaited(_pollLoop(token)); unawaited(_pollLoop(token, ++_pollGeneration));
} }
void _stopLongPoll() { void _stopLongPoll() {
_pollingToken = null; _pollingToken = null;
_pollGeneration++;
_backoffMs = 0; _backoffMs = 0;
} }
Future<void> _pollLoop(String token) async { Future<void> _pollLoop(String token, int generation) async {
while (_pollingToken == token && !isClosed) { bool active() => generation == _pollGeneration && !isClosed;
while (active()) {
try { try {
final response = await LongPollChat( final response = await LongPollChat(
chatToken: token, chatToken: token,
lastKnownMessageId: _lastKnownMessageId, lastKnownMessageId: _lastKnownMessageId,
).run(); ).run();
if (_pollingToken != token || isClosed) return; if (!active()) return;
_backoffMs = 0; _backoffMs = 0;
if (response == null) continue; if (response == null) continue;
@@ -296,7 +316,7 @@ class ChatBloc
_chatListBloc?.markRoomAsRead(token, _lastKnownMessageId); _chatListBloc?.markRoomAsRead(token, _lastKnownMessageId);
} }
} on Object catch (e) { } on Object catch (e) {
if (_pollingToken != token || isClosed) return; if (!active()) return;
log('LongPoll error for $token: $e'); log('LongPoll error for $token: $e');
_backoffMs = _backoffMs == 0 ? 2000 : math.min(_backoffMs * 2, 30000); _backoffMs = _backoffMs == 0 ? 2000 : math.min(_backoffMs * 2, 30000);
await Future.delayed(Duration(milliseconds: _backoffMs)); await Future.delayed(Duration(milliseconds: _backoffMs));
@@ -1,11 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'package:flutter_app_badge/flutter_app_badge.dart'; import 'package:flutter_app_badge/flutter_app_badge.dart';
import '../../../../../access/access_requirement.dart'; import '../../../../../access/access_requirement.dart';
import '../../../../../api/marianumcloud/talk/actions/talk_actions.dart';
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../../utils/session_single_flight.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../repository/chat_list_repository.dart'; import '../repository/chat_list_repository.dart';
@@ -31,6 +34,13 @@ class ChatListBloc
return super.close(); return super.close();
} }
// The timer outlives the app shell; the next shell re-arms it after login.
@override
Future<void> reset() {
setAutoRefreshInterval(null);
return super.reset();
}
/// Silent refresh — explicit pull-to-refresh and tab-activation are non-silent. /// Silent refresh — explicit pull-to-refresh and tab-activation are non-silent.
void setAutoRefreshInterval(Duration? interval) { void setAutoRefreshInterval(Duration? interval) {
if (interval == _autoRefreshInterval) return; if (interval == _autoRefreshInterval) return;
@@ -72,14 +82,34 @@ class ChatListBloc
renew: renew, renew: renew,
onError: (e) => capturedError = e, onError: (e) => capturedError = e,
); );
_lastRoomsJson = null;
add(DataGathered((s) => s.copyWith(rooms: rooms))); add(DataGathered((s) => s.copyWith(rooms: rooms)));
_updateAppBadge(rooms); _updateAppBadge(rooms);
if (capturedError != null) throw capturedError!; if (capturedError != null) throw capturedError!;
} }
final SessionSingleFlight _refreshFlight = SessionSingleFlight();
/// Concurrent callers (tab switch, poll, push, resume) join the running
/// refresh instead of each fetching and re-emitting the full room list.
Future<void> refresh({bool renew = true, bool silent = false}) async { Future<void> refresh({bool renew = true, bool silent = false}) async {
if (!requirementsMet) return; if (!requirementsMet || !ownsSession) return;
await _refreshFlight.run(() => _refresh(renew: renew, silent: silent));
}
/// Skips the refresh when the list was fetched within [maxAge].
Future<void> refreshIfOlderThan(Duration maxAge) {
final lastFetch = state.lastFetch;
if (lastFetch != null &&
DateTime.now().millisecondsSinceEpoch - lastFetch <
maxAge.inMilliseconds) {
return Future.value();
}
return refresh();
}
Future<void> _refresh({required bool renew, required bool silent}) async {
if (!silent) add(RefetchStarted<ChatListState>()); if (!silent) add(RefetchStarted<ChatListState>());
Object? capturedError; Object? capturedError;
try { try {
@@ -87,6 +117,11 @@ class ChatListBloc
renew: renew, renew: renew,
onError: (e) => capturedError = e, onError: (e) => capturedError = e,
); );
if (silent) {
if (_isUnchanged(rooms)) return;
} else {
_lastRoomsJson = null;
}
add(DataGathered((s) => s.copyWith(rooms: rooms))); add(DataGathered((s) => s.copyWith(rooms: rooms)));
_updateAppBadge(rooms); _updateAppBadge(rooms);
} catch (e) { } catch (e) {
@@ -95,6 +130,23 @@ class ChatListBloc
if (capturedError != null) addLoadingError(capturedError!); if (capturedError != null) addLoadingError(capturedError!);
} }
// Encoded room data of the last applied poll result; response headers are
// left out because they differ on every request.
String? _lastRoomsJson;
/// A background poll that returns the same rooms would otherwise rebuild the
/// list and re-persist the whole state every 15 s.
bool _isUnchanged(GetRoomResponse rooms) {
final encoded = jsonEncode(rooms.data.toList());
final unchanged =
encoded == _lastRoomsJson &&
innerState?.rooms != null &&
state.error == null &&
!state.isLoading;
_lastRoomsJson = encoded;
return unchanged;
}
/// Creates (or resolves) a 1:1 chat and returns its room token, or null in /// Creates (or resolves) a 1:1 chat and returns its room token, or null in
/// demo mode. Refreshes the list so the room shows up. /// demo mode. Refreshes the list so the room shows up.
Future<String?> createDirectChat(String invite) async { Future<String?> createDirectChat(String invite) async {
@@ -126,6 +178,18 @@ class ChatListBloc
}); });
} }
Future<void> setNotificationLevel(
String token,
GetRoomResponseObjectParticipantNotificationLevel level,
) async {
await SetNotificationLevel(token, level).run();
_mutateRoom(token, (r) {
if (r.notificationLevel == level) return false;
r.notificationLevel = level;
return true;
});
}
/// Clears unread too — long-poll only feeds this in for an actively-open chat. /// Clears unread too — long-poll only feeds this in for an actively-open chat.
void applyIncomingMessage(String token, GetChatResponseObject message) { void applyIncomingMessage(String token, GetChatResponseObject message) {
_mutateRoom(token, (r) { _mutateRoom(token, (r) {
@@ -158,6 +222,7 @@ class ChatListBloc
}).toSet(); }).toSet();
if (!changed) return; if (!changed) return;
final newRooms = GetRoomResponse(updated)..headers = rooms.headers; final newRooms = GetRoomResponse(updated)..headers = rooms.headers;
_lastRoomsJson = null;
add(Emit((s) => s.copyWith(rooms: newRooms))); add(Emit((s) => s.copyWith(rooms: newRooms)));
_updateAppBadge(newRooms); _updateAppBadge(newRooms);
} }
@@ -1,5 +1,6 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:enough_icalendar/enough_icalendar.dart'; import 'package:enough_icalendar/enough_icalendar.dart';
import 'package:flutter/foundation.dart';
import '../bloc/marianum_dates_state.dart'; import '../bloc/marianum_dates_state.dart';
@@ -19,6 +20,12 @@ class MarianumDatesGetEvents {
final body = response.data; final body = response.data;
if (body == null || body.isEmpty) return []; if (body == null || body.isEmpty) return [];
// The public feed holds hundreds of events back to 1981; parsing it on
// the UI isolate froze the page for a noticeable moment on every open.
return compute(parseEvents, body);
}
static List<MarianumDate> parseEvents(String body) {
final root = VComponent.parse(body); final root = VComponent.parse(body);
final calendar = root is VCalendar ? root : null; final calendar = root is VCalendar ? root : null;
final source = calendar?.children ?? root.children; final source = calendar?.children ?? root.children;
@@ -65,8 +65,12 @@ class NextcloudCapabilitiesCubit
emit(DemoNextcloudCapabilities.state()); emit(DemoNextcloudCapabilities.state());
return; return;
} }
final epoch = SessionManager().sessionEpoch;
try { try {
final caps = await GetNextcloudCapabilities().run(); final caps = await GetNextcloudCapabilities().run();
// A slow answer for the previous account must not decide the modules
// of the next one.
if (!SessionManager().isCurrentSession(epoch)) return;
emit( emit(
NextcloudCapabilitiesState( NextcloudCapabilitiesState(
apiEnabled: caps.apiEnabled, apiEnabled: caps.apiEnabled,
@@ -92,7 +96,7 @@ class NextcloudCapabilitiesCubit
} }
} }
void reset() => emit(const NextcloudCapabilitiesState()); Future<void> reset() async => emit(const NextcloudCapabilitiesState());
@override @override
NextcloudCapabilitiesState fromJson(Map<String, dynamic> json) { NextcloudCapabilitiesState fromJson(Map<String, dynamic> json) {
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../storage/settings.dart'; import '../../../../../storage/settings.dart';
@@ -10,8 +11,15 @@ import '../../../../../view/pages/settings/data/default_settings.dart';
class SettingsCubit extends HydratedCubit<Settings> { class SettingsCubit extends HydratedCubit<Settings> {
static const _debounceTag = 'settings_persist'; static const _debounceTag = 'settings_persist';
bool _emitScheduled = false; bool _emitScheduled = false;
Map<String, dynamic>? _lastEmittedJson;
Timer? _silentSave;
SettingsCubit({super.storage}) : super(DefaultSettings.get()); final Storage? _storage;
// ignore: use_super_parameters
SettingsCubit({Storage? storage})
: _storage = storage,
super(DefaultSettings.get(), storage: storage);
Settings val({bool write = false}) { Settings val({bool write = false}) {
if (write) { if (write) {
@@ -34,21 +42,53 @@ class SettingsCubit extends HydratedCubit<Settings> {
return state; return state;
} }
/// Persists in-place mutations without notifying listeners. For high-frequency
/// writes nobody renders live (chat drafts per keystroke): a regular write
/// would rebuild the app root and every settings watcher on each change.
void saveSilently() {
_silentSave?.cancel();
_silentSave = Timer(const Duration(milliseconds: 800), flushSilentSave);
}
/// Writes a pending [saveSilently] right away (e.g. when the app pauses).
void flushSilentSave() {
if (_silentSave == null) return;
_silentSave!.cancel();
_silentSave = null;
(_storage ?? HydratedBloc.storage).write(storageToken, state.toJson());
}
void _emitFreshInstance() { void _emitFreshInstance() {
try { try {
emit(Settings.fromJson(state.toJson())); final json = state.toJson();
// The debounced emit usually follows the microtask emit with identical
// content; skip it instead of rebuilding every listener a second time.
if (const DeepCollectionEquality().equals(json, _lastEmittedJson)) {
return;
}
_lastEmittedJson = json;
emit(Settings.fromJson(json));
} catch (e) { } catch (e) {
log('Failed to refresh settings state: $e'); log('Failed to refresh settings state: $e');
} }
} }
Future<void> reset() async { Future<void> reset() async {
_silentSave?.cancel();
_silentSave = null;
_lastEmittedJson = null;
emit(DefaultSettings.get()); emit(DefaultSettings.get());
} }
// Modules missing from a stale persisted moduleOrder are handled at read // Modules missing from a stale persisted moduleOrder are handled at read
// time by AppModule.effectiveModuleOrder (inserted at their default // time by AppModule.effectiveModuleOrder (inserted at their default
// position) — no healing on hydration needed. // position) — no healing on hydration needed.
@override
Future<void> close() {
flushSilentSave();
return super.close();
}
@override @override
Settings fromJson(Map<String, dynamic> json) { Settings fromJson(Map<String, dynamic> json) {
try { try {
@@ -1,3 +1,8 @@
import 'dart:async';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../../../api/errors/ticker_content_unavailable_exception.dart'; import '../../../../../api/errors/ticker_content_unavailable_exception.dart';
import '../../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart'; import '../../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
@@ -17,7 +22,35 @@ class TickerPageBloc
> { > {
final String slug; final String slug;
TickerPageBloc(this.slug); TickerPageBloc(this.slug) {
unawaited(_rememberSlug());
}
static const _recentSlugsKey = 'tickerPageRecentSlugs';
static const _keepPages = 50;
/// Every page ever opened used to stay in the hydrated box forever — and
/// the whole box is decoded before the first frame on each cold start.
/// The most recently opened pages (far more than a normal reader revisits)
/// keep their offline copy; only long-forgotten ones are dropped.
Future<void> _rememberSlug() async {
try {
final prefs = await SharedPreferences.getInstance();
final recent = prefs.getStringList(_recentSlugsKey) ?? <String>[];
recent
..remove(slug)
..insert(0, slug);
for (final evicted in recent.skip(_keepPages)) {
await HydratedBloc.storage.delete('$storagePrefix$evicted');
}
await prefs.setStringList(
_recentSlugsKey,
recent.take(_keepPages).toList(),
);
} on Object {
// Best effort: a failed cleanup only leaves an extra offline copy.
}
}
@override @override
String get id => slug; String get id => slug;
@@ -48,7 +48,7 @@ class TimetableBloc
TimetableState fromNothing() { TimetableState fromNothing() {
final reference = DateTime.now().addDays(2); final reference = DateTime.now().addDays(2);
return TimetableState( return TimetableState(
startDate: _startOfWeek(reference), startDate: reference.mondayOfWeek,
endDate: _endOfWeek(reference), endDate: _endOfWeek(reference),
); );
} }
@@ -61,7 +61,7 @@ class TimetableBloc
final stored = TimetableState.fromJson(json); final stored = TimetableState.fromJson(json);
final reference = DateTime.now().addDays(2); final reference = DateTime.now().addDays(2);
return stored.copyWith( return stored.copyWith(
startDate: _startOfWeek(reference), startDate: reference.mondayOfWeek,
endDate: _endOfWeek(reference), endDate: _endOfWeek(reference),
accessibleStartDate: null, accessibleStartDate: null,
accessibleEndDate: null, accessibleEndDate: null,
@@ -120,13 +120,15 @@ class TimetableBloc
final current = innerState ?? fromNothing(); final current = innerState ?? fromNothing();
if (current.startDate == startDate && current.endDate == endDate) return; if (current.startDate == startDate && current.endDate == endDate) return;
add(Emit((s) => s.copyWith(startDate: startDate, endDate: endDate))); add(Emit((s) => s.copyWith(startDate: startDate, endDate: endDate)));
runInSession(() {
_loadCurrentWeek(startDate, endDate); _loadCurrentWeek(startDate, endDate);
_prefetchAdjacentWeeks(startDate, endDate); _prefetchAdjacentWeeks(startDate, endDate);
});
} }
void resetWeek() { void resetWeek() {
final reference = DateTime.now().addDays(2); final reference = DateTime.now().addDays(2);
changeWeek(_startOfWeek(reference), _endOfWeek(reference)); changeWeek(reference.mondayOfWeek, _endOfWeek(reference));
} }
void refresh() => fetch(); void refresh() => fetch();
@@ -171,11 +173,7 @@ class TimetableBloc
Future<void> _refreshSubjects() async { Future<void> _refreshSubjects() async {
final subjects = await repo.data.getSubjects(renew: true); final subjects = await repo.data.getSubjects(renew: true);
add( add(DataGathered((s) => s.withReferenceData(subjects: subjects)));
DataGathered(
(s) => s.copyWith(subjects: subjects, dataVersion: s.dataVersion + 1),
),
);
} }
Future<void> _loadCurrentWeek( Future<void> _loadCurrentWeek(
@@ -214,12 +212,11 @@ class TimetableBloc
add( add(
Emit( Emit(
(s) => s.copyWith( (s) => s.withReferenceData(
rooms: rooms, rooms: rooms,
subjects: subjects, subjects: subjects,
schoolHolidays: schoolHolidays, schoolHolidays: schoolHolidays,
schoolyear: schoolyear, schoolyear: schoolyear,
dataVersion: s.dataVersion + 1,
), ),
), ),
); );
@@ -229,11 +226,7 @@ class TimetableBloc
try { try {
final timegrid = await repo.data.getTimegrid(renew: renew); final timegrid = await repo.data.getTimegrid(renew: renew);
add( add(Emit((s) => s.withReferenceData(timegrid: timegrid)));
Emit(
(s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1),
),
);
} catch (_) { } catch (_) {
// Timegrid load failure falls back to a hardcoded schedule in the UI layer. // Timegrid load failure falls back to a hardcoded schedule in the UI layer.
} }
@@ -249,12 +242,7 @@ class TimetableBloc
renew: renew, renew: renew,
onError: onError, onError: onError,
); );
add( add(Emit((s) => s.withReferenceData(customEvents: events)));
Emit(
(s) =>
s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1),
),
);
} catch (e) { } catch (e) {
onError?.call(e); onError?.call(e);
} }
@@ -262,11 +250,7 @@ class TimetableBloc
Future<void> _refreshCustomEvents() async { Future<void> _refreshCustomEvents() async {
final events = await repo.data.getCustomEvents(renew: true); final events = await repo.data.getCustomEvents(renew: true);
add( add(DataGathered((s) => s.withReferenceData(customEvents: events)));
DataGathered(
(s) => s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1),
),
);
} }
void _prefetchAdjacentWeeks(DateTime start, DateTime end) { void _prefetchAdjacentWeeks(DateTime start, DateTime end) {
@@ -282,19 +266,7 @@ class TimetableBloc
} }
void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) { void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) {
final key = weekStart.weekKey(); add(Emit((s) => s.withFetchedWeek(weekStart, week)));
add(
Emit((s) {
final updated = Map<String, TimetableGetWeekResponse>.of(s.weekCache);
updated[key] = week;
return s.copyWith(weekCache: updated, dataVersion: s.dataVersion + 1);
}),
);
}
static DateTime _startOfWeek(DateTime reference) {
final monday = reference.subtractDays(reference.weekday - 1);
return DateTime(monday.year, monday.month, monday.day);
} }
static DateTime _endOfWeek(DateTime reference) { static DateTime _endOfWeek(DateTime reference) {
@@ -7,6 +7,8 @@ import '../../../../../api/marianumconnect/queries/timetable_get_subjects/timeta
import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../../../../../utils/json_equality.dart';
import 'week_cache.dart';
part 'timetable_state.freezed.dart'; part 'timetable_state.freezed.dart';
part 'timetable_state.g.dart'; part 'timetable_state.g.dart';
@@ -40,6 +42,62 @@ abstract class TimetableState with _$TimetableState {
Iterable<McTimetableEntry> getAllKnownLessons() => Iterable<McTimetableEntry> getAllKnownLessons() =>
weekCache.values.expand((response) => response.entries); weekCache.values.expand((response) => response.entries);
/// This state with [week] cached, or `this` when its content is unchanged
/// (so the emit is dropped as equal).
TimetableState withFetchedWeek(
DateTime weekStart,
TimetableGetWeekResponse week,
) {
final updated = mergeWeekIntoCache(
weekCache,
weekStart,
week,
viewedWeekStart: startDate,
now: DateTime.now(),
);
if (updated == null) return this;
return copyWith(weekCache: updated, dataVersion: dataVersion + 1);
}
/// Applies freshly loaded reference data. Values whose content equals the
/// current one keep the old instance, and when nothing changed at all this
/// returns `this` — every `dataVersion` bump and new identity makes the
/// calendar rebuild all appointments and break regions.
TimetableState withReferenceData({
TimetableGetRoomsResponse? rooms,
TimetableGetSubjectsResponse? subjects,
TimetableGetHolidaysResponse? schoolHolidays,
TimetableGetSchoolyearResponse? schoolyear,
TimetableGetTimegridResponse? timegrid,
GetCustomTimetableEventResponse? customEvents,
}) {
T? changed<T>(T? next, T? current) =>
next == null || sameJson(next, current) ? null : next;
final newRooms = changed(rooms, this.rooms);
final newSubjects = changed(subjects, this.subjects);
final newHolidays = changed(schoolHolidays, this.schoolHolidays);
final newSchoolyear = changed(schoolyear, this.schoolyear);
final newTimegrid = changed(timegrid, this.timegrid);
final newCustomEvents = changed(customEvents, this.customEvents);
if (newRooms == null &&
newSubjects == null &&
newHolidays == null &&
newSchoolyear == null &&
newTimegrid == null &&
newCustomEvents == null) {
return this;
}
return copyWith(
rooms: newRooms ?? this.rooms,
subjects: newSubjects ?? this.subjects,
schoolHolidays: newHolidays ?? this.schoolHolidays,
schoolyear: newSchoolyear ?? this.schoolyear,
timegrid: newTimegrid ?? this.timegrid,
customEvents: newCustomEvents ?? this.customEvents,
dataVersion: dataVersion + 1,
);
}
/// Whether the calendar has everything it needs to render. Custom events /// Whether the calendar has everything it needs to render. Custom events
/// only exist for subjects that support them; requiring them elsewhere would /// only exist for subjects that support them; requiring them elsewhere would
/// keep foreign plans loading forever. /// keep foreign plans loading forever.

Some files were not shown because too many files have changed in this diff Show More