Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a8fed24bb | ||
|
|
b413fe05de | ||
|
|
38a651f916 | ||
|
|
f98a6979fb | ||
|
|
3e856d0b1c | ||
|
|
8eddc8f771 | ||
|
|
27563a6dc1 | ||
|
|
5e65c4671a | ||
|
|
79b33ab248 | ||
|
|
1fd6264d10 | ||
|
|
2ca9feb49a | ||
|
|
2aed3d104c | ||
|
|
e6f74ca6fe | ||
|
|
0f6c388940 | ||
|
|
a71f4f379b | ||
|
|
84098af7e2 | ||
|
|
630497abdd | ||
|
|
2423c1a75e | ||
|
|
67c935c05b |
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
# MarianumMobile Client
|
# MarianumMobile Client
|
||||||
|
|
||||||
Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Files, Custom MHSL-Backend (Breaker, Custom Events, Push).
|
Flutter-App für die Schul-Community: Stundenplan, Ticker, Newsletter & Co. über das MarianumConnect-Backend, Nextcloud Talk + Files. Zwei Kontoarten: Schul-Konto (Schüler/Lehrer, Benutzername + Passwort) und Eltern-Konto (passwortlos per E-Mail-Code/App-Link, sieht die Stundenpläne der zugeordneten Kinder und erhält Elternbriefe, kein Talk/Files).
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -16,7 +16,10 @@ Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Fil
|
|||||||
|
|
||||||
```
|
```
|
||||||
lib/
|
lib/
|
||||||
├── api/ HTTP-Layer pro Backend (mhsl/, marianumcloud/, webuntis/, holidays/)
|
├── api/ HTTP-Layer pro Backend (marianumconnect/, marianumcloud/, mhsl/ Legacy, demo/)
|
||||||
|
├── session/ Session-Modell (CredentialSession / GuardianSession), SessionManager, SessionLifecycle
|
||||||
|
├── access/ UserRole, AccessRequirement (Gating von Modulen/Settings)
|
||||||
|
├── auth_link/ Eltern-Login: App-Link-Listener, Link-Parser, Geräte-Bindung
|
||||||
├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...)
|
├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...)
|
||||||
├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine
|
├── state/app/infrastructure LoadableState<T>, DataLoader, geteilte BLoC-Bausteine
|
||||||
├── view/ Screens
|
├── view/ Screens
|
||||||
@@ -51,6 +54,16 @@ lib/
|
|||||||
|
|
||||||
**Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc.
|
**Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc.
|
||||||
|
|
||||||
|
**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; 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.
|
||||||
|
|
||||||
|
**Stundenplan:** Ein `TimetableBloc(subject: …)` für alle Fälle (eigener Plan, Fremdplan, Kind). Den globalen Bloc stellt `PrimaryTimetableScope` bereit und tauscht ihn beim Kindwechsel aus; page-scoped Fremdpläne nutzen `ScopedTimetableBloc`. Die Kinderauswahl (`ChildSelectionCubit`) ist modulübergreifend.
|
||||||
|
|
||||||
|
**Elternbriefe:** Modul `parentLetters` (nur Eltern-Sessions, `AccessRequirement.guardian`): Lehrer-Mitteilungen aus MarianumConnect mit Gelesen-Status, Kenntnisnahme/Auswahl/Unterschrift pro Kind, Thread und Anhängen. Der Posteingang (`ParentLettersBloc`) ist global (Modul-Badge, Push-Refresh), ein Brief ist page-scoped (`ParentLetterBloc`). Ob und wie geantwortet werden darf, entscheidet der Server (`editable`); `ParentLetterFormPolicy.resolve` macht daraus das Formular. Push: Connect-Direct-Push `type: parent-letter`, Tap-Routing allein über `parentLetterId`. Alle Benachrichtigungs-Taps (lokal gerendert wie FCM) werden von `resolvePushTarget` (`lib/push/push_target.dart`) aufgelöst und in `NotificationTasks.openPushTarget` navigiert – neue Push-Ziele nur dort ergänzen.
|
||||||
|
|
||||||
## Build / Run
|
## Build / Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -65,13 +78,12 @@ flutter test # Tests (siehe test
|
|||||||
|
|
||||||
| Backend | Pfad | Zweck |
|
| Backend | Pfad | Zweck |
|
||||||
|---------------------------|-----------------------|----------------------------------------|
|
|---------------------------|-----------------------|----------------------------------------|
|
||||||
| Webuntis | `lib/api/webuntis/` | Stundenplan, Klassen, Räume, Lehrer |
|
| MarianumConnect (Bearer) | `lib/api/marianumconnect/` | Auth, Stundenplan (Webuntis-Proxy), Ticker, Newsletter, Ferien, Abwesenheit, Elternbriefe, Capabilities, Push |
|
||||||
| Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung |
|
| Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung |
|
||||||
| Custom MHSL-Server | `lib/api/mhsl/` | Breaker, Custom Events, Notify, Noten |
|
| MHSL (Legacy) | `lib/api/mhsl/` | nur noch Einmal-Migration der Custom Events |
|
||||||
| Holiday-Calendar | `lib/api/holidays/` | Ferien |
|
|
||||||
|
|
||||||
`nextcloud`-Paket ist auf einen Custom-Fork gepinnt (siehe `pubspec.yaml` `dependency_overrides`).
|
`nextcloud`-Paket ist auf einen Custom-Fork gepinnt (siehe `pubspec.yaml` `dependency_overrides`).
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
`test/` deckt aktuell nur Kern-Funktionen ab (DateTime-Extensions, AsyncActionController, LessonResolver). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu.
|
`test/` deckt vor allem pure Funktionen ab (DateTime-Extensions, Stundenplan-Logik, Session-Codec, Policies, Eltern-Login-Controller). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu.
|
||||||
@@ -41,6 +41,23 @@
|
|||||||
<data android:mimeType="video/*" />
|
<data android:mimeType="video/*" />
|
||||||
<data android:mimeType="application/*" />
|
<data android:mimeType="application/*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- Guardian login mail link (App Link). Verified via
|
||||||
|
/.well-known/assetlinks.json on both hosts. -->
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data android:scheme="https" />
|
||||||
|
<data android:host="connect.marianum-fulda.de" />
|
||||||
|
<data android:host="connect-beta.marianum-fulda.de" />
|
||||||
|
<data android:path="/app/guardian-login" />
|
||||||
|
</intent-filter>
|
||||||
|
<!-- The app routes links itself (GuardianLinkListener); Flutter's
|
||||||
|
built-in handling would push the path as a named route onto a
|
||||||
|
MaterialApp that only has `home`. -->
|
||||||
|
<meta-data
|
||||||
|
android:name="flutter_deeplinking_enabled"
|
||||||
|
android:value="false" />
|
||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
@@ -81,6 +98,7 @@
|
|||||||
the home_widget Flutter plugin resolves them as <app-package>.<name>. -->
|
the home_widget Flutter plugin resolves them as <app-package>.<name>. -->
|
||||||
<receiver
|
<receiver
|
||||||
android:name="eu.mhsl.marianum.mobile.client.TimetableDayWidget"
|
android:name="eu.mhsl.marianum.mobile.client.TimetableDayWidget"
|
||||||
|
android:enabled="@bool/widget_supported"
|
||||||
android:label="@string/widget_day_label"
|
android:label="@string/widget_day_label"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -93,6 +111,7 @@
|
|||||||
|
|
||||||
<receiver
|
<receiver
|
||||||
android:name="eu.mhsl.marianum.mobile.client.TimetableWeekWidget"
|
android:name="eu.mhsl.marianum.mobile.client.TimetableWeekWidget"
|
||||||
|
android:enabled="@bool/widget_supported"
|
||||||
android:label="@string/widget_week_label"
|
android:label="@string/widget_week_label"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import java.util.TimeZone
|
|||||||
// Mirror of lib/widget_data/widget_data.dart — JSON keys + enum names
|
// Mirror of lib/widget_data/widget_data.dart — JSON keys + enum names
|
||||||
// must stay in sync.
|
// must stay in sync.
|
||||||
enum class WidgetLessonStatus {
|
enum class WidgetLessonStatus {
|
||||||
REGULAR, ONGOING, PAST, CANCELLED, IRREGULAR, TEACHER_CHANGED, EVENT;
|
REGULAR, ONGOING, PAST, CANCELLED, IRREGULAR, TEACHER_CHANGED, EVENT, DUTY;
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromWire(raw: String?): WidgetLessonStatus = when (raw) {
|
fun fromWire(raw: String?): WidgetLessonStatus = when (raw) {
|
||||||
@@ -22,6 +22,7 @@ enum class WidgetLessonStatus {
|
|||||||
"irregular" -> IRREGULAR
|
"irregular" -> IRREGULAR
|
||||||
"teacherChanged" -> TEACHER_CHANGED
|
"teacherChanged" -> TEACHER_CHANGED
|
||||||
"event" -> EVENT
|
"event" -> EVENT
|
||||||
|
"duty" -> DUTY
|
||||||
else -> REGULAR
|
else -> REGULAR
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,6 +40,9 @@ data class WidgetLesson(
|
|||||||
val originalTeacher: String?,
|
val originalTeacher: String?,
|
||||||
val status: WidgetLessonStatus,
|
val status: WidgetLessonStatus,
|
||||||
val customColor: String?,
|
val customColor: String?,
|
||||||
|
// Tile colour resolved in Dart (subject colours, palettes); null only for
|
||||||
|
// payloads written before the field existed.
|
||||||
|
val color: Int?,
|
||||||
val siblingCount: Int,
|
val siblingCount: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,6 +54,12 @@ data class WidgetPeriod(
|
|||||||
val virtualEndMinutes: Int,
|
val virtualEndMinutes: Int,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class WidgetDayInfo(
|
||||||
|
val date: Date,
|
||||||
|
val isHoliday: Boolean,
|
||||||
|
val holidayName: String?,
|
||||||
|
)
|
||||||
|
|
||||||
data class WidgetTimetableData(
|
data class WidgetTimetableData(
|
||||||
val fetchedAt: Date,
|
val fetchedAt: Date,
|
||||||
val anchorDate: Date,
|
val anchorDate: Date,
|
||||||
@@ -57,6 +67,8 @@ data class WidgetTimetableData(
|
|||||||
val periods: List<WidgetPeriod>,
|
val periods: List<WidgetPeriod>,
|
||||||
val isHoliday: Boolean,
|
val isHoliday: Boolean,
|
||||||
val holidayName: String?,
|
val holidayName: String?,
|
||||||
|
// Week payload only; empty for the day payload.
|
||||||
|
val days: List<WidgetDayInfo>,
|
||||||
)
|
)
|
||||||
|
|
||||||
object WidgetDataParser {
|
object WidgetDataParser {
|
||||||
@@ -81,6 +93,11 @@ object WidgetDataParser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parseColor(raw: String?): Int? {
|
||||||
|
if (raw == null || !Regex("#[0-9A-Fa-f]{6}").matches(raw)) return null
|
||||||
|
return 0xFF000000.toInt() or raw.substring(1).toInt(16)
|
||||||
|
}
|
||||||
|
|
||||||
fun parse(json: String?): WidgetTimetableData? {
|
fun parse(json: String?): WidgetTimetableData? {
|
||||||
if (json.isNullOrEmpty()) return null
|
if (json.isNullOrEmpty()) return null
|
||||||
return try {
|
return try {
|
||||||
@@ -102,6 +119,7 @@ object WidgetDataParser {
|
|||||||
originalTeacher = obj.stringOrNull("originalTeacher"),
|
originalTeacher = obj.stringOrNull("originalTeacher"),
|
||||||
status = WidgetLessonStatus.fromWire(obj.stringOrNull("status")),
|
status = WidgetLessonStatus.fromWire(obj.stringOrNull("status")),
|
||||||
customColor = obj.stringOrNull("customColor"),
|
customColor = obj.stringOrNull("customColor"),
|
||||||
|
color = parseColor(obj.stringOrNull("color")),
|
||||||
siblingCount = obj.optInt("siblingCount", 0),
|
siblingCount = obj.optInt("siblingCount", 0),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -120,6 +138,18 @@ object WidgetDataParser {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val daysArray = root.optJSONArray("days")
|
||||||
|
val days = mutableListOf<WidgetDayInfo>()
|
||||||
|
if (daysArray != null) {
|
||||||
|
for (i in 0 until daysArray.length()) {
|
||||||
|
val obj = daysArray.optJSONObject(i) ?: continue
|
||||||
|
days += WidgetDayInfo(
|
||||||
|
date = parseDate(obj.stringOrNull("date")) ?: continue,
|
||||||
|
isHoliday = obj.optBoolean("isHoliday", false),
|
||||||
|
holidayName = obj.stringOrNull("holidayName"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
WidgetTimetableData(
|
WidgetTimetableData(
|
||||||
fetchedAt = parseDate(root.stringOrNull("fetchedAt")) ?: Date(),
|
fetchedAt = parseDate(root.stringOrNull("fetchedAt")) ?: Date(),
|
||||||
anchorDate = parseDate(root.stringOrNull("anchorDate")) ?: Date(),
|
anchorDate = parseDate(root.stringOrNull("anchorDate")) ?: Date(),
|
||||||
@@ -127,6 +157,7 @@ object WidgetDataParser {
|
|||||||
periods = periods,
|
periods = periods,
|
||||||
isHoliday = root.optBoolean("isHoliday", false),
|
isHoliday = root.optBoolean("isHoliday", false),
|
||||||
holidayName = root.stringOrNull("holidayName"),
|
holidayName = root.stringOrNull("holidayName"),
|
||||||
|
days = days,
|
||||||
)
|
)
|
||||||
} catch (_: JSONException) {
|
} catch (_: JSONException) {
|
||||||
null
|
null
|
||||||
|
|||||||
+590
-331
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Material Icons "coffee_outlined", matches the in-app period ruler. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M18.52 3H6C4.92 3 3.98 3.89 3.98 5.02V10.69C3.98 14.53 6.94 17.91 10.78 18C14.72 18.14 18 14.95 18 11.02V9.98H18.52C20.44 9.98 21.98 8.44 21.98 6.52C21.98 4.55 20.44 3 18.52 3ZM15.98 5.02V8.02H6V5.02H15.98ZM15.98 9.98V11.02C15.98 13.78 13.78 15.98 11.02 15.98C8.25 15.98 6 13.78 6 11.02V9.98ZM18.52 8.02H18V5.02H18.52C19.31 5.02 20.02 5.67 20.02 6.52C20.02 7.31 19.31 8.02 18.52 8.02ZM3.98 18.98H20.02V21H3.98V18.98Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Material Icons "event_outlined", matches the in-app holiday region. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M18.98 3.98H18V2.02H15.98V3.98H8.02V2.02H6V3.98H5.02C3.89 3.98 3 4.92 3 6V20.02C3 21.09 3.89 21.98 5.02 21.98H18.98C20.11 21.98 21 21.09 21 20.02V6C21 4.92 20.11 3.98 18.98 3.98ZM18.98 20.02H5.02V9.98H18.98V20.02ZM18.98 8.02H5.02V6H18.98V8.02ZM12 12.98H17.02V18H12V12.98Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Material Icons "restaurant", matches the in-app break region. -->
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M11.02 9H9V2.02H6.98V9H5.02V2.02H3V9C3 11.11 4.64 12.84 6.75 12.98V21.98H9.23V12.98C11.34 12.84 12.98 11.11 12.98 9V2.02H11.02V9ZM15.98 6V14.02H18.52V21.98H21V2.02C18.23 2.02 15.98 4.22 15.98 6Z" />
|
||||||
|
</vector>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Tinted at runtime with the tile colour resolved in Dart. -->
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<corners android:radius="7dp" />
|
||||||
|
<solid android:color="@android:color/white" />
|
||||||
|
</shape>
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<item>
|
|
||||||
<shape android:shape="rectangle">
|
|
||||||
<corners android:radius="6dp" />
|
|
||||||
<solid android:color="@color/widget_lesson_cancelled" />
|
|
||||||
<stroke android:width="1.5dp" android:color="#C8FF0000" />
|
|
||||||
</shape>
|
|
||||||
</item>
|
|
||||||
<item
|
|
||||||
android:left="3dp"
|
|
||||||
android:top="3dp"
|
|
||||||
android:right="3dp"
|
|
||||||
android:bottom="3dp"
|
|
||||||
android:drawable="@drawable/widget_lesson_cancelled_x" />
|
|
||||||
</layer-list>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:shape="rectangle">
|
|
||||||
<corners android:radius="6dp" />
|
|
||||||
<solid android:color="#FF2196F3" />
|
|
||||||
</shape>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:shape="rectangle">
|
|
||||||
<corners android:radius="6dp" />
|
|
||||||
<solid android:color="#FF4CAF50" />
|
|
||||||
</shape>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:shape="rectangle">
|
|
||||||
<corners android:radius="6dp" />
|
|
||||||
<solid android:color="#FFEF6C00" />
|
|
||||||
</shape>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:shape="rectangle">
|
|
||||||
<corners android:radius="6dp" />
|
|
||||||
<solid android:color="@color/widget_lesson_irregular" />
|
|
||||||
</shape>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:shape="rectangle">
|
|
||||||
<corners android:radius="6dp" />
|
|
||||||
<solid android:color="@color/widget_lesson_ongoing" />
|
|
||||||
</shape>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:shape="rectangle">
|
|
||||||
<corners android:radius="6dp" />
|
|
||||||
<solid android:color="@color/widget_lesson_past" />
|
|
||||||
</shape>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:shape="rectangle">
|
|
||||||
<corners android:radius="6dp" />
|
|
||||||
<solid android:color="@color/widget_lesson_regular" />
|
|
||||||
</shape>
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:shape="rectangle">
|
|
||||||
<corners android:radius="6dp" />
|
|
||||||
<solid android:color="@color/widget_lesson_teacher_changed" />
|
|
||||||
</shape>
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item>
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<corners android:radius="7dp" />
|
||||||
|
<solid android:color="@android:color/transparent" />
|
||||||
|
<stroke android:width="2dp" android:color="#C8F44336" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<item
|
||||||
|
android:left="2dp"
|
||||||
|
android:top="2dp"
|
||||||
|
android:right="2dp"
|
||||||
|
android:bottom="2dp"
|
||||||
|
android:drawable="@drawable/widget_lesson_cancelled_x" />
|
||||||
|
</layer-list>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
android:viewportWidth="100"
|
android:viewportWidth="100"
|
||||||
android:viewportHeight="100">
|
android:viewportHeight="100">
|
||||||
<path
|
<path
|
||||||
android:strokeColor="#C8FF0000"
|
android:strokeColor="#C8F44336"
|
||||||
android:strokeWidth="5"
|
android:strokeWidth="2.5"
|
||||||
android:pathData="M0,0 L100,100 M100,0 L0,100" />
|
android:pathData="M0,0 L100,100 M100,0 L0,100" />
|
||||||
</vector>
|
</vector>
|
||||||
+2
-3
@@ -1,6 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:shape="rectangle">
|
android:shape="oval">
|
||||||
<corners android:radius="6dp" />
|
<solid android:color="@android:color/white" />
|
||||||
<solid android:color="#FF993333" />
|
|
||||||
</shape>
|
</shape>
|
||||||
@@ -3,6 +3,14 @@
|
|||||||
android:id="@+id/widget_break_block_root"
|
android:id="@+id/widget_break_block_root"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="14dp"
|
android:layout_height="14dp"
|
||||||
android:layout_marginTop="0dp"
|
android:layout_marginTop="0dp">
|
||||||
android:layout_marginStart="2dp"
|
|
||||||
android:layout_marginEnd="2dp" />
|
<ImageView
|
||||||
|
android:id="@+id/widget_break_block_icon"
|
||||||
|
android:layout_width="12dp"
|
||||||
|
android:layout_height="12dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:src="@drawable/widget_icon_restaurant" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/widget_break_label_root"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="14dp"
|
||||||
|
android:layout_marginTop="0dp">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/widget_break_label_icon"
|
||||||
|
android:layout_width="10dp"
|
||||||
|
android:layout_height="10dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:src="@drawable/widget_icon_coffee" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- FrameLayout root so the Marianum watermark can sit at bottom|end behind
|
<!-- FrameLayout root so the Marianum watermark can sit at bottom|end behind
|
||||||
the actual widget content without disturbing the LinearLayout flow. -->
|
the actual widget content without disturbing the LinearLayout flow.
|
||||||
|
Text sizes and chrome heights are dp: WidgetRenderer derives the grid
|
||||||
|
height from them, so they must not follow the system font scale. -->
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/widget_root"
|
android:id="@+id/widget_root"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="@drawable/app_widget_background">
|
android:background="@drawable/app_widget_background"
|
||||||
|
tools:ignore="SpUsage,SmallSp">
|
||||||
|
|
||||||
<!-- Bottom-leading so the mark looks like it's peeking out of the lower
|
<!-- Width/height/end+bottom margins are overridden in the renderer so
|
||||||
left corner. Width/height/start+bottom margins are overridden in the
|
the mark scales with the widget size. -->
|
||||||
renderer so the mark scales with the widget size. -->
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/widget_watermark"
|
android:id="@+id/widget_watermark"
|
||||||
android:layout_width="160dp"
|
android:layout_width="160dp"
|
||||||
@@ -26,43 +28,68 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="10dp">
|
android:paddingStart="8dp"
|
||||||
|
android:paddingEnd="8dp"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="20dp"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center_vertical">
|
android:gravity="center_vertical">
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_day_title"
|
android:id="@+id/widget_day_title"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:textSize="14sp"
|
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:textColor="@color/widget_text_primary"
|
android:textColor="@color/widget_text_primary"
|
||||||
android:singleLine="true"
|
android:maxLines="1"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
|
android:gravity="start|center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:autoSizeTextType="uniform"
|
||||||
|
android:autoSizeMinTextSize="9dp"
|
||||||
|
android:autoSizeMaxTextSize="14dp"
|
||||||
|
android:autoSizeStepGranularity="0.5dp"
|
||||||
tools:text="Heute · 08.05." />
|
tools:text="Heute · 08.05." />
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_day_subtitle"
|
android:id="@+id/widget_day_subtitle"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textSize="10sp"
|
android:layout_marginStart="6dp"
|
||||||
|
android:textSize="10dp"
|
||||||
android:textColor="@color/widget_text_secondary"
|
android:textColor="@color/widget_text_secondary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
tools:text="Stand: 14:32" />
|
tools:text="Stand: 14:32" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Narrow day widgets can't fit title and freshness side by side —
|
||||||
|
the renderer moves the freshness label down here instead. -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_day_subtitle_below"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="12dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:gravity="start|center_vertical"
|
||||||
|
android:textSize="9dp"
|
||||||
|
android:textColor="@color/widget_text_secondary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
tools:text="Stand: 14:32" />
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_marginTop="4dp"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal">
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/widget_day_time_labels"
|
android:id="@+id/widget_day_time_labels"
|
||||||
android:layout_width="32dp"
|
android:layout_width="30dp"
|
||||||
android:layout_height="match_parent" />
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Static picker preview of the day widget, laid out like WidgetRenderer's output. -->
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="@color/widget_background">
|
android:background="@color/widget_background"
|
||||||
|
tools:ignore="SpUsage,SmallSp,HardcodedText,UseAppTint">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="160dp"
|
android:layout_width="160dp"
|
||||||
@@ -20,164 +23,126 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="10dp">
|
android:paddingStart="8dp"
|
||||||
|
android:paddingEnd="8dp"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
<LinearLayout
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="20dp"
|
||||||
android:orientation="horizontal"
|
android:gravity="start|center_vertical"
|
||||||
android:gravity="center_vertical">
|
android:includeFontPadding="false"
|
||||||
<TextView
|
android:textSize="14dp"
|
||||||
android:layout_width="0dp"
|
android:textStyle="bold"
|
||||||
android:layout_height="wrap_content"
|
android:textColor="@color/widget_text_primary"
|
||||||
android:layout_weight="1"
|
android:maxLines="1"
|
||||||
android:textSize="14sp"
|
android:text="Heute · 06.05." />
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="@color/widget_text_primary"
|
|
||||||
android:singleLine="true"
|
|
||||||
android:text="Heute · 06.05." />
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="10sp"
|
|
||||||
android:textColor="@color/widget_text_secondary"
|
|
||||||
android:text="Stand: 07:50" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:orientation="horizontal"
|
android:layout_marginTop="4dp"
|
||||||
android:layout_marginTop="6dp">
|
android:orientation="horizontal">
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout android:layout_width="30dp" android:layout_height="match_parent">
|
||||||
android:layout_width="32dp"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
|
||||||
android:layout_height="match_parent">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="0dp">
|
||||||
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="07:55" />
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
|
<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" />
|
||||||
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
|
<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" />
|
||||||
android:layout_marginTop="22dp">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
|
||||||
android:textColor="@color/widget_text_primary" android:text="1." />
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="07:55" />
|
|
||||||
</LinearLayout>
|
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
|
|
||||||
android:layout_marginTop="50dp">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
|
||||||
android:textColor="@color/widget_text_primary" android:text="2." />
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="08:40" />
|
|
||||||
</LinearLayout>
|
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
|
|
||||||
android:layout_marginTop="79dp">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
|
||||||
android:textColor="@color/widget_text_primary" android:text="3." />
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="09:30" />
|
|
||||||
</LinearLayout>
|
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
|
|
||||||
android:layout_marginTop="115dp">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
|
||||||
android:textColor="@color/widget_text_primary" android:text="4." />
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="10:35" />
|
|
||||||
</LinearLayout>
|
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
|
|
||||||
android:layout_marginTop="142dp">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
|
||||||
android:textColor="@color/widget_text_primary" android:text="5." />
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="11:25" />
|
|
||||||
</LinearLayout>
|
|
||||||
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical" android:gravity="end" android:paddingEnd="3dp"
|
|
||||||
android:layout_marginTop="169dp">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
|
||||||
android:textColor="@color/widget_text_primary" android:text="6." />
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="7sp" android:textColor="@color/widget_text_secondary" android:text="12:15" />
|
|
||||||
</LinearLayout>
|
|
||||||
</FrameLayout>
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="match_parent"
|
|
||||||
android:layout_weight="1">
|
|
||||||
|
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="29dp" android:background="@color/widget_divider" />
|
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="56dp" android:background="@color/widget_divider" />
|
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="83dp" android:background="@color/widget_divider" />
|
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="115dp" android:background="@color/widget_divider" />
|
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="143dp" android:background="@color/widget_divider" />
|
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="170dp" android:background="@color/widget_divider" />
|
|
||||||
|
|
||||||
<FrameLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="22dp"
|
|
||||||
android:layout_marginTop="29dp"
|
|
||||||
android:layout_marginStart="2dp"
|
|
||||||
android:layout_marginEnd="2dp"
|
|
||||||
android:padding="3dp"
|
|
||||||
android:background="@drawable/widget_lesson_block_regular">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
|
||||||
android:textColor="@android:color/white" android:text="MA-LK" />
|
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
<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
|
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
||||||
android:layout_width="match_parent"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
|
||||||
android:layout_height="55dp"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
|
||||||
android:layout_marginTop="83dp"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
|
||||||
android:layout_marginStart="2dp"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
|
||||||
android:layout_marginEnd="2dp"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
|
||||||
android:padding="3dp"
|
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
|
||||||
android:background="@drawable/widget_lesson_block_regular">
|
</FrameLayout>
|
||||||
<LinearLayout android:layout_width="wrap_content" android:layout_height="wrap_content"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
|
||||||
android:orientation="vertical">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
|
||||||
android:textColor="@android:color/white" android:text="DE-GK" />
|
<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">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
android:textSize="9sp" android:textColor="#CCFFFFFF" android:text="B11" />
|
<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>
|
</LinearLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
|
||||||
<FrameLayout
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
android:layout_width="match_parent"
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="DE" />
|
||||||
android:layout_height="22dp"
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A204" />
|
||||||
android:layout_marginTop="143dp"
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SCHN" />
|
||||||
android:layout_marginStart="2dp"
|
</LinearLayout>
|
||||||
android:layout_marginEnd="2dp"
|
|
||||||
android:padding="3dp"
|
|
||||||
android:background="@drawable/widget_lesson_block_cancelled">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
|
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
|
||||||
android:textColor="@android:color/white" android:text="BIO" />
|
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#0097A7">
|
||||||
<FrameLayout
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
android:layout_width="match_parent"
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EN" />
|
||||||
android:layout_height="22dp"
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="F2.04" />
|
||||||
android:layout_marginTop="170dp"
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="KRA" />
|
||||||
android:layout_marginStart="2dp"
|
</LinearLayout>
|
||||||
android:layout_marginEnd="2dp"
|
</FrameLayout>
|
||||||
android:padding="3dp"
|
<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">
|
||||||
android:background="@drawable/widget_lesson_block_teacher_changed">
|
<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"
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="BIO" />
|
||||||
android:textSize="11sp" android:textStyle="bold"
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N103" />
|
||||||
android: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="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>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Mirrors the in-app holiday region: icon and "Schulfrei" on top, the
|
||||||
|
holiday name rotated along the column. The name's width is set by the
|
||||||
|
renderer — after the rotation it is the length along the column. -->
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
android:id="@+id/widget_holiday_root"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
tools:ignore="SpUsage,SmallSp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:gravity="center_horizontal">
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/widget_holiday_icon"
|
||||||
|
android:layout_width="14dp"
|
||||||
|
android:layout_height="14dp"
|
||||||
|
android:importantForAccessibility="no"
|
||||||
|
android:contentDescription="@null"
|
||||||
|
android:src="@drawable/widget_icon_event" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_holiday_label"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="12dp"
|
||||||
|
android:layout_marginTop="2dp"
|
||||||
|
android:paddingStart="2dp"
|
||||||
|
android:paddingEnd="2dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="@string/widget_school_free"
|
||||||
|
android:textColor="@color/widget_text_primary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:autoSizeTextType="uniform"
|
||||||
|
android:autoSizeMinTextSize="6dp"
|
||||||
|
android:autoSizeMaxTextSize="9dp"
|
||||||
|
android:autoSizeStepGranularity="0.5dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_holiday_name"
|
||||||
|
android:layout_width="120dp"
|
||||||
|
android:layout_height="14dp"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:rotation="90"
|
||||||
|
android:gravity="center"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:letterSpacing="0.1"
|
||||||
|
android:textColor="@color/widget_text_primary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:autoSizeTextType="uniform"
|
||||||
|
android:autoSizeMinTextSize="7dp"
|
||||||
|
android:autoSizeMaxTextSize="11dp"
|
||||||
|
android:autoSizeStepGranularity="0.5dp"
|
||||||
|
tools:text="Herbstferien" />
|
||||||
|
</FrameLayout>
|
||||||
@@ -1,90 +1,113 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Mirrors AppointmentTile: title on top, room and teacher stacked below,
|
||||||
|
each line shrinking on its own to fit the tile width. Text sizes are dp
|
||||||
|
(not sp) because the line boxes are sized in dp by the renderer. -->
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/widget_lesson_block_root"
|
android:id="@+id/widget_lesson_block_root"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="40dp"
|
android:layout_height="40dp"
|
||||||
android:layout_marginStart="2dp"
|
android:layout_marginStart="1dp"
|
||||||
android:layout_marginEnd="2dp"
|
android:layout_marginEnd="1dp"
|
||||||
android:layout_marginTop="0dp"
|
android:layout_marginTop="0dp"
|
||||||
android:paddingStart="7dp"
|
android:background="@drawable/widget_lesson_block"
|
||||||
android:paddingEnd="7dp"
|
tools:ignore="SpUsage,SmallSp">
|
||||||
android:paddingTop="3dp"
|
|
||||||
android:paddingBottom="3dp"
|
|
||||||
android:background="@drawable/widget_lesson_block_regular">
|
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="horizontal"
|
android:orientation="vertical"
|
||||||
android:gravity="top"
|
android:paddingStart="4dp"
|
||||||
android:baselineAligned="false">
|
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
|
<TextView
|
||||||
android:id="@+id/widget_lesson_subject"
|
android:id="@+id/widget_lesson_room"
|
||||||
android:layout_width="0dp"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="10dp"
|
||||||
android:layout_weight="1"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="@android:color/white"
|
android:textColor="@android:color/white"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
android:gravity="top|start"
|
android:gravity="start|center_vertical"
|
||||||
android:includeFontPadding="false"
|
android:includeFontPadding="false"
|
||||||
android:autoSizeTextType="uniform"
|
android:autoSizeTextType="uniform"
|
||||||
android:autoSizeMinTextSize="5sp"
|
android:autoSizeMinTextSize="5.5dp"
|
||||||
android:autoSizeMaxTextSize="11sp"
|
android:autoSizeMaxTextSize="10dp"
|
||||||
android:autoSizeStepGranularity="1sp"
|
android:autoSizeStepGranularity="0.5dp"
|
||||||
tools:text="MA-LK" />
|
tools:text="A12" />
|
||||||
|
|
||||||
<LinearLayout
|
<TextView
|
||||||
android:id="@+id/widget_lesson_secondary_stack"
|
android:id="@+id/widget_lesson_teacher"
|
||||||
android:layout_width="0dp"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="10dp"
|
||||||
android:layout_weight="1"
|
android:textColor="@android:color/white"
|
||||||
android:orientation="vertical"
|
android:maxLines="1"
|
||||||
android:gravity="top|end"
|
android:ellipsize="end"
|
||||||
android:layout_marginStart="4dp">
|
android:gravity="start|center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
<TextView
|
android:autoSizeTextType="uniform"
|
||||||
android:id="@+id/widget_lesson_room"
|
android:autoSizeMinTextSize="5.5dp"
|
||||||
android:layout_width="wrap_content"
|
android:autoSizeMaxTextSize="10dp"
|
||||||
android:layout_height="12dp"
|
android:autoSizeStepGranularity="0.5dp"
|
||||||
android:textColor="#CCFFFFFF"
|
tools:text="Müller" />
|
||||||
android:maxLines="1"
|
|
||||||
android:ellipsize="end"
|
|
||||||
android:gravity="end|top"
|
|
||||||
android:includeFontPadding="false"
|
|
||||||
android:autoSizeTextType="uniform"
|
|
||||||
android:autoSizeMinTextSize="5sp"
|
|
||||||
android:autoSizeMaxTextSize="11sp"
|
|
||||||
android:autoSizeStepGranularity="1sp"
|
|
||||||
tools:text="A12" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/widget_lesson_teacher"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="12dp"
|
|
||||||
android:textColor="#B3FFFFFF"
|
|
||||||
android:maxLines="1"
|
|
||||||
android:ellipsize="end"
|
|
||||||
android:gravity="end|top"
|
|
||||||
android:includeFontPadding="false"
|
|
||||||
android:autoSizeTextType="uniform"
|
|
||||||
android:autoSizeMinTextSize="5sp"
|
|
||||||
android:autoSizeMaxTextSize="11sp"
|
|
||||||
android:autoSizeStepGranularity="1sp"
|
|
||||||
tools:text="Müller" />
|
|
||||||
</LinearLayout>
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
<FrameLayout
|
||||||
|
android:id="@+id/widget_lesson_cancelled"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@drawable/widget_lesson_cancelled_overlay"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_lesson_sibling_badge"
|
android:id="@+id/widget_lesson_sibling_badge"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_gravity="bottom|start"
|
android:layout_gravity="bottom|end"
|
||||||
|
android:layout_marginEnd="4dp"
|
||||||
|
android:layout_marginBottom="2dp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:textSize="12sp"
|
android:textSize="9dp"
|
||||||
android:textColor="@android:color/white"
|
android:textColor="@android:color/white"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:includeFontPadding="false"
|
android:includeFontPadding="false"
|
||||||
|
|||||||
@@ -1,31 +1,50 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<!-- One period cell of the ruler, mirroring the in-app _PeriodLabel: number
|
||||||
|
centred, start time above, end time below. -->
|
||||||
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/widget_time_label_root"
|
android:id="@+id/widget_time_label_root"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="36dp"
|
||||||
android:layout_marginTop="0dp"
|
android:layout_marginTop="0dp"
|
||||||
android:orientation="vertical"
|
tools:ignore="SpUsage,SmallSp">
|
||||||
android:gravity="end"
|
|
||||||
android:paddingEnd="3dp">
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_time_label_time"
|
android:id="@+id/widget_time_label_start"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textSize="9sp"
|
android:layout_gravity="top"
|
||||||
android:textColor="@color/widget_text_primary"
|
android:layout_marginTop="2dp"
|
||||||
android:singleLine="true"
|
android:gravity="center_horizontal"
|
||||||
android:lineSpacingExtra="-2dp"
|
android:textSize="7dp"
|
||||||
|
android:textColor="@color/widget_text_secondary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
tools:text="07:55" />
|
tools:text="07:55" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_time_label_number"
|
android:id="@+id/widget_time_label_number"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textSize="7sp"
|
android:layout_gravity="center_vertical"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:fontFamily="sans-serif-medium"
|
||||||
|
android:textSize="11dp"
|
||||||
|
android:textColor="@color/widget_text_primary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
tools:text="1" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/widget_time_label_end"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="bottom"
|
||||||
|
android:layout_marginBottom="2dp"
|
||||||
|
android:gravity="center_horizontal"
|
||||||
|
android:textSize="7dp"
|
||||||
android:textColor="@color/widget_text_secondary"
|
android:textColor="@color/widget_text_secondary"
|
||||||
android:singleLine="true"
|
android:maxLines="1"
|
||||||
android:lineSpacingExtra="-2dp"
|
android:includeFontPadding="false"
|
||||||
tools:text="1." />
|
tools:text="08:40" />
|
||||||
</LinearLayout>
|
</FrameLayout>
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- FrameLayout root so the Marianum watermark can sit at bottom|end behind
|
||||||
|
the actual widget content without disturbing the LinearLayout flow.
|
||||||
|
Text sizes and chrome heights are dp: WidgetRenderer derives the grid
|
||||||
|
height from them, so they must not follow the system font scale. -->
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/widget_root"
|
android:id="@+id/widget_root"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="@drawable/app_widget_background">
|
android:background="@drawable/app_widget_background"
|
||||||
|
tools:ignore="SpUsage,SmallSp">
|
||||||
|
|
||||||
|
<!-- Width/height/end+bottom margins are overridden in the renderer so
|
||||||
|
the mark scales with the widget size. -->
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/widget_watermark"
|
android:id="@+id/widget_watermark"
|
||||||
android:layout_width="160dp"
|
android:layout_width="160dp"
|
||||||
@@ -21,42 +28,53 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="10dp">
|
android:paddingStart="8dp"
|
||||||
|
android:paddingEnd="8dp"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="20dp"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:gravity="center_vertical">
|
android:gravity="center_vertical">
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_week_title"
|
android:id="@+id/widget_week_title"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:textSize="14sp"
|
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:textColor="@color/widget_text_primary"
|
android:textColor="@color/widget_text_primary"
|
||||||
android:singleLine="true"
|
android:maxLines="1"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
|
android:gravity="start|center_vertical"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
android:autoSizeTextType="uniform"
|
||||||
|
android:autoSizeMinTextSize="9dp"
|
||||||
|
android:autoSizeMaxTextSize="14dp"
|
||||||
|
android:autoSizeStepGranularity="0.5dp"
|
||||||
tools:text="KW 19 · 06.05.–10.05." />
|
tools:text="KW 19 · 06.05.–10.05." />
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_week_subtitle"
|
android:id="@+id/widget_week_subtitle"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textSize="10sp"
|
android:layout_marginStart="6dp"
|
||||||
|
android:textSize="10dp"
|
||||||
android:textColor="@color/widget_text_secondary"
|
android:textColor="@color/widget_text_secondary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
tools:text="Stand: 14:32" />
|
tools:text="Stand: 14:32" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="32dp"
|
||||||
android:orientation="horizontal"
|
android:layout_marginTop="4dp"
|
||||||
android:layout_marginTop="6dp">
|
android:orientation="horizontal">
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:layout_width="28dp"
|
android:layout_width="30dp"
|
||||||
android:layout_height="wrap_content" />
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:layout_width="1dp"
|
android:layout_width="1dp"
|
||||||
@@ -66,7 +84,7 @@
|
|||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/widget_week_header_mon"
|
android:id="@+id/widget_week_header_mon"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
@@ -77,7 +95,7 @@
|
|||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/widget_week_header_tue"
|
android:id="@+id/widget_week_header_tue"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
@@ -88,7 +106,7 @@
|
|||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/widget_week_header_wed"
|
android:id="@+id/widget_week_header_wed"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
@@ -99,7 +117,7 @@
|
|||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/widget_week_header_thu"
|
android:id="@+id/widget_week_header_thu"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
@@ -110,7 +128,7 @@
|
|||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/widget_week_header_fri"
|
android:id="@+id/widget_week_header_fri"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:layout_weight="1" />
|
android:layout_weight="1" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
@@ -123,7 +141,7 @@
|
|||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
android:id="@+id/widget_week_time_labels"
|
android:id="@+id/widget_week_time_labels"
|
||||||
android:layout_width="28dp"
|
android:layout_width="30dp"
|
||||||
android:layout_height="match_parent" />
|
android:layout_height="match_parent" />
|
||||||
|
|
||||||
<FrameLayout
|
<FrameLayout
|
||||||
|
|||||||
@@ -1,28 +1,35 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<!-- Mirrors the in-app _DayHeaderCell: weekday in caps, day number below,
|
||||||
|
today inside an accent circle. -->
|
||||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/widget_week_day_header_root"
|
android:id="@+id/widget_week_day_header_root"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:paddingTop="2dp"
|
tools:ignore="SpUsage,SmallSp">
|
||||||
android:paddingBottom="3dp">
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_week_day_header_weekday"
|
android:id="@+id/widget_week_day_header_weekday"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textSize="11sp"
|
android:fontFamily="sans-serif-medium"
|
||||||
android:textStyle="bold"
|
android:textSize="9dp"
|
||||||
android:textColor="@color/widget_text_primary"
|
android:textColor="@color/widget_text_secondary"
|
||||||
tools:text="Mo" />
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
tools:text="MO." />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/widget_week_day_header_date"
|
android:id="@+id/widget_week_day_header_date"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="18dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="18dp"
|
||||||
android:textSize="9sp"
|
android:layout_marginTop="1dp"
|
||||||
android:textColor="@color/widget_text_secondary"
|
android:gravity="center"
|
||||||
tools:text="06.05." />
|
android:textSize="11dp"
|
||||||
|
android:textColor="@color/widget_text_primary"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:includeFontPadding="false"
|
||||||
|
tools:text="6" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<!-- Static preview for the week widget. Five day columns with two short
|
<!-- Static picker preview of the week widget, laid out like WidgetRenderer's output. -->
|
||||||
demo blocks each so the structure is recognisable in the picker. -->
|
|
||||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:background="@color/widget_background">
|
android:background="@color/widget_background"
|
||||||
|
tools:ignore="SpUsage,SmallSp,HardcodedText,UseAppTint">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:layout_width="200dp"
|
android:layout_width="200dp"
|
||||||
@@ -22,124 +23,312 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="10dp">
|
android:paddingStart="8dp"
|
||||||
|
android:paddingEnd="8dp"
|
||||||
|
android:paddingTop="10dp"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
|
<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
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="32dp"
|
||||||
android:orientation="horizontal"
|
android:layout_marginTop="4dp"
|
||||||
android:gravity="center_vertical">
|
android:orientation="horizontal">
|
||||||
<TextView
|
<FrameLayout android:layout_width="30dp" android:layout_height="match_parent" />
|
||||||
android:layout_width="0dp"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:layout_weight="1"
|
|
||||||
android:textSize="14sp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:textColor="@color/widget_text_primary"
|
|
||||||
android:singleLine="true"
|
|
||||||
android:text="KW 19 · 06.05.–10.05." />
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textSize="10sp"
|
|
||||||
android:textColor="@color/widget_text_secondary"
|
|
||||||
android:text="Stand: 07:50" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<!-- Day-name + date row -->
|
|
||||||
<LinearLayout
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="horizontal"
|
|
||||||
android:layout_marginTop="6dp">
|
|
||||||
<FrameLayout android:layout_width="20dp" android:layout_height="wrap_content" />
|
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Mo" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="MO." />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="06.05." />
|
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="4" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Di" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="DI." />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="07.05." />
|
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="5" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Mi" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="MI." />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="08.05." />
|
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="6" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Do" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="DO." />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="09.05." />
|
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="7" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
<LinearLayout android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
<LinearLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" android:orientation="vertical" android:gravity="center">
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="11sp" android:textStyle="bold" android:textColor="@color/widget_text_primary" android:text="Fr" />
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:textSize="9dp" android:textColor="@color/widget_text_secondary" android:text="FR." />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textColor="@color/widget_text_secondary" android:text="10.05." />
|
<TextView android:layout_width="18dp" android:layout_height="18dp" android:layout_marginTop="1dp" android:gravity="center" android:includeFontPadding="false" android:textSize="11dp" android:textColor="@color/widget_text_primary" android:text="8" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<!-- Time grid: time-label column + 5 day columns -->
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="0dp"
|
android:layout_height="0dp"
|
||||||
android:layout_weight="1"
|
android:layout_weight="1"
|
||||||
android:orientation="horizontal"
|
android:layout_marginTop="2dp"
|
||||||
android:layout_marginTop="2dp">
|
android:orientation="horizontal">
|
||||||
|
|
||||||
<FrameLayout android:layout_width="20dp" android:layout_height="match_parent">
|
<FrameLayout android:layout_width="30dp" android:layout_height="match_parent">
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="0dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="08" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="36dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="10" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="0dp">
|
||||||
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="72dp" android:gravity="end" android:paddingEnd="3dp" android:textSize="8sp" android:textColor="@color/widget_text_secondary" android:text="12" />
|
<TextView android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" android:layout_marginTop="2dp" android:gravity="center_horizontal" android:includeFontPadding="false" android:textSize="7dp" android:textColor="@color/widget_text_secondary" android:text="07:55" />
|
||||||
</FrameLayout>
|
<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" />
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<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" />
|
||||||
|
|
||||||
<!-- Mon -->
|
|
||||||
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
|
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="MA" />
|
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="40dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="EN" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="36dp" android:layout_marginTop="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>
|
</FrameLayout>
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
|
|
||||||
<!-- Tue -->
|
|
||||||
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="20dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_cancelled">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="BIO" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
|
||||||
|
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="60dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="DE" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#5C6BC0">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MA-LK" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="B11" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MUE" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="DE" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A204" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SCHN" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#0097A7">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EN" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="F2.04" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="KRA" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#000000">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="BIO" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N103" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="WEB" />
|
||||||
|
</LinearLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="match_parent" android:background="@drawable/widget_lesson_cancelled_overlay" />
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
|
|
||||||
<!-- Wed -->
|
|
||||||
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="32dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="MA" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
|
||||||
|
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#26A69A">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="SPO" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="TH1" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BAU" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="36.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#8F19B3">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="PoWi" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A105" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="HOF" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="84.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#29639B">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="PH" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N201" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="LEH" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#9E9D24">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="GE" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A110" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BER" />
|
||||||
|
</LinearLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
|
|
||||||
<!-- Thu -->
|
|
||||||
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="2dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_teacher_changed">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="GE" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
|
||||||
|
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="40dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="PH" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#E91E63">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="CH" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N305" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MEI" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#5C6BC0">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MA-LK" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="B11" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MUE" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="DE" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A204" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SCHN" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="194.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#0097A7">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EN" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="F2.04" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="KRA" />
|
||||||
|
</LinearLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
|
|
||||||
<!-- Fri -->
|
|
||||||
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
||||||
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="20dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:padding="2dp" android:background="@drawable/widget_lesson_block_regular">
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
|
||||||
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:textSize="9sp" android:textStyle="bold" android:textColor="@android:color/white" android:text="EN" />
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
|
||||||
|
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#78909C">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="EK" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A301" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="LAN" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="36.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="BIO" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N103" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="WEB" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#7E57C2">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="MU" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="M1" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="SIN" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="158.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#F4511E">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="KU" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="K2" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="MAL" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="1dp" android:layout_height="match_parent" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1">
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="0dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="36dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="72dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="108dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="14dp" android:layout_marginTop="108dp" android:background="@color/widget_preview_break">
|
||||||
|
<ImageView android:layout_width="12dp" android:layout_height="12dp" android:layout_gravity="center" android:importantForAccessibility="no" android:contentDescription="@null" android:tint="@color/widget_preview_accent" android:src="@drawable/widget_icon_restaurant" />
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="122dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="158dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="194dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="1dp" android:layout_marginTop="230dp" android:background="@color/widget_divider" />
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="70.5dp" android:layout_marginTop="0.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#993333">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="PH" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="N201" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="LEH" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="72.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#FFA000">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="LA" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A108" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="CIC" />
|
||||||
|
</LinearLayout>
|
||||||
|
</FrameLayout>
|
||||||
|
<FrameLayout android:layout_width="match_parent" android:layout_height="34.5dp" android:layout_marginTop="122.8dp" android:layout_marginStart="1dp" android:layout_marginEnd="1dp" android:background="@drawable/widget_lesson_block" android:backgroundTint="#9E9D24">
|
||||||
|
<LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingTop="2dp" android:paddingBottom="2dp">
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:fontFamily="sans-serif-medium" android:maxLines="1" android:textSize="11dp" android:textColor="@android:color/white" android:text="GE" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="A110" />
|
||||||
|
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:includeFontPadding="false" android:textSize="8dp" android:textColor="@android:color/white" android:text="BER" />
|
||||||
|
</LinearLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -4,4 +4,6 @@
|
|||||||
<color name="widget_text_primary">#FFF1F1F1</color>
|
<color name="widget_text_primary">#FFF1F1F1</color>
|
||||||
<color name="widget_text_secondary">#FFB0B0B0</color>
|
<color name="widget_text_secondary">#FFB0B0B0</color>
|
||||||
<color name="widget_divider">#33FFFFFF</color>
|
<color name="widget_divider">#33FFFFFF</color>
|
||||||
|
<color name="widget_preview_accent">#FFFFB3AF</color>
|
||||||
|
<color name="widget_preview_break">#32FFB3AF</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<bool name="widget_supported">true</bool>
|
||||||
|
</resources>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
<string name="widget_week_description">Stundenplan und Vertretungen für die ganze Schulwoche.</string>
|
<string name="widget_week_description">Stundenplan und Vertretungen für die ganze Schulwoche.</string>
|
||||||
<string name="widget_no_lessons">Keine Stunden</string>
|
<string name="widget_no_lessons">Keine Stunden</string>
|
||||||
<string name="widget_holiday">Ferien</string>
|
<string name="widget_holiday">Ferien</string>
|
||||||
|
<string name="widget_school_free">Schulfrei</string>
|
||||||
<string name="widget_login_required">Bitte einloggen, um den Stundenplan zu laden</string>
|
<string name="widget_login_required">Bitte einloggen, um den Stundenplan zu laden</string>
|
||||||
<string name="widget_loading">Lade…</string>
|
<string name="widget_loading">Lade…</string>
|
||||||
<string name="widget_status_label">Stand: %1$s</string>
|
<string name="widget_status_label">Stand: %1$s</string>
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<!-- Status colors mirror lib/view/pages/timetable/data/lesson_color.dart
|
|
||||||
exactly so widget tiles match the in-app calendar. -->
|
|
||||||
<color name="widget_lesson_regular">#FF993333</color>
|
|
||||||
<color name="widget_lesson_ongoing">#FFC83333</color>
|
|
||||||
<color name="widget_lesson_past">#FF993333</color>
|
|
||||||
<color name="widget_lesson_cancelled">#FF000000</color>
|
|
||||||
<color name="widget_lesson_irregular">#FF8F19B3</color>
|
|
||||||
<color name="widget_lesson_teacher_changed">#FF29639B</color>
|
|
||||||
<color name="widget_lesson_event">#FF2E7D32</color>
|
|
||||||
|
|
||||||
<color name="widget_background">#FFFCF7F5</color>
|
<color name="widget_background">#FFFCF7F5</color>
|
||||||
<color name="widget_text_primary">#FF111111</color>
|
<color name="widget_text_primary">#FF111111</color>
|
||||||
<color name="widget_text_secondary">#FF555555</color>
|
<color name="widget_text_secondary">#FF555555</color>
|
||||||
<color name="widget_divider">#22000000</color>
|
<color name="widget_divider">#22000000</color>
|
||||||
|
<color name="widget_preview_accent">#FF904A47</color>
|
||||||
|
<color name="widget_preview_break">#32904A47</color>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- WidgetRenderer positions every tile with RemoteViews calls that only
|
||||||
|
exist from Android 12 (API 31). Below that the widgets are not
|
||||||
|
offered at all rather than showing up empty. -->
|
||||||
|
<bool name="widget_supported">false</bool>
|
||||||
|
</resources>
|
||||||
@@ -106,12 +106,14 @@ void _log(String message) => debugPrint('SHOTS: $message');
|
|||||||
Future<void> _login(WidgetTester tester) async {
|
Future<void> _login(WidgetTester tester) async {
|
||||||
final loginVisible = await _pumpUntil(
|
final loginVisible = await _pumpUntil(
|
||||||
tester,
|
tester,
|
||||||
find.byKey(const Key('login-username-field')),
|
find.byKey(const Key('login-audience-school')),
|
||||||
);
|
);
|
||||||
if (!loginVisible) {
|
if (!loginVisible) {
|
||||||
_log('kein Login-Screen sichtbar – bereits angemeldet, überspringe Login');
|
_log('kein Login-Screen sichtbar – bereits angemeldet, überspringe Login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await tester.tap(find.byKey(const Key('login-audience-school')));
|
||||||
|
await _pumpUntil(tester, find.byKey(const Key('login-username-field')));
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.byKey(const Key('login-username-field')),
|
find.byKey(const Key('login-username-field')),
|
||||||
'demo@screenshots',
|
'demo@screenshots',
|
||||||
|
|||||||
@@ -180,11 +180,11 @@ AppDelegate) exakt so ab: `GenericPassword` + `kSecAttrAccount = <key>` +
|
|||||||
| `push_server_public_key_pem` | **Server**-Public-Key (per User), SPKI-PEM | `push_registration_store.dart` | **NSE** (Signatur prüfen) |
|
| `push_server_public_key_pem` | **Server**-Public-Key (per User), SPKI-PEM | `push_registration_store.dart` | **NSE** (Signatur prüfen) |
|
||||||
| `push_device_identifier` | NC Device-Identifier | `push_registration_store.dart` | Dart |
|
| `push_device_identifier` | NC Device-Identifier | `push_registration_store.dart` | Dart |
|
||||||
| `push_registered_fcm_token` | FCM-Token der Registrierung | `push_registration_store.dart` | Dart |
|
| `push_registered_fcm_token` | FCM-Token der Registrierung | `push_registration_store.dart` | Dart |
|
||||||
| `nextcloud_app_password` | NC App-Password | `account_data.dart` | **AppDelegate** (Basic-Auth) |
|
| `nextcloud_app_password` | NC App-Password | `session_manager.dart` | **AppDelegate** (Basic-Auth) |
|
||||||
| `nextcloud_username` | NC Username | `push_registration_store.dart` (**neu**) | **AppDelegate** (Basic-Auth) |
|
| `nextcloud_username` | NC Username | `push_registration_store.dart` (**neu**) | **AppDelegate** (Basic-Auth) |
|
||||||
| `nextcloud_base_url` | z.B. `https://cloud.marianum-fulda.de` | `push_registration_store.dart` (**neu**) | **AppDelegate** (OCS-URL) |
|
| `nextcloud_base_url` | z.B. `https://cloud.marianum-fulda.de` | `push_registration_store.dart` (**neu**) | **AppDelegate** (OCS-URL) |
|
||||||
|
|
||||||
> `username` und `password` (Realpasswort) liegen in AccountDatas **Default**-Storage
|
> `username` und `password` (Realpasswort) liegen im **Default**-Storage des SessionManagers
|
||||||
> **ohne** `groupId` → nur im primären Access-Group der App, **für die NSE
|
> **ohne** `groupId` → nur im primären Access-Group der App, **für die NSE
|
||||||
> unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei
|
> unsichtbar**. Deshalb werden `nextcloud_username` + `nextcloud_base_url` bei
|
||||||
> jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das
|
> jeder `register()`-Runde zusätzlich **group-scoped** geschrieben. Das
|
||||||
|
|||||||
+2
-8
@@ -12,8 +12,6 @@ PODS:
|
|||||||
- PhoneNumberKit/PhoneNumberKitCore (3.7.11)
|
- PhoneNumberKit/PhoneNumberKitCore (3.7.11)
|
||||||
- PhoneNumberKit/UIKit (3.7.11):
|
- PhoneNumberKit/UIKit (3.7.11):
|
||||||
- PhoneNumberKit/PhoneNumberKitCore
|
- PhoneNumberKit/PhoneNumberKitCore
|
||||||
- workmanager_apple (0.0.1):
|
|
||||||
- Flutter
|
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- eraser (from `.symlinks/plugins/eraser/ios`)
|
- eraser (from `.symlinks/plugins/eraser/ios`)
|
||||||
@@ -21,7 +19,6 @@ DEPENDENCIES:
|
|||||||
- flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`)
|
- flutter_app_badge (from `.symlinks/plugins/flutter_app_badge/ios`)
|
||||||
- open_filex (from `.symlinks/plugins/open_filex/ios`)
|
- open_filex (from `.symlinks/plugins/open_filex/ios`)
|
||||||
- PhoneNumberKit (~> 3.7.6)
|
- PhoneNumberKit (~> 3.7.6)
|
||||||
- workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`)
|
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
trunk:
|
trunk:
|
||||||
@@ -36,17 +33,14 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/flutter_app_badge/ios"
|
:path: ".symlinks/plugins/flutter_app_badge/ios"
|
||||||
open_filex:
|
open_filex:
|
||||||
:path: ".symlinks/plugins/open_filex/ios"
|
:path: ".symlinks/plugins/open_filex/ios"
|
||||||
workmanager_apple:
|
|
||||||
:path: ".symlinks/plugins/workmanager_apple/ios"
|
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
eraser: 83a4b06985f3702aa3d8dec816f9693266012937
|
eraser: 83a4b06985f3702aa3d8dec816f9693266012937
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
|
||||||
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
|
flutter_app_badge: ca742dd659a157c1090ef7cd881cb78f48f3bcdf
|
||||||
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
|
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
|
||||||
PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c
|
PhoneNumberKit: 9ff0c5ae9fe4770193b68a3d3e6c938fe976788c
|
||||||
workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778
|
|
||||||
|
|
||||||
PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6
|
PODFILE CHECKSUM: 087d168982f24fb137e2d46f893b771b4b9955c6
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.17.0
|
||||||
@@ -3,11 +3,12 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 54;
|
objectVersion = 70;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
034BD2FF7A860C6DC2FED514 /* Pods_Share_Extension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */; };
|
034BD2FF7A860C6DC2FED514 /* Pods_Share_Extension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */; };
|
||||||
|
0B36AA42294848D99E4E0742 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = AFE007D963441C0650CD3ED9 /* PrivacyInfo.xcprivacy */; };
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||||
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3321F8052FB1C00C0011C712 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
3321F80F2FB1C00C0011C712 /* Share Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 3321F8052FB1C00C0011C712 /* Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */; };
|
33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */; };
|
||||||
@@ -77,19 +78,19 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
12B96C78930441C73F123636 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = Info.plist; path = Info.plist; sourceTree = "<group>"; };
|
12B96C78930441C73F123636 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||||
17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Release.xcconfig"; path = "Flutter/NotificationServiceExtension-Release.xcconfig"; sourceTree = "<group>"; };
|
17E2EE012C4361AC05DCA4C9 /* NotificationServiceExtension-Release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Release.xcconfig"; path = "Flutter/NotificationServiceExtension-Release.xcconfig"; sourceTree = "<group>"; };
|
||||||
2960F029246C39E2A03F6D87 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NotificationService.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
2960F029246C39E2A03F6D87 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
|
||||||
3321F8052FB1C00C0011C712 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
3321F8052FB1C00C0011C712 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
33FDB0972EE9ABDC000B2391 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
|
||||||
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Debug.xcconfig"; path = "Flutter/NotificationServiceExtension-Debug.xcconfig"; sourceTree = "<group>"; };
|
36C6C71327C68B43F522F5B2 /* NotificationServiceExtension-Debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "NotificationServiceExtension-Debug.xcconfig"; path = "Flutter/NotificationServiceExtension-Debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
4509EC31CB08BA9BF367AF6C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
4509EC31CB08BA9BF367AF6C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
4F2428AC5384E0EF8DAB462A /* Pods_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
509DCCD474353408FE5806C5 /* PEM.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PEM.swift; path = PEM.swift; sourceTree = "<group>"; };
|
509DCCD474353408FE5806C5 /* PEM.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PEM.swift; sourceTree = "<group>"; };
|
||||||
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; name = NotificationServiceExtension.entitlements; path = NotificationServiceExtension.entitlements; sourceTree = "<group>"; };
|
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = NotificationServiceExtension.entitlements; sourceTree = "<group>"; };
|
||||||
60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; };
|
60E1803A3FB28FCC6F435E99 /* Pods-Share Extension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Share Extension.release.xcconfig"; path = "Target Support Files/Pods-Share Extension/Pods-Share Extension.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
64801C012A9112D500E8B558 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
64801C012A9112D500E8B558 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
@@ -107,6 +108,7 @@
|
|||||||
AA0101020000000011111111 /* TimetableWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TimetableWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
AA0101020000000011111111 /* TimetableWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TimetableWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
AA0102020000000022222222 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
AA0102020000000022222222 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
|
||||||
AA6B03D1433E7395021F7730 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
AA6B03D1433E7395021F7730 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
AFE007D963441C0650CD3ED9 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||||
BB0001010000000011111111 /* ShareExtension-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "ShareExtension-Debug.xcconfig"; path = "Flutter/ShareExtension-Debug.xcconfig"; sourceTree = "<group>"; };
|
BB0001010000000011111111 /* ShareExtension-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "ShareExtension-Debug.xcconfig"; path = "Flutter/ShareExtension-Debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
BB0001020000000011111111 /* ShareExtension-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "ShareExtension-Release.xcconfig"; path = "Flutter/ShareExtension-Release.xcconfig"; sourceTree = "<group>"; };
|
BB0001020000000011111111 /* ShareExtension-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "ShareExtension-Release.xcconfig"; path = "Flutter/ShareExtension-Release.xcconfig"; sourceTree = "<group>"; };
|
||||||
BB0001030000000011111111 /* ShareExtension-Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "ShareExtension-Profile.xcconfig"; path = "Flutter/ShareExtension-Profile.xcconfig"; sourceTree = "<group>"; };
|
BB0001030000000011111111 /* ShareExtension-Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "ShareExtension-Profile.xcconfig"; path = "Flutter/ShareExtension-Profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
@@ -222,7 +224,6 @@
|
|||||||
12B96C78930441C73F123636 /* Info.plist */,
|
12B96C78930441C73F123636 /* Info.plist */,
|
||||||
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */,
|
5C2F4C79DD573778092882AB /* NotificationServiceExtension.entitlements */,
|
||||||
);
|
);
|
||||||
name = NotificationServiceExtension;
|
|
||||||
path = NotificationServiceExtension;
|
path = NotificationServiceExtension;
|
||||||
sourceTree = SOURCE_ROOT;
|
sourceTree = SOURCE_ROOT;
|
||||||
};
|
};
|
||||||
@@ -304,6 +305,7 @@
|
|||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
AA0102020000000022222222 /* SceneDelegate.swift */,
|
AA0102020000000022222222 /* SceneDelegate.swift */,
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||||
|
AFE007D963441C0650CD3ED9 /* PrivacyInfo.xcprivacy */,
|
||||||
);
|
);
|
||||||
path = Runner;
|
path = Runner;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -469,6 +471,7 @@
|
|||||||
33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */,
|
33FDB0982EE9ABDC000B2391 /* GoogleService-Info.plist in Resources */,
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
|
||||||
|
0B36AA42294848D99E4E0742 /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -50,8 +50,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270",
|
"revision" : "27eaab3918e0bf78711cf1abf240577176326432",
|
||||||
"version" : "12.15.0"
|
"version" : "12.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,8 +59,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "dc39082d8881109d35b94b1c122164c0e8d08a55",
|
"revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee",
|
||||||
"version" : "3.6.1"
|
"version" : "3.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -68,8 +68,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b",
|
"revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f",
|
||||||
"version" : "12.15.0"
|
"version" : "12.19.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -86,8 +86,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/GoogleUtilities.git",
|
"location" : "https://github.com/google/GoogleUtilities.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "9f183ae842be978784f2963a343682e0c46d8fb3",
|
"revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
|
||||||
"version" : "8.1.2"
|
"version" : "8.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -50,8 +50,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
"location" : "https://github.com/firebase/firebase-ios-sdk",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "42e81d245e30e49ea6a5830cf2842d44a1591270",
|
"revision" : "27eaab3918e0bf78711cf1abf240577176326432",
|
||||||
"version" : "12.15.0"
|
"version" : "12.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -59,8 +59,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
"location" : "https://github.com/googleads/google-ads-on-device-conversion-ios-sdk",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "9bfcc6cf435b2e7c5562c1900b8680c594fa9a64",
|
"revision" : "0208e2681ec81f8a9c81084696f60fcce26cb7ee",
|
||||||
"version" : "3.6.0"
|
"version" : "3.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -68,8 +68,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
"location" : "https://github.com/google/GoogleAppMeasurement.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "144855f40d8668927f256a3045f7fdc4c3f4338b",
|
"revision" : "b8455dd2ff55cc269fda7a4f9eac6c1e3588c53f",
|
||||||
"version" : "12.15.0"
|
"version" : "12.19.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -86,8 +86,8 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/google/GoogleUtilities.git",
|
"location" : "https://github.com/google/GoogleUtilities.git",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "c46e5f8b7c23265f17c24ca7f9fa1b13ded7a822",
|
"revision" : "92c8f6dc3ac375d6febdfcb3db68bc3d10633db3",
|
||||||
"version" : "8.1.1"
|
"version" : "8.1.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,12 +37,14 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
|
<key>FlutterDeepLinkingEnabled</key>
|
||||||
|
<false/>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Um Fotos direkt aus der App aufnehmen und teilen zu können wird Zugriff auf die Kamera benötigt.</string>
|
<string>Um Fotos direkt aus der App aufnehmen und teilen zu können, wird Zugriff auf die Kamera benötigt.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Um Medien mit anderen zu teilen wird Zugriff zu deine Dateien benötigt.</string>
|
<string>Um Fotos und Videos mit anderen zu teilen, wird Zugriff auf deine Mediathek benötigt.</string>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyTrackingDomains</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyCollectedDataTypes</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>CA92.1</string>
|
||||||
|
<string>1C8F.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -4,6 +4,11 @@
|
|||||||
<dict>
|
<dict>
|
||||||
<key>aps-environment</key>
|
<key>aps-environment</key>
|
||||||
<string>development</string>
|
<string>development</string>
|
||||||
|
<key>com.apple.developer.associated-domains</key>
|
||||||
|
<array>
|
||||||
|
<string>applinks:connect.marianum-fulda.de</string>
|
||||||
|
<string>applinks:connect-beta.marianum-fulda.de</string>
|
||||||
|
</array>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
|
<string>group.eu.mhsl.marianum.mobile.client.widget</string>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyTrackingDomains</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyCollectedDataTypes</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>CA92.1</string>
|
||||||
|
<string>1C8F.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyTracking</key>
|
||||||
|
<false/>
|
||||||
|
<key>NSPrivacyTrackingDomains</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyCollectedDataTypes</key>
|
||||||
|
<array/>
|
||||||
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>CA92.1</string>
|
||||||
|
<string>1C8F.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -1,59 +1,6 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
|
||||||
// Layout constants — must mirror WidgetRenderer.kt on Android, otherwise
|
|
||||||
// the platforms drift apart on the same widget size.
|
|
||||||
let FALLBACK_VIRTUAL_MINUTES = 11 * 60
|
|
||||||
let MIN_HOUR_HEIGHT: CGFloat = 18
|
|
||||||
let MAX_HOUR_HEIGHT: CGFloat = 72
|
|
||||||
let MIN_BLOCK_HEIGHT: CGFloat = 16
|
|
||||||
let LESSON_GAP: CGFloat = 1.5
|
|
||||||
|
|
||||||
func realMinutesToVirtual(_ realMin: Int, periods: [WidgetPeriod]) -> CGFloat {
|
|
||||||
guard !periods.isEmpty else { return CGFloat(realMin) }
|
|
||||||
for p in periods where realMin >= p.startMinutes && realMin <= p.endMinutes {
|
|
||||||
return CGFloat(p.virtualStartMinutes + (realMin - p.startMinutes))
|
|
||||||
}
|
|
||||||
let first = periods.first!
|
|
||||||
if realMin < first.startMinutes {
|
|
||||||
return CGFloat(realMin - first.startMinutes + first.virtualStartMinutes)
|
|
||||||
}
|
|
||||||
let last = periods.last!
|
|
||||||
if realMin > last.endMinutes {
|
|
||||||
return CGFloat(last.virtualEndMinutes + (realMin - last.endMinutes))
|
|
||||||
}
|
|
||||||
var prev = first
|
|
||||||
for i in 1..<periods.count {
|
|
||||||
let curr = periods[i]
|
|
||||||
if realMin > prev.endMinutes && realMin < curr.startMinutes {
|
|
||||||
let gap = curr.startMinutes - prev.endMinutes
|
|
||||||
let virtualGap = curr.virtualStartMinutes - prev.virtualEndMinutes
|
|
||||||
if gap > 0 {
|
|
||||||
return CGFloat(prev.virtualEndMinutes) +
|
|
||||||
CGFloat(realMin - prev.endMinutes) * CGFloat(virtualGap) / CGFloat(gap)
|
|
||||||
}
|
|
||||||
return CGFloat(curr.virtualStartMinutes)
|
|
||||||
}
|
|
||||||
prev = curr
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Below these tile heights the secondary stack collapses progressively:
|
|
||||||
// shorter than ROOM_MIN drops everything, between ROOM_MIN and
|
|
||||||
// TEACHER_SEPARATE_MIN keeps the room but drops the teacher.
|
|
||||||
let BLOCK_SHOW_ROOM_MIN: CGFloat = 13
|
|
||||||
let BLOCK_SHOW_TEACHER_SEPARATE_MIN: CGFloat = 20
|
|
||||||
|
|
||||||
let MIN_SUBJECT_FONT: CGFloat = 9
|
|
||||||
let MAX_SUBJECT_FONT: CGFloat = 14
|
|
||||||
let MIN_SECONDARY_FONT: CGFloat = 7
|
|
||||||
|
|
||||||
func subjectFont(forHourHeight hourHeight: CGFloat) -> CGFloat {
|
|
||||||
let t = max(0, min(1, (hourHeight - MIN_HOUR_HEIGHT) / (MAX_HOUR_HEIGHT - MIN_HOUR_HEIGHT)))
|
|
||||||
return MIN_SUBJECT_FONT + t * (MAX_SUBJECT_FONT - MIN_SUBJECT_FONT)
|
|
||||||
}
|
|
||||||
|
|
||||||
struct TimetableDayView: View {
|
struct TimetableDayView: View {
|
||||||
let entry: TimetableEntry
|
let entry: TimetableEntry
|
||||||
@Environment(\.widgetPalette) private var palette
|
@Environment(\.widgetPalette) private var palette
|
||||||
@@ -73,7 +20,7 @@ struct TimetableDayView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func content(data: WidgetTimetableData) -> some View {
|
private func content(data: WidgetTimetableData) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
VStack(alignment: .leading, spacing: GRID_MARGIN_TOP) {
|
||||||
header(data: data)
|
header(data: data)
|
||||||
if data.isHoliday {
|
if data.isHoliday {
|
||||||
emptyState(text: data.holidayName ?? "Ferien")
|
emptyState(text: data.holidayName ?? "Ferien")
|
||||||
@@ -81,37 +28,41 @@ struct TimetableDayView: View {
|
|||||||
emptyState(text: "Keine Stunden")
|
emptyState(text: "Keine Stunden")
|
||||||
} else {
|
} else {
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES)
|
let periods = trimTrailingPeriods(data.periods, lessons: data.lessons)
|
||||||
TimeGridView(
|
HStack(alignment: .top, spacing: 0) {
|
||||||
lessons: data.lessons,
|
PeriodRuler(
|
||||||
periods: data.periods,
|
periods: periods,
|
||||||
hourHeight: max(
|
hourHeight: resolveHourHeight(forGridHeight: geo.size.height, periods: periods)
|
||||||
MIN_HOUR_HEIGHT,
|
)
|
||||||
min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60)
|
Rectangle()
|
||||||
),
|
.fill(palette.divider)
|
||||||
showRoom: true,
|
.frame(width: 1)
|
||||||
showTeacher: true,
|
TimeGridView(
|
||||||
showTimeLabels: true
|
lessons: data.lessons,
|
||||||
)
|
periods: periods,
|
||||||
|
hourHeight: resolveHourHeight(forGridHeight: geo.size.height, periods: periods)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func header(data: WidgetTimetableData) -> some View {
|
private func header(data: WidgetTimetableData) -> some View {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 6) {
|
||||||
Text(dayLabel(for: data.anchorDate, relativeTo: entry.date))
|
Text(dayLabel(for: data.anchorDate, relativeTo: entry.date))
|
||||||
.font(.system(size: 13, weight: .semibold))
|
.font(.system(size: 14, weight: .bold))
|
||||||
.foregroundStyle(palette.textPrimary)
|
.foregroundStyle(palette.textPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.7)
|
.minimumScaleFactor(0.65)
|
||||||
Spacer(minLength: 4)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
|
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
|
||||||
.font(.system(size: 9))
|
.font(.system(size: 10))
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.7)
|
.fixedSize()
|
||||||
}
|
}
|
||||||
|
.frame(height: TITLE_ROW_HEIGHT)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func emptyState(text: String) -> some View {
|
private func emptyState(text: String) -> some View {
|
||||||
@@ -139,54 +90,23 @@ struct TimetableDayView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TimeGridView: View {
|
/// Mirrors the in-app _PeriodLabel: period number centred, start time above,
|
||||||
let lessons: [WidgetLesson]
|
/// end time below, a cup in the breaks.
|
||||||
|
struct PeriodRuler: View {
|
||||||
let periods: [WidgetPeriod]
|
let periods: [WidgetPeriod]
|
||||||
let hourHeight: CGFloat
|
let hourHeight: CGFloat
|
||||||
let showRoom: Bool
|
|
||||||
let showTeacher: Bool
|
|
||||||
let showTimeLabels: Bool
|
|
||||||
/// Week-widget passes 3 for narrow columns; day-widget keeps 7.
|
|
||||||
var horizontalPadding: CGFloat = 7
|
|
||||||
|
|
||||||
@Environment(\.widgetPalette) private var palette
|
@Environment(\.widgetPalette) private var palette
|
||||||
|
|
||||||
private var totalVirtualMinutes: Int {
|
|
||||||
periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
|
|
||||||
}
|
|
||||||
|
|
||||||
private var totalHeight: CGFloat {
|
private var totalHeight: CGFloat {
|
||||||
CGFloat(totalVirtualMinutes) * hourHeight / 60.0
|
CGFloat(totalVirtualMinutes(periods)) * hourHeight / 60.0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Below this per-hour height the two-line label collapses to a single
|
|
||||||
/// period number — time + number overlap otherwise.
|
|
||||||
private var compactLabels: Bool { hourHeight < 26 }
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: 0) {
|
// Every child has layout-y 0 (`.offset` only shifts the rendering),
|
||||||
if showTimeLabels {
|
// so both the ZStack and its frame need `.top` — the default
|
||||||
// 28pt matches the Week widget's time-label column so the
|
// `.center` would anchor the whole ruler at the column's midpoint.
|
||||||
// visual rhythm is identical across both widgets.
|
ZStack(alignment: .top) {
|
||||||
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) {
|
|
||||||
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
|
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(palette.divider)
|
.fill(palette.divider)
|
||||||
@@ -194,39 +114,92 @@ struct TimeGridView: View {
|
|||||||
.offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
|
.offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
|
||||||
}
|
}
|
||||||
ForEach(periods, id: \.startMinutes) { period in
|
ForEach(periods, id: \.startMinutes) { period in
|
||||||
VStack(alignment: .trailing, spacing: -2) {
|
cell(period)
|
||||||
if compactLabels {
|
}
|
||||||
Text("\(period.name).")
|
ForEach(periodBreaks(periods), id: \.self) { pause in
|
||||||
.font(.system(size: 9, weight: .bold))
|
let height = CGFloat(pause.virtualMinutes) * hourHeight / 60.0
|
||||||
.foregroundStyle(palette.textPrimary)
|
if height >= BREAK_ICON_MIN_HEIGHT {
|
||||||
.lineLimit(1)
|
Image(systemName: "cup.and.saucer")
|
||||||
} else {
|
.font(.system(size: 8))
|
||||||
Text(formatHm(period.startMinutes))
|
.foregroundStyle(palette.textSecondary)
|
||||||
.font(.system(size: 9))
|
.frame(width: RULER_WIDTH, height: height)
|
||||||
.foregroundStyle(palette.textPrimary)
|
.offset(y: CGFloat(pause.virtualStart) * hourHeight / 60.0)
|
||||||
.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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.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)
|
||||||
|
.minimumScaleFactor(0.8)
|
||||||
|
.padding(.vertical, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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 {
|
private var gridLines: some View {
|
||||||
// `.frame(height: totalHeight, alignment: .top)` — without the
|
// `.top` on both the ZStack and the frame — see PeriodRuler.
|
||||||
// explicit `.top`, SwiftUI's frame modifier defaults to `.center`.
|
|
||||||
// Every Rectangle here has layout-y=0 (the `.offset()`s shift the
|
|
||||||
// rendered position only, not layout), so the inner ZStack's
|
|
||||||
// natural height is just 1pt; a 1pt ZStack centred in a 300pt
|
|
||||||
// frame anchors at the middle, and every line then renders
|
|
||||||
// relative to that midpoint — the grid visually starts halfway
|
|
||||||
// down the column and the later periods clip past the bottom.
|
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
|
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
|
||||||
Rectangle()
|
Rectangle()
|
||||||
@@ -239,166 +212,191 @@ struct TimeGridView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var breakBlocks: some View {
|
private var breakBlocks: some View {
|
||||||
// Same `.center`-vs-`.top` bug as gridLines — see comment above.
|
|
||||||
ZStack(alignment: .top) {
|
ZStack(alignment: .top) {
|
||||||
ForEach(0..<max(0, periods.count - 1), id: \.self) { i in
|
ForEach(periodBreaks(periods), id: \.self) { pause in
|
||||||
let curr = periods[i]
|
let height = CGFloat(pause.virtualMinutes) * hourHeight / 60.0
|
||||||
let next = periods[i + 1]
|
ZStack {
|
||||||
let virtualGap = next.virtualStartMinutes - curr.virtualEndMinutes
|
palette.breakBlock
|
||||||
if virtualGap > 0 {
|
if height >= BREAK_ICON_MIN_HEIGHT {
|
||||||
Rectangle()
|
Image(systemName: "fork.knife")
|
||||||
.fill(palette.breakBlock)
|
.font(.system(size: 9))
|
||||||
.frame(height: CGFloat(virtualGap) * hourHeight / 60.0)
|
.foregroundStyle(palette.accent)
|
||||||
.padding(.horizontal, 1)
|
}
|
||||||
.offset(y: CGFloat(curr.virtualEndMinutes) * hourHeight / 60.0)
|
|
||||||
}
|
}
|
||||||
|
.frame(height: height)
|
||||||
|
.offset(y: CGFloat(pause.virtualStart) * hourHeight / 60.0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(height: totalHeight, alignment: .top)
|
.frame(height: totalHeight, alignment: .top)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func formatHm(_ minutes: Int) -> String {
|
/// Mirrors the in-app holiday region: icon and "Schulfrei" on top, the
|
||||||
String(format: "%02d:%02d", minutes / 60, minutes % 60)
|
/// holiday name rotated along the column.
|
||||||
|
private func holidayColumn(_ holiday: WidgetDayInfo) -> some View {
|
||||||
|
let nameLength = max(40, totalHeight - 2 * HOLIDAY_NAME_INSET)
|
||||||
|
return ZStack {
|
||||||
|
palette.holidayTint
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Image(systemName: "calendar")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
Text("Schulfrei")
|
||||||
|
.font(.system(size: 9))
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.6)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.horizontal, 2)
|
||||||
|
.frame(maxHeight: .infinity, alignment: .top)
|
||||||
|
// The outer frame carries the rotated footprint: rotationEffect
|
||||||
|
// alone leaves the layout box at its unrotated size.
|
||||||
|
Text(holiday.holidayName ?? "")
|
||||||
|
.font(.system(size: 11, weight: .bold))
|
||||||
|
.kerning(1)
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.6)
|
||||||
|
.frame(width: nameLength, height: 14)
|
||||||
|
.rotationEffect(.degrees(90))
|
||||||
|
.frame(width: 14, height: nameLength)
|
||||||
|
}
|
||||||
|
.foregroundStyle(palette.textPrimary)
|
||||||
|
.frame(height: totalHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func lessonBlock(_ lesson: WidgetLesson) -> some View {
|
private func lessonBlock(
|
||||||
let cal = Calendar.current
|
_ lesson: WidgetLesson,
|
||||||
let comps = cal.dateComponents([.hour, .minute], from: lesson.start)
|
metrics: TileMetrics,
|
||||||
let startMinutes = (comps.hour ?? 0) * 60 + (comps.minute ?? 0)
|
innerWidth: CGFloat
|
||||||
|
) -> some View {
|
||||||
|
let startMinutes = minutesOfDay(lesson.start)
|
||||||
let durationMinutes = max(15, Int(lesson.end.timeIntervalSince(lesson.start) / 60))
|
let durationMinutes = max(15, Int(lesson.end.timeIntervalSince(lesson.start) / 60))
|
||||||
let virtualStart = realMinutesToVirtual(startMinutes, periods: periods)
|
let virtualStart = realMinutesToVirtual(startMinutes, periods: periods)
|
||||||
let virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods: periods)
|
let virtualEnd = realMinutesToVirtual(startMinutes + durationMinutes, periods: periods)
|
||||||
|
|
||||||
if virtualEnd > virtualStart {
|
if virtualEnd > virtualStart {
|
||||||
|
// Half the gap above + half below so the grid line under the
|
||||||
|
// tile stays visible.
|
||||||
let top = virtualStart * hourHeight / 60.0 + LESSON_GAP / 2
|
let top = virtualStart * hourHeight / 60.0 + LESSON_GAP / 2
|
||||||
let height = max(
|
let height = max(
|
||||||
MIN_BLOCK_HEIGHT,
|
MIN_BLOCK_HEIGHT,
|
||||||
(virtualEnd - virtualStart) * hourHeight / 60.0 - LESSON_GAP
|
(virtualEnd - virtualStart) * hourHeight / 60.0 - LESSON_GAP
|
||||||
)
|
)
|
||||||
let subjectSize = subjectFont(forHourHeight: hourHeight)
|
LessonTile(
|
||||||
let secondarySize = max(MIN_SECONDARY_FONT, subjectSize - 2)
|
lesson: lesson,
|
||||||
let room = lesson.room
|
height: height,
|
||||||
let teacher = lesson.teacher ?? lesson.originalTeacher
|
metrics: metrics,
|
||||||
let hasSecondary = (room?.isEmpty == false) || (teacher?.isEmpty == false)
|
layout: TileTextLayout(
|
||||||
HStack(alignment: .top, spacing: 4) {
|
lesson: lesson,
|
||||||
Text(subjectLabel(lesson))
|
innerHeight: height - 2 * TILE_PADDING_V,
|
||||||
.font(.system(size: subjectSize, weight: .semibold))
|
innerWidth: innerWidth,
|
||||||
.foregroundStyle(.white)
|
metrics: metrics
|
||||||
.lineLimit(hasSecondary ? 1 : 2)
|
)
|
||||||
.minimumScaleFactor(0.5)
|
)
|
||||||
if hasSecondary {
|
|
||||||
Spacer(minLength: 0)
|
|
||||||
VStack(alignment: .trailing, spacing: -1) {
|
|
||||||
if showRoom && height >= BLOCK_SHOW_ROOM_MIN {
|
|
||||||
if let room, !room.isEmpty {
|
|
||||||
Text(room)
|
|
||||||
.font(.system(size: secondarySize))
|
|
||||||
.foregroundStyle(.white.opacity(0.85))
|
|
||||||
.lineLimit(1)
|
|
||||||
.minimumScaleFactor(0.5)
|
|
||||||
}
|
|
||||||
if showTeacher,
|
|
||||||
height >= BLOCK_SHOW_TEACHER_SEPARATE_MIN,
|
|
||||||
let teacher,
|
|
||||||
!teacher.isEmpty {
|
|
||||||
Text(teacher)
|
|
||||||
.font(.system(size: secondarySize))
|
|
||||||
.foregroundStyle(.white.opacity(0.7))
|
|
||||||
.lineLimit(1)
|
|
||||||
.minimumScaleFactor(0.5)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, horizontalPadding)
|
|
||||||
.padding(.vertical, 3)
|
|
||||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
|
||||||
.frame(height: height, alignment: .topLeading)
|
|
||||||
.background(blockColor(lesson))
|
|
||||||
.cornerRadius(6)
|
|
||||||
.overlay(alignment: .bottomLeading) {
|
|
||||||
// Separate fixed-size badge so the +N hint stays readable
|
|
||||||
// when the subject autoshrinks on narrow tiles.
|
|
||||||
if let count = lesson.siblingCount, count > 0 {
|
|
||||||
Text("+\(count)")
|
|
||||||
.font(.system(size: 12, weight: .bold))
|
|
||||||
.foregroundStyle(.white)
|
|
||||||
.padding(.leading, horizontalPadding)
|
|
||||||
.padding(.bottom, 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.overlay {
|
|
||||||
// CrossPainter parity: clip cross to the rounded shape so
|
|
||||||
// the diagonals don't bleed past the corners.
|
|
||||||
if lesson.status == .cancelled {
|
|
||||||
ZStack {
|
|
||||||
RoundedRectangle(cornerRadius: 6)
|
|
||||||
.stroke(Color.red.opacity(0.78), lineWidth: 1.5)
|
|
||||||
GeometryReader { geo in
|
|
||||||
Path { p in
|
|
||||||
p.move(to: .zero)
|
|
||||||
p.addLine(to: CGPoint(x: geo.size.width, y: geo.size.height))
|
|
||||||
p.move(to: CGPoint(x: geo.size.width, y: 0))
|
|
||||||
p.addLine(to: CGPoint(x: 0, y: geo.size.height))
|
|
||||||
}
|
|
||||||
.stroke(Color.red.opacity(0.78), lineWidth: 3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.horizontal, showRoom ? 2 : 1)
|
|
||||||
.offset(y: top)
|
.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
|
/// Mirrors the in-app AppointmentTile: title on top, room and teacher
|
||||||
/// either side of a break get their own (bracketing the break block).
|
/// stacked below, each line shrinking on its own to fit the tile width.
|
||||||
func periodBoundaries(_ periods: [WidgetPeriod]) -> [Int] {
|
private struct LessonTile: View {
|
||||||
var seen = Set<Int>()
|
let lesson: WidgetLesson
|
||||||
var result: [Int] = []
|
let height: CGFloat
|
||||||
for p in periods {
|
let metrics: TileMetrics
|
||||||
for v in [p.virtualStartMinutes, p.virtualEndMinutes] {
|
let layout: TileTextLayout
|
||||||
if seen.insert(v).inserted { result.append(v) }
|
|
||||||
|
@Environment(\.widgetRenderingMode) private var renderingMode
|
||||||
|
|
||||||
|
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 { tileBackground(shape) }
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tinted / clear home screens flatten an opaque colour block into a
|
||||||
|
/// solid slab the white text disappears on; a translucent fill with an
|
||||||
|
/// outline keeps tile and text apart in every rendering mode.
|
||||||
|
@ViewBuilder
|
||||||
|
private func tileBackground(_ shape: RoundedRectangle) -> some View {
|
||||||
|
if renderingMode == .fullColor {
|
||||||
|
lesson.tileColor
|
||||||
|
} else {
|
||||||
|
ZStack {
|
||||||
|
shape.fill(Color.white.opacity(0.18))
|
||||||
|
shape.strokeBorder(Color.white.opacity(0.55), lineWidth: 1)
|
||||||
|
}
|
||||||
|
.widgetAccentable()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result.sorted()
|
|
||||||
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fixed-locale formatters cached once — DateFormatter setup is the
|
/// Fixed-locale formatters cached once — DateFormatter setup is the
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
/// Lower than Android's 30dp: a .systemLarge widget only gets ~27pt per
|
||||||
|
/// period, so iOS packs the ruler cell tighter (see PeriodRuler.cell)
|
||||||
|
/// instead of dropping the times.
|
||||||
|
let RULER_TIMES_MIN_CELL: CGFloat = 24
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import WidgetKit
|
|||||||
struct TimetableWeekView: View {
|
struct TimetableWeekView: View {
|
||||||
let entry: TimetableEntry
|
let entry: TimetableEntry
|
||||||
@Environment(\.widgetPalette) private var palette
|
@Environment(\.widgetPalette) private var palette
|
||||||
|
@Environment(\.widgetRenderingMode) private var renderingMode
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
@@ -20,36 +21,44 @@ struct TimetableWeekView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func content(data: WidgetTimetableData) -> some View {
|
private func content(data: WidgetTimetableData) -> some View {
|
||||||
|
let cal = Calendar.current
|
||||||
|
let days = (0..<5).map { offset in
|
||||||
|
cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate
|
||||||
|
}
|
||||||
|
let weekLessons = data.lessons.filter { lesson in
|
||||||
|
days.contains { cal.isDate(lesson.start, inSameDayAs: $0) }
|
||||||
|
}
|
||||||
|
let periods = trimTrailingPeriods(data.periods, lessons: weekLessons)
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
header(data: data)
|
header(data: data)
|
||||||
dayHeaderRow(data: data)
|
dayHeaderRow(days: days)
|
||||||
|
.padding(.top, GRID_MARGIN_TOP)
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
let totalMin = CGFloat(data.periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES)
|
let height = resolveHourHeight(forGridHeight: geo.size.height, periods: periods)
|
||||||
let hourHeight = max(
|
|
||||||
MIN_HOUR_HEIGHT,
|
|
||||||
min(MAX_HOUR_HEIGHT, geo.size.height / max(totalMin, 60) * 60)
|
|
||||||
)
|
|
||||||
let dayColumnWidth = (geo.size.width - 28 - 4) / 5
|
|
||||||
// 45pt is the minimum where a 7pt-font room (e.g. "F2.04")
|
|
||||||
// plus the subject fits without `minimumScaleFactor`
|
|
||||||
// shrinking the subject into illegibility.
|
|
||||||
let subjectOnly = dayColumnWidth < 45
|
|
||||||
HStack(alignment: .top, spacing: 0) {
|
HStack(alignment: .top, spacing: 0) {
|
||||||
timeLabelsColumn(hourHeight: hourHeight, periods: data.periods)
|
PeriodRuler(periods: periods, hourHeight: height)
|
||||||
.frame(width: 28, alignment: .topTrailing)
|
|
||||||
columnDivider
|
columnDivider
|
||||||
ForEach(0..<5, id: \.self) { offset in
|
ForEach(days.indices, id: \.self) { index in
|
||||||
column(
|
TimeGridView(
|
||||||
data: data,
|
lessons: weekLessons.filter {
|
||||||
offset: offset,
|
cal.isDate($0.start, inSameDayAs: days[index])
|
||||||
hourHeight: hourHeight,
|
},
|
||||||
subjectOnly: subjectOnly
|
periods: periods,
|
||||||
|
hourHeight: height,
|
||||||
|
holiday: holiday(on: days[index], data: data),
|
||||||
|
isToday: cal.isDate(days[index], inSameDayAs: entry.date)
|
||||||
)
|
)
|
||||||
.frame(maxWidth: .infinity)
|
if index < 4 { columnDivider }
|
||||||
if offset < 4 { columnDivider }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(.top, 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func holiday(on day: Date, data: WidgetTimetableData) -> WidgetDayInfo? {
|
||||||
|
data.days?.first {
|
||||||
|
$0.isHoliday && Calendar.current.isDate($0.date, inSameDayAs: day)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,112 +72,65 @@ struct TimetableWeekView: View {
|
|||||||
let cal = Calendar.current
|
let cal = Calendar.current
|
||||||
let week = cal.component(.weekOfYear, from: data.anchorDate)
|
let week = cal.component(.weekOfYear, from: data.anchorDate)
|
||||||
let endDate = cal.date(byAdding: .day, value: 4, to: data.anchorDate) ?? data.anchorDate
|
let endDate = cal.date(byAdding: .day, value: 4, to: data.anchorDate) ?? data.anchorDate
|
||||||
return HStack {
|
return HStack(spacing: 6) {
|
||||||
Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))")
|
Text("KW \(week) · \(shortDate(data.anchorDate))–\(shortDate(endDate))")
|
||||||
.font(.system(size: 12, weight: .semibold))
|
.font(.system(size: 14, weight: .bold))
|
||||||
.foregroundStyle(palette.textPrimary)
|
.foregroundStyle(palette.textPrimary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.8)
|
.minimumScaleFactor(0.65)
|
||||||
Spacer()
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
|
Text("Stand: \(freshnessLabel(for: data.fetchedAt, relativeTo: entry.date))")
|
||||||
.font(.system(size: 9))
|
.font(.system(size: 10))
|
||||||
.foregroundStyle(palette.textSecondary)
|
.foregroundStyle(palette.textSecondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
.fixedSize()
|
||||||
}
|
}
|
||||||
|
.frame(height: TITLE_ROW_HEIGHT)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func dayHeaderRow(data: WidgetTimetableData) -> some View {
|
/// Mirrors the in-app _DayHeaderCell: weekday in caps, day number below,
|
||||||
let cal = Calendar.current
|
/// today inside an accent circle.
|
||||||
// `.frame(maxWidth: .infinity)` is critical: without an explicit
|
private func dayHeaderRow(days: [Date]) -> some View {
|
||||||
// greedy width (or a true `Spacer()` inside), this HStack collapses
|
|
||||||
// to the sum of its fixed-width children in a `.leading` VStack
|
|
||||||
// and the 5 day-columns end up sharing ~5pt of width — the labels
|
|
||||||
// crunch to the left and stop aligning with the grid columns below.
|
|
||||||
// The fixed height keeps `columnDivider`'s vertically-flexible
|
// The fixed height keeps `columnDivider`'s vertically-flexible
|
||||||
// Rectangle from stealing space from the GeometryReader.
|
// Rectangle from stealing space from the GeometryReader below.
|
||||||
return HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Spacer().frame(width: 28)
|
Spacer().frame(width: RULER_WIDTH)
|
||||||
columnDivider
|
columnDivider
|
||||||
ForEach(0..<5, id: \.self) { offset in
|
ForEach(days.indices, id: \.self) { index in
|
||||||
let day = cal.date(byAdding: .day, value: offset, to: data.anchorDate) ?? data.anchorDate
|
dayHeaderCell(days[index])
|
||||||
VStack(spacing: -1) {
|
.frame(maxWidth: .infinity)
|
||||||
Text(weekday(for: day))
|
if index < 4 { columnDivider }
|
||||||
.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 }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 20pt fits 10pt weekday + 8pt date with tight -1pt spacing.
|
.frame(maxWidth: .infinity, minHeight: WEEK_HEADER_HEIGHT, maxHeight: WEEK_HEADER_HEIGHT)
|
||||||
.frame(maxWidth: .infinity, minHeight: 20, maxHeight: 20)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func timeLabelsColumn(hourHeight: CGFloat, periods: [WidgetPeriod]) -> some View {
|
private func dayHeaderCell(_ day: Date) -> some View {
|
||||||
let totalMin = periods.last?.virtualEndMinutes ?? FALLBACK_VIRTUAL_MINUTES
|
// `entry.date`, not `Date()` — WidgetKit archives entries ahead of
|
||||||
let totalHeight = CGFloat(totalMin) * hourHeight / 60.0
|
// time, the midnight entry has to mark its own day.
|
||||||
// Mirrors TimeGridView.compactLabels — below 26pt/hour a stacked
|
let isToday = Calendar.current.isDate(day, inSameDayAs: entry.date)
|
||||||
// time+name no longer fits in one period slot and the two labels
|
return VStack(spacing: 1) {
|
||||||
// visually clip into the next slot. Drop to a single bold period
|
Text(WidgetDateFormatters.weekdayShort.string(from: day).uppercased())
|
||||||
// number until there's room to read both lines.
|
.font(.system(size: 9, weight: .medium))
|
||||||
let compact = hourHeight < 26
|
.foregroundStyle(isToday ? palette.accent : palette.textSecondary)
|
||||||
return ZStack(alignment: .topTrailing) {
|
.lineLimit(1)
|
||||||
ForEach(periodBoundaries(periods), id: \.self) { virtualMin in
|
Text("\(Calendar.current.component(.day, from: day))")
|
||||||
Rectangle()
|
.font(.system(size: 11, weight: isToday ? .bold : .regular))
|
||||||
.fill(palette.divider)
|
.foregroundStyle(isToday ? todayNumberColor : palette.textPrimary)
|
||||||
.frame(height: 1)
|
.lineLimit(1)
|
||||||
.offset(y: CGFloat(virtualMin) * hourHeight / 60.0)
|
.frame(width: 18, height: 18)
|
||||||
}
|
.background(Circle().fill(isToday ? todayCircleColor : Color.clear))
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(height: totalHeight, alignment: .topTrailing)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func column(
|
// An opaque accent circle flattens to the same shade as its number on
|
||||||
data: WidgetTimetableData,
|
// tinted / clear home screens; keep the circle translucent there.
|
||||||
offset: Int,
|
private var todayCircleColor: Color {
|
||||||
hourHeight: CGFloat,
|
renderingMode == .fullColor ? palette.accent : Color.white.opacity(0.3)
|
||||||
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 {
|
private var todayNumberColor: Color {
|
||||||
WidgetDateFormatters.weekdayShort.string(from: date)
|
renderingMode == .fullColor ? palette.onAccent : palette.textPrimary
|
||||||
}
|
}
|
||||||
|
|
||||||
private func placeholder(_ message: String) -> some View {
|
private func placeholder(_ message: String) -> some View {
|
||||||
|
|||||||
@@ -201,12 +201,15 @@ private struct WidgetSurface<Content: View>: View {
|
|||||||
let entry: TimetableEntry
|
let entry: TimetableEntry
|
||||||
@ViewBuilder let content: () -> Content
|
@ViewBuilder let content: () -> Content
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
@Environment(\.widgetRenderingMode) private var renderingMode
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
let palette = WidgetPalette.resolve(
|
// Tinted / clear home screens map every colour to its luminance, so
|
||||||
themeMode: entry.themeMode,
|
// the light palette's near-black text would vanish — use the dark
|
||||||
colorScheme: colorScheme
|
// palette's light text there regardless of the theme setting.
|
||||||
)
|
let palette = renderingMode == .fullColor
|
||||||
|
? WidgetPalette.resolve(themeMode: entry.themeMode, colorScheme: colorScheme)
|
||||||
|
: .dark
|
||||||
return AnyView(
|
return AnyView(
|
||||||
background(content: content(), palette: palette)
|
background(content: content(), palette: palette)
|
||||||
.environment(\.widgetPalette, palette)
|
.environment(\.widgetPalette, palette)
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ struct WidgetLesson: Codable {
|
|||||||
let originalTeacher: String?
|
let originalTeacher: String?
|
||||||
let status: WidgetLessonStatus
|
let status: WidgetLessonStatus
|
||||||
let customColor: String?
|
let customColor: String?
|
||||||
|
// Tile colour resolved in Dart as "#RRGGBB" (subject colours, palettes);
|
||||||
|
// nil only for payloads written before the field existed.
|
||||||
|
let color: String?
|
||||||
let siblingCount: Int?
|
let siblingCount: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,15 +9,27 @@ struct WidgetPalette {
|
|||||||
let textPrimary: Color
|
let textPrimary: Color
|
||||||
let textSecondary: Color
|
let textSecondary: Color
|
||||||
let divider: Color
|
let divider: Color
|
||||||
|
let accent: Color
|
||||||
|
let onAccent: Color
|
||||||
let breakBlock: Color
|
let breakBlock: Color
|
||||||
|
let todayTint: Color
|
||||||
|
let holidayTint: Color
|
||||||
let watermarkOpacity: Double
|
let watermarkOpacity: Double
|
||||||
|
|
||||||
|
// ColorScheme.fromSeed(#993333).primary of the app's light / dark theme.
|
||||||
|
private static let lightAccent = Color(red: 0x90 / 255, green: 0x4A / 255, blue: 0x47 / 255)
|
||||||
|
private static let darkAccent = Color(red: 0xFF / 255, green: 0xB3 / 255, blue: 0xAF / 255)
|
||||||
|
|
||||||
static let light = WidgetPalette(
|
static let light = WidgetPalette(
|
||||||
background: Color(red: 0xFC / 255, green: 0xF7 / 255, blue: 0xF5 / 255),
|
background: Color(red: 0xFC / 255, green: 0xF7 / 255, blue: 0xF5 / 255),
|
||||||
textPrimary: Color(red: 0x11 / 255, green: 0x11 / 255, blue: 0x11 / 255),
|
textPrimary: Color(red: 0x11 / 255, green: 0x11 / 255, blue: 0x11 / 255),
|
||||||
textSecondary: Color(red: 0x55 / 255, green: 0x55 / 255, blue: 0x55 / 255),
|
textSecondary: Color(red: 0x55 / 255, green: 0x55 / 255, blue: 0x55 / 255),
|
||||||
divider: Color.black.opacity(0x22 / 255.0),
|
divider: Color.black.opacity(0x22 / 255.0),
|
||||||
breakBlock: Color.black.opacity(0x0C / 255.0),
|
accent: lightAccent,
|
||||||
|
onAccent: .white,
|
||||||
|
breakBlock: lightAccent.opacity(0x32 / 255.0),
|
||||||
|
todayTint: lightAccent.opacity(0x0E / 255.0),
|
||||||
|
holidayTint: Color.black.opacity(0x32 / 255.0),
|
||||||
watermarkOpacity: 0.014
|
watermarkOpacity: 0.014
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,7 +38,11 @@ struct WidgetPalette {
|
|||||||
textPrimary: Color(red: 0xF1 / 255, green: 0xF1 / 255, blue: 0xF1 / 255),
|
textPrimary: Color(red: 0xF1 / 255, green: 0xF1 / 255, blue: 0xF1 / 255),
|
||||||
textSecondary: Color(red: 0xB0 / 255, green: 0xB0 / 255, blue: 0xB0 / 255),
|
textSecondary: Color(red: 0xB0 / 255, green: 0xB0 / 255, blue: 0xB0 / 255),
|
||||||
divider: Color.white.opacity(0x33 / 255.0),
|
divider: Color.white.opacity(0x33 / 255.0),
|
||||||
breakBlock: Color.white.opacity(0x14 / 255.0),
|
accent: darkAccent,
|
||||||
|
onAccent: Color(red: 0x57 / 255, green: 0x1D / 255, blue: 0x1D / 255),
|
||||||
|
breakBlock: darkAccent.opacity(0x32 / 255.0),
|
||||||
|
todayTint: darkAccent.opacity(0x0E / 255.0),
|
||||||
|
holidayTint: Color.white.opacity(0x32 / 255.0),
|
||||||
watermarkOpacity: 0.025
|
watermarkOpacity: 0.025
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import '../session/session.dart';
|
||||||
|
|
||||||
|
/// Backend identity a feature needs. Modules and settings sections declare
|
||||||
|
/// these; anything whose requirements the session does not meet is hidden.
|
||||||
|
enum AccessRequirement {
|
||||||
|
nextcloud,
|
||||||
|
guardian;
|
||||||
|
|
||||||
|
bool isMetBy(Session? session) => switch (this) {
|
||||||
|
AccessRequirement.nextcloud => session?.nextcloud != null,
|
||||||
|
AccessRequirement.guardian => session is GuardianSession,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
extension AccessRequirements on Set<AccessRequirement> {
|
||||||
|
bool areMetBy(Session? session) => every((r) => r.isMetBy(session));
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/// Account role as reported by MarianumConnect. Only used for display and for
|
||||||
|
/// deriving policies; features gate on capabilities and requirements instead
|
||||||
|
/// of comparing roles.
|
||||||
|
enum UserRole {
|
||||||
|
student,
|
||||||
|
teacher,
|
||||||
|
staff,
|
||||||
|
parent,
|
||||||
|
unknown;
|
||||||
|
|
||||||
|
/// Unknown or missing values map to [unknown] so a new server-side role
|
||||||
|
/// never breaks older app versions.
|
||||||
|
static UserRole parse(String? wire) => switch (wire) {
|
||||||
|
'STUDENT' => UserRole.student,
|
||||||
|
'TEACHER' => UserRole.teacher,
|
||||||
|
'STAFF' => UserRole.staff,
|
||||||
|
'PARENT' => UserRole.parent,
|
||||||
|
_ => UserRole.unknown,
|
||||||
|
};
|
||||||
|
}
|
||||||
+74
-40
@@ -5,6 +5,9 @@ import 'dart:io';
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:path_provider/path_provider.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.
|
/// One cached response: the raw JSON payload and when it was stored.
|
||||||
class CacheEntry {
|
class CacheEntry {
|
||||||
final String json;
|
final String json;
|
||||||
@@ -20,6 +23,9 @@ class CacheEntry {
|
|||||||
/// in memory for the whole session and JSON-encodes payloads a second time.
|
/// 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
|
/// Here every access is async and touches exactly one file, and the payload is
|
||||||
/// stored verbatim behind a one-line header (`<lastUpdate>\n<json>`).
|
/// 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 {
|
class CacheStore {
|
||||||
CacheStore._();
|
CacheStore._();
|
||||||
|
|
||||||
@@ -28,15 +34,30 @@ class CacheStore {
|
|||||||
/// Payloads above this size are decoded on a background isolate.
|
/// Payloads above this size are decoded on a background isolate.
|
||||||
static const int isolateDecodeThreshold = 64 * 1024;
|
static const int isolateDecodeThreshold = 64 * 1024;
|
||||||
|
|
||||||
Future<Directory>? _dir;
|
static const _directoryPrefix = 'request_cache';
|
||||||
|
|
||||||
|
final Map<String, Future<Directory>> _dirs = {};
|
||||||
final Map<String, Future<void>> _writes = {};
|
final Map<String, Future<void>> _writes = {};
|
||||||
|
|
||||||
Future<Directory> _directory() => _dir ??= () async {
|
/// Directory name for an account's storage [namespace]; null while no
|
||||||
final base = await getApplicationCacheDirectory();
|
/// account is active. The account from before multi-account support
|
||||||
final dir = Directory('${base.path}/request_cache');
|
/// (namespace '') keeps the original directory.
|
||||||
await dir.create(recursive: true);
|
@visibleForTesting
|
||||||
return dir;
|
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 =>
|
Future<File> _file(String key) async =>
|
||||||
File('${(await _directory()).path}/${_safeName(key)}');
|
File('${(await _directory()).path}/${_safeName(key)}');
|
||||||
@@ -47,8 +68,9 @@ class CacheStore {
|
|||||||
|
|
||||||
Future<CacheEntry?> read(String key) async {
|
Future<CacheEntry?> read(String key) async {
|
||||||
try {
|
try {
|
||||||
await _writes[key];
|
final file = await _file(key);
|
||||||
return parse(await (await _file(key)).readAsString());
|
await _writes[file.path];
|
||||||
|
return parse(await file.readAsString());
|
||||||
} on Object {
|
} on Object {
|
||||||
// Missing (PathNotFoundException) or unreadable: a cache miss.
|
// Missing (PathNotFoundException) or unreadable: a cache miss.
|
||||||
return null;
|
return null;
|
||||||
@@ -62,18 +84,21 @@ class CacheStore {
|
|||||||
return {for (var i = 0; i < keys.length; i++) keys[i]: ?entries[i]};
|
return {for (var i = 0; i < keys.length; i++) keys[i]: ?entries[i]};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> write(String key, String json) {
|
Future<void> write(String key, String json) async {
|
||||||
final previous = _writes[key] ?? Future<void>.value();
|
// 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;
|
late final Future<void> current;
|
||||||
current = previous.then((_) => _write(key, json)).whenComplete(() {
|
current = previous.then((_) => _write(file, json)).whenComplete(() {
|
||||||
if (identical(_writes[key], current)) _writes.remove(key);
|
if (identical(_writes[path], current)) _writes.remove(path);
|
||||||
});
|
});
|
||||||
return _writes[key] = current;
|
return _writes[path] = current;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _write(String key, String json) async {
|
Future<void> _write(File file, String json) async {
|
||||||
try {
|
try {
|
||||||
final file = await _file(key);
|
|
||||||
// Write-then-rename so a reader (or the widget background isolate)
|
// Write-then-rename so a reader (or the widget background isolate)
|
||||||
// never sees a half-written file.
|
// never sees a half-written file.
|
||||||
final tmp = File('${file.path}.tmp');
|
final tmp = File('${file.path}.tmp');
|
||||||
@@ -83,19 +108,21 @@ class CacheStore {
|
|||||||
);
|
);
|
||||||
await tmp.rename(file.path);
|
await tmp.rename(file.path);
|
||||||
} on Object catch (e) {
|
} on Object catch (e) {
|
||||||
debugPrint('CacheStore.write($key) failed: $e');
|
debugPrint('CacheStore.write(${file.path}) failed: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> delete(String key) async {
|
Future<void> delete(String key) async {
|
||||||
try {
|
try {
|
||||||
await _writes[key];
|
final file = await _file(key);
|
||||||
await (await _file(key)).delete();
|
await _writes[file.path];
|
||||||
|
await file.delete();
|
||||||
} on Object {
|
} on Object {
|
||||||
// A missing or locked file is as good as deleted for a cache.
|
// A missing or locked file is as good as deleted for a cache.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Empties the active account's cache.
|
||||||
Future<void> clear() async {
|
Future<void> clear() async {
|
||||||
try {
|
try {
|
||||||
final dir = await _directory();
|
final dir = await _directory();
|
||||||
@@ -103,7 +130,20 @@ class CacheStore {
|
|||||||
} on Object catch (e) {
|
} on Object catch (e) {
|
||||||
debugPrint('CacheStore.clear failed: $e');
|
debugPrint('CacheStore.clear failed: $e');
|
||||||
}
|
}
|
||||||
_dir = null;
|
_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].
|
/// All stored keys, optionally filtered by [prefix].
|
||||||
@@ -122,17 +162,22 @@ class CacheStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes entries not written for [maxAge], judged by file mtime so nothing
|
/// Removes entries not written for [maxAge] from every account's cache,
|
||||||
/// has to be read or decoded.
|
/// judged by file mtime so nothing has to be read or decoded.
|
||||||
Future<void> deleteOlderThan(Duration maxAge) async {
|
Future<void> deleteOlderThan(Duration maxAge) async {
|
||||||
try {
|
try {
|
||||||
final dir = await _directory();
|
final base = await getApplicationCacheDirectory();
|
||||||
final cutoff = DateTime.now().subtract(maxAge);
|
final cutoff = DateTime.now().subtract(maxAge);
|
||||||
await for (final entity in dir.list()) {
|
await for (final dir in base.list()) {
|
||||||
if (entity is! File) continue;
|
if (dir is! Directory) continue;
|
||||||
// ignore: avoid_slow_async_io
|
final name = dir.uri.pathSegments.lastWhere((s) => s.isNotEmpty);
|
||||||
final modified = (await entity.stat()).modified;
|
if (!name.startsWith(_directoryPrefix)) continue;
|
||||||
if (modified.isBefore(cutoff)) await entity.delete();
|
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) {
|
} on Object catch (e) {
|
||||||
debugPrint('CacheStore.deleteOlderThan failed: $e');
|
debugPrint('CacheStore.deleteOlderThan failed: $e');
|
||||||
@@ -140,18 +185,7 @@ class CacheStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Total size of all entries in bytes.
|
/// Total size of all entries in bytes.
|
||||||
Future<int> totalSize() async {
|
Future<int> totalSize() async => directorySize(await _directory());
|
||||||
try {
|
|
||||||
final dir = await _directory();
|
|
||||||
var sum = 0;
|
|
||||||
await for (final entity in dir.list()) {
|
|
||||||
if (entity is File) sum += await entity.length();
|
|
||||||
}
|
|
||||||
return sum;
|
|
||||||
} on Object {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses the stored file format; public for tests.
|
/// Parses the stored file format; public for tests.
|
||||||
static CacheEntry? parse(String raw) {
|
static CacheEntry? parse(String raw) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart';
|
import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart';
|
||||||
import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart';
|
import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart';
|
||||||
|
import '../../marianumconnect/queries/get_capabilities/guardian_child.dart';
|
||||||
|
import '../demo_persona.dart';
|
||||||
|
|
||||||
/// Demo fixtures for the mobile capability flags — everything granted so the
|
/// Demo fixtures for the mobile capability flags — everything granted so the
|
||||||
/// demo persona sees every feature (incl. push) as available and the timetable
|
/// demo persona sees every feature (incl. push) as available and the timetable
|
||||||
@@ -17,6 +19,27 @@ class DemoCapabilities {
|
|||||||
userType: 'STUDENT',
|
userType: 'STUDENT',
|
||||||
loaded: true,
|
loaded: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Guardian persona with two children, so the child switcher is visible.
|
||||||
|
static CapabilitiesState guardianState() => const CapabilitiesState(
|
||||||
|
pushNotifications: true,
|
||||||
|
userType: 'PARENT',
|
||||||
|
children: [
|
||||||
|
GuardianChild(
|
||||||
|
id: 'demo-child-1',
|
||||||
|
firstName: DemoPersona.studentFirstName,
|
||||||
|
lastName: 'Hoffmann',
|
||||||
|
className: DemoPersona.className,
|
||||||
|
),
|
||||||
|
GuardianChild(
|
||||||
|
id: 'demo-child-2',
|
||||||
|
firstName: 'Jonas',
|
||||||
|
lastName: 'Hoffmann',
|
||||||
|
className: '6a',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
loaded: true,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Demo fixtures for the Nextcloud `files_sharing` capabilities — a permissive
|
/// Demo fixtures for the Nextcloud `files_sharing` capabilities — a permissive
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import '../../marianumconnect/queries/parent_letters/parent_letter_models.dart';
|
||||||
|
import '../demo_persona.dart';
|
||||||
|
|
||||||
|
/// Demo inbox of the guardian persona: one letter per variant (information,
|
||||||
|
/// acknowledgement, choice, signature, answered, expired, thread, attachment).
|
||||||
|
/// Child ids match [DemoCapabilities.guardianState].
|
||||||
|
class DemoParentLetters {
|
||||||
|
const DemoParentLetters._();
|
||||||
|
|
||||||
|
static const _lena = 'demo-child-1';
|
||||||
|
static const _jonas = 'demo-child-2';
|
||||||
|
|
||||||
|
static final Set<String> _readInSession = {};
|
||||||
|
|
||||||
|
static void markRead(String letterId) => _readInSession.add(letterId);
|
||||||
|
|
||||||
|
static ParentLetterListResponse list() {
|
||||||
|
final items = [for (final letter in _letters()) letter.summary];
|
||||||
|
return ParentLetterListResponse(
|
||||||
|
items: items,
|
||||||
|
unreadCount: items.where((letter) => !letter.read).length,
|
||||||
|
openCount: items
|
||||||
|
.where((letter) => letter.status == ParentLetterStatus.open)
|
||||||
|
.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ParentLetterDetail detail(String letterId) {
|
||||||
|
final letters = _letters();
|
||||||
|
return letters.firstWhere(
|
||||||
|
(letter) => letter.summary.id == letterId,
|
||||||
|
orElse: () => letters.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static ParentLetterPerson _teacher(int index) =>
|
||||||
|
ParentLetterPerson(displayName: DemoPersona.teachers[index].name);
|
||||||
|
|
||||||
|
static const _participation = ParentLetterField(
|
||||||
|
id: 'participation',
|
||||||
|
type: ParentLetterFieldType.singleChoice,
|
||||||
|
label: 'Nimmt Ihr Kind teil?',
|
||||||
|
isRequired: true,
|
||||||
|
options: [
|
||||||
|
ParentLetterOption(id: 'yes', label: 'Ja'),
|
||||||
|
ParentLetterOption(id: 'no', label: 'Nein'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
static List<ParentLetterDetail> _letters() {
|
||||||
|
final now = DateTime.now();
|
||||||
|
ParentLetterDetail letter({
|
||||||
|
required String id,
|
||||||
|
required String subject,
|
||||||
|
required ParentLetterPerson sender,
|
||||||
|
required Duration age,
|
||||||
|
required String body,
|
||||||
|
required List<String> childIds,
|
||||||
|
bool read = true,
|
||||||
|
ParentLetterStatus status = ParentLetterStatus.info,
|
||||||
|
ParentLetterRequest? request,
|
||||||
|
List<ParentLetterChildState> children = const [],
|
||||||
|
List<ParentLetterAttachment> attachments = const [],
|
||||||
|
ParentLetterThread thread = const ParentLetterThread(enabled: true),
|
||||||
|
}) => ParentLetterDetail(
|
||||||
|
summary: ParentLetterSummary(
|
||||||
|
id: id,
|
||||||
|
subject: subject,
|
||||||
|
preview: body.replaceAll('\n', ' ').trim(),
|
||||||
|
sender: sender,
|
||||||
|
sentAt: now.subtract(age),
|
||||||
|
read: read || _readInSession.contains(id),
|
||||||
|
childIds: childIds,
|
||||||
|
attachmentCount: attachments.length,
|
||||||
|
status: status,
|
||||||
|
deadline: request?.deadline,
|
||||||
|
),
|
||||||
|
content: ParentLetterContent(
|
||||||
|
body: body,
|
||||||
|
attachments: attachments,
|
||||||
|
request: request,
|
||||||
|
children: children,
|
||||||
|
thread: thread,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
letter(
|
||||||
|
id: 'demo-letter-hike',
|
||||||
|
subject: 'Wandertag der ${DemoPersona.className}',
|
||||||
|
sender: _teacher(0),
|
||||||
|
age: const Duration(hours: 2),
|
||||||
|
read: false,
|
||||||
|
childIds: const [_lena],
|
||||||
|
status: ParentLetterStatus.open,
|
||||||
|
body:
|
||||||
|
'Liebe Eltern,\n\nam Freitag in zwei Wochen findet unser Wandertag '
|
||||||
|
'statt. Wir fahren mit dem Bus in die Rhön und sind gegen 16 Uhr '
|
||||||
|
'zurück. Alle Einzelheiten finden Sie im angehängten Schreiben.\n\n'
|
||||||
|
'Bitte geben Sie uns eine verbindliche Rückmeldung.\n\n'
|
||||||
|
'Viele Grüße\n${DemoPersona.teachers[0].name}',
|
||||||
|
attachments: const [
|
||||||
|
ParentLetterAttachment(
|
||||||
|
id: 'demo-attachment-hike',
|
||||||
|
fileName: 'Wandertag.pdf',
|
||||||
|
mimeType: 'application/pdf',
|
||||||
|
size: 48211,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
request: ParentLetterRequest(
|
||||||
|
fields: const [_participation],
|
||||||
|
signatureRequired: true,
|
||||||
|
deadline: now.add(const Duration(days: 10)),
|
||||||
|
),
|
||||||
|
children: const [
|
||||||
|
ParentLetterChildState(
|
||||||
|
childId: _lena,
|
||||||
|
status: ParentLetterStatus.open,
|
||||||
|
editable: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
letter(
|
||||||
|
id: 'demo-letter-parents-evening',
|
||||||
|
subject: 'Einladung zum Elternabend der 6a',
|
||||||
|
sender: _teacher(1),
|
||||||
|
age: const Duration(days: 1),
|
||||||
|
read: false,
|
||||||
|
childIds: const [_jonas],
|
||||||
|
status: ParentLetterStatus.open,
|
||||||
|
body:
|
||||||
|
'Liebe Eltern,\n\nhiermit lade ich Sie herzlich zum ersten '
|
||||||
|
'Elternabend des Schuljahres ein. Wir treffen uns am kommenden '
|
||||||
|
'Dienstag um 19 Uhr in Raum ${DemoPersona.rooms[0]}.\n\n'
|
||||||
|
'Bitte bestätigen Sie kurz den Erhalt dieser Einladung.',
|
||||||
|
request: const ParentLetterRequest(),
|
||||||
|
children: const [
|
||||||
|
ParentLetterChildState(
|
||||||
|
childId: _jonas,
|
||||||
|
status: ParentLetterStatus.open,
|
||||||
|
editable: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
letter(
|
||||||
|
id: 'demo-letter-school-festival',
|
||||||
|
subject: 'Schulfest: Wer kommt mit?',
|
||||||
|
sender: _teacher(5),
|
||||||
|
age: const Duration(days: 3),
|
||||||
|
childIds: const [_lena, _jonas],
|
||||||
|
status: ParentLetterStatus.open,
|
||||||
|
body:
|
||||||
|
'Liebe Eltern,\n\nfür die Planung unseres Schulfestes möchten wir '
|
||||||
|
'wissen, welche Kinder teilnehmen. Die Angabe können Sie bis zum '
|
||||||
|
'Ablauf der Frist noch ändern.\n\nWeitere Informationen: '
|
||||||
|
'https://www.marianum-fulda.de',
|
||||||
|
request: ParentLetterRequest(
|
||||||
|
fields: const [_participation],
|
||||||
|
deadline: now.add(const Duration(days: 5)),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
ParentLetterChildState(
|
||||||
|
childId: _lena,
|
||||||
|
status: ParentLetterStatus.done,
|
||||||
|
editable: true,
|
||||||
|
response: ParentLetterResponse(
|
||||||
|
respondedAt: now.subtract(const Duration(days: 2)),
|
||||||
|
respondedBy: const ParentLetterPerson(self: true),
|
||||||
|
answers: const [
|
||||||
|
ParentLetterAnswer(
|
||||||
|
fieldId: 'participation',
|
||||||
|
optionIds: ['yes'],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const ParentLetterChildState(
|
||||||
|
childId: _jonas,
|
||||||
|
status: ParentLetterStatus.open,
|
||||||
|
editable: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
letter(
|
||||||
|
id: 'demo-letter-reports',
|
||||||
|
subject: 'Zeugnisausgabe und Unterrichtsschluss',
|
||||||
|
sender: _teacher(3),
|
||||||
|
age: const Duration(days: 7),
|
||||||
|
childIds: const [_lena, _jonas],
|
||||||
|
body:
|
||||||
|
'Liebe Eltern,\n\nam letzten Schultag vor den Ferien endet der '
|
||||||
|
'Unterricht nach der dritten Stunde mit der Zeugnisausgabe. Die '
|
||||||
|
'Busse fahren entsprechend früher.',
|
||||||
|
thread: ParentLetterThread(
|
||||||
|
enabled: true,
|
||||||
|
messages: [
|
||||||
|
ParentLetterThreadMessage(
|
||||||
|
id: 'demo-thread-1',
|
||||||
|
author: const ParentLetterPerson(self: true),
|
||||||
|
body: 'Gilt das auch für die Nachmittagsbetreuung?',
|
||||||
|
sentAt: now.subtract(const Duration(days: 6, hours: 20)),
|
||||||
|
),
|
||||||
|
ParentLetterThreadMessage(
|
||||||
|
id: 'demo-thread-2',
|
||||||
|
author: _teacher(3),
|
||||||
|
body:
|
||||||
|
'Ja, die Betreuung entfällt an diesem Tag ebenfalls. Bei '
|
||||||
|
'Bedarf melden Sie sich bitte im Sekretariat.',
|
||||||
|
sentAt: now.subtract(const Duration(days: 6, hours: 2)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
letter(
|
||||||
|
id: 'demo-letter-swimming',
|
||||||
|
subject: 'Einverständnis Schwimmunterricht',
|
||||||
|
sender: _teacher(6),
|
||||||
|
age: const Duration(days: 21),
|
||||||
|
childIds: const [_jonas],
|
||||||
|
status: ParentLetterStatus.done,
|
||||||
|
body:
|
||||||
|
'Liebe Eltern,\n\nim zweiten Halbjahr findet der Sportunterricht '
|
||||||
|
'der 6a im Hallenbad statt. Dafür benötigen wir Ihr '
|
||||||
|
'Einverständnis.',
|
||||||
|
request: const ParentLetterRequest(
|
||||||
|
fields: [_participation],
|
||||||
|
signatureRequired: true,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
ParentLetterChildState(
|
||||||
|
childId: _jonas,
|
||||||
|
status: ParentLetterStatus.done,
|
||||||
|
response: ParentLetterResponse(
|
||||||
|
respondedAt: now.subtract(const Duration(days: 20)),
|
||||||
|
respondedBy: const ParentLetterPerson(displayName: 'M. Hoffmann'),
|
||||||
|
answers: const [
|
||||||
|
ParentLetterAnswer(
|
||||||
|
fieldId: 'participation',
|
||||||
|
optionIds: ['yes'],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
signed: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
thread: const ParentLetterThread(),
|
||||||
|
),
|
||||||
|
letter(
|
||||||
|
id: 'demo-letter-ski-trip',
|
||||||
|
subject: 'Anmeldung zur Skifreizeit',
|
||||||
|
sender: _teacher(6),
|
||||||
|
age: const Duration(days: 35),
|
||||||
|
childIds: const [_lena],
|
||||||
|
status: ParentLetterStatus.expired,
|
||||||
|
body:
|
||||||
|
'Liebe Eltern,\n\ndie Anmeldung zur Skifreizeit der Jahrgangsstufe '
|
||||||
|
'10 ist ab sofort möglich. Die Plätze sind begrenzt.',
|
||||||
|
request: ParentLetterRequest(
|
||||||
|
fields: const [_participation],
|
||||||
|
signatureRequired: true,
|
||||||
|
deadline: now.subtract(const Duration(days: 14)),
|
||||||
|
),
|
||||||
|
children: const [
|
||||||
|
ParentLetterChildState(
|
||||||
|
childId: _lena,
|
||||||
|
status: ParentLetterStatus.expired,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
thread: const ParentLetterThread(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import '../../../model/account_data.dart';
|
import '../../../session/session_manager.dart';
|
||||||
import '../../marianumcloud/talk/chat/get_chat_response.dart';
|
import '../../marianumcloud/talk/chat/get_chat_response.dart';
|
||||||
import '../../marianumcloud/talk/room/get_room_response.dart';
|
import '../../marianumcloud/talk/room/get_room_response.dart';
|
||||||
import '../demo_persona.dart';
|
import '../demo_persona.dart';
|
||||||
@@ -204,7 +204,8 @@ class DemoTalk {
|
|||||||
id: base + 2,
|
id: base + 2,
|
||||||
token: token,
|
token: token,
|
||||||
ago: const Duration(days: 1, hours: 6),
|
ago: const Duration(days: 1, hours: 6),
|
||||||
message: 'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?',
|
message:
|
||||||
|
'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?',
|
||||||
),
|
),
|
||||||
_msg(
|
_msg(
|
||||||
id: base + 3,
|
id: base + 3,
|
||||||
@@ -366,7 +367,7 @@ class DemoTalk {
|
|||||||
}) => _msg(
|
}) => _msg(
|
||||||
id: id,
|
id: id,
|
||||||
token: token,
|
token: token,
|
||||||
actor: AccountData().getUsername(),
|
actor: SessionManager().requireNextcloud().username,
|
||||||
display: DemoPersona.studentName,
|
display: DemoPersona.studentName,
|
||||||
ago: ago,
|
ago: ago,
|
||||||
message: message,
|
message: message,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import '../../model/account_data.dart';
|
import '../../session/session_manager.dart';
|
||||||
|
|
||||||
/// Central switch for the client-side demo mode.
|
/// Central switch for the client-side demo mode.
|
||||||
///
|
///
|
||||||
@@ -8,7 +8,7 @@ import '../../model/account_data.dart';
|
|||||||
/// Play reviewers and automated screenshot runs see a fully populated app
|
/// Play reviewers and automated screenshot runs see a fully populated app
|
||||||
/// without a real account and without any network dependency.
|
/// without a real account and without any network dependency.
|
||||||
///
|
///
|
||||||
/// The flag is persisted through [AccountData.isDemo], so a demo session
|
/// The flag is persisted through [Session.isDemo], so a demo session
|
||||||
/// survives cold starts exactly like a normal login. It works in release builds
|
/// survives cold starts exactly like a normal login. It works in release builds
|
||||||
/// too — reviewers run the shipped release build.
|
/// too — reviewers run the shipped release build.
|
||||||
class DemoMode {
|
class DemoMode {
|
||||||
@@ -22,6 +22,14 @@ class DemoMode {
|
|||||||
static bool matches(String username) =>
|
static bool matches(String username) =>
|
||||||
username.trim().toLowerCase().startsWith(usernamePrefix);
|
username.trim().toLowerCase().startsWith(usernamePrefix);
|
||||||
|
|
||||||
|
/// Guardian logins are passwordless, so reviewers need a fixed address that
|
||||||
|
/// skips the mail round-trip. Deliberately not the `demo@` prefix, which a
|
||||||
|
/// real e-mail address could start with.
|
||||||
|
static const String guardianEmail = 'demo-eltern@marianum-fulda.de';
|
||||||
|
|
||||||
|
static bool matchesGuardian(String email) =>
|
||||||
|
email.trim().toLowerCase() == guardianEmail;
|
||||||
|
|
||||||
/// True while the active session is a demo session.
|
/// True while the active session is a demo session.
|
||||||
static bool get active => AccountData().isDemo;
|
static bool get active => SessionManager().isDemo;
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:nextcloud/nextcloud.dart';
|
import 'package:nextcloud/nextcloud.dart';
|
||||||
|
|
||||||
|
import '../../session/session.dart';
|
||||||
import '../api_error.dart';
|
import '../api_error.dart';
|
||||||
import '../http_errors.dart';
|
import '../http_errors.dart';
|
||||||
import '../marianumcloud/talk/talk_error.dart';
|
import '../marianumcloud/talk/talk_error.dart';
|
||||||
@@ -61,7 +62,9 @@ AppException? _dioToAppException(DioException error) {
|
|||||||
AppException _dynamiteToAppException(DynamiteApiException error) {
|
AppException _dynamiteToAppException(DynamiteApiException error) {
|
||||||
final status = error.statusCode;
|
final status = error.statusCode;
|
||||||
final preview = previewBody(error.body);
|
final preview = previewBody(error.body);
|
||||||
final detail = preview.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview';
|
final detail = preview.isEmpty
|
||||||
|
? 'HTTP $status'
|
||||||
|
: 'HTTP $status body=$preview';
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 401:
|
case 401:
|
||||||
return AuthException.unauthorized(technicalDetails: detail);
|
return AuthException.unauthorized(technicalDetails: detail);
|
||||||
@@ -86,6 +89,9 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
|
|||||||
if (error is AppException) return error.userMessage;
|
if (error is AppException) return error.userMessage;
|
||||||
|
|
||||||
if (error is TalkError) return TalkException(error).userMessage;
|
if (error is TalkError) return TalkException(error).userMessage;
|
||||||
|
if (error is NextcloudUnavailableException) {
|
||||||
|
return 'Diese Funktion ist mit deinem Konto nicht verfügbar.';
|
||||||
|
}
|
||||||
|
|
||||||
if (error is DioException) {
|
if (error is DioException) {
|
||||||
final mapped = _dioToAppException(error);
|
final mapped = _dioToAppException(error);
|
||||||
@@ -136,6 +142,7 @@ String? errorToTechnicalDetails(Object? error) {
|
|||||||
bool errorAllowsRetry(Object? error) {
|
bool errorAllowsRetry(Object? error) {
|
||||||
if (error == null) return true;
|
if (error == null) return true;
|
||||||
if (error is AppException) return error.allowRetry;
|
if (error is AppException) return error.allowRetry;
|
||||||
|
if (error is NextcloudUnavailableException) return false;
|
||||||
if (error is DioException) {
|
if (error is DioException) {
|
||||||
final mapped = _dioToAppException(error);
|
final mapped = _dioToAppException(error);
|
||||||
if (mapped != null) return mapped.allowRetry;
|
if (mapped != null) return mapped.allowRetry;
|
||||||
|
|||||||
@@ -16,10 +16,15 @@ class DeleteAppPassword {
|
|||||||
Future<void> run({String? authorizationHeader}) async {
|
Future<void> run({String? authorizationHeader}) async {
|
||||||
await _client.delete(
|
await _client.delete(
|
||||||
NextcloudOcs.uri('core/apppassword'),
|
NextcloudOcs.uri('core/apppassword'),
|
||||||
headers: {
|
// An explicit header may belong to an inactive account, so the active
|
||||||
...NextcloudOcs.headers(),
|
// session's headers must not be required then.
|
||||||
'Authorization': ?authorizationHeader,
|
headers: authorizationHeader == null
|
||||||
},
|
? NextcloudOcs.headers()
|
||||||
|
: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'OCS-APIRequest': 'true',
|
||||||
|
'Authorization': authorizationHeader,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,13 +2,13 @@ import 'dart:convert';
|
|||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../../../model/account_data.dart';
|
import '../../../session/session_manager.dart';
|
||||||
import '../../http_errors.dart';
|
import '../../http_errors.dart';
|
||||||
import '../nextcloud_ocs.dart';
|
import '../nextcloud_ocs.dart';
|
||||||
|
|
||||||
/// Exchanges the user's real Nextcloud password for a scoped app password via
|
/// Exchanges the user's real Nextcloud password for a scoped app password via
|
||||||
/// `GET /ocs/v2.php/core/getapppassword`. All subsequent Nextcloud calls then
|
/// `GET /ocs/v2.php/core/getapppassword`. All subsequent Nextcloud calls then
|
||||||
/// authenticate with the app password (see [AccountData.getBasicAuthHeader]),
|
/// authenticate with the app password (see [NextcloudCredentials.basicAuthHeader]),
|
||||||
/// which is what the push-v2 registration binds to.
|
/// which is what the push-v2 registration binds to.
|
||||||
///
|
///
|
||||||
/// Must authenticate with the *real* password — an app password cannot mint
|
/// Must authenticate with the *real* password — an app password cannot mint
|
||||||
@@ -33,7 +33,9 @@ class GetAppPassword {
|
|||||||
// Deliberately NOT the shared Authorization value: that one prefers
|
// Deliberately NOT the shared Authorization value: that one prefers
|
||||||
// the app password, but an app password cannot mint another one —
|
// the app password, but an app password cannot mint another one —
|
||||||
// this endpoint requires the real password.
|
// this endpoint requires the real password.
|
||||||
'Authorization': AccountData().getRealPasswordBasicAuthHeader(),
|
'Authorization': SessionManager()
|
||||||
|
.requireNextcloud()
|
||||||
|
.realPasswordBasicAuthHeader,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
))!;
|
))!;
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import 'dart:typed_data';
|
|||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../../../model/account_data.dart';
|
|
||||||
import '../../../model/endpoint_data.dart';
|
import '../../../model/endpoint_data.dart';
|
||||||
|
import '../../../session/session_manager.dart';
|
||||||
import '../../errors/parse_exception.dart';
|
import '../../errors/parse_exception.dart';
|
||||||
import '../../http_errors.dart';
|
import '../../http_errors.dart';
|
||||||
import '../nextcloud_ocs.dart';
|
import '../nextcloud_ocs.dart';
|
||||||
@@ -27,12 +27,12 @@ Uri _coreAvatarUri() {
|
|||||||
return Uri.https(endpoint.domain, '${endpoint.path}/avatar/');
|
return Uri.https(endpoint.domain, '${endpoint.path}/avatar/');
|
||||||
}
|
}
|
||||||
|
|
||||||
Uri _userInfoUri() =>
|
Uri _userInfoUri() => NextcloudOcs.uri(
|
||||||
NextcloudOcs.uri('cloud/users/${AccountData().getUsername()}');
|
'cloud/users/${SessionManager().requireNextcloud().username}',
|
||||||
|
);
|
||||||
|
|
||||||
Future<http.Response> _send(
|
Future<http.Response> _send(
|
||||||
Future<http.Response> Function(Uri uri, Map<String, String> headers)
|
Future<http.Response> Function(Uri uri, Map<String, String> headers) perform,
|
||||||
perform,
|
|
||||||
Uri uri,
|
Uri uri,
|
||||||
) async {
|
) async {
|
||||||
final headers = NextcloudOcs.headers();
|
final headers = NextcloudOcs.headers();
|
||||||
@@ -98,16 +98,13 @@ class GetUserInfo {
|
|||||||
try {
|
try {
|
||||||
final root = jsonDecode(response.body) as Map<String, dynamic>;
|
final root = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
final data =
|
final data =
|
||||||
(root['ocs'] as Map<String, dynamic>)['data']
|
(root['ocs'] as Map<String, dynamic>)['data'] as Map<String, dynamic>;
|
||||||
as Map<String, dynamic>;
|
|
||||||
return CloudUserInfo(
|
return CloudUserInfo(
|
||||||
userId: data['id'] as String,
|
userId: data['id'] as String,
|
||||||
displayName: (data['displayname'] as String?) ?? '',
|
displayName: (data['displayname'] as String?) ?? '',
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw ParseException(
|
throw ParseException(technicalDetails: 'Cloud $uri user info parse: $e');
|
||||||
technicalDetails: 'Cloud $uri user info parse: $e',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import '../../model/account_data.dart';
|
|
||||||
import '../../model/endpoint_data.dart';
|
import '../../model/endpoint_data.dart';
|
||||||
|
import '../../session/session_manager.dart';
|
||||||
|
|
||||||
/// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by
|
/// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by
|
||||||
/// TalkApi, AutocompleteApi, FileSharingApi.
|
/// TalkApi, AutocompleteApi, FileSharingApi.
|
||||||
@@ -16,7 +16,7 @@ class NextcloudOcs {
|
|||||||
static Map<String, String> headers() => {
|
static Map<String, String> headers() => {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'OCS-APIRequest': 'true',
|
'OCS-APIRequest': 'true',
|
||||||
'Authorization': AccountData().getBasicAuthHeader(),
|
'Authorization': SessionManager().requireNextcloud().basicAuthHeader,
|
||||||
};
|
};
|
||||||
|
|
||||||
static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) {
|
static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:nextcloud/nextcloud.dart';
|
import 'package:nextcloud/nextcloud.dart';
|
||||||
|
|
||||||
import '../../../model/account_data.dart';
|
|
||||||
import '../../../model/endpoint_data.dart';
|
import '../../../model/endpoint_data.dart';
|
||||||
|
import '../../../session/session_manager.dart';
|
||||||
import '../../api_response.dart';
|
import '../../api_response.dart';
|
||||||
|
|
||||||
abstract class WebdavApi<T> {
|
abstract class WebdavApi<T> {
|
||||||
@@ -20,8 +20,8 @@ abstract class WebdavApi<T> {
|
|||||||
static Future<WebDavClient> get webdav {
|
static Future<WebDavClient> get webdav {
|
||||||
// Keyed by user too: two accounts may share a password (no app password
|
// Keyed by user too: two accounts may share a password (no app password
|
||||||
// minted), and the client would keep the previous login name.
|
// minted), and the client would keep the previous login name.
|
||||||
final secret =
|
final nextcloud = SessionManager().requireNextcloud();
|
||||||
'${AccountData().getUsername()}:${AccountData().getNextcloudSecret()}';
|
final secret = '${nextcloud.username}:${nextcloud.secret}';
|
||||||
if (_webdav == null || _webdavSecret != secret) {
|
if (_webdav == null || _webdavSecret != secret) {
|
||||||
_webdavSecret = secret;
|
_webdavSecret = secret;
|
||||||
_webdav = establishWebdavConnection();
|
_webdav = establishWebdavConnection();
|
||||||
@@ -33,13 +33,13 @@ abstract class WebdavApi<T> {
|
|||||||
NextcloudClient(
|
NextcloudClient(
|
||||||
Uri.parse('https://${EndpointData().nextcloud().full()}'),
|
Uri.parse('https://${EndpointData().nextcloud().full()}'),
|
||||||
// App password preferred — with 2FA the real password is not accepted
|
// App password preferred — with 2FA the real password is not accepted
|
||||||
// by Nextcloud at all (see AccountData.usesLoginFlow).
|
// by Nextcloud at all (see NextcloudCredentials.usesLoginFlow).
|
||||||
password: AccountData().getNextcloudSecret(),
|
password: SessionManager().requireNextcloud().secret,
|
||||||
loginName: AccountData().getUsername(),
|
loginName: SessionManager().requireNextcloud().username,
|
||||||
).webdav;
|
).webdav;
|
||||||
|
|
||||||
/// Builds the WebDAV download URL without embedded credentials. Callers must
|
/// Builds the WebDAV download URL without embedded credentials. Callers must
|
||||||
/// authenticate via the [AccountData.authHeaders] header instead.
|
/// authenticate via the [NextcloudCredentials.authHeaders] header instead.
|
||||||
static String buildWebdavUrl() =>
|
static String buildWebdavUrl() =>
|
||||||
'https://${EndpointData().nextcloud().full()}/remote.php/dav/files/${AccountData().getUsername()}/';
|
'https://${EndpointData().nextcloud().full()}/remote.php/dav/files/${SessionManager().requireNextcloud().username}/';
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../../../model/account_data.dart';
|
import '../../../session/session.dart';
|
||||||
|
import '../../../session/session_manager.dart';
|
||||||
import '../queries/auth_login/auth_login.dart';
|
import '../queries/auth_login/auth_login.dart';
|
||||||
import 'device_token_name.dart';
|
import 'device_token_name.dart';
|
||||||
import 'token_storage.dart';
|
import 'token_storage.dart';
|
||||||
|
|
||||||
/// Adds the bearer token to outgoing Marianum-Connect requests and, on 401,
|
/// Adds the bearer token to outgoing Marianum-Connect requests and, on 401,
|
||||||
/// re-logs in once with the credentials in [AccountData] before retrying.
|
/// renews the token once before retrying. Only password accounts can renew
|
||||||
|
/// silently; passwordless accounts surface the 401.
|
||||||
class MarianumConnectAuthInterceptor extends Interceptor {
|
class MarianumConnectAuthInterceptor extends Interceptor {
|
||||||
static const _retriedKey = 'mc_auth_retried';
|
static const _retriedKey = 'mc_auth_retried';
|
||||||
|
|
||||||
@@ -18,6 +20,15 @@ class MarianumConnectAuthInterceptor extends Interceptor {
|
|||||||
// each spawning a fresh row in api_tokens.
|
// each spawning a fresh row in api_tokens.
|
||||||
Future<bool>? _pendingReLogin;
|
Future<bool>? _pendingReLogin;
|
||||||
|
|
||||||
|
static Future<bool>? _anyPendingReLogin;
|
||||||
|
|
||||||
|
/// Resolves once no silent re-login is running. An account switch waits for
|
||||||
|
/// it — the renewed token would otherwise land in the next account's slot.
|
||||||
|
static Future<void> idle() async {
|
||||||
|
final pending = _anyPendingReLogin;
|
||||||
|
if (pending != null) await pending;
|
||||||
|
}
|
||||||
|
|
||||||
MarianumConnectAuthInterceptor({
|
MarianumConnectAuthInterceptor({
|
||||||
MarianumConnectTokenStorage tokenStorage =
|
MarianumConnectTokenStorage tokenStorage =
|
||||||
const MarianumConnectTokenStorage(),
|
const MarianumConnectTokenStorage(),
|
||||||
@@ -64,6 +75,9 @@ class MarianumConnectAuthInterceptor extends Interceptor {
|
|||||||
}
|
}
|
||||||
final refreshed = await _attemptReLogin();
|
final refreshed = await _attemptReLogin();
|
||||||
if (!refreshed) {
|
if (!refreshed) {
|
||||||
|
if (SessionManager().current is GuardianSession) {
|
||||||
|
SessionManager().reportUnauthorized();
|
||||||
|
}
|
||||||
handler.next(err);
|
handler.next(err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -80,32 +94,35 @@ class MarianumConnectAuthInterceptor extends Interceptor {
|
|||||||
if (inFlight != null) return inFlight;
|
if (inFlight != null) return inFlight;
|
||||||
final fresh = _performReLogin();
|
final fresh = _performReLogin();
|
||||||
_pendingReLogin = fresh;
|
_pendingReLogin = fresh;
|
||||||
|
_anyPendingReLogin = fresh;
|
||||||
fresh.whenComplete(() {
|
fresh.whenComplete(() {
|
||||||
if (identical(_pendingReLogin, fresh)) _pendingReLogin = null;
|
if (identical(_pendingReLogin, fresh)) _pendingReLogin = null;
|
||||||
|
if (identical(_anyPendingReLogin, fresh)) _anyPendingReLogin = null;
|
||||||
});
|
});
|
||||||
return fresh;
|
return fresh;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _performReLogin() async {
|
Future<bool> _performReLogin() async {
|
||||||
if (!AccountData().isPopulated()) return false;
|
final session = SessionManager().current;
|
||||||
final username = AccountData().getUsername();
|
if (session is! CredentialSession) return false;
|
||||||
|
final username = session.username;
|
||||||
// A background engine (widget task) keeps the account it loaded. When
|
// A background engine (widget task) keeps the account it loaded. When
|
||||||
// the app signed that account out, its revoked token answers 401 — a
|
// 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.
|
// re-login would mint a fresh token for it into the shared keystore.
|
||||||
if (await AccountData().readStoredUsername() != username) return false;
|
if (await SessionManager().readStoredUsername() != username) return false;
|
||||||
try {
|
try {
|
||||||
await _loginClient.run(
|
await _loginClient.run(
|
||||||
username: username,
|
username: username,
|
||||||
password: AccountData().getPassword(),
|
password: session.password,
|
||||||
tokenName: await DeviceTokenName.resolve(),
|
tokenName: await DeviceTokenName.resolve(),
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (await AccountData().readStoredUsername() == username) {
|
if (await SessionManager().readStoredUsername() == username) {
|
||||||
await _tokenStorage.clear();
|
await _tokenStorage.clear();
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
final stored = await AccountData().readStoredUsername();
|
final stored = await SessionManager().readStoredUsername();
|
||||||
if (stored == username) return true;
|
if (stored == username) return true;
|
||||||
// Signed out during the login: drop the orphaned token, unless another
|
// Signed out during the login: drop the orphaned token, unless another
|
||||||
// account already stored its own.
|
// account already stored its own.
|
||||||
|
|||||||
@@ -1,39 +1,50 @@
|
|||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
import '../../../model/account_data.dart';
|
import '../../../session/session.dart';
|
||||||
|
import '../../../session/session_lifecycle.dart';
|
||||||
|
import '../../../session/session_manager.dart';
|
||||||
import '../../errors/auth_exception.dart';
|
import '../../errors/auth_exception.dart';
|
||||||
import '../queries/auth_logout/auth_logout.dart';
|
import '../queries/auth_me/auth_me.dart';
|
||||||
import '../queries/auth_verify/auth_verify.dart';
|
import '../queries/auth_verify/auth_verify.dart';
|
||||||
import 'token_storage.dart';
|
|
||||||
|
|
||||||
/// Background credential probe — a server-side password rotation forces a
|
/// Credential probe. For password accounts a server-side password rotation
|
||||||
/// re-login on the next cold start even when the bearer token would still
|
/// forces a re-login on the next cold start even when the bearer token would
|
||||||
/// be accepted.
|
/// still be accepted; for guardians it confirms a rejected token before the
|
||||||
|
/// session is dropped. Another stored account then takes over.
|
||||||
class SessionValidator {
|
class SessionValidator {
|
||||||
static Future<void> probeStored({
|
static Future<void> probeStored({
|
||||||
required Future<void> Function() onInvalidated,
|
required Future<void> Function(String? nextAccountId) onInvalidated,
|
||||||
}) async {
|
}) async {
|
||||||
if (!AccountData().isPopulated()) return;
|
final session = SessionManager().current;
|
||||||
// AuthVerify uses its own dio (bypassing the demo interceptor), so a demo
|
// The probes use their own dio (bypassing the demo interceptor), so a demo
|
||||||
// session must be skipped here or its missing token would 401 into a logout.
|
// session must be skipped or its missing token would 401 into a logout.
|
||||||
if (AccountData().isDemo) return;
|
if (session == null || session.isDemo) return;
|
||||||
final username = AccountData().getUsername();
|
final epoch = SessionManager().sessionEpoch;
|
||||||
final password = AccountData().getPassword();
|
|
||||||
final epoch = AccountData().sessionEpoch;
|
|
||||||
try {
|
try {
|
||||||
await AuthVerify().run(username: username, password: password);
|
switch (session) {
|
||||||
|
case CredentialSession(:final username, :final password):
|
||||||
|
await AuthVerify().run(username: username, password: password);
|
||||||
|
case GuardianSession():
|
||||||
|
await AuthMe().run();
|
||||||
|
}
|
||||||
} on AuthException catch (e) {
|
} on AuthException catch (e) {
|
||||||
if (e.statusCode != 401) return;
|
if (e.statusCode != 401) return;
|
||||||
// The probed account already signed out; the 401 must not sign out
|
// The probed account is no longer the active one; the 401 must not
|
||||||
// whoever logged in meanwhile.
|
// sign out whoever took over meanwhile.
|
||||||
if (!AccountData().isCurrentSession(epoch)) return;
|
if (!SessionManager().isCurrentSession(epoch)) return;
|
||||||
log('MC: stored credentials rejected — forcing re-login');
|
log('MC: stored session rejected — forcing re-login');
|
||||||
await AuthLogout().run();
|
final next = await SessionLifecycle.signOut(
|
||||||
await const MarianumConnectTokenStorage().clear();
|
notice: switch (session) {
|
||||||
await AccountData().removeData();
|
CredentialSession() =>
|
||||||
await onInvalidated();
|
'Deine Zugangsdaten wurden vom Server abgelehnt. Vermutlich '
|
||||||
|
'wurde dein Passwort geändert. Bitte melde dich erneut an.',
|
||||||
|
GuardianSession() =>
|
||||||
|
'Deine Anmeldung ist abgelaufen. Bitte melde dich erneut an.',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await onInvalidated(next);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log('MC: background credential check failed (transient): $e');
|
log('MC: background session check failed (transient): $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
import '../../errors/auth_exception.dart';
|
||||||
|
|
||||||
/// `first_unlock` accessibility so the token can be read during background
|
/// `first_unlock` accessibility so the token can be read during background
|
||||||
/// requests (telemetry heartbeat, push-triggered syncs) after the first device
|
/// requests (telemetry heartbeat, push-triggered syncs) after the first device
|
||||||
/// unlock following a reboot. The keychain default (`whenUnlocked`) throws
|
/// unlock following a reboot. The keychain default (`whenUnlocked`) throws
|
||||||
@@ -9,7 +12,7 @@ const IOSOptions _mcIosOptions = IOSOptions(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// Persists the Marianum-Connect bearer token in the platform keystore. Kept
|
/// Persists the Marianum-Connect bearer token in the platform keystore. Kept
|
||||||
/// separate from `AccountData` because the username/password live on (Nextcloud
|
/// separate from `SessionManager` because the username/password live on (Nextcloud
|
||||||
/// + MHSL still need them) while the MC token is short-lived and per-endpoint.
|
/// + MHSL still need them) while the MC token is short-lived and per-endpoint.
|
||||||
class MarianumConnectTokenStorage {
|
class MarianumConnectTokenStorage {
|
||||||
static const _tokenKey = 'mc_bearer_token';
|
static const _tokenKey = 'mc_bearer_token';
|
||||||
@@ -24,6 +27,18 @@ class MarianumConnectTokenStorage {
|
|||||||
|
|
||||||
Future<String?> readToken() => _storage.read(key: _tokenKey);
|
Future<String?> readToken() => _storage.read(key: _tokenKey);
|
||||||
|
|
||||||
|
/// Request options carrying the stored token, for probes that bypass the
|
||||||
|
/// auth interceptor. Throws [AuthException] when no token is stored.
|
||||||
|
Future<Options> requireBearerOptions(String caller) async {
|
||||||
|
final token = await readToken();
|
||||||
|
if (token == null || token.isEmpty) {
|
||||||
|
throw AuthException.unauthorized(
|
||||||
|
technicalDetails: '$caller: no bearer token in storage',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Options(headers: {'Authorization': 'Bearer $token'});
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> readTokenId() => _storage.read(key: _tokenIdKey);
|
Future<String?> readTokenId() => _storage.read(key: _tokenIdKey);
|
||||||
|
|
||||||
Future<DateTime?> readExpiresAt() async {
|
Future<DateTime?> readExpiresAt() async {
|
||||||
@@ -45,6 +60,26 @@ class MarianumConnectTokenStorage {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const bearerKey = _tokenKey;
|
||||||
|
static const fieldKeys = [_tokenKey, _tokenIdKey, _expiresAtKey];
|
||||||
|
|
||||||
|
/// Raw stored fields, for parking the token of an inactive account.
|
||||||
|
Future<Map<String, String>> readAll() async => {
|
||||||
|
for (final key in fieldKeys) key: ?await _storage.read(key: key),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Restores fields from [readAll]; missing ones are deleted.
|
||||||
|
Future<void> writeAll(Map<String, String> fields) async {
|
||||||
|
for (final key in fieldKeys) {
|
||||||
|
final value = fields[key];
|
||||||
|
if (value == null) {
|
||||||
|
await _storage.delete(key: key);
|
||||||
|
} else {
|
||||||
|
await _storage.write(key: key, value: value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> clear() async {
|
Future<void> clear() async {
|
||||||
await _storage.delete(key: _tokenKey);
|
await _storage.delete(key: _tokenKey);
|
||||||
await _storage.delete(key: _tokenIdKey);
|
await _storage.delete(key: _tokenIdKey);
|
||||||
|
|||||||
@@ -6,6 +6,22 @@ import '../../errors/network_exception.dart';
|
|||||||
import '../../errors/parse_exception.dart';
|
import '../../errors/parse_exception.dart';
|
||||||
import '../../errors/server_exception.dart';
|
import '../../errors/server_exception.dart';
|
||||||
|
|
||||||
|
/// The error code of a rejected call: the server answers either with JSON
|
||||||
|
/// `{"error": "<code>"}` or with the plain text `Fehler: <code>` of its
|
||||||
|
/// generic error handler.
|
||||||
|
String? marianumConnectErrorCode(Object? body) {
|
||||||
|
if (body is Map) {
|
||||||
|
final code = body['error'];
|
||||||
|
return code is String ? code : null;
|
||||||
|
}
|
||||||
|
if (body is String) {
|
||||||
|
const prefix = 'Fehler: ';
|
||||||
|
return (body.startsWith(prefix) ? body.substring(prefix.length) : body)
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Converts a DioException raised against the Marianum-Connect API into one of
|
/// Converts a DioException raised against the Marianum-Connect API into one of
|
||||||
/// the app's typed AppExceptions. Keeps the dio dependency out of call sites
|
/// the app's typed AppExceptions. Keeps the dio dependency out of call sites
|
||||||
/// that just want to render an error message.
|
/// that just want to render an error message.
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../errors/app_exception.dart';
|
||||||
import 'errors/marianumconnect_error.dart';
|
import 'errors/marianumconnect_error.dart';
|
||||||
import 'marianumconnect_api.dart';
|
import 'marianumconnect_api.dart';
|
||||||
import 'marianumconnect_endpoint.dart';
|
import 'marianumconnect_endpoint.dart';
|
||||||
@@ -23,10 +26,14 @@ abstract class MarianumConnectQuery {
|
|||||||
try {
|
try {
|
||||||
return await body();
|
return await body();
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
throw mapMarianumConnectError(e);
|
throw mapError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The AppException a failed call surfaces as. Query families with domain
|
||||||
|
/// error codes override this instead of re-implementing [guard].
|
||||||
|
AppException mapError(DioException error) => mapMarianumConnectError(error);
|
||||||
|
|
||||||
/// GETs [path] and parses the JSON object body with [fromJson].
|
/// GETs [path] and parses the JSON object body with [fromJson].
|
||||||
Future<T> getObject<T>(
|
Future<T> getObject<T>(
|
||||||
String path,
|
String path,
|
||||||
@@ -55,6 +62,16 @@ abstract class MarianumConnectQuery {
|
|||||||
.toList();
|
.toList();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// GETs the raw bytes of [path] (files that need the bearer token).
|
||||||
|
Future<Uint8List> getBytes(String path) => guard(() async {
|
||||||
|
final response = await dio.get<List<int>>(
|
||||||
|
endpoint(path),
|
||||||
|
options: Options(responseType: ResponseType.bytes),
|
||||||
|
);
|
||||||
|
final bytes = response.data!;
|
||||||
|
return bytes is Uint8List ? bytes : Uint8List.fromList(bytes);
|
||||||
|
});
|
||||||
|
|
||||||
/// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params.
|
/// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params.
|
||||||
String isoDate(DateTime d) =>
|
String isoDate(DateTime d) =>
|
||||||
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||||
|
|||||||
@@ -1,10 +1,30 @@
|
|||||||
|
import '../../../errors/app_exception.dart';
|
||||||
import '../../marianumconnect_query.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
import 'absence_prefill_response.dart';
|
import 'absence_prefill_response.dart';
|
||||||
|
|
||||||
/// GETs the absence-report form prefill (`absence/prefill`, bearer-auth).
|
/// GETs the absence-report form prefill (`absence/prefill`, bearer-auth).
|
||||||
|
/// Guardians pass the [childId] the report is for; the identity then comes
|
||||||
|
/// from that child.
|
||||||
class AbsencePrefill extends MarianumConnectQuery {
|
class AbsencePrefill extends MarianumConnectQuery {
|
||||||
AbsencePrefill({super.dio});
|
AbsencePrefill({super.dio});
|
||||||
|
|
||||||
Future<AbsencePrefillResponse> run() =>
|
Future<AbsencePrefillResponse> run({String? childId}) => getObject(
|
||||||
getObject('absence/prefill', AbsencePrefillResponse.fromJson);
|
'absence/prefill',
|
||||||
|
AbsencePrefillResponse.fromJson,
|
||||||
|
queryParameters: {'childId': ?childId},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The prefill of a guardian's report is missing a field the form cannot ask
|
||||||
|
/// for: name and class come from the child and are not editable there, so an
|
||||||
|
/// incomplete prefill would leave a locked, unsubmittable form behind.
|
||||||
|
class AbsencePrefillIncompleteException extends AppException {
|
||||||
|
const AbsencePrefillIncompleteException({super.technicalDetails})
|
||||||
|
: super(
|
||||||
|
userMessage:
|
||||||
|
'Für dieses Kind sind in der Schulverwaltung noch nicht alle '
|
||||||
|
'Angaben hinterlegt, die für eine Abwesenheitsmeldung nötig sind. '
|
||||||
|
'Bitte wende dich an das Sekretariat.',
|
||||||
|
allowRetry: false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,8 @@ import '../../marianumconnect_query.dart';
|
|||||||
/// Submits an absence report (`POST absence`, bearer-auth, `source=mobile`).
|
/// Submits an absence report (`POST absence`, bearer-auth, `source=mobile`).
|
||||||
/// Empty identity fields are backfilled from LDAP server-side; validation
|
/// Empty identity fields are backfilled from LDAP server-side; validation
|
||||||
/// (all fields required, class must exist, no past start date, end >= start)
|
/// (all fields required, class must exist, no past start date, end >= start)
|
||||||
/// also runs server-side and mirrors the client checks.
|
/// also runs server-side and mirrors the client checks. For guardians the
|
||||||
|
/// server takes name and class from the child given by [childId].
|
||||||
class AbsenceSubmit extends MarianumConnectQuery {
|
class AbsenceSubmit extends MarianumConnectQuery {
|
||||||
AbsenceSubmit({super.dio});
|
AbsenceSubmit({super.dio});
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ class AbsenceSubmit extends MarianumConnectQuery {
|
|||||||
required DateTime absentUntil,
|
required DateTime absentUntil,
|
||||||
required String phone,
|
required String phone,
|
||||||
required String note,
|
required String note,
|
||||||
|
String? childId,
|
||||||
}) => guard(() async {
|
}) => guard(() async {
|
||||||
await dio.post<void>(
|
await dio.post<void>(
|
||||||
endpoint('absence'),
|
endpoint('absence'),
|
||||||
@@ -26,6 +28,7 @@ class AbsenceSubmit extends MarianumConnectQuery {
|
|||||||
'absentUntil': isoDate(absentUntil),
|
'absentUntil': isoDate(absentUntil),
|
||||||
'phone': phone,
|
'phone': phone,
|
||||||
'note': note,
|
'note': note,
|
||||||
|
'childId': ?childId,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../../../auth_link/pending_guardian_request.dart';
|
||||||
|
import '../../marianumconnect_api.dart';
|
||||||
|
import '../../marianumconnect_query.dart';
|
||||||
|
import 'guardian_login_exception.dart';
|
||||||
|
|
||||||
|
class AuthGuardianRequestResponse {
|
||||||
|
final String requestId;
|
||||||
|
final DateTime expiresAt;
|
||||||
|
final DateTime resendAvailableAt;
|
||||||
|
final int codeLength;
|
||||||
|
|
||||||
|
const AuthGuardianRequestResponse({
|
||||||
|
required this.requestId,
|
||||||
|
required this.expiresAt,
|
||||||
|
required this.resendAvailableAt,
|
||||||
|
required this.codeLength,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory AuthGuardianRequestResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
AuthGuardianRequestResponse(
|
||||||
|
requestId: json['requestId'] as String,
|
||||||
|
expiresAt: DateTime.parse(json['expiresAt'] as String),
|
||||||
|
resendAvailableAt: DateTime.parse(json['resendAvailableAt'] as String),
|
||||||
|
codeLength:
|
||||||
|
json['codeLength'] as int? ??
|
||||||
|
PendingGuardianRequest.defaultCodeLength,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts a passwordless guardian login: the server mails a code and a link.
|
||||||
|
/// An unknown address is reported as such (`email_not_registered`) instead of
|
||||||
|
/// being answered like a known one — a deliberate trade: without it guardians
|
||||||
|
/// wait for a mail that never arrives. Registered addresses are therefore
|
||||||
|
/// enumerable; keep that in mind when changing the server contract.
|
||||||
|
class AuthGuardianRequest extends MarianumConnectQuery {
|
||||||
|
AuthGuardianRequest({Dio? dio})
|
||||||
|
: super(dio: dio ?? MarianumConnectApi.plainDio());
|
||||||
|
|
||||||
|
Future<AuthGuardianRequestResponse> run({
|
||||||
|
required String email,
|
||||||
|
required String deviceChallenge,
|
||||||
|
required String tokenName,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await dio.post<Map<String, dynamic>>(
|
||||||
|
endpoint('auth/guardian/request'),
|
||||||
|
data: {
|
||||||
|
'email': email,
|
||||||
|
'deviceChallenge': deviceChallenge,
|
||||||
|
'tokenName': tokenName,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return AuthGuardianRequestResponse.fromJson(response.data!);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw GuardianLoginException.fromDio(e, verifying: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../auth/token_storage.dart';
|
||||||
|
import '../../marianumconnect_api.dart';
|
||||||
|
import '../../marianumconnect_query.dart';
|
||||||
|
import '../auth_login/auth_login_response.dart';
|
||||||
|
import 'guardian_login_exception.dart';
|
||||||
|
|
||||||
|
/// Completes a guardian login with the mailed code or link token and stores
|
||||||
|
/// the issued bearer token.
|
||||||
|
class AuthGuardianVerify extends MarianumConnectQuery {
|
||||||
|
final MarianumConnectTokenStorage _tokenStorage;
|
||||||
|
|
||||||
|
AuthGuardianVerify({
|
||||||
|
MarianumConnectTokenStorage tokenStorage =
|
||||||
|
const MarianumConnectTokenStorage(),
|
||||||
|
Dio? dio,
|
||||||
|
}) : _tokenStorage = tokenStorage,
|
||||||
|
super(dio: dio ?? MarianumConnectApi.plainDio());
|
||||||
|
|
||||||
|
Future<AuthLoginResponse> run({
|
||||||
|
required String requestId,
|
||||||
|
required String deviceVerifier,
|
||||||
|
required String tokenName,
|
||||||
|
String? code,
|
||||||
|
String? linkToken,
|
||||||
|
}) async {
|
||||||
|
assert((code == null) != (linkToken == null));
|
||||||
|
try {
|
||||||
|
final response = await dio.post<Map<String, dynamic>>(
|
||||||
|
endpoint('auth/guardian/verify'),
|
||||||
|
data: {
|
||||||
|
'requestId': requestId,
|
||||||
|
'deviceVerifier': deviceVerifier,
|
||||||
|
'tokenName': tokenName,
|
||||||
|
'code': ?code,
|
||||||
|
'linkToken': ?linkToken,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final payload = AuthLoginResponse.fromJson(response.data!);
|
||||||
|
await _tokenStorage.write(
|
||||||
|
token: payload.token,
|
||||||
|
tokenId: payload.tokenId,
|
||||||
|
expiresAt: payload.expiresAt,
|
||||||
|
);
|
||||||
|
return payload;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
throw GuardianLoginException.fromDio(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../../errors/app_exception.dart';
|
||||||
|
import '../../errors/marianumconnect_error.dart';
|
||||||
|
|
||||||
|
enum GuardianLoginError {
|
||||||
|
invalidRequest,
|
||||||
|
emailNotRegistered,
|
||||||
|
accountDisabled,
|
||||||
|
invalidCode,
|
||||||
|
deviceMismatch,
|
||||||
|
requestConsumed,
|
||||||
|
requestExpired,
|
||||||
|
tooManyAttempts,
|
||||||
|
rateLimited,
|
||||||
|
unsupportedServer,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rejected guardian login step with the reason the server reported.
|
||||||
|
class GuardianLoginException extends AppException {
|
||||||
|
final GuardianLoginError error;
|
||||||
|
final int? attemptsLeft;
|
||||||
|
|
||||||
|
const GuardianLoginException(
|
||||||
|
this.error, {
|
||||||
|
required super.userMessage,
|
||||||
|
this.attemptsLeft,
|
||||||
|
super.technicalDetails,
|
||||||
|
}) : super(allowRetry: false);
|
||||||
|
|
||||||
|
/// Maps a failed guardian auth call. Only 4xx answers that carry a guardian
|
||||||
|
/// login reason become [GuardianLoginException]; everything else keeps the
|
||||||
|
/// generic MarianumConnect mapping (network, 5xx, …).
|
||||||
|
///
|
||||||
|
/// [verifying] false marks the e-mail step, where no code exists yet: a bare
|
||||||
|
/// 401 (reverse proxy, gateway) must not be reported as a wrong code there.
|
||||||
|
static AppException fromDio(DioException e, {bool verifying = true}) {
|
||||||
|
final response = e.response;
|
||||||
|
final status = response?.statusCode;
|
||||||
|
if (status == null || status < 400 || status >= 500) {
|
||||||
|
return mapMarianumConnectError(e);
|
||||||
|
}
|
||||||
|
final (code, attemptsLeft) = _parseBody(response!.data);
|
||||||
|
final error = _errorFor(code, status, verifying: verifying);
|
||||||
|
if (error == null) return mapMarianumConnectError(e);
|
||||||
|
return GuardianLoginException(
|
||||||
|
error,
|
||||||
|
attemptsLeft: attemptsLeft,
|
||||||
|
userMessage: messageFor(error, attemptsLeft: attemptsLeft),
|
||||||
|
technicalDetails: 'MC $status: ${response.data}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String messageFor(
|
||||||
|
GuardianLoginError error, {
|
||||||
|
int? attemptsLeft,
|
||||||
|
}) => switch (error) {
|
||||||
|
GuardianLoginError.invalidRequest =>
|
||||||
|
'Bitte gib eine gültige E-Mail-Adresse ein.',
|
||||||
|
GuardianLoginError.emailNotRegistered =>
|
||||||
|
'Unter dieser E-Mail-Adresse ist kein Eltern-Zugang hinterlegt. '
|
||||||
|
'Bitte prüfe, ob die korrekte Adresse verwendet wurde. Bitte wende dich an das Sekretariat, '
|
||||||
|
'wenn du nicht weißt, welche Adresse für dich registriert ist.',
|
||||||
|
GuardianLoginError.accountDisabled =>
|
||||||
|
'Dieser Eltern-Zugang ist derzeit deaktiviert. Bitte wende dich an '
|
||||||
|
'das Sekretariat.',
|
||||||
|
GuardianLoginError.invalidCode =>
|
||||||
|
attemptsLeft == null
|
||||||
|
? 'Der Code ist falsch.'
|
||||||
|
: 'Der Code ist falsch. Noch $attemptsLeft '
|
||||||
|
'${attemptsLeft == 1 ? 'Versuch' : 'Versuche'}.',
|
||||||
|
GuardianLoginError.deviceMismatch =>
|
||||||
|
'Dieser Anmeldelink wurde auf einem anderen Gerät angefordert. '
|
||||||
|
'Bitte gib stattdessen den Code aus der E-Mail ein.',
|
||||||
|
GuardianLoginError.requestConsumed =>
|
||||||
|
'Diese Anmeldung wurde bereits verwendet. Bitte fordere einen neuen '
|
||||||
|
'Code an.',
|
||||||
|
GuardianLoginError.requestExpired =>
|
||||||
|
'Der Code ist abgelaufen. Bitte fordere einen neuen an.',
|
||||||
|
GuardianLoginError.tooManyAttempts =>
|
||||||
|
'Zu viele Fehlversuche. Bitte fordere einen neuen Code an.',
|
||||||
|
GuardianLoginError.rateLimited =>
|
||||||
|
'Zu viele Anfragen. Bitte warte einige Stunden und versuche es '
|
||||||
|
'erneut.',
|
||||||
|
GuardianLoginError.unsupportedServer =>
|
||||||
|
'Die Eltern-Anmeldung ist auf diesem Server noch nicht verfügbar. '
|
||||||
|
'Bitte versuche es später erneut.',
|
||||||
|
};
|
||||||
|
|
||||||
|
static (String?, int?) _parseBody(Object? data) {
|
||||||
|
final attempts = data is Map ? data['attemptsLeft'] : null;
|
||||||
|
return (marianumConnectErrorCode(data), attempts is int ? attempts : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static GuardianLoginError? _errorFor(
|
||||||
|
String? code,
|
||||||
|
int status, {
|
||||||
|
required bool verifying,
|
||||||
|
}) => switch (code) {
|
||||||
|
'invalid_request' => GuardianLoginError.invalidRequest,
|
||||||
|
'email_not_registered' => GuardianLoginError.emailNotRegistered,
|
||||||
|
'account_disabled' => GuardianLoginError.accountDisabled,
|
||||||
|
'invalid_code' => GuardianLoginError.invalidCode,
|
||||||
|
'device_mismatch' => GuardianLoginError.deviceMismatch,
|
||||||
|
'request_consumed' => GuardianLoginError.requestConsumed,
|
||||||
|
'request_expired' => GuardianLoginError.requestExpired,
|
||||||
|
'too_many_attempts' => GuardianLoginError.tooManyAttempts,
|
||||||
|
_ => switch (status) {
|
||||||
|
401 when verifying => GuardianLoginError.invalidCode,
|
||||||
|
// The server names an unknown address explicitly
|
||||||
|
// (`email_not_registered`); a bare 404/405 means the endpoint itself
|
||||||
|
// is missing, i.e. a server version without guardian login.
|
||||||
|
404 || 405 => GuardianLoginError.unsupportedServer,
|
||||||
|
409 => GuardianLoginError.requestConsumed,
|
||||||
|
410 => GuardianLoginError.requestExpired,
|
||||||
|
429 => GuardianLoginError.rateLimited,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
import '../../auth/token_storage.dart';
|
import '../../auth/token_storage.dart';
|
||||||
|
import '../../marianumconnect_api.dart';
|
||||||
|
import '../../marianumconnect_endpoint.dart';
|
||||||
import '../../marianumconnect_query.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
/// Revokes the stored MC bearer token both server-side and locally. Best-effort
|
/// Revokes the stored MC bearer token both server-side and locally. Best-effort
|
||||||
@@ -24,4 +26,17 @@ class AuthLogout extends MarianumConnectQuery {
|
|||||||
await _tokenStorage.clear();
|
await _tokenStorage.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Revokes the token of an inactive account; the stored (active) token is
|
||||||
|
/// left alone. Best-effort.
|
||||||
|
static Future<void> revoke(String token) async {
|
||||||
|
try {
|
||||||
|
await MarianumConnectApi.plainDio().post<void>(
|
||||||
|
MarianumConnectEndpoint.resolve('auth/logout'),
|
||||||
|
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
||||||
|
);
|
||||||
|
} on DioException catch (_) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../../errors/auth_exception.dart';
|
||||||
|
import '../../auth/token_storage.dart';
|
||||||
|
import '../../marianumconnect_api.dart';
|
||||||
|
import '../../marianumconnect_query.dart';
|
||||||
|
import '../auth_login/auth_login_response.dart';
|
||||||
|
|
||||||
|
/// Probes that the stored bearer token is still accepted. Used for accounts
|
||||||
|
/// without a password (guardians), whose token cannot be renewed silently.
|
||||||
|
///
|
||||||
|
/// Bypasses the shared dio singleton so the auth interceptor does not react
|
||||||
|
/// to the 401 this probe is meant to observe.
|
||||||
|
class AuthMe extends MarianumConnectQuery {
|
||||||
|
final MarianumConnectTokenStorage _tokenStorage;
|
||||||
|
|
||||||
|
AuthMe({
|
||||||
|
MarianumConnectTokenStorage tokenStorage =
|
||||||
|
const MarianumConnectTokenStorage(),
|
||||||
|
Dio? dio,
|
||||||
|
}) : _tokenStorage = tokenStorage,
|
||||||
|
super(dio: dio ?? MarianumConnectApi.plainDio());
|
||||||
|
|
||||||
|
/// Throws [AuthException] when the token is missing or rejected.
|
||||||
|
Future<void> run() async {
|
||||||
|
await user();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The signed-in user (names, type). Throws like [run].
|
||||||
|
Future<AuthLoginUser> user() async {
|
||||||
|
final options = await _tokenStorage.requireBearerOptions('AuthMe');
|
||||||
|
return guard(() async {
|
||||||
|
final response = await dio.get<Map<String, dynamic>>(
|
||||||
|
endpoint('auth/me'),
|
||||||
|
options: options,
|
||||||
|
);
|
||||||
|
return AuthLoginUser.fromJson(response.data!);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,17 +29,12 @@ class AuthVerify extends MarianumConnectQuery {
|
|||||||
required String username,
|
required String username,
|
||||||
required String password,
|
required String password,
|
||||||
}) async {
|
}) async {
|
||||||
final token = await _tokenStorage.readToken();
|
final options = await _tokenStorage.requireBearerOptions('AuthVerify');
|
||||||
if (token == null || token.isEmpty) {
|
|
||||||
throw AuthException.unauthorized(
|
|
||||||
technicalDetails: 'AuthVerify: no bearer token in storage',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return guard(() async {
|
return guard(() async {
|
||||||
await dio.post<void>(
|
await dio.post<void>(
|
||||||
endpoint('auth/verify'),
|
endpoint('auth/verify'),
|
||||||
data: {'username': username, 'password': password},
|
data: {'username': username, 'password': password},
|
||||||
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
options: options,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
import 'guardian_child.dart';
|
||||||
|
|
||||||
part 'get_capabilities_response.g.dart';
|
part 'get_capabilities_response.g.dart';
|
||||||
|
|
||||||
/// Slimmed-down capability flags the mobile UI gates features on. The backend
|
/// Slimmed-down capability flags the mobile UI gates features on. The backend
|
||||||
@@ -23,16 +25,21 @@ class CapabilitiesResponse {
|
|||||||
|
|
||||||
final int? timetableFutureDays;
|
final int? timetableFutureDays;
|
||||||
|
|
||||||
/// LDAP user type ('TEACHER' | 'STUDENT' | 'STAFF'). Null when the backend
|
/// User type ('TEACHER' | 'STUDENT' | 'STAFF' | 'PARENT'). Null when the
|
||||||
/// predates the field or has no LDAP record for the user.
|
/// backend predates the field or has no record for the user.
|
||||||
final String? userType;
|
final String? userType;
|
||||||
|
|
||||||
|
/// Students linked to a guardian account; empty for everyone else.
|
||||||
|
@JsonKey(defaultValue: <GuardianChild>[])
|
||||||
|
final List<GuardianChild> children;
|
||||||
|
|
||||||
CapabilitiesResponse({
|
CapabilitiesResponse({
|
||||||
required this.viewForeignTimetables,
|
required this.viewForeignTimetables,
|
||||||
required this.pushNotifications,
|
required this.pushNotifications,
|
||||||
this.timetablePastDays,
|
this.timetablePastDays,
|
||||||
this.timetableFutureDays,
|
this.timetableFutureDays,
|
||||||
this.userType,
|
this.userType,
|
||||||
|
this.children = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) =>
|
factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ CapabilitiesResponse _$CapabilitiesResponseFromJson(
|
|||||||
timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(),
|
timetablePastDays: (json['timetablePastDays'] as num?)?.toInt(),
|
||||||
timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(),
|
timetableFutureDays: (json['timetableFutureDays'] as num?)?.toInt(),
|
||||||
userType: json['userType'] as String?,
|
userType: json['userType'] as String?,
|
||||||
|
children:
|
||||||
|
(json['children'] as List<dynamic>?)
|
||||||
|
?.map((e) => GuardianChild.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$CapabilitiesResponseToJson(
|
Map<String, dynamic> _$CapabilitiesResponseToJson(
|
||||||
@@ -24,4 +29,5 @@ Map<String, dynamic> _$CapabilitiesResponseToJson(
|
|||||||
'timetablePastDays': instance.timetablePastDays,
|
'timetablePastDays': instance.timetablePastDays,
|
||||||
'timetableFutureDays': instance.timetableFutureDays,
|
'timetableFutureDays': instance.timetableFutureDays,
|
||||||
'userType': instance.userType,
|
'userType': instance.userType,
|
||||||
|
'children': instance.children,
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'guardian_child.freezed.dart';
|
||||||
|
part 'guardian_child.g.dart';
|
||||||
|
|
||||||
|
/// A student linked to the signed-in guardian. [id] is an opaque server id,
|
||||||
|
/// not a WebUntis id.
|
||||||
|
@freezed
|
||||||
|
abstract class GuardianChild with _$GuardianChild {
|
||||||
|
const GuardianChild._();
|
||||||
|
|
||||||
|
const factory GuardianChild({
|
||||||
|
required String id,
|
||||||
|
required String firstName,
|
||||||
|
required String lastName,
|
||||||
|
@Default('') String className,
|
||||||
|
|
||||||
|
/// Login of the child, also its Nextcloud id (avatar). Null on servers
|
||||||
|
/// that do not send it yet.
|
||||||
|
String? username,
|
||||||
|
}) = _GuardianChild;
|
||||||
|
|
||||||
|
factory GuardianChild.fromJson(Map<String, Object?> json) =>
|
||||||
|
_$GuardianChildFromJson(json);
|
||||||
|
|
||||||
|
String get displayName => '$firstName $lastName'.trim();
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// coverage:ignore-file
|
||||||
|
// ignore_for_file: type=lint, type=warning, deprecated_member_use, deprecated_member_use_from_same_package
|
||||||
|
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
|
||||||
|
|
||||||
|
part of 'guardian_child.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// FreezedGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// dart format off
|
||||||
|
T _$identity<T>(T value) => value;
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
mixin _$GuardianChild {
|
||||||
|
|
||||||
|
String get id; String get firstName; String get lastName; String get className;/// Login of the child, also its Nextcloud id (avatar). Null on servers
|
||||||
|
/// that do not send it yet.
|
||||||
|
String? get username;
|
||||||
|
/// Create a copy of GuardianChild
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
$GuardianChildCopyWith<GuardianChild> get copyWith => _$GuardianChildCopyWithImpl<GuardianChild>(this as GuardianChild, _$identity);
|
||||||
|
|
||||||
|
/// Serializes this GuardianChild to a JSON map.
|
||||||
|
Map<String, dynamic> toJson();
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
final _this = this as GuardianChild;
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is GuardianChild&&(identical(other.id, _this.id) || other.id == _this.id)&&(identical(other.firstName, _this.firstName) || other.firstName == _this.firstName)&&(identical(other.lastName, _this.lastName) || other.lastName == _this.lastName)&&(identical(other.className, _this.className) || other.className == _this.className)&&(identical(other.username, _this.username) || other.username == _this.username));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
final _this = this as GuardianChild;
|
||||||
|
return Object.hash(runtimeType,_this.id,_this.firstName,_this.lastName,_this.className,_this.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
final _this = this as GuardianChild;
|
||||||
|
return 'GuardianChild(id: ${_this.id}, firstName: ${_this.firstName}, lastName: ${_this.lastName}, className: ${_this.className}, username: ${_this.username})';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class $GuardianChildCopyWith<$Res> {
|
||||||
|
factory $GuardianChildCopyWith(GuardianChild value, $Res Function(GuardianChild) _then) = _$GuardianChildCopyWithImpl;
|
||||||
|
@useResult
|
||||||
|
$Res call({
|
||||||
|
String id, String firstName, String lastName, String className, String? username
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class _$GuardianChildCopyWithImpl<$Res>
|
||||||
|
implements $GuardianChildCopyWith<$Res> {
|
||||||
|
_$GuardianChildCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final GuardianChild _self;
|
||||||
|
final $Res Function(GuardianChild) _then;
|
||||||
|
|
||||||
|
/// Create a copy of GuardianChild
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? firstName = null,Object? lastName = null,Object? className = null,Object? username = freezed,}) {
|
||||||
|
return _then(GuardianChild(
|
||||||
|
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,lastName: null == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,className: null == className ? _self.className : className // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,username: freezed == username ? _self.username : username // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Adds pattern-matching-related methods to [GuardianChild].
|
||||||
|
extension GuardianChildPatterns on GuardianChild {
|
||||||
|
/// A variant of `map` that fallback to returning `orElse`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _GuardianChild value)? $default,{required TResult orElse(),}){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _GuardianChild() when $default != null:
|
||||||
|
return $default(_that);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// Callbacks receives the raw object, upcasted.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case final Subclass2 value:
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _GuardianChild value) $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _GuardianChild():
|
||||||
|
return $default(_that);case _:
|
||||||
|
throw StateError('Unexpected subclass');
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `map` that fallback to returning `null`.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case final Subclass value:
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _GuardianChild value)? $default,){
|
||||||
|
final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _GuardianChild() when $default != null:
|
||||||
|
return $default(_that);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to an `orElse` callback.
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return orElse();
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String firstName, String lastName, String className, String? username)? $default,{required TResult orElse(),}) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _GuardianChild() when $default != null:
|
||||||
|
return $default(_that.id,_that.firstName,_that.lastName,_that.className,_that.username);case _:
|
||||||
|
return orElse();
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A `switch`-like method, using callbacks.
|
||||||
|
///
|
||||||
|
/// As opposed to `map`, this offers destructuring.
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case Subclass2(:final field2):
|
||||||
|
/// return ...;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String firstName, String lastName, String className, String? username) $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _GuardianChild():
|
||||||
|
return $default(_that.id,_that.firstName,_that.lastName,_that.className,_that.username);case _:
|
||||||
|
throw StateError('Unexpected subclass');
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// A variant of `when` that fallback to returning `null`
|
||||||
|
///
|
||||||
|
/// It is equivalent to doing:
|
||||||
|
/// ```dart
|
||||||
|
/// switch (sealedClass) {
|
||||||
|
/// case Subclass(:final field):
|
||||||
|
/// return ...;
|
||||||
|
/// case _:
|
||||||
|
/// return null;
|
||||||
|
/// }
|
||||||
|
/// ```
|
||||||
|
|
||||||
|
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String firstName, String lastName, String className, String? username)? $default,) {final _that = this;
|
||||||
|
switch (_that) {
|
||||||
|
case _GuardianChild() when $default != null:
|
||||||
|
return $default(_that.id,_that.firstName,_that.lastName,_that.className,_that.username);case _:
|
||||||
|
return null;
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
@JsonSerializable()
|
||||||
|
|
||||||
|
class _GuardianChild extends GuardianChild {
|
||||||
|
const _GuardianChild({required this.id, required this.firstName, required this.lastName, this.className = '', this.username}): super._();
|
||||||
|
factory _GuardianChild.fromJson(Map<String, dynamic> json) => _$GuardianChildFromJson(json);
|
||||||
|
|
||||||
|
@override final String id;
|
||||||
|
@override final String firstName;
|
||||||
|
@override final String lastName;
|
||||||
|
@override@JsonKey() final String className;
|
||||||
|
/// Login of the child, also its Nextcloud id (avatar). Null on servers
|
||||||
|
/// that do not send it yet.
|
||||||
|
@override final String? username;
|
||||||
|
|
||||||
|
/// Create a copy of GuardianChild
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@pragma('vm:prefer-inline')
|
||||||
|
_$GuardianChildCopyWith<_GuardianChild> get copyWith => __$GuardianChildCopyWithImpl<_GuardianChild>(this, _$identity);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return _$GuardianChildToJson(this, );
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return identical(this, other) || (other.runtimeType == runtimeType&&other is _GuardianChild&&(identical(other.id, id) || other.id == id)&&(identical(other.firstName, firstName) || other.firstName == firstName)&&(identical(other.lastName, lastName) || other.lastName == lastName)&&(identical(other.className, className) || other.className == className)&&(identical(other.username, username) || other.username == username));
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return Object.hash(runtimeType,id,firstName,lastName,className,username);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'GuardianChild(id: $id, firstName: $firstName, lastName: $lastName, className: $className, username: $username)';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// @nodoc
|
||||||
|
abstract mixin class _$GuardianChildCopyWith<$Res> implements $GuardianChildCopyWith<$Res> {
|
||||||
|
factory _$GuardianChildCopyWith(_GuardianChild value, $Res Function(_GuardianChild) _then) = __$GuardianChildCopyWithImpl;
|
||||||
|
@override @useResult
|
||||||
|
$Res call({
|
||||||
|
String id, String firstName, String lastName, String className, String? username
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
/// @nodoc
|
||||||
|
class __$GuardianChildCopyWithImpl<$Res>
|
||||||
|
implements _$GuardianChildCopyWith<$Res> {
|
||||||
|
__$GuardianChildCopyWithImpl(this._self, this._then);
|
||||||
|
|
||||||
|
final _GuardianChild _self;
|
||||||
|
final $Res Function(_GuardianChild) _then;
|
||||||
|
|
||||||
|
/// Create a copy of GuardianChild
|
||||||
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
|
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? firstName = null,Object? lastName = null,Object? className = null,Object? username = freezed,}) {
|
||||||
|
return _then(_GuardianChild(
|
||||||
|
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,firstName: null == firstName ? _self.firstName : firstName // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,lastName: null == lastName ? _self.lastName : lastName // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,className: null == className ? _self.className : className // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String,username: freezed == username ? _self.username : username // ignore: cast_nullable_to_non_nullable
|
||||||
|
as String?,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// dart format on
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'guardian_child.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
_GuardianChild _$GuardianChildFromJson(Map<String, dynamic> json) =>
|
||||||
|
_GuardianChild(
|
||||||
|
id: json['id'] as String,
|
||||||
|
firstName: json['firstName'] as String,
|
||||||
|
lastName: json['lastName'] as String,
|
||||||
|
className: json['className'] as String? ?? '',
|
||||||
|
username: json['username'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$GuardianChildToJson(_GuardianChild instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'firstName': instance.firstName,
|
||||||
|
'lastName': instance.lastName,
|
||||||
|
'className': instance.className,
|
||||||
|
'username': instance.username,
|
||||||
|
};
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
|
|
||||||
import '../../marianumconnect_query.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
/// Downloads the raw PDF bytes of a Marianum Message from
|
/// Downloads the raw PDF bytes of a Marianum Message from
|
||||||
@@ -15,11 +13,6 @@ class GetNewsletterFile extends MarianumConnectQuery {
|
|||||||
|
|
||||||
GetNewsletterFile(this.id, {super.dio});
|
GetNewsletterFile(this.id, {super.dio});
|
||||||
|
|
||||||
Future<Uint8List> run() => guard(() async {
|
Future<Uint8List> run() =>
|
||||||
final response = await dio.get<List<int>>(
|
getBytes('newsletter/${Uri.encodeComponent(id)}/file');
|
||||||
endpoint('newsletter/${Uri.encodeComponent(id)}/file'),
|
|
||||||
options: Options(responseType: ResponseType.bytes),
|
|
||||||
);
|
|
||||||
return Uint8List.fromList(response.data!);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
|
|
||||||
import '../../marianumconnect_query.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
/// Downloads the raw bytes of a PROXIED_FILE ticker page from
|
/// Downloads the raw bytes of a PROXIED_FILE ticker page from
|
||||||
@@ -15,11 +13,6 @@ class GetTickerPageFile extends MarianumConnectQuery {
|
|||||||
|
|
||||||
GetTickerPageFile(this.slug, {super.dio});
|
GetTickerPageFile(this.slug, {super.dio});
|
||||||
|
|
||||||
Future<Uint8List> run() => guard(() async {
|
Future<Uint8List> run() =>
|
||||||
final response = await dio.get<List<int>>(
|
getBytes('ticker/pages/${Uri.encodeComponent(slug)}/file');
|
||||||
endpoint('ticker/pages/${Uri.encodeComponent(slug)}/file'),
|
|
||||||
options: Options(responseType: ResponseType.bytes),
|
|
||||||
);
|
|
||||||
return Uint8List.fromList(response.data!);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import 'parent_letter_models.dart';
|
||||||
|
import 'parent_letter_query.dart';
|
||||||
|
|
||||||
|
class GetParentLetter extends ParentLetterQuery {
|
||||||
|
GetParentLetter({super.dio});
|
||||||
|
|
||||||
|
Future<ParentLetterDetail> run(String letterId) =>
|
||||||
|
getObject(letterPath(letterId), ParentLetterDetail.fromJson);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'parent_letter_query.dart';
|
||||||
|
|
||||||
|
class GetParentLetterAttachment extends ParentLetterQuery {
|
||||||
|
GetParentLetterAttachment({super.dio});
|
||||||
|
|
||||||
|
Future<Uint8List> run({
|
||||||
|
required String letterId,
|
||||||
|
required String attachmentId,
|
||||||
|
}) => getBytes(
|
||||||
|
letterPath(letterId, '/attachments/${Uri.encodeComponent(attachmentId)}'),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import 'parent_letter_models.dart';
|
||||||
|
import 'parent_letter_query.dart';
|
||||||
|
|
||||||
|
/// `GET parent-letters`: the guardian's inbox across all children, newest
|
||||||
|
/// first. [before] continues after the given letter (keyset paging).
|
||||||
|
class GetParentLetters extends ParentLetterQuery {
|
||||||
|
static const int pageSize = 50;
|
||||||
|
|
||||||
|
GetParentLetters({super.dio});
|
||||||
|
|
||||||
|
Future<ParentLetterListResponse> run({String? before}) => getObject(
|
||||||
|
'parent-letters',
|
||||||
|
ParentLetterListResponse.fromJson,
|
||||||
|
queryParameters: {'limit': pageSize, 'before': ?before},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import 'parent_letter_query.dart';
|
||||||
|
|
||||||
|
/// `POST parent-letters/{id}/read`: marks the letter and its thread as read
|
||||||
|
/// for this guardian. Idempotent.
|
||||||
|
class MarkParentLetterRead extends ParentLetterQuery {
|
||||||
|
MarkParentLetterRead({super.dio});
|
||||||
|
|
||||||
|
Future<void> run(String letterId) => guard(() async {
|
||||||
|
await dio.post<void>(endpoint(letterPath(letterId, '/read')));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../../errors/app_exception.dart';
|
||||||
|
import '../../errors/marianumconnect_error.dart';
|
||||||
|
|
||||||
|
enum ParentLetterError {
|
||||||
|
guardianRequired,
|
||||||
|
letterNotFound,
|
||||||
|
childNotFound,
|
||||||
|
attachmentNotFound,
|
||||||
|
invalidAnswers,
|
||||||
|
signatureRequired,
|
||||||
|
responseFinal,
|
||||||
|
deadlinePassed,
|
||||||
|
threadDisabled,
|
||||||
|
invalidRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A parent-letter call the server rejected with a domain reason.
|
||||||
|
class ParentLetterException extends AppException {
|
||||||
|
final ParentLetterError error;
|
||||||
|
|
||||||
|
const ParentLetterException(
|
||||||
|
this.error, {
|
||||||
|
required super.userMessage,
|
||||||
|
super.technicalDetails,
|
||||||
|
}) : super(allowRetry: false);
|
||||||
|
|
||||||
|
/// Only 4xx answers with a parent-letter reason become a
|
||||||
|
/// [ParentLetterException]; everything else (network, 401, 5xx) keeps the
|
||||||
|
/// generic MarianumConnect mapping.
|
||||||
|
static AppException fromDio(DioException e) {
|
||||||
|
final response = e.response;
|
||||||
|
final status = response?.statusCode;
|
||||||
|
if (status == null || status < 400 || status >= 500 || status == 401) {
|
||||||
|
return mapMarianumConnectError(e);
|
||||||
|
}
|
||||||
|
final error = _errorFor(marianumConnectErrorCode(response!.data), status);
|
||||||
|
if (error == null) return mapMarianumConnectError(e);
|
||||||
|
return ParentLetterException(
|
||||||
|
error,
|
||||||
|
userMessage: messageFor(error),
|
||||||
|
technicalDetails: 'MC $status: ${response.data}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String messageFor(ParentLetterError error) => switch (error) {
|
||||||
|
ParentLetterError.guardianRequired =>
|
||||||
|
'Elternbriefe sind nur mit einem Eltern-Konto verfügbar.',
|
||||||
|
ParentLetterError.letterNotFound =>
|
||||||
|
'Dieser Elternbrief ist nicht mehr verfügbar.',
|
||||||
|
ParentLetterError.childNotFound =>
|
||||||
|
'Dieser Elternbrief betrifft das ausgewählte Kind nicht.',
|
||||||
|
ParentLetterError.attachmentNotFound =>
|
||||||
|
'Der Anhang ist nicht mehr verfügbar.',
|
||||||
|
ParentLetterError.invalidAnswers =>
|
||||||
|
'Die Rückmeldung ist unvollständig. Bitte prüfe deine Angaben.',
|
||||||
|
ParentLetterError.signatureRequired =>
|
||||||
|
'Für diese Rückmeldung wird eine Unterschrift benötigt.',
|
||||||
|
ParentLetterError.responseFinal =>
|
||||||
|
'Die Rückmeldung wurde bereits endgültig abgegeben und kann nicht mehr '
|
||||||
|
'geändert werden.',
|
||||||
|
ParentLetterError.deadlinePassed =>
|
||||||
|
'Die Frist für diese Rückmeldung ist abgelaufen.',
|
||||||
|
ParentLetterError.threadDisabled =>
|
||||||
|
'Auf diesen Elternbrief kann nicht geantwortet werden.',
|
||||||
|
ParentLetterError.invalidRequest =>
|
||||||
|
'Die Anfrage konnte nicht verarbeitet werden.',
|
||||||
|
};
|
||||||
|
|
||||||
|
static ParentLetterError? _errorFor(String? code, int status) =>
|
||||||
|
switch (code) {
|
||||||
|
'guardian_required' => ParentLetterError.guardianRequired,
|
||||||
|
'letter_not_found' => ParentLetterError.letterNotFound,
|
||||||
|
'child_not_found' => ParentLetterError.childNotFound,
|
||||||
|
'attachment_not_found' => ParentLetterError.attachmentNotFound,
|
||||||
|
'invalid_answers' => ParentLetterError.invalidAnswers,
|
||||||
|
'signature_required' => ParentLetterError.signatureRequired,
|
||||||
|
'response_final' => ParentLetterError.responseFinal,
|
||||||
|
'deadline_passed' => ParentLetterError.deadlinePassed,
|
||||||
|
'thread_disabled' => ParentLetterError.threadDisabled,
|
||||||
|
'invalid_request' => ParentLetterError.invalidRequest,
|
||||||
|
_ => switch (status) {
|
||||||
|
404 => ParentLetterError.letterNotFound,
|
||||||
|
409 => ParentLetterError.responseFinal,
|
||||||
|
410 => ParentLetterError.deadlinePassed,
|
||||||
|
_ => null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
part 'parent_letter_models.freezed.dart';
|
||||||
|
part 'parent_letter_models.g.dart';
|
||||||
|
|
||||||
|
enum ParentLetterStatus { info, open, done, expired }
|
||||||
|
|
||||||
|
enum ParentLetterFieldType {
|
||||||
|
@JsonValue('single_choice')
|
||||||
|
singleChoice,
|
||||||
|
unknown,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sender, thread author or responding guardian. [self] marks the signed-in
|
||||||
|
/// guardian.
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterPerson with _$ParentLetterPerson {
|
||||||
|
const factory ParentLetterPerson({
|
||||||
|
@Default('') String displayName,
|
||||||
|
@Default(false) bool self,
|
||||||
|
}) = _ParentLetterPerson;
|
||||||
|
|
||||||
|
factory ParentLetterPerson.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterPersonFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inbox entry from `GET parent-letters`. [childIds] are the opaque guardian
|
||||||
|
/// child ids from `me/capabilities`.
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterSummary with _$ParentLetterSummary {
|
||||||
|
const factory ParentLetterSummary({
|
||||||
|
required String id,
|
||||||
|
@Default('') String subject,
|
||||||
|
@Default('') String preview,
|
||||||
|
@Default(ParentLetterPerson()) ParentLetterPerson sender,
|
||||||
|
required DateTime sentAt,
|
||||||
|
DateTime? editedAt,
|
||||||
|
@Default(true) bool read,
|
||||||
|
@Default([]) List<String> childIds,
|
||||||
|
@Default(0) int attachmentCount,
|
||||||
|
@JsonKey(unknownEnumValue: ParentLetterStatus.info)
|
||||||
|
@Default(ParentLetterStatus.info)
|
||||||
|
ParentLetterStatus status,
|
||||||
|
DateTime? deadline,
|
||||||
|
}) = _ParentLetterSummary;
|
||||||
|
|
||||||
|
factory ParentLetterSummary.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterSummaryFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterListResponse with _$ParentLetterListResponse {
|
||||||
|
const factory ParentLetterListResponse({
|
||||||
|
@Default([]) List<ParentLetterSummary> items,
|
||||||
|
@Default(false) bool hasMore,
|
||||||
|
@Default(0) int unreadCount,
|
||||||
|
@Default(0) int openCount,
|
||||||
|
}) = _ParentLetterListResponse;
|
||||||
|
|
||||||
|
factory ParentLetterListResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterListResponseFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterAttachment with _$ParentLetterAttachment {
|
||||||
|
const factory ParentLetterAttachment({
|
||||||
|
required String id,
|
||||||
|
@Default('') String fileName,
|
||||||
|
@Default('') String mimeType,
|
||||||
|
@Default(0) int size,
|
||||||
|
}) = _ParentLetterAttachment;
|
||||||
|
|
||||||
|
factory ParentLetterAttachment.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterAttachmentFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterOption with _$ParentLetterOption {
|
||||||
|
const factory ParentLetterOption({
|
||||||
|
required String id,
|
||||||
|
@Default('') String label,
|
||||||
|
}) = _ParentLetterOption;
|
||||||
|
|
||||||
|
factory ParentLetterOption.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterOptionFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterField with _$ParentLetterField {
|
||||||
|
const factory ParentLetterField({
|
||||||
|
required String id,
|
||||||
|
@JsonKey(unknownEnumValue: ParentLetterFieldType.unknown)
|
||||||
|
@Default(ParentLetterFieldType.unknown)
|
||||||
|
ParentLetterFieldType type,
|
||||||
|
@Default('') String label,
|
||||||
|
@JsonKey(name: 'required') @Default(false) bool isRequired,
|
||||||
|
@Default([]) List<ParentLetterOption> options,
|
||||||
|
}) = _ParentLetterField;
|
||||||
|
|
||||||
|
factory ParentLetterField.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterFieldFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the sender asks for. No fields and no signature means a plain
|
||||||
|
/// acknowledgement.
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterRequest with _$ParentLetterRequest {
|
||||||
|
const factory ParentLetterRequest({
|
||||||
|
@Default([]) List<ParentLetterField> fields,
|
||||||
|
@Default(false) bool signatureRequired,
|
||||||
|
DateTime? deadline,
|
||||||
|
}) = _ParentLetterRequest;
|
||||||
|
|
||||||
|
factory ParentLetterRequest.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterRequestFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterAnswer with _$ParentLetterAnswer {
|
||||||
|
const factory ParentLetterAnswer({
|
||||||
|
required String fieldId,
|
||||||
|
@Default([]) List<String> optionIds,
|
||||||
|
String? text,
|
||||||
|
}) = _ParentLetterAnswer;
|
||||||
|
|
||||||
|
factory ParentLetterAnswer.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterAnswerFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterResponse with _$ParentLetterResponse {
|
||||||
|
const factory ParentLetterResponse({
|
||||||
|
DateTime? respondedAt,
|
||||||
|
@Default(ParentLetterPerson()) ParentLetterPerson respondedBy,
|
||||||
|
@Default([]) List<ParentLetterAnswer> answers,
|
||||||
|
@Default(false) bool signed,
|
||||||
|
}) = _ParentLetterResponse;
|
||||||
|
|
||||||
|
factory ParentLetterResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterResponseFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-child state of a letter. [editable] is the server's verdict on whether
|
||||||
|
/// this guardian may (re)submit right now.
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterChildState with _$ParentLetterChildState {
|
||||||
|
const factory ParentLetterChildState({
|
||||||
|
required String childId,
|
||||||
|
@JsonKey(unknownEnumValue: ParentLetterStatus.info)
|
||||||
|
@Default(ParentLetterStatus.info)
|
||||||
|
ParentLetterStatus status,
|
||||||
|
@Default(false) bool editable,
|
||||||
|
ParentLetterResponse? response,
|
||||||
|
}) = _ParentLetterChildState;
|
||||||
|
|
||||||
|
factory ParentLetterChildState.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterChildStateFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterThreadMessage with _$ParentLetterThreadMessage {
|
||||||
|
const factory ParentLetterThreadMessage({
|
||||||
|
required String id,
|
||||||
|
@Default(ParentLetterPerson()) ParentLetterPerson author,
|
||||||
|
@Default('') String body,
|
||||||
|
required DateTime sentAt,
|
||||||
|
}) = _ParentLetterThreadMessage;
|
||||||
|
|
||||||
|
factory ParentLetterThreadMessage.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterThreadMessageFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterThread with _$ParentLetterThread {
|
||||||
|
const factory ParentLetterThread({
|
||||||
|
@Default(false) bool enabled,
|
||||||
|
@Default([]) List<ParentLetterThreadMessage> messages,
|
||||||
|
}) = _ParentLetterThread;
|
||||||
|
|
||||||
|
factory ParentLetterThread.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterThreadFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The detail-only part of `GET parent-letters/{id}`.
|
||||||
|
@freezed
|
||||||
|
abstract class ParentLetterContent with _$ParentLetterContent {
|
||||||
|
const factory ParentLetterContent({
|
||||||
|
@Default('') String body,
|
||||||
|
@Default([]) List<ParentLetterAttachment> attachments,
|
||||||
|
ParentLetterRequest? request,
|
||||||
|
@Default([]) List<ParentLetterChildState> children,
|
||||||
|
@Default(ParentLetterThread()) ParentLetterThread thread,
|
||||||
|
}) = _ParentLetterContent;
|
||||||
|
|
||||||
|
factory ParentLetterContent.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$ParentLetterContentFromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A full letter. The server sends the inbox fields and the detail fields in
|
||||||
|
/// one flat object; they are parsed into [summary] and [content] so the inbox
|
||||||
|
/// entry can be replaced from a detail answer without copying fields.
|
||||||
|
@immutable
|
||||||
|
class ParentLetterDetail {
|
||||||
|
final ParentLetterSummary summary;
|
||||||
|
final ParentLetterContent content;
|
||||||
|
|
||||||
|
const ParentLetterDetail({required this.summary, required this.content});
|
||||||
|
|
||||||
|
factory ParentLetterDetail.fromJson(Map<String, dynamic> json) =>
|
||||||
|
ParentLetterDetail(
|
||||||
|
summary: ParentLetterSummary.fromJson(json),
|
||||||
|
content: ParentLetterContent.fromJson(json),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {...summary.toJson(), ...content.toJson()};
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is ParentLetterDetail &&
|
||||||
|
other.summary == summary &&
|
||||||
|
other.content == content;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(summary, content);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,331 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'parent_letter_models.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
_ParentLetterPerson _$ParentLetterPersonFromJson(Map<String, dynamic> json) =>
|
||||||
|
_ParentLetterPerson(
|
||||||
|
displayName: json['displayName'] as String? ?? '',
|
||||||
|
self: json['self'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterPersonToJson(_ParentLetterPerson instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'displayName': instance.displayName,
|
||||||
|
'self': instance.self,
|
||||||
|
};
|
||||||
|
|
||||||
|
_ParentLetterSummary _$ParentLetterSummaryFromJson(Map<String, dynamic> json) =>
|
||||||
|
_ParentLetterSummary(
|
||||||
|
id: json['id'] as String,
|
||||||
|
subject: json['subject'] as String? ?? '',
|
||||||
|
preview: json['preview'] as String? ?? '',
|
||||||
|
sender: json['sender'] == null
|
||||||
|
? const ParentLetterPerson()
|
||||||
|
: ParentLetterPerson.fromJson(json['sender'] as Map<String, dynamic>),
|
||||||
|
sentAt: DateTime.parse(json['sentAt'] as String),
|
||||||
|
editedAt: json['editedAt'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['editedAt'] as String),
|
||||||
|
read: json['read'] as bool? ?? true,
|
||||||
|
childIds:
|
||||||
|
(json['childIds'] as List<dynamic>?)
|
||||||
|
?.map((e) => e as String)
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
attachmentCount: (json['attachmentCount'] as num?)?.toInt() ?? 0,
|
||||||
|
status:
|
||||||
|
$enumDecodeNullable(
|
||||||
|
_$ParentLetterStatusEnumMap,
|
||||||
|
json['status'],
|
||||||
|
unknownValue: ParentLetterStatus.info,
|
||||||
|
) ??
|
||||||
|
ParentLetterStatus.info,
|
||||||
|
deadline: json['deadline'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['deadline'] as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterSummaryToJson(
|
||||||
|
_ParentLetterSummary instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'subject': instance.subject,
|
||||||
|
'preview': instance.preview,
|
||||||
|
'sender': instance.sender,
|
||||||
|
'sentAt': instance.sentAt.toIso8601String(),
|
||||||
|
'editedAt': instance.editedAt?.toIso8601String(),
|
||||||
|
'read': instance.read,
|
||||||
|
'childIds': instance.childIds,
|
||||||
|
'attachmentCount': instance.attachmentCount,
|
||||||
|
'status': _$ParentLetterStatusEnumMap[instance.status]!,
|
||||||
|
'deadline': instance.deadline?.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const _$ParentLetterStatusEnumMap = {
|
||||||
|
ParentLetterStatus.info: 'info',
|
||||||
|
ParentLetterStatus.open: 'open',
|
||||||
|
ParentLetterStatus.done: 'done',
|
||||||
|
ParentLetterStatus.expired: 'expired',
|
||||||
|
};
|
||||||
|
|
||||||
|
_ParentLetterListResponse _$ParentLetterListResponseFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => _ParentLetterListResponse(
|
||||||
|
items:
|
||||||
|
(json['items'] as List<dynamic>?)
|
||||||
|
?.map((e) => ParentLetterSummary.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
hasMore: json['hasMore'] as bool? ?? false,
|
||||||
|
unreadCount: (json['unreadCount'] as num?)?.toInt() ?? 0,
|
||||||
|
openCount: (json['openCount'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterListResponseToJson(
|
||||||
|
_ParentLetterListResponse instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'items': instance.items,
|
||||||
|
'hasMore': instance.hasMore,
|
||||||
|
'unreadCount': instance.unreadCount,
|
||||||
|
'openCount': instance.openCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
_ParentLetterAttachment _$ParentLetterAttachmentFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => _ParentLetterAttachment(
|
||||||
|
id: json['id'] as String,
|
||||||
|
fileName: json['fileName'] as String? ?? '',
|
||||||
|
mimeType: json['mimeType'] as String? ?? '',
|
||||||
|
size: (json['size'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterAttachmentToJson(
|
||||||
|
_ParentLetterAttachment instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'fileName': instance.fileName,
|
||||||
|
'mimeType': instance.mimeType,
|
||||||
|
'size': instance.size,
|
||||||
|
};
|
||||||
|
|
||||||
|
_ParentLetterOption _$ParentLetterOptionFromJson(Map<String, dynamic> json) =>
|
||||||
|
_ParentLetterOption(
|
||||||
|
id: json['id'] as String,
|
||||||
|
label: json['label'] as String? ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterOptionToJson(_ParentLetterOption instance) =>
|
||||||
|
<String, dynamic>{'id': instance.id, 'label': instance.label};
|
||||||
|
|
||||||
|
_ParentLetterField _$ParentLetterFieldFromJson(Map<String, dynamic> json) =>
|
||||||
|
_ParentLetterField(
|
||||||
|
id: json['id'] as String,
|
||||||
|
type:
|
||||||
|
$enumDecodeNullable(
|
||||||
|
_$ParentLetterFieldTypeEnumMap,
|
||||||
|
json['type'],
|
||||||
|
unknownValue: ParentLetterFieldType.unknown,
|
||||||
|
) ??
|
||||||
|
ParentLetterFieldType.unknown,
|
||||||
|
label: json['label'] as String? ?? '',
|
||||||
|
isRequired: json['required'] as bool? ?? false,
|
||||||
|
options:
|
||||||
|
(json['options'] as List<dynamic>?)
|
||||||
|
?.map(
|
||||||
|
(e) => ParentLetterOption.fromJson(e as Map<String, dynamic>),
|
||||||
|
)
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterFieldToJson(_ParentLetterField instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'type': _$ParentLetterFieldTypeEnumMap[instance.type]!,
|
||||||
|
'label': instance.label,
|
||||||
|
'required': instance.isRequired,
|
||||||
|
'options': instance.options,
|
||||||
|
};
|
||||||
|
|
||||||
|
const _$ParentLetterFieldTypeEnumMap = {
|
||||||
|
ParentLetterFieldType.singleChoice: 'single_choice',
|
||||||
|
ParentLetterFieldType.unknown: 'unknown',
|
||||||
|
};
|
||||||
|
|
||||||
|
_ParentLetterRequest _$ParentLetterRequestFromJson(Map<String, dynamic> json) =>
|
||||||
|
_ParentLetterRequest(
|
||||||
|
fields:
|
||||||
|
(json['fields'] as List<dynamic>?)
|
||||||
|
?.map(
|
||||||
|
(e) => ParentLetterField.fromJson(e as Map<String, dynamic>),
|
||||||
|
)
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
signatureRequired: json['signatureRequired'] as bool? ?? false,
|
||||||
|
deadline: json['deadline'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['deadline'] as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterRequestToJson(
|
||||||
|
_ParentLetterRequest instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'fields': instance.fields,
|
||||||
|
'signatureRequired': instance.signatureRequired,
|
||||||
|
'deadline': instance.deadline?.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
_ParentLetterAnswer _$ParentLetterAnswerFromJson(Map<String, dynamic> json) =>
|
||||||
|
_ParentLetterAnswer(
|
||||||
|
fieldId: json['fieldId'] as String,
|
||||||
|
optionIds:
|
||||||
|
(json['optionIds'] as List<dynamic>?)
|
||||||
|
?.map((e) => e as String)
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
text: json['text'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterAnswerToJson(_ParentLetterAnswer instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'fieldId': instance.fieldId,
|
||||||
|
'optionIds': instance.optionIds,
|
||||||
|
'text': instance.text,
|
||||||
|
};
|
||||||
|
|
||||||
|
_ParentLetterResponse _$ParentLetterResponseFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => _ParentLetterResponse(
|
||||||
|
respondedAt: json['respondedAt'] == null
|
||||||
|
? null
|
||||||
|
: DateTime.parse(json['respondedAt'] as String),
|
||||||
|
respondedBy: json['respondedBy'] == null
|
||||||
|
? const ParentLetterPerson()
|
||||||
|
: ParentLetterPerson.fromJson(
|
||||||
|
json['respondedBy'] as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
answers:
|
||||||
|
(json['answers'] as List<dynamic>?)
|
||||||
|
?.map((e) => ParentLetterAnswer.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
signed: json['signed'] as bool? ?? false,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterResponseToJson(
|
||||||
|
_ParentLetterResponse instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'respondedAt': instance.respondedAt?.toIso8601String(),
|
||||||
|
'respondedBy': instance.respondedBy,
|
||||||
|
'answers': instance.answers,
|
||||||
|
'signed': instance.signed,
|
||||||
|
};
|
||||||
|
|
||||||
|
_ParentLetterChildState _$ParentLetterChildStateFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => _ParentLetterChildState(
|
||||||
|
childId: json['childId'] as String,
|
||||||
|
status:
|
||||||
|
$enumDecodeNullable(
|
||||||
|
_$ParentLetterStatusEnumMap,
|
||||||
|
json['status'],
|
||||||
|
unknownValue: ParentLetterStatus.info,
|
||||||
|
) ??
|
||||||
|
ParentLetterStatus.info,
|
||||||
|
editable: json['editable'] as bool? ?? false,
|
||||||
|
response: json['response'] == null
|
||||||
|
? null
|
||||||
|
: ParentLetterResponse.fromJson(json['response'] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterChildStateToJson(
|
||||||
|
_ParentLetterChildState instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'childId': instance.childId,
|
||||||
|
'status': _$ParentLetterStatusEnumMap[instance.status]!,
|
||||||
|
'editable': instance.editable,
|
||||||
|
'response': instance.response,
|
||||||
|
};
|
||||||
|
|
||||||
|
_ParentLetterThreadMessage _$ParentLetterThreadMessageFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => _ParentLetterThreadMessage(
|
||||||
|
id: json['id'] as String,
|
||||||
|
author: json['author'] == null
|
||||||
|
? const ParentLetterPerson()
|
||||||
|
: ParentLetterPerson.fromJson(json['author'] as Map<String, dynamic>),
|
||||||
|
body: json['body'] as String? ?? '',
|
||||||
|
sentAt: DateTime.parse(json['sentAt'] as String),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterThreadMessageToJson(
|
||||||
|
_ParentLetterThreadMessage instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'author': instance.author,
|
||||||
|
'body': instance.body,
|
||||||
|
'sentAt': instance.sentAt.toIso8601String(),
|
||||||
|
};
|
||||||
|
|
||||||
|
_ParentLetterThread _$ParentLetterThreadFromJson(Map<String, dynamic> json) =>
|
||||||
|
_ParentLetterThread(
|
||||||
|
enabled: json['enabled'] as bool? ?? false,
|
||||||
|
messages:
|
||||||
|
(json['messages'] as List<dynamic>?)
|
||||||
|
?.map(
|
||||||
|
(e) => ParentLetterThreadMessage.fromJson(
|
||||||
|
e as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterThreadToJson(_ParentLetterThread instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'enabled': instance.enabled,
|
||||||
|
'messages': instance.messages,
|
||||||
|
};
|
||||||
|
|
||||||
|
_ParentLetterContent _$ParentLetterContentFromJson(Map<String, dynamic> json) =>
|
||||||
|
_ParentLetterContent(
|
||||||
|
body: json['body'] as String? ?? '',
|
||||||
|
attachments:
|
||||||
|
(json['attachments'] as List<dynamic>?)
|
||||||
|
?.map(
|
||||||
|
(e) =>
|
||||||
|
ParentLetterAttachment.fromJson(e as Map<String, dynamic>),
|
||||||
|
)
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
request: json['request'] == null
|
||||||
|
? null
|
||||||
|
: ParentLetterRequest.fromJson(
|
||||||
|
json['request'] as Map<String, dynamic>,
|
||||||
|
),
|
||||||
|
children:
|
||||||
|
(json['children'] as List<dynamic>?)
|
||||||
|
?.map(
|
||||||
|
(e) =>
|
||||||
|
ParentLetterChildState.fromJson(e as Map<String, dynamic>),
|
||||||
|
)
|
||||||
|
.toList() ??
|
||||||
|
const [],
|
||||||
|
thread: json['thread'] == null
|
||||||
|
? const ParentLetterThread()
|
||||||
|
: ParentLetterThread.fromJson(json['thread'] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$ParentLetterContentToJson(
|
||||||
|
_ParentLetterContent instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'body': instance.body,
|
||||||
|
'attachments': instance.attachments,
|
||||||
|
'request': instance.request,
|
||||||
|
'children': instance.children,
|
||||||
|
'thread': instance.thread,
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import '../../../errors/app_exception.dart';
|
||||||
|
import '../../marianumconnect_query.dart';
|
||||||
|
import 'parent_letter_exception.dart';
|
||||||
|
|
||||||
|
/// Base for the parent-letter calls: domain rejections surface as
|
||||||
|
/// [ParentLetterException].
|
||||||
|
abstract class ParentLetterQuery extends MarianumConnectQuery {
|
||||||
|
ParentLetterQuery({super.dio});
|
||||||
|
|
||||||
|
String letterPath(String letterId, [String suffix = '']) =>
|
||||||
|
'parent-letters/${Uri.encodeComponent(letterId)}$suffix';
|
||||||
|
|
||||||
|
@override
|
||||||
|
AppException mapError(DioException error) =>
|
||||||
|
ParentLetterException.fromDio(error);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import 'parent_letter_models.dart';
|
||||||
|
import 'parent_letter_query.dart';
|
||||||
|
|
||||||
|
/// `POST parent-letters/{id}/thread`. [clientMessageId] makes a retry
|
||||||
|
/// idempotent on the server.
|
||||||
|
class PostParentLetterThreadMessage extends ParentLetterQuery {
|
||||||
|
PostParentLetterThreadMessage({super.dio});
|
||||||
|
|
||||||
|
Future<ParentLetterThreadMessage> run({
|
||||||
|
required String letterId,
|
||||||
|
required String body,
|
||||||
|
required String clientMessageId,
|
||||||
|
}) => guard(() async {
|
||||||
|
final response = await dio.post<Map<String, dynamic>>(
|
||||||
|
endpoint(letterPath(letterId, '/thread')),
|
||||||
|
data: {'body': body, 'clientMessageId': clientMessageId},
|
||||||
|
);
|
||||||
|
return ParentLetterThreadMessage.fromJson(response.data!);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'parent_letter_models.dart';
|
||||||
|
import 'parent_letter_query.dart';
|
||||||
|
|
||||||
|
/// `PUT parent-letters/{id}/responses/{childId}`: creates or (while editable)
|
||||||
|
/// replaces the response for one child. Answers with the updated letter.
|
||||||
|
class SubmitParentLetterResponse extends ParentLetterQuery {
|
||||||
|
SubmitParentLetterResponse({super.dio});
|
||||||
|
|
||||||
|
Future<ParentLetterDetail> run({
|
||||||
|
required String letterId,
|
||||||
|
required String childId,
|
||||||
|
required List<ParentLetterAnswer> answers,
|
||||||
|
Uint8List? signaturePng,
|
||||||
|
}) => guard(() async {
|
||||||
|
final response = await dio.put<Map<String, dynamic>>(
|
||||||
|
endpoint(
|
||||||
|
letterPath(letterId, '/responses/${Uri.encodeComponent(childId)}'),
|
||||||
|
),
|
||||||
|
data: {
|
||||||
|
'answers': [
|
||||||
|
for (final answer in answers)
|
||||||
|
{'fieldId': answer.fieldId, 'optionIds': answer.optionIds},
|
||||||
|
],
|
||||||
|
'signaturePng': signaturePng == null
|
||||||
|
? null
|
||||||
|
: base64Encode(signaturePng),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return ParentLetterDetail.fromJson(response.data!);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
import '../../marianumconnect_query.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
|
|
||||||
/// Registers (upserts) this device's push subscription with MarianumConnect via
|
/// Registers (upserts) this device's push subscription with MarianumConnect via
|
||||||
/// `PUT /api/mobile/v1/me/push-device`. The backend verifies the Nextcloud
|
/// `PUT /api/mobile/v1/me/push-device`. For Nextcloud registrations the backend
|
||||||
/// device-identifier signature, stores the routing metadata and starts
|
/// verifies the device-identifier signature and forwards Nextcloud pushes to
|
||||||
/// forwarding Nextcloud pushes to this device's FCM token. Responds 204.
|
/// this device's FCM token; `direct` registrations (accounts without
|
||||||
|
/// Nextcloud) carry no signature and only receive MarianumConnect pushes.
|
||||||
|
/// Responds 204.
|
||||||
class PushDeviceRegister extends MarianumConnectQuery {
|
class PushDeviceRegister extends MarianumConnectQuery {
|
||||||
PushDeviceRegister({super.dio});
|
PushDeviceRegister({super.dio});
|
||||||
|
|
||||||
Future<void> run({
|
Future<void> run({
|
||||||
required String deviceIdentifier,
|
required String deviceIdentifier,
|
||||||
required String deviceIdentifierSignature,
|
String? deviceIdentifierSignature,
|
||||||
required String userPublicKey,
|
String? userPublicKey,
|
||||||
required String pushToken,
|
required String pushToken,
|
||||||
required String platform,
|
required String platform,
|
||||||
required String registrationType,
|
required String registrationType,
|
||||||
@@ -20,12 +22,13 @@ class PushDeviceRegister extends MarianumConnectQuery {
|
|||||||
endpoint('me/push-device'),
|
endpoint('me/push-device'),
|
||||||
data: {
|
data: {
|
||||||
'deviceIdentifier': deviceIdentifier,
|
'deviceIdentifier': deviceIdentifier,
|
||||||
'deviceIdentifierSignature': deviceIdentifierSignature,
|
'deviceIdentifierSignature': ?deviceIdentifierSignature,
|
||||||
'userPublicKey': userPublicKey,
|
'userPublicKey': ?userPublicKey,
|
||||||
'pushToken': pushToken,
|
'pushToken': pushToken,
|
||||||
'platform': platform,
|
'platform': platform,
|
||||||
// 'general' | 'talk' — the backend derives the NC hash comparison
|
// 'general' | 'talk' — the backend derives the NC hash comparison
|
||||||
// value from it (general = sha512(token), talk = sha512(token+'#talk')).
|
// value from it (general = sha512(token), talk = sha512(token+'#talk')).
|
||||||
|
// 'direct' — no Nextcloud subscription behind it.
|
||||||
'registrationType': registrationType,
|
'registrationType': registrationType,
|
||||||
'appVersion': ?appVersion,
|
'appVersion': ?appVersion,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
|
import '../../../../utils/random_id.dart';
|
||||||
|
|
||||||
/// A stable, anonymous per-install identifier for telemetry. Generated once on
|
/// A stable, anonymous per-install identifier for telemetry. Generated once on
|
||||||
/// first use (128 bits from a cryptographic RNG) and persisted in the secure
|
/// first use (128 bits from a cryptographic RNG) and persisted in the secure
|
||||||
/// keystore, so a device stays a single row across password rotations and FCM
|
/// keystore, so a device stays a single row across password rotations and FCM
|
||||||
@@ -21,15 +21,9 @@ class TelemetryDeviceId {
|
|||||||
_cached = existing;
|
_cached = existing;
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
final generated = _generate();
|
final generated = randomHexId();
|
||||||
await _storage.write(key: _key, value: generated);
|
await _storage.write(key: _key, value: generated);
|
||||||
_cached = generated;
|
_cached = generated;
|
||||||
return generated;
|
return generated;
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _generate() {
|
|
||||||
final random = Random.secure();
|
|
||||||
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
|
|
||||||
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -3,41 +3,71 @@ import 'dart:convert';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:device_info_plus/device_info_plus.dart';
|
import 'package:device_info_plus/device_info_plus.dart';
|
||||||
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
|
import '../../../../push/push_registration.dart';
|
||||||
import '../../../../push/push_registration_store.dart';
|
import '../../../../push/push_registration_store.dart';
|
||||||
import '../../../../push/push_registration_type.dart';
|
import '../../../../push/push_registration_type.dart';
|
||||||
import '../../marianumconnect_query.dart';
|
import '../../marianumconnect_query.dart';
|
||||||
import 'telemetry_device_id.dart';
|
import 'telemetry_device_id.dart';
|
||||||
|
|
||||||
|
typedef _PushState = ({
|
||||||
|
bool enabled,
|
||||||
|
AuthorizationStatus? permission,
|
||||||
|
String? pushDeviceIdentifier,
|
||||||
|
});
|
||||||
|
|
||||||
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) —
|
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) —
|
||||||
/// upserts the stable install id, platform, app version and device info. Sent
|
/// upserts the stable install id, platform, app version and device info. Sent
|
||||||
/// on app start, again once push registration completes that session (so a
|
/// on app start, on resume and once push registration completes, throttled to
|
||||||
/// fresh registration isn't under-reported until the next launch), and on
|
/// one per 15 minutes unless the push state changed in between.
|
||||||
/// resume after the app spent more than 15 minutes in the background.
|
|
||||||
/// Bearer-authenticated via the shared dio interceptor. Replaces the legacy
|
/// Bearer-authenticated via the shared dio interceptor. Replaces the legacy
|
||||||
/// mhsl.eu `server/userIndex/update` call.
|
/// mhsl.eu `server/userIndex/update` call.
|
||||||
class TelemetryHeartbeat extends MarianumConnectQuery {
|
class TelemetryHeartbeat extends MarianumConnectQuery {
|
||||||
TelemetryHeartbeat({super.dio});
|
TelemetryHeartbeat({super.dio});
|
||||||
|
|
||||||
|
static const Duration _interval = Duration(minutes: 15);
|
||||||
|
|
||||||
|
// Wall-clock throttle rather than Debouncer.throttle: a Timer does not tick
|
||||||
|
// reliably while the app is suspended, so the window would still be open on
|
||||||
|
// the resume it is supposed to let through.
|
||||||
|
static DateTime? _lastSentAt;
|
||||||
|
static _PushState? _lastPushState;
|
||||||
|
|
||||||
/// Fire-and-forget: schedules a heartbeat and swallows any error, so a failed
|
/// Fire-and-forget: schedules a heartbeat and swallows any error, so a failed
|
||||||
/// send never disrupts app start. Used from the app shell's initState and
|
/// send never disrupts app start. A changed push state (toggle, OS
|
||||||
/// lifecycle handler, and re-emitted once push registration completes (see
|
/// permission — e.g. back from the system settings — or a fresh
|
||||||
/// `_MainState._syncPush`).
|
/// registration) bypasses the throttle: the server picks visible vs. silent
|
||||||
|
/// delivery from it, so it must not wait for the next window.
|
||||||
static void report({required bool notificationsEnabled}) {
|
static void report({required bool notificationsEnabled}) {
|
||||||
unawaited(
|
unawaited(_report(notificationsEnabled).catchError((Object _) {}));
|
||||||
TelemetryHeartbeat()
|
|
||||||
.send(notificationsEnabled: notificationsEnabled)
|
|
||||||
.catchError((Object _) {}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> send({required bool notificationsEnabled}) => guard(() async {
|
static Future<void> _report(bool enabled) async {
|
||||||
|
final pushState = (
|
||||||
|
enabled: enabled,
|
||||||
|
permission: await PushRegistration.osPermissionStatus(),
|
||||||
|
pushDeviceIdentifier: await const PushRegistrationStore()
|
||||||
|
.deviceIdentifier(PushRegistrationType.general),
|
||||||
|
);
|
||||||
|
final now = DateTime.now();
|
||||||
|
final last = _lastSentAt;
|
||||||
|
if (last != null &&
|
||||||
|
now.difference(last) < _interval &&
|
||||||
|
pushState == _lastPushState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Claimed before sending so overlapping triggers don't both go out.
|
||||||
|
_lastSentAt = now;
|
||||||
|
_lastPushState = pushState;
|
||||||
|
await TelemetryHeartbeat()._send(pushState);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _send(_PushState push) => guard(() async {
|
||||||
final info = DeviceInfoPlugin();
|
final info = DeviceInfoPlugin();
|
||||||
final package = await PackageInfo.fromPlatform();
|
final package = await PackageInfo.fromPlatform();
|
||||||
final deviceIdentifier = await TelemetryDeviceId.resolve();
|
final deviceIdentifier = await TelemetryDeviceId.resolve();
|
||||||
final pushDeviceIdentifier = await const PushRegistrationStore()
|
|
||||||
.deviceIdentifier(PushRegistrationType.general);
|
|
||||||
|
|
||||||
var platform = 'unknown';
|
var platform = 'unknown';
|
||||||
String? deviceModel;
|
String? deviceModel;
|
||||||
@@ -62,11 +92,12 @@ class TelemetryHeartbeat extends MarianumConnectQuery {
|
|||||||
data: {
|
data: {
|
||||||
'deviceIdentifier': deviceIdentifier,
|
'deviceIdentifier': deviceIdentifier,
|
||||||
// `pushDeviceIdentifier` reflects a *completed* registration and is
|
// `pushDeviceIdentifier` reflects a *completed* registration and is
|
||||||
// absent until it lands; `pushEnabled` carries the user's intent
|
// absent until it lands; `pushEnabled` carries the user's intent (the
|
||||||
// (the notification toggle) so the backend can tell "user wants push"
|
// toggle). The server combines it with `osPermission` into the
|
||||||
// apart from "registration not finished yet".
|
// visible-vs-silent delivery decision.
|
||||||
'pushDeviceIdentifier': ?pushDeviceIdentifier,
|
'pushDeviceIdentifier': ?push.pushDeviceIdentifier,
|
||||||
'pushEnabled': notificationsEnabled,
|
'pushEnabled': push.enabled,
|
||||||
|
'osPermission': ?osPermissionWireValue(push.permission),
|
||||||
'platform': platform,
|
'platform': platform,
|
||||||
'appVersion': package.version,
|
'appVersion': package.version,
|
||||||
'appBuild': int.tryParse(package.buildNumber),
|
'appBuild': int.tryParse(package.buildNumber),
|
||||||
@@ -77,3 +108,13 @@ class TelemetryHeartbeat extends MarianumConnectQuery {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wire value of the OS notification permission for the heartbeat.
|
||||||
|
String? osPermissionWireValue(AuthorizationStatus? status) => switch (status) {
|
||||||
|
AuthorizationStatus.authorized => 'granted',
|
||||||
|
AuthorizationStatus.provisional => 'provisional',
|
||||||
|
AuthorizationStatus.denied ||
|
||||||
|
AuthorizationStatus.deniedPermanently => 'denied',
|
||||||
|
AuthorizationStatus.notDetermined => 'notDetermined',
|
||||||
|
null => null,
|
||||||
|
};
|
||||||
+13
-8
@@ -2,8 +2,8 @@ import 'dart:developer';
|
|||||||
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../../../../model/account_data.dart';
|
import '../../../../session/session.dart';
|
||||||
import '../../../demo/demo_mode.dart';
|
import '../../../../session/session_manager.dart';
|
||||||
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event.dart';
|
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event.dart';
|
||||||
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart';
|
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart';
|
||||||
import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart';
|
import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart';
|
||||||
@@ -26,28 +26,33 @@ class CustomEventsMigration {
|
|||||||
const CustomEventsMigration._();
|
const CustomEventsMigration._();
|
||||||
|
|
||||||
static Future<void> runOnce() async {
|
static Future<void> runOnce() async {
|
||||||
if (DemoMode.active) return;
|
// Guardians never had MHSL events; only password accounts can derive the
|
||||||
|
// legacy identity.
|
||||||
|
final session = SessionManager().current;
|
||||||
|
if (session is! CredentialSession || session.isDemo) return;
|
||||||
if (await _isDone()) return;
|
if (await _isDone()) return;
|
||||||
|
|
||||||
final epoch = AccountData().sessionEpoch;
|
final epoch = SessionManager().sessionEpoch;
|
||||||
try {
|
try {
|
||||||
final response = await GetCustomTimetableEvent(
|
final response = await GetCustomTimetableEvent(
|
||||||
GetCustomTimetableEventParams(AccountData().getUserSecret()),
|
GetCustomTimetableEventParams(session.legacyUserSecret),
|
||||||
).run();
|
).run();
|
||||||
|
|
||||||
for (final event in response.events) {
|
for (final event in response.events) {
|
||||||
// The POST authenticates with whoever is signed in now; after a
|
// The POST authenticates with whoever is signed in now; after a
|
||||||
// sign-out the previous account's events would land in the next one.
|
// sign-out the previous account's events would land in the next one.
|
||||||
if (!AccountData().isCurrentSession(epoch)) return;
|
if (!SessionManager().isCurrentSession(epoch)) return;
|
||||||
await TimetableCustomEventsAdd().run(event);
|
await TimetableCustomEventsAdd().run(event);
|
||||||
await RemoveCustomTimetableEvent(
|
await RemoveCustomTimetableEvent(
|
||||||
RemoveCustomTimetableEventParams(event.id),
|
RemoveCustomTimetableEventParams(event.id),
|
||||||
).run();
|
).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!AccountData().isCurrentSession(epoch)) return;
|
if (!SessionManager().isCurrentSession(epoch)) return;
|
||||||
await _markDone();
|
await _markDone();
|
||||||
log('Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.');
|
log(
|
||||||
|
'Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.',
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Leave the flag unset so the next launch retries; the delete-after-post
|
// Leave the flag unset so the next launch retries; the delete-after-post
|
||||||
// above keeps a partial run duplicate-free.
|
// above keeps a partial run duplicate-free.
|
||||||
|
|||||||
Loaded 100 of 264 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user