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).
**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.
+3 -1
View File
@@ -9,7 +9,7 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:launchMode="singleTask"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
@@ -98,6 +98,7 @@
the home_widget Flutter plugin resolves them as <app-package>.<name>. -->
<receiver
android:name="eu.mhsl.marianum.mobile.client.TimetableDayWidget"
android:enabled="@bool/widget_supported"
android:label="@string/widget_day_label"
android:exported="true">
<intent-filter>
@@ -110,6 +111,7 @@
<receiver
android:name="eu.mhsl.marianum.mobile.client.TimetableWeekWidget"
android:enabled="@bool/widget_supported"
android:label="@string/widget_week_label"
android:exported="true">
<intent-filter>
@@ -1,15 +1,38 @@
package eu.mhsl.marianum.mobile.client
import android.content.Intent
import android.os.Bundle
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
private val widgetChannel = "eu.mhsl.marianum.widget"
private val shareChannel = "eu.mhsl.marianum.share"
/// Last seen widget tap target. Cleared by Dart via `consumePendingNavigation`
/// so the same intent isn't replayed on every resume.
private var pendingTimetableTap: Boolean = false
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) {
super.configureFlutterEngine(flutterEngine)
@@ -26,17 +49,35 @@ class MainActivity : FlutterActivity() {
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)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if (!shareListenerReady && intent.action in shareActions) {
bufferedShare = intent
}
consumeIntentData(intent)
}
private fun isWidgetTap(intent: Intent?): Boolean =
intent?.getBooleanExtra("widget_open_timetable", false) == true
private fun consumeIntentData(intent: Intent?) {
if (intent?.getBooleanExtra("widget_open_timetable", false) == true) {
pendingTimetableTap = true
}
if (isWidgetTap(intent)) pendingTimetableTap = true
}
}
@@ -11,7 +11,7 @@ import java.util.TimeZone
// Mirror of lib/widget_data/widget_data.dart — JSON keys + enum names
// must stay in sync.
enum class WidgetLessonStatus {
REGULAR, ONGOING, PAST, CANCELLED, IRREGULAR, TEACHER_CHANGED, EVENT;
REGULAR, ONGOING, PAST, CANCELLED, IRREGULAR, TEACHER_CHANGED, EVENT, DUTY;
companion object {
fun fromWire(raw: String?): WidgetLessonStatus = when (raw) {
@@ -22,6 +22,7 @@ enum class WidgetLessonStatus {
"irregular" -> IRREGULAR
"teacherChanged" -> TEACHER_CHANGED
"event" -> EVENT
"duty" -> DUTY
else -> REGULAR
}
}
@@ -39,6 +40,9 @@ data class WidgetLesson(
val originalTeacher: String?,
val status: WidgetLessonStatus,
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,
)
@@ -50,6 +54,12 @@ data class WidgetPeriod(
val virtualEndMinutes: Int,
)
data class WidgetDayInfo(
val date: Date,
val isHoliday: Boolean,
val holidayName: String?,
)
data class WidgetTimetableData(
val fetchedAt: Date,
val anchorDate: Date,
@@ -57,6 +67,8 @@ data class WidgetTimetableData(
val periods: List<WidgetPeriod>,
val isHoliday: Boolean,
val holidayName: String?,
// Week payload only; empty for the day payload.
val days: List<WidgetDayInfo>,
)
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? {
if (json.isNullOrEmpty()) return null
return try {
@@ -102,6 +119,7 @@ object WidgetDataParser {
originalTeacher = obj.stringOrNull("originalTeacher"),
status = WidgetLessonStatus.fromWire(obj.stringOrNull("status")),
customColor = obj.stringOrNull("customColor"),
color = parseColor(obj.stringOrNull("color")),
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(
fetchedAt = parseDate(root.stringOrNull("fetchedAt")) ?: Date(),
anchorDate = parseDate(root.stringOrNull("anchorDate")) ?: Date(),
@@ -127,6 +157,7 @@ object WidgetDataParser {
periods = periods,
isHoliday = root.optBoolean("isHoliday", false),
holidayName = root.stringOrNull("holidayName"),
days = days,
)
} catch (_: JSONException) {
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:viewportHeight="100">
<path
android:strokeColor="#C8FF0000"
android:strokeWidth="5"
android:strokeColor="#C8F44336"
android:strokeWidth="2.5"
android:pathData="M0,0 L100,100 M100,0 L0,100" />
</vector>
@@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="6dp" />
<solid android:color="#FF993333" />
android:shape="oval">
<solid android:color="@android:color/white" />
</shape>
@@ -3,6 +3,14 @@
android:id="@+id/widget_break_block_root"
android:layout_width="match_parent"
android:layout_height="14dp"
android:layout_marginTop="0dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp" />
android:layout_marginTop="0dp">
<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"?>
<!-- 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"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/app_widget_background">
android:background="@drawable/app_widget_background"
tools:ignore="SpUsage,SmallSp">
<!-- Bottom-leading so the mark looks like it's peeking out of the lower
left corner. Width/height/start+bottom margins are overridden in the
renderer so the mark scales with the widget size. -->
<!-- Width/height/end+bottom margins are overridden in the renderer so
the mark scales with the widget size. -->
<ImageView
android:id="@+id/widget_watermark"
android:layout_width="160dp"
@@ -26,43 +28,68 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
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:layout_height="20dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/widget_day_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_height="match_parent"
android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
android:singleLine="true"
android:maxLines="1"
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." />
<TextView
android:id="@+id/widget_day_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:layout_marginStart="6dp"
android:textSize="10dp"
android:textColor="@color/widget_text_secondary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="Stand: 14:32" />
</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
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:layout_marginTop="6dp"
android:layout_marginTop="4dp"
android:orientation="horizontal">
<FrameLayout
android:id="@+id/widget_day_time_labels"
android:layout_width="32dp"
android:layout_width="30dp"
android:layout_height="match_parent" />
<FrameLayout
@@ -1,8 +1,11 @@
<?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"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/widget_background">
android:background="@color/widget_background"
tools:ignore="SpUsage,SmallSp,HardcodedText,UseAppTint">
<ImageView
android:layout_width="160dp"
@@ -20,164 +23,126 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="10dp">
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="8dp">
<LinearLayout
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
android:singleLine="true"
android:text="Heute · 06.05." />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/widget_text_secondary"
android:text="Stand: 07:50" />
</LinearLayout>
android:layout_height="20dp"
android:gravity="start|center_vertical"
android:includeFontPadding="false"
android:textSize="14dp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
android:maxLines="1"
android:text="Heute · 06.05." />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal"
android:layout_marginTop="6dp">
android:layout_marginTop="4dp"
android:orientation="horizontal">
<FrameLayout
android:layout_width="32dp"
android:layout_height="match_parent">
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="22dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="1." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="07:55" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="50dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="2." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="79dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="3." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="09:30" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="115dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="4." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="10:35" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="142dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="5." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="11:25" />
</LinearLayout>
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
android:layout_marginTop="169dp">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@color/widget_text_primary" android:text="6." />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="12:15" />
</LinearLayout>
</FrameLayout>
<FrameLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="29dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="56dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="83dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="115dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="143dp" android:background="@color/widget_divider" />
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="170dp" android:background="@color/widget_divider" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="22dp"
android:layout_marginTop="29dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:padding="3dp"
android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@android:color/white" android:text="MA-LK" />
<FrameLayout android:layout_width="30dp" android:layout_height="match_parent">
<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="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" />
<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" />
<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="#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="22dp"
android:layout_marginTop="143dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:padding="3dp"
android:background="@drawable/widget_lesson_block_cancelled">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@android:color/white" android:text="BIO" />
<FrameLayout 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="22dp"
android:layout_marginTop="170dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:padding="3dp"
android:background="@drawable/widget_lesson_block_teacher_changed">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:textSize="11sp" android:textStyle="bold"
android:textColor="@android:color/white" android:text="GE" />
<FrameLayout 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 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">
<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>
</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"?>
<!-- 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"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_lesson_block_root"
android:layout_width="match_parent"
android:layout_height="40dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:layout_marginStart="1dp"
android:layout_marginEnd="1dp"
android:layout_marginTop="0dp"
android:paddingStart="7dp"
android:paddingEnd="7dp"
android:paddingTop="3dp"
android:paddingBottom="3dp"
android:background="@drawable/widget_lesson_block_regular">
android:background="@drawable/widget_lesson_block"
tools:ignore="SpUsage,SmallSp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:gravity="top"
android:baselineAligned="false">
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:baselineAligned="false">
<TextView
android:id="@+id/widget_lesson_subject"
android:layout_width="0dp"
android:layout_height="14dp"
android:layout_weight="1"
android:fontFamily="sans-serif-medium"
android:textColor="@android:color/white"
android:maxLines="1"
android:ellipsize="end"
android:gravity="start|center_vertical"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="6.5dp"
android:autoSizeMaxTextSize="15dp"
android:autoSizeStepGranularity="0.5dp"
tools:text="MA-LK" />
<!-- Only for tiles too short for a body line below the title. -->
<TextView
android:id="@+id/widget_lesson_inline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="3dp"
android:textColor="@android:color/white"
android:textSize="7dp"
android:maxLines="1"
android:includeFontPadding="false"
android:visibility="gone"
tools:text="B11" />
</LinearLayout>
<TextView
android:id="@+id/widget_lesson_subject"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textStyle="bold"
android:id="@+id/widget_lesson_room"
android:layout_width="match_parent"
android:layout_height="10dp"
android:textColor="@android:color/white"
android:maxLines="1"
android:ellipsize="end"
android:gravity="top|start"
android:gravity="start|center_vertical"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="5sp"
android:autoSizeMaxTextSize="11sp"
android:autoSizeStepGranularity="1sp"
tools:text="MA-LK" />
android:autoSizeMinTextSize="5.5dp"
android:autoSizeMaxTextSize="10dp"
android:autoSizeStepGranularity="0.5dp"
tools:text="A12" />
<LinearLayout
android:id="@+id/widget_lesson_secondary_stack"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="top|end"
android:layout_marginStart="4dp">
<TextView
android:id="@+id/widget_lesson_room"
android:layout_width="wrap_content"
android:layout_height="12dp"
android:textColor="#CCFFFFFF"
android:maxLines="1"
android:ellipsize="end"
android:gravity="end|top"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="5sp"
android:autoSizeMaxTextSize="11sp"
android:autoSizeStepGranularity="1sp"
tools:text="A12" />
<TextView
android:id="@+id/widget_lesson_teacher"
android:layout_width="wrap_content"
android:layout_height="12dp"
android:textColor="#B3FFFFFF"
android:maxLines="1"
android:ellipsize="end"
android:gravity="end|top"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="5sp"
android:autoSizeMaxTextSize="11sp"
android:autoSizeStepGranularity="1sp"
tools:text="Müller" />
</LinearLayout>
<TextView
android:id="@+id/widget_lesson_teacher"
android:layout_width="match_parent"
android:layout_height="10dp"
android:textColor="@android:color/white"
android:maxLines="1"
android:ellipsize="end"
android:gravity="start|center_vertical"
android:includeFontPadding="false"
android:autoSizeTextType="uniform"
android:autoSizeMinTextSize="5.5dp"
android:autoSizeMaxTextSize="10dp"
android:autoSizeStepGranularity="0.5dp"
tools:text="Müller" />
</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
android:id="@+id/widget_lesson_sibling_badge"
android:layout_width="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:textSize="12sp"
android:textSize="9dp"
android:textColor="@android:color/white"
android:maxLines="1"
android:includeFontPadding="false"
@@ -1,31 +1,50 @@
<?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"
android:id="@+id/widget_time_label_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="36dp"
android:layout_marginTop="0dp"
android:orientation="vertical"
android:gravity="end"
android:paddingEnd="3dp">
tools:ignore="SpUsage,SmallSp">
<TextView
android:id="@+id/widget_time_label_time"
android:layout_width="wrap_content"
android:id="@+id/widget_time_label_start"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="9sp"
android:textColor="@color/widget_text_primary"
android:singleLine="true"
android:lineSpacingExtra="-2dp"
android:layout_gravity="top"
android:layout_marginTop="2dp"
android:gravity="center_horizontal"
android:textSize="7dp"
android:textColor="@color/widget_text_secondary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="07:55" />
<TextView
android:id="@+id/widget_time_label_number"
android:layout_width="wrap_content"
android:layout_width="match_parent"
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:singleLine="true"
android:lineSpacingExtra="-2dp"
tools:text="1." />
</LinearLayout>
android:maxLines="1"
android:includeFontPadding="false"
tools:text="08:40" />
</FrameLayout>
+36 -18
View File
@@ -1,11 +1,18 @@
<?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"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/app_widget_background">
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
android:id="@+id/widget_watermark"
android:layout_width="160dp"
@@ -21,42 +28,53 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
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:layout_height="20dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/widget_week_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_height="match_parent"
android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
android:singleLine="true"
android:maxLines="1"
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." />
<TextView
android:id="@+id/widget_week_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:layout_marginStart="6dp"
android:textSize="10dp"
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_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="6dp">
android:layout_height="32dp"
android:layout_marginTop="4dp"
android:orientation="horizontal">
<FrameLayout
android:layout_width="28dp"
android:layout_height="wrap_content" />
android:layout_width="30dp"
android:layout_height="match_parent" />
<FrameLayout
android:layout_width="1dp"
@@ -66,7 +84,7 @@
<FrameLayout
android:id="@+id/widget_week_header_mon"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_height="match_parent"
android:layout_weight="1" />
<FrameLayout
@@ -77,7 +95,7 @@
<FrameLayout
android:id="@+id/widget_week_header_tue"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_height="match_parent"
android:layout_weight="1" />
<FrameLayout
@@ -88,7 +106,7 @@
<FrameLayout
android:id="@+id/widget_week_header_wed"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_height="match_parent"
android:layout_weight="1" />
<FrameLayout
@@ -99,7 +117,7 @@
<FrameLayout
android:id="@+id/widget_week_header_thu"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_height="match_parent"
android:layout_weight="1" />
<FrameLayout
@@ -110,7 +128,7 @@
<FrameLayout
android:id="@+id/widget_week_header_fri"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_height="match_parent"
android:layout_weight="1" />
</LinearLayout>
@@ -123,7 +141,7 @@
<FrameLayout
android:id="@+id/widget_week_time_labels"
android:layout_width="28dp"
android:layout_width="30dp"
android:layout_height="match_parent" />
<FrameLayout
@@ -1,28 +1,35 @@
<?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"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/widget_week_day_header_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:paddingTop="2dp"
android:paddingBottom="3dp">
tools:ignore="SpUsage,SmallSp">
<TextView
android:id="@+id/widget_week_day_header_weekday"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="11sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
tools:text="Mo" />
android:fontFamily="sans-serif-medium"
android:textSize="9dp"
android:textColor="@color/widget_text_secondary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="MO." />
<TextView
android:id="@+id/widget_week_day_header_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="9sp"
android:textColor="@color/widget_text_secondary"
tools:text="06.05." />
android:layout_width="18dp"
android:layout_height="18dp"
android:layout_marginTop="1dp"
android:gravity="center"
android:textSize="11dp"
android:textColor="@color/widget_text_primary"
android:maxLines="1"
android:includeFontPadding="false"
tools:text="6" />
</LinearLayout>
@@ -1,10 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Static preview for the week widget. Five day columns with two short
demo blocks each so the structure is recognisable in the picker. -->
<!-- Static picker preview of the week widget, laid out like WidgetRenderer's output. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/widget_background">
android:background="@color/widget_background"
tools:ignore="SpUsage,SmallSp,HardcodedText,UseAppTint">
<ImageView
android:layout_width="200dp"
@@ -22,124 +23,312 @@
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="10dp">
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="8dp">
<TextView
android:layout_width="match_parent"
android:layout_height="20dp"
android:gravity="start|center_vertical"
android:includeFontPadding="false"
android:textSize="14dp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
android:maxLines="1"
android:text="KW 19 · 04.05.–08.05." />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="@color/widget_text_primary"
android:singleLine="true"
android:text="KW 19 · 06.05.–10.05." />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="10sp"
android:textColor="@color/widget_text_secondary"
android:text="Stand: 07:50" />
</LinearLayout>
<!-- Day-name + date row -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="6dp">
<FrameLayout android:layout_width="20dp" android:layout_height="wrap_content" />
android:layout_height="32dp"
android:layout_marginTop="4dp"
android:orientation="horizontal">
<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" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Mo" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="06.05." />
<LinearLayout 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:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="MO." />
<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>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Di" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="07.05." />
<LinearLayout 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:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="DI." />
<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>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Mi" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="08.05." />
<LinearLayout 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:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="MI." />
<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>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Do" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="09.05." />
<LinearLayout 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:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="DO." />
<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>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Fr" />
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="10.05." />
<LinearLayout 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:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="FR." />
<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>
<!-- Time grid: time-label column + 5 day columns -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:orientation="horizontal"
android:layout_marginTop="2dp">
android:layout_marginTop="2dp"
android:orientation="horizontal">
<FrameLayout android:layout_width="20dp" android:layout_height="match_parent">
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="0dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="08" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="36dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="10" />
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="72dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="12" />
</FrameLayout>
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Mon -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="MA" />
<FrameLayout android:layout_width="30dp" android:layout_height="match_parent">
<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="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="14dp" android:layout_marginTop="40dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="EN" />
<FrameLayout 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" />
<!-- Tue -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="20dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_cancelled">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="BIO" />
<FrameLayout 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="14dp" android:layout_marginTop="60dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="DE" />
<FrameLayout 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="#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 android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Wed -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="32dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="MA" />
<FrameLayout 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="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 android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Thu -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_teacher_changed">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="GE" />
<FrameLayout 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="14dp" android:layout_marginTop="40dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="PH" />
<FrameLayout 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="#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 android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
<!-- Fri -->
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="20dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="EN" />
<FrameLayout 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="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>
</LinearLayout>
@@ -4,4 +4,6 @@
<color name="widget_text_primary">#FFF1F1F1</color>
<color name="widget_text_secondary">#FFB0B0B0</color>
<color name="widget_divider">#33FFFFFF</color>
<color name="widget_preview_accent">#FFFFB3AF</color>
<color name="widget_preview_break">#32FFB3AF</color>
</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_no_lessons">Keine Stunden</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_loading">Lade…</string>
<string name="widget_status_label">Stand: %1$s</string>
@@ -1,17 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Status colors mirror lib/view/pages/timetable/data/lesson_color.dart
exactly so widget tiles match the in-app calendar. -->
<color name="widget_lesson_regular">#FF993333</color>
<color name="widget_lesson_ongoing">#FFC83333</color>
<color name="widget_lesson_past">#FF993333</color>
<color name="widget_lesson_cancelled">#FF000000</color>
<color name="widget_lesson_irregular">#FF8F19B3</color>
<color name="widget_lesson_teacher_changed">#FF29639B</color>
<color name="widget_lesson_event">#FF2E7D32</color>
<color name="widget_background">#FFFCF7F5</color>
<color name="widget_text_primary">#FF111111</color>
<color name="widget_text_secondary">#FF555555</color>
<color name="widget_divider">#22000000</color>
<color name="widget_preview_accent">#FF904A47</color>
<color name="widget_preview_break">#32904A47</color>
</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_device_identifier` | NC Device-Identifier | `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_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
> unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei
> jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das
+25 -2
View File
@@ -13,21 +13,44 @@ class SceneDelegate: FlutterSceneDelegate {
options connectionOptions: UIScene.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(
UIApplication.shared,
didFinishLaunchingWithOptions: [UIApplication.LaunchOptionsKey.url: context.url]
)
handledShare = true
}
if handledShare { clearSharedPayload() }
}
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(
UIApplication.shared,
open: context.url,
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")
}
}
+46 -10
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
.containerURL(forSecurityApplicationGroupIdentifier: appGroupId) else {
return nil
}
let name = src.lastPathComponent.isEmpty ? UUID().uuidString : src.lastPathComponent
let dst = container.appendingPathComponent(name)
let root = container.appendingPathComponent("share_intent", isDirectory: true)
sweepStaleShares(in: root)
let dir = root.appendingPathComponent(UUID().uuidString, isDirectory: true)
do {
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
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 {
if FileManager.default.fileExists(atPath: dst.path) {
try FileManager.default.removeItem(at: dst)
}
try FileManager.default.copyItem(at: src, to: dst)
return dst
} catch {
@@ -171,10 +208,9 @@ final class ShareViewController: UIViewController {
}
private func writePng(image: UIImage) -> URL? {
guard let container = FileManager.default
.containerURL(forSecurityApplicationGroupIdentifier: appGroupId),
let data = image.pngData() else { return nil }
let dst = container.appendingPathComponent("\(UUID().uuidString).png")
guard let data = image.pngData(),
let dir = fileQueue.sync(execute: { shareDir }) else { return nil }
let dst = dir.appendingPathComponent("\(UUID().uuidString).png")
return (try? data.write(to: dst)) != nil ? dst : nil
}
@@ -1,59 +1,6 @@
import SwiftUI
import WidgetKit
// Layout constants — must mirror WidgetRenderer.kt on Android, otherwise
// the platforms drift apart on the same widget size.
let FALLBACK_VIRTUAL_MINUTES = 11 * 60
let MIN_HOUR_HEIGHT: CGFloat = 18
let MAX_HOUR_HEIGHT: CGFloat = 72
let MIN_BLOCK_HEIGHT: CGFloat = 16
let LESSON_GAP: CGFloat = 1.5
func realMinutesToVirtual(_ realMin: Int, periods: [WidgetPeriod]) -> CGFloat {
guard !periods.isEmpty else { return CGFloat(realMin) }
for p in periods where realMin >= p.startMinutes && realMin <= p.endMinutes {
return CGFloat(p.virtualStartMinutes + (realMin - p.startMinutes))
}
let first = periods.first!
if realMin < first.startMinutes {
return CGFloat(realMin - first.startMinutes + first.virtualStartMinutes)
}
let last = periods.last!
if realMin > last.endMinutes {
return CGFloat(last.virtualEndMinutes + (realMin - last.endMinutes))
}
var prev = first
for i in 1..<periods.count {
let curr = periods[i]
if realMin > prev.endMinutes && realMin < curr.startMinutes {
let gap = curr.startMinutes - prev.endMinutes
let virtualGap = curr.virtualStartMinutes - prev.virtualEndMinutes
if gap > 0 {
return CGFloat(prev.virtualEndMinutes) +
CGFloat(realMin - prev.endMinutes) * CGFloat(virtualGap) / CGFloat(gap)
}
return CGFloat(curr.virtualStartMinutes)
}
prev = curr
}
return 0
}
// 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 {
let entry: TimetableEntry
@Environment(\.widgetPalette) private var palette
@@ -73,7 +20,7 @@ struct TimetableDayView: View {
@ViewBuilder
private func content(data: WidgetTimetableData) -> some View {
VStack(alignment: .leading, spacing: 3) {
VStack(alignment: .leading, spacing: GRID_MARGIN_TOP) {
header(data: data)
if data.isHoliday {
emptyState(text: data.holidayName ?? "Ferien")
@@ -81,37 +28,41 @@ struct TimetableDayView: View {
emptyState(text: "Keine Stunden")
} else {
GeometryReader { geo in
let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES)
TimeGridView(
lessons: data.lessons,
periods: data.periods,
hourHeight: max(
MIN_HOUR_HEIGHT,
min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60)
),
showRoom: true,
showTeacher: true,
showTimeLabels: true
)
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(
lessons: data.lessons,
periods: periods,
hourHeight: resolveHourHeight(forGridHeight: geo.size.height, periods: periods)
)
}
}
}
}
}
private func header(data: WidgetTimetableData) -> some View {
HStack(spacing: 4) {
HStack(spacing: 6) {
Text(dayLabel(for: data.anchorDate, relativeTo: entry.date))
.font(.system(size: 13, weight: .semibold))
.font(.system(size: 14, weight: .bold))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.7)
Spacer(minLength: 4)
.minimumScaleFactor(0.65)
.frame(maxWidth: .infinity, alignment: .leading)
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
.font(.system(size: 9))
.font(.system(size: 10))
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.minimumScaleFactor(0.7)
.fixedSize()
}
.frame(height: TITLE_ROW_HEIGHT)
}
private func emptyState(text: String) -> some View {
@@ -139,54 +90,23 @@ struct TimetableDayView: View {
}
}
struct TimeGridView: View {
let lessons: [WidgetLesson]
/// Mirrors the in-app _PeriodLabel: period number centred, start time above,
/// end time below, a cup in the breaks.
struct PeriodRuler: View {
let periods: [WidgetPeriod]
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
private var totalVirtualMinutes: Int {
periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
}
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 {
HStack(alignment: .top, spacing: 0) {
if showTimeLabels {
// 28pt matches the Week widget's time-label column so the
// visual rhythm is identical across both widgets.
timeLabelsColumn
.frame(width: 28, alignment: .topTrailing)
Rectangle()
.fill(palette.divider)
.frame(width: 1)
}
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) {
// Every child has layout-y 0 (`.offset` only shifts the rendering),
// so both the ZStack and its frame need `.top` — the default
// `.center` would anchor the whole ruler at the column's midpoint.
ZStack(alignment: .top) {
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
Rectangle()
.fill(palette.divider)
@@ -194,39 +114,91 @@ struct TimeGridView: View {
.offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
}
ForEach(periods, id: \.startMinutes) { period in
VStack(alignment: .trailing, spacing: -2) {
if compactLabels {
Text("\(period.name).")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
} else {
Text(formatHm(period.startMinutes))
.font(.system(size: 9))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
Text("\(period.name).")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
}
cell(period)
}
ForEach(periodBreaks(periods), id: \.self) { pause in
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)
}
.padding(.trailing, 4)
.offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
}
}
.frame(height: totalHeight, alignment: .topTrailing)
.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)
.lineLimit(1)
if height >= RULER_TIMES_MIN_CELL {
VStack(spacing: 0) {
Text(formatHm(period.startMinutes))
Spacer(minLength: 0)
Text(formatHm(period.endMinutes))
}
.font(.system(size: timeFont))
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.padding(.vertical, 2)
}
}
.frame(width: RULER_WIDTH, height: height)
.offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
}
private func formatHm(_ minutes: Int) -> String {
String(format: "%02d:%02d", minutes / 60, minutes % 60)
}
}
/// 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 {
// `.frame(height: totalHeight, alignment: .top)` — without the
// 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.
// `.top` on both the ZStack and the frame — see PeriodRuler.
ZStack(alignment: .top) {
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
Rectangle()
@@ -239,166 +211,173 @@ struct TimeGridView: View {
}
private var breakBlocks: some View {
// Same `.center`-vs-`.top` bug as gridLines — see comment above.
ZStack(alignment: .top) {
ForEach(0..<max(0, periods.count - 1), id: \.self) { i in
let curr = periods[i]
let next = periods[i + 1]
let virtualGap = next.virtualStartMinutes - curr.virtualEndMinutes
if virtualGap > 0 {
Rectangle()
.fill(palette.breakBlock)
.frame(height: CGFloat(virtualGap) * hourHeight / 60.0)
.padding(.horizontal, 1)
.offset(y: CGFloat(curr.virtualEndMinutes) * hourHeight / 60.0)
ForEach(periodBreaks(periods), id: \.self) { pause in
let height = CGFloat(pause.virtualMinutes) * hourHeight / 60.0
ZStack {
palette.breakBlock
if height >= BREAK_ICON_MIN_HEIGHT {
Image(systemName: "fork.knife")
.font(.system(size: 9))
.foregroundStyle(palette.accent)
}
}
.frame(height: height)
.offset(y: CGFloat(pause.virtualStart) * hourHeight / 60.0)
}
}
.frame(height: totalHeight, alignment: .top)
}
private func formatHm(_ minutes: Int) -> String {
String(format: "%02d:%02d", minutes / 60, minutes % 60)
/// Mirrors the in-app holiday region: icon and "Schulfrei" on top, the
/// 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
private func lessonBlock(_ lesson: WidgetLesson) -> some View {
let cal = Calendar.current
let comps = cal.dateComponents([.hour, .minute], from: lesson.start)
let startMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
private func lessonBlock(
_ lesson: WidgetLesson,
metrics: TileMetrics,
innerWidth: CGFloat
) -> some View {
let startMinutes = minutesOfDay(lesson.start)
let durationMinutes = max(15, Int(lesson.end.timeIntervalSince(lesson.start) / 60))
let virtualStart = realMinutesToVirtual(startMinutes, periods: periods)
let virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods: periods)
if virtualEnd > virtualStart {
// 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 height = max(
MIN_BLOCK_HEIGHT,
(virtualEnd - virtualStart) * hourHeight / 60.0 - LESSON_GAP
)
let subjectSize = subjectFont(forHourHeight: hourHeight)
let secondarySize = max(MIN_SECONDARY_FONT, subjectSize - 2)
let room = lesson.room
let teacher = lesson.teacher ?? lesson.originalTeacher
let hasSecondary = (room?.isEmpty == false) || (teacher?.isEmpty == false)
HStack(alignment: .top, spacing: 4) {
Text(subjectLabel(lesson))
.font(.system(size: subjectSize, weight: .semibold))
.foregroundStyle(.white)
.lineLimit(hasSecondary ? 1 : 2)
.minimumScaleFactor(0.5)
if hasSecondary {
Spacer(minLength: 0)
VStack(alignment: .trailing, spacing: -1) {
if showRoom && height >= BLOCK_SHOW_ROOM_MIN {
if let room, !room.isEmpty {
Text(room)
.font(.system(size: secondarySize))
.foregroundStyle(.white.opacity(0.85))
.lineLimit(1)
.minimumScaleFactor(0.5)
}
if showTeacher,
height >= BLOCK_SHOW_TEACHER_SEPARATE_MIN,
let teacher,
!teacher.isEmpty {
Text(teacher)
.font(.system(size: secondarySize))
.foregroundStyle(.white.opacity(0.7))
.lineLimit(1)
.minimumScaleFactor(0.5)
}
}
}
}
}
.padding(.horizontal, horizontalPadding)
.padding(.vertical, 3)
.frame(maxWidth: .infinity, alignment: .topLeading)
.frame(height: height, alignment: .topLeading)
.background(blockColor(lesson))
.cornerRadius(6)
.overlay(alignment: .bottomLeading) {
// Separate fixed-size badge so the +N hint stays readable
// when the subject autoshrinks on narrow tiles.
if let count = lesson.siblingCount, count > 0 {
Text("+\(count)")
.font(.system(size: 12, weight: .bold))
.foregroundStyle(.white)
.padding(.leading, horizontalPadding)
.padding(.bottom, 2)
}
}
.overlay {
// CrossPainter parity: clip cross to the rounded shape so
// the diagonals don't bleed past the corners.
if lesson.status == .cancelled {
ZStack {
RoundedRectangle(cornerRadius: 6)
.stroke(Color.red.opacity(0.78), lineWidth: 1.5)
GeometryReader { geo in
Path { p in
p.move(to: .zero)
p.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height))
p.move(to: CGPoint(x: geo.size.width, y: 0))
p.addLine(to: CGPoint(x: 0, y: geo.size.height))
}
.stroke(Color.red.opacity(0.78), lineWidth: 3)
}
}
.clipShape(RoundedRectangle(cornerRadius: 6))
}
}
.padding(.horizontal, showRoom ? 2 : 1)
LessonTile(
lesson: lesson,
height: height,
metrics: metrics,
layout: TileTextLayout(
lesson: lesson,
innerHeight: height - 2 * TILE_PADDING_V,
innerWidth: innerWidth,
metrics: metrics
)
)
.offset(y: top)
}
}
private func subjectLabel(_ lesson: WidgetLesson) -> String {
!lesson.subjectShort.isEmpty
? lesson.subjectShort
: (lesson.subjectLong ?? "—")
}
/// Mirrors lesson_color.dart + custom_event_colors.dart so the widget
/// matches the in-app calendar exactly.
private func blockColor(_ lesson: WidgetLesson) -> Color {
if lesson.status == .event, let custom = lesson.customColor {
switch custom {
case "orange": return Color(red: 239/255.0, green: 108/255.0, blue: 0/255.0)
case "red": return Color(red: 153/255.0, green: 51/255.0, blue: 51/255.0)
case "green": return Color(red: 76/255.0, green: 175/255.0, blue: 80/255.0)
case "blue": return Color(red: 33/255.0, green: 150/255.0, blue: 243/255.0)
default: break
}
}
switch lesson.status {
// `.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) }
/// 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)
.padding(.horizontal, TILE_PADDING_H)
.padding(.vertical, TILE_PADDING_V)
.frame(maxWidth: .infinity, alignment: .topLeading)
.frame(height: height, alignment: .topLeading)
.background(lesson.tileColor)
.clipShape(shape)
.overlay(alignment: .bottomTrailing) {
if let count = lesson.siblingCount, count > 0 {
Text("+\(count)")
.font(.system(size: 9, weight: .bold))
.foregroundStyle(.white)
.padding(.trailing, TILE_PADDING_H)
.padding(.bottom, TILE_PADDING_V)
}
}
.overlay {
if lesson.status == .cancelled {
ZStack {
shape.strokeBorder(LessonTile.cancelledRed, lineWidth: 2)
GeometryReader { geo in
Path { p in
p.move(to: .zero)
p.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height))
p.move(to: CGPoint(x: geo.size.width, y: 0))
p.addLine(to: CGPoint(x: 0, y: geo.size.height))
}
.stroke(LessonTile.cancelledRed, lineWidth: 2)
}
}
.clipShape(shape)
}
}
.padding(.horizontal, TILE_MARGIN_H)
}
private var titleRow: some View {
HStack(alignment: .center, spacing: 3) {
Text(layout.title)
.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))
}
private func badgeInset(line: Int) -> CGFloat {
layout.badgeLine == line ? BADGE_WIDTH : 0
}
return result.sorted()
}
/// 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
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) {
header(data: data)
dayHeaderRow(data: data)
dayHeaderRow(days: days)
.padding(.top, GRID_MARGIN_TOP)
GeometryReader { geo in
let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES)
let hourHeight = max(
MIN_HOUR_HEIGHT,
min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60)
)
let dayColumnWidth = (geo.size.width - 28 - 4) / 5
// 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
let height = resolveHourHeight(forGridHeight: geo.size.height, periods: periods)
HStack(alignment: .top, spacing: 0) {
timeLabelsColumn(hourHeight: hourHeight, periods: data.periods)
.frame(width: 28, alignment: .topTrailing)
PeriodRuler(periods: periods, hourHeight: height)
columnDivider
ForEach(0..<5, id: \.self) { offset in
column(
data: data,
offset: offset,
hourHeight: hourHeight,
subjectOnly: subjectOnly
ForEach(days.indices, id: \.self) { index in
TimeGridView(
lessons: weekLessons.filter {
cal.isDate($0.start, inSameDayAs: days[index])
},
periods: periods,
hourHeight: height,
holiday: holiday(on: days[index], data: data),
isToday: cal.isDate(days[index], inSameDayAs: entry.date)
)
.frame(maxWidth: .infinity)
if offset < 4 { columnDivider }
if index < 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,112 +71,55 @@ struct TimetableWeekView: View {
let cal = Calendar.current
let week = cal.component(.weekOfYear, from: data.anchorDate)
let endDate = cal.date(byAdding: .day, value: 4, to: data.anchorDate) ?? data.anchorDate
return HStack {
return HStack(spacing: 6) {
Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))")
.font(.system(size: 12, weight: .semibold))
.font(.system(size: 14, weight: .bold))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
.minimumScaleFactor(0.8)
Spacer()
.minimumScaleFactor(0.65)
.frame(maxWidth: .infinity, alignment: .leading)
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
.font(.system(size: 9))
.font(.system(size: 10))
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
.fixedSize()
}
.frame(height: TITLE_ROW_HEIGHT)
}
private func dayHeaderRow(data: WidgetTimetableData) -> some View {
let cal = Calendar.current
// `.frame(maxWidth: .infinity)` is critical: without an explicit
// 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.
/// Mirrors the in-app _DayHeaderCell: weekday in caps, day number below,
/// today inside an accent circle.
private func dayHeaderRow(days: [Date]) -> some View {
// The fixed height keeps `columnDivider`'s vertically-flexible
// Rectangle from stealing space from the GeometryReader.
return HStack(spacing: 0) {
Spacer().frame(width: 28)
// Rectangle from stealing space from the GeometryReader below.
HStack(spacing: 0) {
Spacer().frame(width: RULER_WIDTH)
columnDivider
ForEach(0..<5, id: \.self) { offset in
let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate
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)
if offset < 4 { columnDivider }
ForEach(days.indices, id: \.self) { index in
dayHeaderCell(days[index])
.frame(maxWidth: .infinity)
if index < 4 { columnDivider }
}
}
// 20pt fits 10pt weekday + 8pt date with tight -1pt spacing.
.frame(maxWidth: .infinity, minHeight: 20, maxHeight: 20)
.frame(maxWidth: .infinity, minHeight: WEEK_HEADER_HEIGHT, maxHeight: WEEK_HEADER_HEIGHT)
}
private func timeLabelsColumn(hourHeight: CGFloat, periods: [WidgetPeriod]) -> some View {
let totalMin = periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
let totalHeight = CGFloat(totalMin) * hourHeight / 60.0
// Mirrors TimeGridView.compactLabels — below 26pt/hour a stacked
// time+name no longer fits in one period slot and the two labels
// visually clip into the next slot. Drop to a single bold period
// number until there's room to read both lines.
let compact = hourHeight < 26
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)
} else {
Text(String(format: "%02d:%02d", period.startMinutes / 60, period.startMinutes % 60))
.font(.system(size: 9))
.foregroundStyle(palette.textPrimary)
.lineLimit(1)
Text("\(period.name).")
.font(.system(size: 7, weight: .bold))
.foregroundStyle(palette.textSecondary)
.lineLimit(1)
}
}
.padding(.trailing, 4)
.offset(y: CGFloat(period.virtualStartMinutes) * hourHeight / 60.0)
}
private func dayHeaderCell(_ day: Date) -> some View {
// `entry.date`, not `Date()` — WidgetKit archives entries ahead of
// time, the midnight entry has to mark its own day.
let isToday = Calendar.current.isDate(day, inSameDayAs: entry.date)
return VStack(spacing: 1) {
Text(WidgetDateFormatters.weekdayShort.string(from: day).uppercased())
.font(.system(size: 9, weight: .medium))
.foregroundStyle(isToday ? palette.accent : palette.textSecondary)
.lineLimit(1)
Text("\(Calendar.current.component(.day, from: day))")
.font(.system(size: 11, weight: isToday ? .bold : .regular))
.foregroundStyle(isToday ? palette.onAccent : palette.textPrimary)
.lineLimit(1)
.frame(width: 18, height: 18)
.background(Circle().fill(isToday ? palette.accent : Color.clear))
}
.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 {
@@ -33,6 +33,9 @@ struct WidgetLesson: Codable {
let originalTeacher: String?
let status: WidgetLessonStatus
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?
}
@@ -9,15 +9,27 @@ struct WidgetPalette {
let textPrimary: Color
let textSecondary: Color
let divider: Color
let accent: Color
let onAccent: Color
let breakBlock: Color
let todayTint: Color
let holidayTint: Color
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(
background: Color(red: 0xFC / 255, green: 0xF7 / 255, blue: 0xF5 / 255),
textPrimary: Color(red: 0x11 / 255, green: 0x11 / 255, blue: 0x11 / 255),
textSecondary: Color(red: 0x55 / 255, green: 0x55 / 255, blue: 0x55 / 255),
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
)
@@ -26,7 +38,11 @@ struct WidgetPalette {
textPrimary: Color(red: 0xF1 / 255, green: 0xF1 / 255, blue: 0xF1 / 255),
textSecondary: Color(red: 0xB0 / 255, green: 0xB0 / 255, blue: 0xB0 / 255),
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
)
+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 '../../errors/server_exception.dart';
import '../../http_errors.dart';
import '../nextcloud_ocs.dart';
import 'autocomplete_response.dart';
@@ -30,7 +31,12 @@ class AutocompleteApi {
'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) {
throw ServerException(
statusCode: response.statusCode,
@@ -4,6 +4,7 @@ import 'package:http/http.dart' as http;
import '../../../api_params.dart';
import '../../../api_response.dart';
import '../room/get_room_response.dart';
import '../talk_api.dart';
/// Small POST/DELETE-only Talk endpoints that have no response payload.
@@ -105,3 +106,21 @@ class DeleteRoomAvatar extends TalkApi {
Map<String, String>? 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,
setReadMarker: GetChatParamsSwitch.on,
// 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.
limit: 50,
),
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../../../errors/server_exception.dart';
@@ -45,8 +46,10 @@ class GetChatHistory {
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
..headers = response.headers;
// A page holds up to 200 messages and lands while the user is scrolling;
// decoding it on the UI isolate stalls the fling.
final parsed = await compute(_parseChatResponse, response.body);
return parsed..headers = response.headers;
}
throw ServerException(
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 '../../nextcloud_ocs.dart';
@@ -10,8 +11,12 @@ class GetRoom extends TalkApi<GetRoomResponse> {
GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson());
@override
GetRoomResponse assemble(String raw) =>
GetRoomResponse.fromJson(NextcloudOcs.decode(raw));
GetRoomResponse assemble(String raw) => _parseRooms(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
Future<http.Response> request(
@@ -20,3 +25,6 @@ class GetRoom extends TalkApi<GetRoomResponse> {
Map<String, String>? headers,
) => http.get(uri, headers: headers);
}
GetRoomResponse _parseRooms(String raw) =>
GetRoomResponse.fromJson(NextcloudOcs.decode(raw));
@@ -130,6 +130,12 @@ enum GetRoomResponseObjectParticipantNotificationLevel {
neverNotify,
}
extension NotificationLevelApiValue
on GetRoomResponseObjectParticipantNotificationLevel {
int get apiValue =>
_$GetRoomResponseObjectParticipantNotificationLevelEnumMap[this]!;
}
enum GetRoomResponseObjectMessageActorType {
@JsonValue('deleted_users')
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() =>
_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 {
try {
final webdav = await WebdavApi.webdav;
+10 -2
View File
@@ -12,6 +12,10 @@ import '../nextcloud_ocs.dart';
import 'talk_error.dart';
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;
ApiParams? body;
Map<String, String>? headers;
@@ -26,6 +30,10 @@ abstract class TalkApi<T extends ApiResponse?> {
);
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 {
final endpoint = NextcloudOcs.uri(
'apps/spreed/api/$path',
@@ -35,7 +43,7 @@ abstract class TalkApi<T extends ApiResponse?> {
final data = await sendGuarded(
'Talk $endpoint',
() => request(endpoint, body, mergedHeaders),
() => request(endpoint, body, mergedHeaders)?.timeout(_timeout),
);
if (data == null) {
throw const NetworkException(
@@ -65,7 +73,7 @@ abstract class TalkApi<T extends ApiResponse?> {
}
try {
final assembled = assemble(data.body);
final assembled = await assembleAsync(data.body);
assembled?.headers = data.headers;
return assembled;
} catch (e) {
@@ -29,6 +29,19 @@ class CacheableFile {
/// file/folder in the list. Nullable so older cached entries decode fine.
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({
required this.path,
required this.isDirectory,
@@ -1,9 +1,9 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:localstore/localstore.dart';
import '../../../../../utils/cache_invalidation_bus.dart';
import '../../../../cache_store.dart';
import '../../../../request_cache.dart';
import 'list_files.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
/// 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.
static Future<void> prefetchRootListing() async {
const rootPath = '';
final cached = await Localstore.instance
.collection(RequestCache.collection)
.doc(_documentId(rootPath))
.get();
final cached = await CacheStore.instance.read(_documentId(rootPath));
if (cached != null) return;
// 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.
@@ -69,10 +66,7 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
/// `_FilesView` for that path via [CacheInvalidationBus] so it refetches
/// even while it is sitting in the background of the navigation stack.
static Future<void> invalidate(String path) async {
await Localstore.instance
.collection(RequestCache.collection)
.doc(_documentId(path))
.delete();
await CacheStore.instance.delete(_documentId(path));
CacheInvalidationBus.notifyListFiles(path);
}
}
@@ -18,6 +18,8 @@ abstract class WebdavApi<T> {
/// changes (app password minted/renewed, account switch) so it never keeps
/// authenticating with stale credentials.
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 secret = '${nextcloud.username}:${nextcloud.secret}';
if (_webdav == null || _webdavSecret != secret) {
@@ -105,17 +105,29 @@ class MarianumConnectAuthInterceptor extends Interceptor {
Future<bool> _performReLogin() async {
final session = SessionManager().current;
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 {
await _loginClient.run(
username: session.username,
username: username,
password: session.password,
tokenName: await DeviceTokenName.resolve(),
);
return true;
} catch (_) {
await _tokenStorage.clear();
if (await SessionManager().readStoredUsername() == username) {
await _tokenStorage.clear();
}
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(
@@ -19,6 +19,7 @@ class SessionValidator {
// 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.
if (session == null || session.isDemo) return;
final epoch = SessionManager().sessionEpoch;
try {
switch (session) {
case CredentialSession(:final username, :final password):
@@ -28,6 +29,9 @@ class SessionValidator {
}
} on AuthException catch (e) {
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');
final next = await SessionLifecycle.signOut(
notice: switch (session) {
@@ -16,6 +16,16 @@ class MarianumConnectApi {
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
/// the auth queries (login/verify) that must bypass the bearer/demo
/// interceptors to avoid a re-auth loop.
@@ -3,41 +3,71 @@ import 'dart:convert';
import 'dart:io';
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 '../../../../push/push_registration.dart';
import '../../../../push/push_registration_store.dart';
import '../../../../push/push_registration_type.dart';
import '../../marianumconnect_query.dart';
import 'telemetry_device_id.dart';
typedef _PushState = ({
bool enabled,
AuthorizationStatus? permission,
String? pushDeviceIdentifier,
});
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) —
/// upserts the stable install id, platform, app version and device info. Sent
/// on app start, again once push registration completes that session (so a
/// fresh registration isn't under-reported until the next launch), and on
/// resume after the app spent more than 15 minutes in the background.
/// on app start, on resume and once push registration completes, throttled to
/// one per 15 minutes unless the push state changed in between.
/// Bearer-authenticated via the shared dio interceptor. Replaces the legacy
/// mhsl.eu `server/userIndex/update` call.
class TelemetryHeartbeat extends MarianumConnectQuery {
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
/// send never disrupts app start. Used from the app shell's initState and
/// lifecycle handler, and re-emitted once push registration completes (see
/// `_MainState._syncPush`).
/// send never disrupts app start. A changed push state (toggle, OS
/// permission — e.g. back from the system settings — or a fresh
/// 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}) {
unawaited(
TelemetryHeartbeat()
.send(notificationsEnabled: notificationsEnabled)
.catchError((Object _) {}),
);
unawaited(_report(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 package = await PackageInfo.fromPlatform();
final deviceIdentifier = await TelemetryDeviceId.resolve();
final pushDeviceIdentifier = await const PushRegistrationStore()
.deviceIdentifier(PushRegistrationType.general);
var platform = 'unknown';
String? deviceModel;
@@ -62,11 +92,12 @@ class TelemetryHeartbeat extends MarianumConnectQuery {
data: {
'deviceIdentifier': deviceIdentifier,
// `pushDeviceIdentifier` reflects a *completed* registration and is
// absent until it lands; `pushEnabled` carries the user's intent
// (the notification toggle) so the backend can tell "user wants push"
// apart from "registration not finished yet".
'pushDeviceIdentifier': ?pushDeviceIdentifier,
'pushEnabled': notificationsEnabled,
// absent until it lands; `pushEnabled` carries the user's intent (the
// toggle). The server combines it with `osPermission` into the
// visible-vs-silent delivery decision.
'pushDeviceIdentifier': ?push.pushDeviceIdentifier,
'pushEnabled': push.enabled,
'osPermission': ?osPermissionWireValue(push.permission),
'platform': platform,
'appVersion': package.version,
'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 'package:localstore/localstore.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../../../session/session.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
/// duplicated. The flag is set only once MHSL reports no remaining events.
class CustomEventsMigration {
static const String _collection = 'MarianumMobile';
static const String _document = 'customEventsMigration';
static const String _doneKey = 'migratedToMc';
static const String _doneKey = 'customEventsMigratedToMc';
const CustomEventsMigration._();
@@ -34,18 +32,23 @@ class CustomEventsMigration {
if (session is! CredentialSession || session.isDemo) return;
if (await _isDone()) return;
final epoch = SessionManager().sessionEpoch;
try {
final response = await GetCustomTimetableEvent(
GetCustomTimetableEventParams(session.legacyUserSecret),
).run();
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 RemoveCustomTimetableEvent(
RemoveCustomTimetableEventParams(event.id),
).run();
}
if (!SessionManager().isCurrentSession(epoch)) return;
await _markDone();
log(
'Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.',
@@ -57,17 +60,11 @@ class CustomEventsMigration {
}
}
static Future<bool> _isDone() async {
final data = await Localstore.instance
.collection(_collection)
.doc(_document)
.get();
return data != null && data[_doneKey] == true;
}
// SharedPreferences, like the old cache document, is cleared on sign-out,
// so the next account runs its own migration.
static Future<bool> _isDone() async =>
(await SharedPreferences.getInstance()).getBool(_doneKey) ?? false;
static Future<void> _markDone() async {
await Localstore.instance.collection(_collection).doc(_document).set({
_doneKey: true,
});
}
static Future<void> _markDone() async =>
(await SharedPreferences.getInstance()).setBool(_doneKey, true);
}
@@ -10,12 +10,17 @@ class McTimetableTeacher {
final String displayName;
final String? originalShortName;
final String? originalDisplayName;
// Webuntis element ids; null from servers that predate them.
final int? id;
final int? originalId;
McTimetableTeacher({
required this.shortName,
required this.displayName,
this.originalShortName,
this.originalDisplayName,
this.id,
this.originalId,
});
factory McTimetableTeacher.fromJson(Map<String, dynamic> json) =>
@@ -45,6 +50,8 @@ class McTimetableEntry {
final String? substitutionText;
final String? lessonText;
final String? infoText;
// Index-aligned with [classNames]; null from servers that predate it.
final List<int>? classIds;
McTimetableEntry({
required this.id,
@@ -60,6 +67,7 @@ class McTimetableEntry {
required this.substitutionText,
required this.lessonText,
required this.infoText,
this.classIds,
});
factory McTimetableEntry.fromJson(Map<String, dynamic> json) =>
@@ -68,10 +76,17 @@ class McTimetableEntry {
/// Combines the calendar date with the hour/minute portion of [startTime]
/// (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 get endDateTime =>
late final DateTime _endDateTime =
DateTime(date.year, date.month, date.day, endTime.hour, endTime.minute);
static DateTime _dateFromJson(String raw) => DateTime.parse(raw);
@@ -12,6 +12,8 @@ McTimetableTeacher _$McTimetableTeacherFromJson(Map<String, dynamic> json) =>
displayName: json['displayName'] as String,
originalShortName: json['originalShortName'] 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) =>
@@ -20,6 +22,8 @@ Map<String, dynamic> _$McTimetableTeacherToJson(McTimetableTeacher instance) =>
'displayName': instance.displayName,
'originalShortName': instance.originalShortName,
'originalDisplayName': instance.originalDisplayName,
'id': instance.id,
'originalId': instance.originalId,
};
McTimetableEntry _$McTimetableEntryFromJson(Map<String, dynamic> json) =>
@@ -43,6 +47,9 @@ McTimetableEntry _$McTimetableEntryFromJson(Map<String, dynamic> json) =>
substitutionText: json['substitutionText'] as String?,
lessonText: json['lessonText'] 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) =>
@@ -60,6 +67,7 @@ Map<String, dynamic> _$McTimetableEntryToJson(McTimetableEntry instance) =>
'substitutionText': instance.substitutionText,
'lessonText': instance.lessonText,
'infoText': instance.infoText,
'classIds': instance.classIds,
};
TimetableGetWeekResponse _$TimetableGetWeekResponseFromJson(
+50 -35
View File
@@ -1,10 +1,11 @@
import 'dart:async';
import 'dart:convert';
import 'package:localstore/localstore.dart';
import '../session/session_manager.dart';
import 'api_response.dart';
import 'cache_store.dart';
import 'errors/parse_exception.dart';
import 'errors/stale_session_exception.dart';
abstract class RequestCache<T extends ApiResponse?> {
static const int cacheNothing = 0;
@@ -12,12 +13,10 @@ abstract class RequestCache<T extends ApiResponse?> {
static const int cacheHour = 60 * 60;
static const int cacheDay = 60 * 60 * 24;
static String collection = 'MarianumMobile';
int maxCacheTime;
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
/// cache hits from authoritative network responses.
void Function(T)? onCacheData;
@@ -48,42 +47,57 @@ abstract class RequestCache<T extends ApiResponse?> {
static void ignore(Exception e) {}
Future<void> start(String document) async {
final epoch = SessionManager().sessionEpoch;
try {
final tableData = await Localstore.instance
.collection(collection)
.doc(document)
.get();
if (tableData != null) {
final cached = onLocalData(tableData['json'] as String);
final entry = await CacheStore.instance.read(document);
var lastUpdate = entry?.lastUpdate ?? 0;
T? cached;
if (entry != null) {
try {
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);
onCacheData?.call(cached);
}
final lastUpdate = (tableData?['lastupdate'] as num?) ?? 0;
if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) <
lastUpdate) {
if (renew == null || !renew!) return;
}
try {
final newValue = await onLoad();
onUpdate?.call(newValue);
onNetworkData?.call(newValue);
unawaited(
Localstore.instance.collection(collection).doc(document).set({
'json': jsonEncode(newValue),
'lastupdate': DateTime.now().millisecondsSinceEpoch,
}),
);
} on Exception catch (e) {
onError(e);
}
await _load(document, epoch, lastUpdate: lastUpdate);
} finally {
if (!_ready.isCompleted) _ready.complete();
}
}
T onLocalData(String json);
Future<void> _load(
String document,
int epoch, {
required int lastUpdate,
}) async {
if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) <
lastUpdate) {
if (renew == null || !renew!) return;
}
try {
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);
onNetworkData?.call(newValue);
unawaited(CacheStore.instance.write(document, jsonEncode(newValue)));
} on Exception catch (e) {
onError(e);
}
}
T fromCacheJson(Map<String, dynamic> json);
Future<T> onLoad();
}
@@ -117,8 +131,7 @@ class SimpleCache<T extends ApiResponse?> extends RequestCache<T> {
Future<T> onLoad() => _loader();
@override
T onLocalData(String json) =>
_fromJson(jsonDecode(json) as Map<String, dynamic>);
T fromCacheJson(Map<String, dynamic> json) => _fromJson(json);
}
/// Captures the latest cache payload (cached or network) and rethrows the
@@ -141,8 +154,10 @@ Future<T> resolveFromCache<T extends ApiResponse?>(
onError?.call(e);
});
await cache.ready;
if (latest != null) return latest as T;
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;
throw ParseException(
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: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/telemetry_heartbeat/telemetry_heartbeat.dart';
import 'main.dart';
@@ -45,18 +46,15 @@ class App extends StatefulWidget {
}
class _AppState extends State<App> with WidgetsBindingObserver {
late Timer _updateTimings;
StreamSubscription<RemoteMessage>? _onMessageSub;
StreamSubscription<RemoteMessage>? _onMessageOpenedAppSub;
StreamSubscription<String>? _fcmTokenRefreshSub;
int _knownTotalTabs = 1;
int _lastTabIndex = 0;
bool _userOnLastTab = false;
DateTime? _lastTelemetryAt;
static const Duration _chatListActiveInterval = Duration(seconds: 15);
static const Duration _chatListIdleInterval = Duration(seconds: 60);
static const Duration _telemetryInterval = Duration(minutes: 15);
void _onTabControllerChanged() {
final newIndex = Main.bottomNavigator.index;
@@ -78,18 +76,15 @@ class _AppState extends State<App> with WidgetsBindingObserver {
bloc.setAutoRefreshInterval(
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() {
if (!mounted) return;
final now = DateTime.now();
final last = _lastTelemetryAt;
if (last != null && now.difference(last) < _telemetryInterval) return;
_lastTelemetryAt = now;
TelemetryHeartbeat.report(
notificationsEnabled: context
.read<SettingsCubit>()
@@ -103,6 +98,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
void didChangeAppLifecycleState(AppLifecycleState state) {
log('AppLifecycle: $state');
if (state == AppLifecycleState.resumed) {
MarianumConnectApi.resetConnections();
_reportTelemetry();
Debouncer.throttle('appLifecycleState', const Duration(seconds: 10), () {
if (!mounted) return;
@@ -113,6 +109,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
_syncChatListPolling(refresh: false);
_handlePendingWidgetNavigation();
} else if (mounted) {
context.read<SettingsCubit>().flushSilentSave();
// Stop polling while backgrounded: a silent refresh failing in the
// background would otherwise leave an error that flashes on the next
// resume before the foreground refetch replaces it.
@@ -162,7 +159,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
void _handlePendingShare() {
if (!mounted) return;
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
// on top with stale (already-cleared) file paths.
// Sharing targets Talk chats and Files folders only.
@@ -181,6 +178,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst || route is PopupRoute);
}
ShareIntentListener.instance.markShown(share);
AppRoutes.openShareTarget(context, share);
}
@@ -198,19 +196,18 @@ class _AppState extends State<App> with WidgetsBindingObserver {
context.read<BreakerBloc>().refresh();
context.read<ChatListBloc>().refresh();
// Initial publish in case hydrated storage already has data. No refresh
// needed: PrimaryTimetableScope hands out a freshly loading bloc.
_publishWidget(context.read<TimetableBloc>());
// needed: PrimaryTimetableScope hands out a freshly loading bloc. The
// 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());
ShareIntentListener.instance.attach();
ShareIntentListener.pending.addListener(_handlePendingShare);
_handlePendingShare();
_syncChatListPolling();
});
_updateTimings = Timer.periodic(const Duration(seconds: 30), (_) {
if (mounted) setState(() {});
});
_reportTelemetry();
// 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);
});
DataCleaner.cleanOldCache();
// Housekeeping only; kept out of the cold-start window.
Future<void>.delayed(
const Duration(seconds: 20),
DataCleaner.cleanOldCache,
);
}
@override
void dispose() {
_updateTimings.cancel();
_onMessageSub?.cancel();
_onMessageOpenedAppSub?.cancel();
_fcmTokenRefreshSub?.cancel();
PushTapRouter.pendingTarget.removeListener(_onPushTargetPending);
ShareIntentListener.pending.removeListener(_handlePendingShare);
ShareIntentListener.instance.detach();
Main.bottomNavigator.removeListener(_onTabControllerChanged);
WidgetsBinding.instance.removeObserver(this);
super.dispose();
@@ -280,7 +279,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
// one widget reload for the burst is enough.
listener: (_, _) => Debouncer.debounce(
'widgetPublish',
const Duration(milliseconds: 500),
const Duration(seconds: 1),
() {
if (mounted) _publishWidget(context.read<TimetableBloc>());
},
@@ -290,7 +289,18 @@ class _AppState extends State<App> with WidgetsBindingObserver {
Widget _buildShell(
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, _) {
final bottomBarModules = AppModule.getBottomBarModules(context);
final totalTabs = bottomBarModules.length + 1;
+5
View File
@@ -36,6 +36,11 @@ extension CalendarDayArithmetic on DateTime {
);
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
+17 -4
View File
@@ -1,16 +1,29 @@
import 'package:flutter/material.dart';
extension TextExt on Text {
Size get size {
final textPainter = TextPainter(
/// Single-line width as rendered in [context] (honours the text scaler).
/// 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),
maxLines: 1,
textDirection: TextDirection.ltr,
)..layout(minWidth: 0, maxWidth: double.infinity);
return textPainter.size;
textScaler: scaler,
)..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.
String firstNonEmpty(List<String?> values) {
for (final v in values) {
+162 -117
View File
@@ -7,6 +7,7 @@ import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart' show timeDilation;
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.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:path_provider/path_provider.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/marianumconnect/auth/session_validator.dart';
import 'api/marianumconnect/marianumconnect_endpoint.dart';
@@ -26,6 +27,7 @@ import 'app.dart';
import 'auth_link/guardian_link_listener.dart';
import 'background/widget_background_task.dart';
import 'firebase_options.dart';
import 'model/session_wipe.dart';
import 'notification/notification_service.dart';
import 'push/notification_permission_prompt.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/bloc/account_bloc.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/chat_list/bloc/chat_list_bloc.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 'widget/avatar_disk_cache.dart';
import 'widget/breaker/breaker.dart';
import 'widget/debug/debug_stats_overlay.dart';
import 'widget/downloads/download_tray.dart';
import 'widget/emergency/emergency_notice_gate.dart';
import 'widget/info_dialog.dart';
@@ -120,6 +124,7 @@ void _installErrorHandlers() {
Future<void> main() async {
log('MarianumMobile started');
WidgetsFlutterBinding.ensureInitialized();
SessionManager().markUiEngine();
// Before any initialisation so startup failures reach the backend too.
_installErrorHandlers();
@@ -179,6 +184,12 @@ Future<void> main() async {
);
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
// Android channels, then register the FCM background isolate handler that
// 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
// triggered during startup hits initialised native storage.
await _startupStep('widget sync', WidgetSync.ensureInitialized);
// The home-screen widget bridge must be ready before runApp so any widget
// render triggered during startup hits initialised native storage.
await widgetSyncInit;
unawaited(
WidgetBackgroundTask.initialize().onError(
(e, _) => log('Workmanager init failed: $e'),
@@ -297,13 +308,19 @@ class Main extends StatefulWidget {
}
class _MainState extends State<Main> {
final List<NavigatorObserver> _navigatorObservers = [
AppRoutes.chatRouteObserver,
DownloadRouteObserver(),
];
bool _showPostLoginSplash = false;
bool _appMounted = true;
late AccountStatus _lastStatus;
@override
void initState() {
super.initState();
Jiffy.setLocale('de');
_lastStatus = context.read<AccountBloc>().state.status;
SessionManager().unauthorizedSignal.addListener(_onUnauthorized);
GuardianLinkListener.pending.addListener(_onGuardianLink);
@@ -332,8 +349,10 @@ class _MainState extends State<Main> {
final capabilitiesCubit = scopeContext.read<CapabilitiesCubit>();
unawaited(
capabilitiesCubit.load().then((_) {
if (!mounted) return;
// Closed: the account was switched away from while loading.
if (!mounted || capabilitiesCubit.isClosed) return;
_syncPush(settingsCubit, capabilitiesCubit);
_applyRoleDefaults(settingsCubit, capabilitiesCubit);
_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
void dispose() {
SessionManager().unauthorizedSignal.removeListener(_onUnauthorized);
@@ -444,55 +476,72 @@ class _MainState extends State<Main> {
if (mounted) setState(() => _appMounted = true);
});
}
if (accountState.status == AccountStatus.addingAccount) {
// Routes pushed on the root navigator (Settings) would otherwise keep
// covering the login screen that replaces the home route.
AppRoutes.rootNavigatorKey.currentState?.popUntil(
(route) => route.isFirst,
);
}
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();
final wasLoggedIn = _lastStatus == AccountStatus.loggedIn;
_lastStatus = accountState.status;
// A cold start that merely resolves as signed out has nothing to wipe,
// and a share waiting for the login must survive it.
if (accountState.status != AccountStatus.loggedOut || !wasLoggedIn) return;
// Deferred until the account-scoped tree is torn down.
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
Widget build(BuildContext context) => Directionality(
textDirection: TextDirection.ltr,
child: BlocBuilder<SettingsCubit, Settings>(
builder: (context, settings) {
// Selects only what the root uses: Settings is mutated in place and
// 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
// background isolate and the iOS NSE can suppress rendering when off.
unawaited(
const PushRegistrationStore().setNotificationsEnabled(
settings.notificationSettings.enabled,
root.notificationsEnabled,
),
);
timeDilation = root.slowAnimations ? 5.0 : 1.0;
return BlocConsumer<AccountBloc, AccountState>(
listenWhen: (previous, current) =>
previous.status != current.status ||
previous.accountId != current.accountId,
listener: _onAccountChanged,
buildWhen: (previous, current) =>
previous.accountId != current.accountId,
_scopeOf(previous) != _scopeOf(current),
builder: (context, account) {
if (account.accountId != _scopeAccountId) {
_scopeAccountId = account.accountId;
final scope = _scopeOf(account);
if (scope != _scope) {
_scope = scope;
// The old navigator (and every route on it) goes with the old
// account; a shared GlobalKey would carry it over instead.
AppRoutes.rootNavigatorKey = GlobalKey<NavigatorState>();
}
return AccountScope(
key: ValueKey(account.accountId),
key: ValueKey(scope),
onActive: _onSessionActive,
child: _buildApp(settings),
child: _buildApp(root),
);
},
);
@@ -500,101 +549,97 @@ class _MainState extends State<Main> {
),
);
Widget _buildApp(Settings settings) {
final devToolsSettings = settings.devToolsSettings;
return MaterialApp(
showPerformanceOverlay: devToolsSettings.showPerformanceOverlay,
checkerboardOffscreenLayers: devToolsSettings.checkerboardOffscreenLayers,
checkerboardRasterCacheImages:
devToolsSettings.checkerboardRasterCacheImages,
debugShowCheckedModeBanner: false,
navigatorKey: AppRoutes.rootNavigatorKey,
// Used by ChatView.didPopNext to reclaim the global ChatBloc.
// DownloadRouteObserver tracks full-page navigations so the downloads
// chip only surfaces once the user leaves the screen they started on.
navigatorObservers: [
AppRoutes.chatRouteObserver,
DownloadRouteObserver(),
],
localizationsDelegates: const [
...GlobalMaterialLocalizations.delegates,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: const [Locale('de'), Locale('en')],
locale: const Locale('de'),
title: 'Marianum Fulda',
themeMode: settings.appTheme,
theme: LightAppTheme.theme,
darkTheme: DarkAppTheme.theme,
// Brand-colored backdrop behind every route. During the logout
// home-swap and route pop animations the framework can briefly
// expose the layer below the topmost Scaffold; without this
// the dark Material default shows through and the user sees a
// black flash.
builder: (context, child) => ColoredBox(
Widget _buildApp(_RootSettings root) => MaterialApp(
showPerformanceOverlay: root.showPerformanceOverlay,
showSemanticsDebugger: root.showSemanticsDebugger,
debugShowCheckedModeBanner: false,
navigatorKey: AppRoutes.rootNavigatorKey,
// Used by ChatView.didPopNext to reclaim the global ChatBloc.
// DownloadRouteObserver tracks full-page navigations so the downloads
// chip only surfaces once the user leaves the screen they started on.
navigatorObservers: _navigatorObservers,
localizationsDelegates: const [
...GlobalMaterialLocalizations.delegates,
GlobalWidgetsLocalizations.delegate,
],
supportedLocales: const [Locale('de'), Locale('en')],
locale: const Locale('de'),
title: 'Marianum Fulda',
themeMode: root.appTheme,
theme: LightAppTheme.theme,
darkTheme: DarkAppTheme.theme,
// Brand-colored backdrop behind every route. During the logout
// home-swap and route pop animations the framework can briefly
// expose the layer below the topmost Scaffold; without this
// the dark Material default shows through and the user sees a
// black flash.
builder: (context, child) {
Widget app = ColoredBox(
color: LightAppTheme.marianumRed,
// Downloads tray mounted ABOVE the navigator so its chip floats over
// every route (folder views, chat, viewer are full-page pushes that
// would otherwise cover it).
// Downloads tray mounted ABOVE the navigator so its chip floats
// over every route (folder views, chat, viewer are full-page
// pushes that would otherwise cover it).
child: DownloadTrayHost(child: child ?? const SizedBox.shrink()),
),
home: EmergencyNoticeGate(
child: LoaderOverlay(
child: Breaker(
breaker: BreakerArea.global,
child: BlocBuilder<AccountBloc, AccountState>(
buildWhen: (previous, current) =>
previous.status != current.status,
builder: (context, accountState) {
switch (accountState.status) {
case AccountStatus.loggedIn:
return Stack(
fit: StackFit.expand,
children: [
if (_appMounted) const App(key: ValueKey('app-shell')),
if (_showPostLoginSplash)
PostLoginSplash(
key: const ValueKey('post-login-splash'),
onComplete: () {
setState(() => _showPostLoginSplash = false);
_promptGuardianNotifications();
},
),
],
);
case AccountStatus.loggedOut:
return const Login();
case AccountStatus.addingAccount:
return const Login(addingAccount: true);
case AccountStatus.undefined:
return const AccountLoadingScreen();
}
},
),
);
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(
child: LoaderOverlay(
child: Breaker(
breaker: BreakerArea.global,
child: BlocBuilder<AccountBloc, AccountState>(
buildWhen: (previous, current) => previous.status != current.status,
builder: (context, accountState) {
switch (accountState.status) {
case AccountStatus.loggedIn:
return Stack(
fit: StackFit.expand,
children: [
if (_appMounted) const App(key: ValueKey('app-shell')),
if (_showPostLoginSplash)
PostLoginSplash(
key: const ValueKey('post-login-splash'),
onComplete: () {
setState(() => _showPostLoginSplash = false);
_promptGuardianNotifications();
},
),
],
);
case AccountStatus.loggedOut:
return const Login();
case AccountStatus.addingAccount:
return const Login(addingAccount: true);
case AccountStatus.undefined:
return const AccountLoadingScreen();
}
},
),
),
),
);
}
),
);
}
/// Wipes what outlives accounts once the last one signed out. Account data
/// itself is removed by [SessionLifecycle.signOut].
Future<void> _wipeDeviceState() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.clear();
// The chat background image lives outside HydratedStorage, so clear it too
// (best-effort) to avoid orphaning the previous user's wallpaper.
final backgroundImage = File(AppPaths.chatBackgroundImage);
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);
}
}
typedef _RootSettings = ({
ThemeMode appTheme,
bool notificationsEnabled,
bool showPerformanceOverlay,
bool showSemanticsDebugger,
bool showFrameStats,
bool showMemoryStats,
bool slowAnimations,
double textScaleOverride,
});
+3 -19
View File
@@ -1,24 +1,8 @@
import 'package:localstore/localstore.dart';
import '../api/request_cache.dart';
import '../api/cache_store.dart';
class DataCleaner {
static Future<void> cleanOldCache() async {
final cacheData = await Localstore.instance
.collection(RequestCache.collection)
.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();
}
});
await CacheStore.deleteLegacyLocalstoreCache();
await CacheStore.instance.deleteOlderThan(const Duration(days: 200));
}
}
+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_target.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/json_viewer.dart';
import '../widget/info_dialog.dart';
@@ -35,7 +37,14 @@ class NotificationController {
);
await NotificationTasks.refreshBadge();
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(
@@ -25,7 +25,12 @@ class NotificationService {
// iOS Talk category mirrors the Android inline reply + mark-as-read actions
// 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.
// The plugin defaults to prompting here, which would block startup on the
// OS dialog; the prompt belongs to maybePromptTalkNotifications instead.
final iosSettings = DarwinInitializationSettings(
requestAlertPermission: false,
requestSoundPermission: false,
requestBadgePermission: false,
notificationCategories: [
DarwinNotificationCategory(
PushRenderer.iosTalkCategory,
+4
View File
@@ -60,6 +60,10 @@ class PushActions {
// auth header, the Talk POST never happens and the RemoteInput spinner
// runs forever.
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(
'action=${response.actionId} payload=${response.payload} '
+19 -1
View File
@@ -1,7 +1,7 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypton/crypton.dart';
import 'package:flutter/foundation.dart';
import 'package:pointycastle/export.dart' as pc;
import 'push_subject.dart';
@@ -25,6 +25,14 @@ class PushDecryptor {
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
/// encrypted subject. Returns true when no server key is configured (the
/// 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 '../notification/notification_service.dart';
import '../session/session_manager.dart';
import 'chat_thread_store.dart';
import 'nid_store.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.
@pragma('vm:entry-point')
Future<void> pushOnBackgroundMessage(RemoteMessage message) async {
// This engine outlives sign-outs and logins in the app.
await SessionManager().reloadFromStorage();
await NotificationService().initializeNotifications();
await PushRenderer.ensureChannels();
await PushMessageHandler().handle(message);
@@ -175,11 +178,15 @@ class PushMessageHandler {
devicePrivateKey: privateKey,
serverPublicKey: serverPublicKey,
);
if (!decryptor.verify(subjectBase64, signatureBase64)) {
final result = await decryptor.verifyAndDecryptInBackground(
subjectBase64,
signatureBase64,
);
if (!result.verified) {
log('Push: signature verification failed');
return;
}
final subject = decryptor.decrypt(subjectBase64);
final subject = result.subject;
if (subject == null) {
log('Push: could not decrypt subject');
return;
+32 -34
View File
@@ -118,6 +118,7 @@ class PushRegistration {
Future<bool> register() async {
if (DemoMode.active) return false;
if (_nextcloudOrNull == null) return _direct.register();
final epoch = SessionManager().sessionEpoch;
final String? fcmToken;
try {
fcmToken = await FirebaseMessaging.instance.getToken();
@@ -167,6 +168,7 @@ class PushRegistration {
fcmToken: fcmToken,
pems: pems,
appVersion: appVersion,
epoch: epoch,
);
allOk = allOk && ok;
}
@@ -178,6 +180,7 @@ class PushRegistration {
required String fcmToken,
required PushKeypairPems pems,
required String? appVersion,
required int epoch,
}) async {
try {
final proxyServer = currentProxyServer;
@@ -193,6 +196,11 @@ class PushRegistration {
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(
type: type,
deviceIdentifier: registration.deviceIdentifier,
@@ -355,9 +363,8 @@ class PushRegistration {
status != AuthorizationStatus.deniedPermanently;
/// Requests the OS notification permission (covers iOS + Android 13) and
/// returns whether registration should proceed. Errors from the plugin are
/// treated as usable — better a possibly-idle registration than silently
/// losing push over a transient failure.
/// returns whether it is usable (not explicitly denied). Errors from the
/// plugin are treated as usable so a transient failure never nags the user.
static Future<bool> requestOsPermission() async {
try {
final settings = await FirebaseMessaging.instance.requestPermission();
@@ -368,56 +375,47 @@ class PushRegistration {
}
}
/// True when the user has explicitly denied the OS notification permission.
/// Read-only (no prompt) — used by the settings UI to surface the state.
static Future<bool> isOsPermissionDenied() async {
/// Current OS notification permission, or null when the plugin can't tell.
/// Read-only — never triggers the OS prompt.
static Future<AuthorizationStatus?> osPermissionStatus() async {
try {
final settings = await FirebaseMessaging.instance
.getNotificationSettings();
return !isPermissionUsable(settings.authorizationStatus);
return settings.authorizationStatus;
} on Object {
return false;
return null;
}
}
/// True when the OS notification permission is already granted
/// (`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 {
try {
final settings = await FirebaseMessaging.instance
.getNotificationSettings();
return settings.authorizationStatus == AuthorizationStatus.authorized ||
settings.authorizationStatus == AuthorizationStatus.provisional;
} on Object {
return false;
}
final status = await osPermissionStatus();
return status != null && canDisplay(status);
}
/// 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.
/// Deliberately independent of the in-app notification toggle: a user who
/// turned notifications off stays registered so silent sync pushes keep
/// flowing — the display is suppressed downstream via the mirrored flag (see
/// [PushRegistrationStore.notificationsEnabled]). Only registers when the OS
/// notification permission is *already* granted — it never triggers the OS
/// prompt itself. Requesting the permission is the job of the first Talk visit
/// (see `maybePromptTalkNotifications`), which keeps the prompt out of the
/// cold-start path. Safe to call on every start — Nextcloud dedups an
/// unchanged registration — which also self-heals a device whose registration
/// was lost.
/// Deliberately independent of both the in-app notification toggle and the
/// OS notification permission: silent sync pushes (deletes, chat/badge
/// refresh, widget refresh) need no permission — data-only FCM on Android,
/// `content-available` on iOS — so every device stays in sync. Whether a
/// visible notification is wanted is reported separately via the telemetry
/// heartbeat (toggle + OS permission), from which the server picks silent
/// pushes on iOS. Never triggers the OS prompt. Safe to call on every
/// start — Nextcloud dedups an unchanged registration — which also
/// self-heals a device whose registration was lost.
/// Returns whether registration was actually *attempted* (all gates passed).
/// Even a partial success persists the `general` device identifier, so the
/// caller re-emits telemetry on `true` to reflect the fresh registration in
/// the same session instead of lagging until the next launch.
static Future<bool> syncSubscription({required bool capable}) async {
if (!capable) return false;
if (!await isOsPermissionGranted()) {
log(
'Push: OS notification permission not granted, skipping registration',
);
return false;
}
final registration = PushRegistration();
// register() below refreshes an unchanged subscription anyway; the check
// only surfaces the endpoint switch in the log for diagnosability.
+5 -16
View File
@@ -1,4 +1,3 @@
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import '../session/session_manager.dart';
@@ -142,21 +141,11 @@ Future<PushStatusReport> collectPushStatus({
}
Future<PushCheck> _osPermission() async {
try {
final settings = await FirebaseMessaging.instance.getNotificationSettings();
switch (settings.authorizationStatus) {
case AuthorizationStatus.authorized:
case AuthorizationStatus.provisional:
return PushCheck.ok;
case AuthorizationStatus.denied:
case AuthorizationStatus.deniedPermanently:
return PushCheck.fail;
case AuthorizationStatus.notDetermined:
return PushCheck.unknown;
}
} on Object {
return PushCheck.unknown;
}
final status = await PushRegistration.osPermissionStatus();
if (status == null) return PushCheck.unknown;
if (PushRegistration.canDisplay(status)) return PushCheck.ok;
if (!PushRegistration.isPermissionUsable(status)) return PushCheck.fail;
return PushCheck.unknown;
}
/// One line in the status checklist.
+23 -2
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/parent_letters/parent_letter_view.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/settings.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/subject_colors/subject_colors_view.dart';
import '../widget/avatar_crop_page.dart';
import '../widget/debug/bloc_storage_view.dart';
import '../widget/debug/cache_view.dart';
import '../widget/debug/json_viewer.dart';
import '../widget/file_viewer.dart';
import '../widget/large_profile_picture_view.dart';
import '../widget/user_avatar.dart';
@@ -294,8 +297,26 @@ class AppRoutes {
pushScreen(context, withNavBar: false, screen: const FeedbackDialog());
}
static void openCacheView(BuildContext context) {
pushScreen(context, withNavBar: false, screen: const CacheView());
static Future<void> openCacheView(BuildContext context) =>
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) {
+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 '../auth_link/guardian_link_listener.dart';
import '../background/widget_background_task.dart';
import '../push/chat_thread_store.dart';
import '../push/nid_store.dart';
import '../model/session_wipe.dart';
import '../push/push_registration.dart';
import '../storage/account_storage.dart';
import '../widget_data/widget_sync.dart';
@@ -41,10 +40,11 @@ abstract final class SessionLifecycle {
}
await AuthLogout().run();
final removed = await SessionManager().signOut();
SessionWipe.clearImmediate();
// A login link that arrived while signed in must not be replayed on the
// login screen that follows.
GuardianLinkListener.clear();
await _clearPushStores();
await SessionWipe.accountLeft();
if (removed != null) await AccountStorage.delete(removed.namespace);
for (
@@ -78,14 +78,22 @@ abstract final class SessionLifecycle {
log('Switch: push deactivation failed: $e');
}
await SessionManager().stashActive();
SessionWipe.clearImmediate();
var restored = false;
try {
await SessionManager().activate(id);
} on Object {
if (previous != null) await SessionManager().activate(previous.id);
if (previous != null) {
await SessionManager().activate(previous.id);
restored = true;
}
rethrow;
} finally {
await _clearPushStores();
await SessionWipe.accountLeft();
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();
}
@@ -94,6 +102,10 @@ abstract final class SessionLifecycle {
static Future<void> beginAddAccount() async {
await SessionManager().stashActive();
_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;
@@ -109,6 +121,7 @@ abstract final class SessionLifecycle {
await SessionManager().signOut();
}
await SessionManager().activate(returnId);
await AccountStorage.activate(SessionManager().activeAccount);
}
/// 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');
}
await SessionManager().activate(added.id);
await _clearPushStores();
await SessionWipe.accountLeft();
}
await AccountStorage.activate(SessionManager().activeAccount);
return added.id;
@@ -191,14 +204,4 @@ abstract final class SessionLifecycle {
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');
}
}
}
+60 -5
View File
@@ -58,6 +58,21 @@ class SessionManager {
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.
final ValueNotifier<AccountIndex> accounts = ValueNotifier(
AccountIndex.empty,
@@ -115,6 +130,7 @@ class SessionManager {
}
Future<void> _writeActive(Session session) async {
if (!_isSameAccount(_current, session)) _sessionEpoch++;
await Future.wait([
for (final MapEntry(:key, :value) in encodeSessionFields(session).entries)
_writeSecret(key, value),
@@ -134,6 +150,7 @@ class SessionManager {
/// stay in their vaults; see [activate].
Future<AccountEntry?> signOut() async {
final removed = activeAccount;
_sessionEpoch++;
_loaded = Completer();
_current = null;
await Future.wait([
@@ -193,6 +210,11 @@ class SessionManager {
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 {
accounts.value = index;
await _secureStorage.write(key: _indexKey, value: index.encode());
@@ -280,8 +302,15 @@ class SessionManager {
Future<void> _migrateAndLoad() async {
await _migrateFromLegacyStorage();
await _migrateKeychainAccessibility();
// On the startup critical path (and every background wake): read in
// parallel instead of one keychain round-trip after the other.
_current = await _readActive();
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(
_sessionFields.map((field) => _secureStorage.read(key: field)),
);
@@ -296,9 +325,35 @@ class SessionManager {
} on Object {
// Group keystore unavailable: fall back to the real password.
}
_current = decodeSession(raw);
await _loadIndex();
if (!_loaded.isCompleted) _loaded.complete();
return decodeSession(raw);
}
/// 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();
} on Object catch (e) {
log('Session reload failed, keeping loaded state: $e');
}
}
Future<void> _loadIndex() async {
+8 -2
View File
@@ -5,11 +5,17 @@ class PendingShare {
final String? text;
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({
required this.filePaths,
required this.text,
required this.receivedAt,
});
List<String>? sourcePaths,
}) : sourcePaths = sourcePaths ?? filePaths;
bool get hasFiles => filePaths.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
/// deliberately ignored here so such duplicates compare equal.
bool contentEquals(PendingShare other) =>
text == other.text && listEquals(filePaths, other.filePaths);
text == other.text && listEquals(sourcePaths, other.sourcePaths);
}
+171 -46
View File
@@ -2,6 +2,8 @@ import 'dart:async';
import 'dart:io';
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 '../auth_link/guardian_login_link.dart';
@@ -15,55 +17,63 @@ class ShareIntentListener {
static final ValueNotifier<PendingShare?> pending = ValueNotifier(null);
StreamSubscription<List<SharedMediaFile>>? _streamSub;
bool _initialized = false;
static const _androidChannel = MethodChannel('eu.mhsl.marianum.share');
/// Reads the cold-start payload exactly once. Call from `main()` before
/// `runApp` so the share is queued before the UI mounts.
/// Per-share folders live below this directory name — on Android in the
/// 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 {
if (_initialized) return;
_initialized = true;
try {
final initial = await ReceiveSharingIntent.instance.getInitialMedia();
final share = _toPendingShare(initial);
if (share != null) _publish(share);
if (Platform.isAndroid) {
_cacheDir = (await getTemporaryDirectory()).path;
_sweepStaleAndroidShares();
}
ReceiveSharingIntent.instance.getMediaStream().listen(
_onItems,
onError: (Object e) => debugPrint('ShareIntentListener stream: $e'),
);
_onItems(await ReceiveSharingIntent.instance.getInitialMedia());
await ReceiveSharingIntent.instance.reset();
if (Platform.isAndroid) {
await _androidChannel.invokeMethod<void>('listenerReady');
}
} catch (e) {
debugPrint('ShareIntentListener.initialize failed: $e');
}
}
/// Subscribes to warm-share stream events. Safe to call multiple times.
void attach() {
_streamSub ??= ReceiveSharingIntent.instance.getMediaStream().listen(
(items) {
final share = _toPendingShare(items);
if (share != null) _publish(share);
},
onError: (Object e) => debugPrint('ShareIntentListener stream error: $e'),
);
/// Whether the share flow for [share] is already on screen. Re-running the
/// navigation for it would pop its own page and thereby clear it.
bool isShown(PendingShare share) => identical(share, _shown);
void markShown(PendingShare share) => _shown = share;
/// Marks a share flow step that must not be interrupted (upload, share API
/// 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
/// 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.
/// Discards the current share and deletes its temp files. Idempotent.
///
/// Pass [ifCurrent] from UI that owns a specific share (e.g. the
/// ShareTargetPage pop handler): the call then only acts while that share
@@ -73,18 +83,58 @@ class ShareIntentListener {
void clear({PendingShare? ifCurrent}) {
final current = pending.value;
if (ifCurrent != null && !identical(current, ifCurrent)) return;
_shown = null;
pending.value = null;
if (current != null) {
for (final path in current.filePaths) {
try {
final f = File(path);
if (f.existsSync()) f.deleteSync();
} catch (_) {
// best-effort cleanup; OS will reclaim cache eventually
}
if (current != null) _deleteFiles(current);
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;
}
}
unawaited(ReceiveSharingIntent.instance.reset());
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
@@ -106,7 +156,9 @@ class ShareIntentListener {
case SharedMediaType.image:
case SharedMediaType.video:
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.url:
if (!isAppLink(item.path)) texts.add(item.path);
@@ -119,4 +171,77 @@ class ShareIntentListener {
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)) {
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);
if (connectivityStatusKnown() && isConnected()) {
if (reFetch == null) return;
reFetch!();
if ((wasOffline || event.fromResume) &&
connectivityStatusKnown() &&
isConnected()) {
reFetch?.call();
}
});
@@ -54,7 +59,12 @@ class LoadableStateBloc extends Bloc<LoadableStateEvent, LoadableStateState>
unawaited(
Connectivity().checkConnectivity().then((result) {
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 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
// rebuild the tree under the ListView and reset its scroll position.
final content = SizedBox(
height: MediaQuery.of(context).size.height,
height: MediaQuery.sizeOf(context).height,
child: hasContent
? child(typedData as TState, isLoading)
: const SizedBox.shrink(),
@@ -95,10 +95,12 @@ class _LoadableStateErrorBarTextState extends State<LoadableStateErrorBarText> {
late Timer _rebuildTimer;
@override
void initState() {
_rebuildTimer = Timer.periodic(
const Duration(seconds: 10),
(timer) => setState(() {}),
);
// Only refresh the relative "last updated" text while this page is the
// visible one; offstage tabs and covered routes have tickers disabled.
_rebuildTimer = Timer.periodic(const Duration(seconds: 10), (timer) {
if (!mounted) return;
if (TickerMode.getValuesNotifier(context).value.enabled) setState(() {});
});
super.initState();
}
@@ -33,6 +33,9 @@ class _LoadableStatePrimaryLoadingState
extends State<LoadableStatePrimaryLoading> {
Timer? _slowHintTimer;
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
void initState() {
@@ -44,6 +47,7 @@ class _LoadableStatePrimaryLoadingState
void didUpdateWidget(covariant LoadableStatePrimaryLoading oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.visible != oldWidget.visible) _restartSlowHintTimer();
if (widget.visible) _spinnerMounted = true;
}
void _restartSlowHintTimer() {
@@ -62,43 +66,49 @@ class _LoadableStatePrimaryLoadingState
}
@override
Widget build(BuildContext context) {
Widget build(BuildContext context) => AnimatedOpacity(
opacity: widget.visible ? 1.0 : 0.0,
duration: LoadableStateConsumer.animationDuration,
curve: Curves.easeInOut,
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 AnimatedOpacity(
opacity: widget.visible ? 1.0 : 0.0,
duration: LoadableStateConsumer.animationDuration,
curve: Curves.easeInOut,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const AppProgressIndicator.large(),
AnimatedSwitcher(
duration: LoadableStateConsumer.animationDuration,
child: status == null
? const SizedBox.shrink()
: Padding(
key: ValueKey(status),
padding: const EdgeInsets.only(
top: 16,
left: 24,
right: 24,
),
child: Text(
status,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).hintColor,
),
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const AppProgressIndicator.large(),
AnimatedSwitcher(
duration: LoadableStateConsumer.animationDuration,
child: status == null
? const SizedBox.shrink()
: Padding(
key: ValueKey(status),
padding: const EdgeInsets.only(
top: 16,
left: 24,
right: 24,
),
child: Text(
status,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).hintColor,
),
),
),
],
),
),
),
],
),
);
}
@@ -1,10 +1,13 @@
import 'dart:async';
import 'dart:developer';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../access/access_requirement.dart';
import '../../../../../api/errors/error_mapper.dart';
import '../../../../../api/errors/stale_session_exception.dart';
import '../../../../../session/session_manager.dart';
import '../../../../../utils/session_single_flight.dart';
import '../../loadable_state/loadable_state.dart';
import '../../loadable_state/loading_error.dart';
import '../../repository/repository.dart';
@@ -19,6 +22,12 @@ abstract class LoadableHydratedBloc<
extends
HydratedBloc<LoadableHydratedBlocEvent<TState>, LoadableState<TState>> {
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()
: super(
const LoadableState(
@@ -112,6 +121,8 @@ abstract class LoadableHydratedBloc<
/// fresh [fetch] (e.g. via [retry] or page-specific refresh) once the user
/// is authenticated again, otherwise the UI would stay blank.
Future<void> reset() async {
_lastPersistedKey = null;
_lastPersistedFetch = null;
await clear();
add(Reset<TState>());
}
@@ -123,6 +134,30 @@ abstract class LoadableHydratedBloc<
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;
TRepository get repo => _repository;
@@ -135,30 +170,47 @@ abstract class LoadableHydratedBloc<
/// 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
/// applies.
void addLoadingError(Object e) => add(
Error(
LoadingError(
message: errorToUserMessage(e),
technicalDetails: errorToTechnicalDetails(e),
allowRetry: errorAllowsRetry(e),
void addLoadingError(Object e) {
// Belongs to a signed-out account; the current one loads on its own.
if (e is StaleSessionException) return;
add(
Error(
LoadingError(
message: errorToUserMessage(e),
technicalDetails: errorToTechnicalDetails(e),
allowRetry: errorAllowsRetry(e),
),
),
),
);
);
}
// 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() {
if (!requirementsMet) return;
log('Fetching data for ${TState.toString()}');
gatherData()
.catchError((Object e) {
log('Error while fetching ${TState.toString()}: ${e.toString()}');
// The bloc may have been closed before this async error landed;
// adding to a closed bloc throws, so swallow that case.
if (isClosed) return;
addLoadingError(e);
})
.then((value) {
log('Fetch for ${TState.toString()} completed!');
});
if (!requirementsMet || !ownsSession) return;
unawaited(
_fetchFlight.run(() {
log('Fetching data for ${TState.toString()}');
return runInSession(
() => gatherData()
.catchError((Object e) {
log(
'Error while fetching ${TState.toString()}: ${e.toString()}',
);
// The bloc may have been closed before this async error
// landed; adding to a closed bloc throws, so swallow that case.
if (isClosed) return;
addLoadingError(e);
})
.then((value) {
log('Fetch for ${TState.toString()} completed!');
}),
);
}),
);
}
@override
@@ -175,13 +227,27 @@ abstract class LoadableHydratedBloc<
@override
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;
try {
final stateData = state.data;
data = stateData is TState ? toStorage(stateData) : null;
} catch (e) {
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(
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();
TRepository repository();
+21
View File
@@ -199,6 +199,27 @@ class AppModule {
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
// indices refer to that subset; the move is applied there and merged back
// into the full order (non-displayed modules keep their relative slots).
@@ -37,8 +37,12 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
);
return;
}
final epoch = SessionManager().sessionEpoch;
try {
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(
CapabilitiesState(
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
CapabilitiesState fromJson(Map<String, dynamic> json) {
+25 -5
View File
@@ -23,6 +23,9 @@ class ChatBloc
final ChatListBloc? _chatListBloc;
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 _lastKnownMessageId = 0;
bool _appResumed = true;
@@ -56,6 +59,12 @@ class ChatBloc
@override
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
Map<String, dynamic>? toStorage(ChatState state) {
final response = state.chatResponse;
@@ -123,6 +132,15 @@ class ChatBloc
/// 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)
/// 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) {
if ((innerState?.currentToken ?? '') != fromToken) return;
_chatViewActive = false;
@@ -256,23 +274,25 @@ class ChatBloc
_pollingToken = token;
_backoffMs = 0;
_lastKnownMessageId = _maxMessageId(innerState?.chatResponse);
unawaited(_pollLoop(token));
unawaited(_pollLoop(token, ++_pollGeneration));
}
void _stopLongPoll() {
_pollingToken = null;
_pollGeneration++;
_backoffMs = 0;
}
Future<void> _pollLoop(String token) async {
while (_pollingToken == token && !isClosed) {
Future<void> _pollLoop(String token, int generation) async {
bool active() => generation == _pollGeneration && !isClosed;
while (active()) {
try {
final response = await LongPollChat(
chatToken: token,
lastKnownMessageId: _lastKnownMessageId,
).run();
if (_pollingToken != token || isClosed) return;
if (!active()) return;
_backoffMs = 0;
if (response == null) continue;
@@ -296,7 +316,7 @@ class ChatBloc
_chatListBloc?.markRoomAsRead(token, _lastKnownMessageId);
}
} on Object catch (e) {
if (_pollingToken != token || isClosed) return;
if (!active()) return;
log('LongPoll error for $token: $e');
_backoffMs = _backoffMs == 0 ? 2000 : math.min(_backoffMs * 2, 30000);
await Future.delayed(Duration(milliseconds: _backoffMs));
@@ -1,11 +1,14 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:flutter_app_badge/flutter_app_badge.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/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_event.dart';
import '../repository/chat_list_repository.dart';
@@ -31,6 +34,13 @@ class ChatListBloc
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.
void setAutoRefreshInterval(Duration? interval) {
if (interval == _autoRefreshInterval) return;
@@ -72,14 +82,34 @@ class ChatListBloc
renew: renew,
onError: (e) => capturedError = e,
);
_lastRoomsJson = null;
add(DataGathered((s) => s.copyWith(rooms: rooms)));
_updateAppBadge(rooms);
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 {
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>());
Object? capturedError;
try {
@@ -87,6 +117,11 @@ class ChatListBloc
renew: renew,
onError: (e) => capturedError = e,
);
if (silent) {
if (_isUnchanged(rooms)) return;
} else {
_lastRoomsJson = null;
}
add(DataGathered((s) => s.copyWith(rooms: rooms)));
_updateAppBadge(rooms);
} catch (e) {
@@ -95,6 +130,23 @@ class ChatListBloc
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
/// demo mode. Refreshes the list so the room shows up.
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.
void applyIncomingMessage(String token, GetChatResponseObject message) {
_mutateRoom(token, (r) {
@@ -158,6 +222,7 @@ class ChatListBloc
}).toSet();
if (!changed) return;
final newRooms = GetRoomResponse(updated)..headers = rooms.headers;
_lastRoomsJson = null;
add(Emit((s) => s.copyWith(rooms: newRooms)));
_updateAppBadge(newRooms);
}
@@ -1,5 +1,6 @@
import 'package:dio/dio.dart';
import 'package:enough_icalendar/enough_icalendar.dart';
import 'package:flutter/foundation.dart';
import '../bloc/marianum_dates_state.dart';
@@ -19,6 +20,12 @@ class MarianumDatesGetEvents {
final body = response.data;
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 calendar = root is VCalendar ? root : null;
final source = calendar?.children ?? root.children;
@@ -65,8 +65,12 @@ class NextcloudCapabilitiesCubit
emit(DemoNextcloudCapabilities.state());
return;
}
final epoch = SessionManager().sessionEpoch;
try {
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(
NextcloudCapabilitiesState(
apiEnabled: caps.apiEnabled,
@@ -92,7 +96,7 @@ class NextcloudCapabilitiesCubit
}
}
void reset() => emit(const NextcloudCapabilitiesState());
Future<void> reset() async => emit(const NextcloudCapabilitiesState());
@override
NextcloudCapabilitiesState fromJson(Map<String, dynamic> json) {
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../storage/settings.dart';
@@ -10,8 +11,15 @@ import '../../../../../view/pages/settings/data/default_settings.dart';
class SettingsCubit extends HydratedCubit<Settings> {
static const _debounceTag = 'settings_persist';
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}) {
if (write) {
@@ -34,21 +42,53 @@ class SettingsCubit extends HydratedCubit<Settings> {
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() {
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) {
log('Failed to refresh settings state: $e');
}
}
Future<void> reset() async {
_silentSave?.cancel();
_silentSave = null;
_lastEmittedJson = null;
emit(DefaultSettings.get());
}
// Modules missing from a stale persisted moduleOrder are handled at read
// time by AppModule.effectiveModuleOrder (inserted at their default
// position) — no healing on hydration needed.
@override
Future<void> close() {
flushSilentSave();
return super.close();
}
@override
Settings fromJson(Map<String, dynamic> json) {
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/marianumconnect/queries/get_ticker_page/get_ticker_page_response.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
@@ -17,7 +22,35 @@ class TickerPageBloc
> {
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
String get id => slug;
@@ -48,7 +48,7 @@ class TimetableBloc
TimetableState fromNothing() {
final reference = DateTime.now().addDays(2);
return TimetableState(
startDate: _startOfWeek(reference),
startDate: reference.mondayOfWeek,
endDate: _endOfWeek(reference),
);
}
@@ -61,7 +61,7 @@ class TimetableBloc
final stored = TimetableState.fromJson(json);
final reference = DateTime.now().addDays(2);
return stored.copyWith(
startDate: _startOfWeek(reference),
startDate: reference.mondayOfWeek,
endDate: _endOfWeek(reference),
accessibleStartDate: null,
accessibleEndDate: null,
@@ -120,13 +120,15 @@ class TimetableBloc
final current = innerState ?? fromNothing();
if (current.startDate == startDate && current.endDate == endDate) return;
add(Emit((s) => s.copyWith(startDate: startDate, endDate: endDate)));
_loadCurrentWeek(startDate, endDate);
_prefetchAdjacentWeeks(startDate, endDate);
runInSession(() {
_loadCurrentWeek(startDate, endDate);
_prefetchAdjacentWeeks(startDate, endDate);
});
}
void resetWeek() {
final reference = DateTime.now().addDays(2);
changeWeek(_startOfWeek(reference), _endOfWeek(reference));
changeWeek(reference.mondayOfWeek, _endOfWeek(reference));
}
void refresh() => fetch();
@@ -171,11 +173,7 @@ class TimetableBloc
Future<void> _refreshSubjects() async {
final subjects = await repo.data.getSubjects(renew: true);
add(
DataGathered(
(s) => s.copyWith(subjects: subjects, dataVersion: s.dataVersion + 1),
),
);
add(DataGathered((s) => s.withReferenceData(subjects: subjects)));
}
Future<void> _loadCurrentWeek(
@@ -214,12 +212,11 @@ class TimetableBloc
add(
Emit(
(s) => s.copyWith(
(s) => s.withReferenceData(
rooms: rooms,
subjects: subjects,
schoolHolidays: schoolHolidays,
schoolyear: schoolyear,
dataVersion: s.dataVersion + 1,
),
),
);
@@ -229,11 +226,7 @@ class TimetableBloc
try {
final timegrid = await repo.data.getTimegrid(renew: renew);
add(
Emit(
(s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1),
),
);
add(Emit((s) => s.withReferenceData(timegrid: timegrid)));
} catch (_) {
// Timegrid load failure falls back to a hardcoded schedule in the UI layer.
}
@@ -249,12 +242,7 @@ class TimetableBloc
renew: renew,
onError: onError,
);
add(
Emit(
(s) =>
s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1),
),
);
add(Emit((s) => s.withReferenceData(customEvents: events)));
} catch (e) {
onError?.call(e);
}
@@ -262,11 +250,7 @@ class TimetableBloc
Future<void> _refreshCustomEvents() async {
final events = await repo.data.getCustomEvents(renew: true);
add(
DataGathered(
(s) => s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1),
),
);
add(DataGathered((s) => s.withReferenceData(customEvents: events)));
}
void _prefetchAdjacentWeeks(DateTime start, DateTime end) {
@@ -282,19 +266,7 @@ class TimetableBloc
}
void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) {
final key = weekStart.weekKey();
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);
add(Emit((s) => s.withFetchedWeek(weekStart, week)));
}
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_week/timetable_get_week_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.g.dart';
@@ -40,6 +42,62 @@ abstract class TimetableState with _$TimetableState {
Iterable<McTimetableEntry> getAllKnownLessons() =>
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
/// only exist for subjects that support them; requiring them elsewhere would
/// keep foreign plans loading forever.

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